From 0976b6ef0d3a4406bd9449e5f57b2dd11b32324e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:22:19 +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 --- lastleg-service/AGENTS.md | 2 +- ...astleg-internal-modularization-baseline.md | 4 + .../analyze-lastleg-dependencies.test.mjs | 4 + .../DispatchLoadingDocumentController.java | 16 +- .../mapper/DispatchAggregateMapper.java | 2 +- .../pda/controller/DispatchPdaController.java | 2 +- .../PdaDispatchLoadingDocumentController.java | 16 +- .../service/DispatchAggregateService.java | 2 +- .../DispatchLoadingDepartureService.java | 23 ++- .../DispatchLoadingDocumentService.java | 85 +++++++++ .../service/DispatchPdaQueryService.java | 37 +--- .../service/DispatchPickingService.java | 54 ++++-- .../service/DispatchReceivedPlan.java | 55 ++++++ .../port/DispatchLoadingPhotoPort.java | 29 +++ .../DispatchLoadingDocumentService.java | 179 ------------------ .../service/DispatchLoadingPhotoAdapter.java | 124 ++++++++++++ .../dispatch-loading-documents-v1.yaml | 3 +- .../openapi/overseas-business-v1.yaml | 4 +- .../openapi/overseas-pda-operations-v1.yaml | 4 +- ...spatchLoadingDeparturePersistenceTest.java | 69 ++++++- .../DispatchPickingPersistenceTest.java | 53 +++++- .../DispatchPlanPdaReadPersistenceTest.java | 39 +++- scripts/agents-index-contract.ps1 | 57 +++++- 23 files changed, 577 insertions(+), 286 deletions(-) rename lastleg-service/src/main/java/com/baiyue/lastleg/{fleet/ownedbusiness => dispatch}/controller/DispatchLoadingDocumentController.java (83%) rename lastleg-service/src/main/java/com/baiyue/lastleg/{fleet/ownedbusiness => dispatch/pda}/controller/PdaDispatchLoadingDocumentController.java (84%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e689cbace..6b429627a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -77,7 +77,7 @@ | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | +| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 13bab30cc..9d7fc7509 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -28,6 +28,10 @@ `fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 +### 装车资料触发派送边界(2026-10-21) + +`DispatchLoadingDocumentService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,调用端口保存照片后在同一事务推进派送并记录原结果。结构门禁仅开放上述三类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 + ### 外配业务原排车详情读取边界(2026-10-21) `OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index de5c65519..db76e8b93 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -72,6 +72,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' + || ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService', + 'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController', + 'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source) + && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort' || [ 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels', 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java similarity index 83% rename from lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java index 8e86f384f..3adc9d1aa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java @@ -1,10 +1,10 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.controller; +package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import org.springframework.http.*; @@ -25,21 +25,21 @@ public class DispatchLoadingDocumentController { /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ @GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") - public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo)); } /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") - public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, - @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable Slot slot,@Valid @RequestBody BindPhoto request) { return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request)); } /** 原上传仅允许当前有权限的原操作者恢复。 */ @@ -51,7 +51,7 @@ public class DispatchLoadingDocumentController { /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ @GetMapping("/{slot}/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") - public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) { var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) .header("X-Content-Type-Options","nosniff").body(value.content()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index b58b70e61..a8a1e3745 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -98,7 +98,7 @@ public interface DispatchAggregateMapper { estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, - delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount}, + delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} WHERE id=#{r.id} AND record_version=#{version} """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index f37aa253b..4a3fec3cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -57,7 +57,7 @@ public class DispatchPdaController { return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } - /** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */ + /** 真实扫码核平后确认装车进入装车后,两端共用同一命令并等待五张装车照片。 */ @PostMapping("/{dispatchNo}/loading-complete") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java similarity index 84% rename from lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java index 9d214c073..229ea7140 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java @@ -1,10 +1,10 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.controller; +package com.baiyue.lastleg.dispatch.pda.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import org.springframework.http.*; @@ -25,21 +25,21 @@ public class PdaDispatchLoadingDocumentController { /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ @GetMapping @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo)); } /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, - @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable Slot slot,@Valid @RequestBody BindPhoto request) { return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request)); } /** 原上传仅允许当前有权限的原操作者恢复。 */ @@ -51,7 +51,7 @@ public class PdaDispatchLoadingDocumentController { /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ @GetMapping("/{slot}/files/{fileId}/content") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) { var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) .header("X-Content-Type-Options","nosniff").body(value.content()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 3693e5a3b..56215676a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -540,7 +540,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); } - /** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */ + /** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java index 0db7c8884..13031244e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.util.List; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; -/** 装车完成停在装车后;资料齐全时由资料服务在保存事务中推进派送。 */ +/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */ @Service public class DispatchLoadingDepartureService { private final DispatchAggregateService workflow; @@ -17,18 +18,32 @@ public class DispatchLoadingDepartureService { private final DispatchDepartureService departure; private final DispatchCommandLedger commands; private final DispatchCommandHasher hashes; + private final DispatchLoadingPhotoPort documents; /** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */ public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery, - DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) { + DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes, + DispatchLoadingPhotoPort documents) { this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; + this.documents=documents; } - /** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */ + /** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { workflow.requireLoadingPermission(actor); - return workflow.loadingComplete(actor,number,request); + var context=workflow.lockForArrangement(actor,number); + var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); + var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get(); + var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), + stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); + var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos()); + var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded; + var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(), + finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false); + commands.append(context.dispatchId(),actor,identity,result); + return result; } /** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java new file mode 100644 index 000000000..e49905832 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.util.*; + +/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */ +@Service +public class DispatchLoadingDocumentService { + public static final List SLOTS=List.of(Slot.values()); + private final DispatchAggregateService workflow; + private final DispatchLoadingDepartureService departure; + private final DispatchLoadingPhotoPort photos; + /** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */ + public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) { + this.workflow=workflow;this.departure=departure;this.photos=photos; + } + /** GET只读,PDA每次按可信主体重验冻结仓。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Documents get(Actor actor,String number) { + require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true)); + } + /** 上传先记原请求意图,不提前绑定或推进派送。 */ + public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) { + require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file); + } + /** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */ + public Photo recover(Actor actor,String number,String key) { + require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key); + } + /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) { + require(actor,true);var context=context(actor,number,true); + var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay; + var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request); + if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) { + departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number); + } + var result=documents(context,state,true); + photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result; + } + /** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */ + @Transactional(readOnly=true) + public Download content(Actor actor,String number,Slot slot,long fileId) { + require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId); + } + /** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */ + private Context context(Actor actor,String number,boolean lock) { + var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number); + if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) + || value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)) + throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料"); + return value; + } + /** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */ + private Documents documents(Context context,State state,boolean editable) { + return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos()); + } + /** 按五个固定框核齐,不能用总数或重复槽位代替。 */ + static boolean complete(List photos) { + return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); + } + /** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */ + private void require(Actor actor,boolean write) { + if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); + } + /** 权限取当前会话,不为PDA补造平台权限。 */ + private boolean allowed(Actor actor,boolean write) { + var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false; + var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList(); + return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate") + && authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR") + :authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 9b756f5e8..33799a454 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -87,7 +87,7 @@ public class DispatchPdaQueryService { if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); - var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); } @@ -95,36 +95,11 @@ public class DispatchPdaQueryService { } /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { - if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of(); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(rows.size()!=1)return List.of(); - var actual=rows.get(0); - if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null - || !Objects.equals(actual.planPalletId(),item.getPlanPalletId()) - || !Objects.equals(actual.customerId(),item.getCustomerId()) - || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) - || !Objects.equals(actual.packageCount(),item.getPackageCount()) - || !Objects.equals(actual.destinationType(),item.getDestinationType()) - || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) - || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of(); - var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); - if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll( - sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of(); - // 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。 - var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection( - SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); - if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of(); - for(var member:group.sources()) { - var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); - if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()) - || n(selected.getPackageCount())!=member.packages())return List.of(); - } - var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); - if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId()) - || !Objects.equals(p.palletId(),actual.warehousePalletId())) - || stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of(); - return stock.stream().filter(p->p.availablePackages()>0) - .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList(); + var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules); + if(received.isEmpty())return List.of(); + // 只修改本次投影对象,不在GET更新排车、版本或库存。 + item.setPackageCount(received.get().source().packageCount()); + return received.get().positions(); } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 5e157632c..72da6cc6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -27,6 +27,7 @@ public class DispatchPickingService { @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; @@ -101,33 +102,44 @@ public class DispatchPickingService { new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); commands.append(row.getId(),actor,identity,result);return result; } - /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ + /** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */ private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { long dispatch=row.getId(); if(item.getWarehousePalletId()!=null) { relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; } - if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); - var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); - if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); - if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) - || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) - || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); - if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); - item.setWarehousePalletId(actual.warehousePalletId()); + var received=new LinkedHashMap(); + for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules) + .ifPresent(fact->received.put(candidate,fact)); + if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对"); + // 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。 + for(var entry:received.entrySet()) { + entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId()); + entry.getKey().setPackageCount(entry.getValue().source().packageCount()); + } relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); - if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); - var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对"); - var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true); - var pallets=new ArrayList();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId()); - var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId()); - try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");} - catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} - item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); - if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); + for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId()); + if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0)) + || !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId()) + || !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码"); + } + var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at); + for(var entry:received.entrySet()) { + var candidate=entry.getKey();var actual=entry.getValue().source(); + try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");} + catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");} + candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED"); + if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存"); + } + row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum())); + var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( + i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) + .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); } /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java new file mode 100644 index 000000000..d9ed44cda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; + +/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */ +final class DispatchReceivedPlan { + record Fact(DispatchPalletCandidate source, List positions) { } + + /** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */ + static Optional resolve(String warehouse, DispatchItem item, List items, + DispatchSourceRepository sources, DispatchService rules) { + if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null + || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)return Optional.empty(); + var actual=rows.get(0); + if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty(); + var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(original.isEmpty() || !new HashSet<>(original).containsAll( + sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty(); + var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection( + SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); + if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty(); + for(var member:group.sources()) { + var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); + if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); + // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 + if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) + && !Objects.equals(Long.valueOf(selected.getPackageCount()),member.packages()))return Optional.empty(); + } + var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); + if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) + || !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0 + || p.allocatedPackages()!=0 || p.pickedPackages()!=0) + || stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty(); + return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0) + .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList())); + } + + /** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */ + static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) { + return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null + && Objects.equals(actual.planPalletId(),item.getPlanPalletId()) + && Objects.equals(actual.customerId(),item.getCustomerId()) + && Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + && Objects.equals(actual.destinationType(),item.getDestinationType()) + && Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) + && Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java new file mode 100644 index 000000000..de58e6d38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.port; + +import jakarta.validation.constraints.*; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +/** 整车装车资料公开端口;排车只组合资料事实,不读取车队持久化实现。 */ +public interface DispatchLoadingPhotoPort { + enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } + record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} + record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + record State(String recordVersion,List photos) {} + record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List photos) {} + record Download(Photo photo,byte[] content) {} + /** 只读取原整车附加资料版本与五框,不初始化记录。 */ + State read(long dispatchId); + /** 上传保存原请求意图;不提前计入已保存照片。 */ + Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); + /** 仅原操作者可恢复原上传结果。 */ + Photo recover(long dispatchId,String operator,String key); + /** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */ + Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request); + /** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */ + State bind(long dispatchId,String operator,Slot slot,BindPhoto request); + /** 与绑定及自动发车共同提交完整原结果。 */ + void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result); + /** 文件只可从本车当前已绑定槽位下载。 */ + Download content(long dispatchId,Slot slot,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java deleted file mode 100644 index 7c20036b1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; -import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; -import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; -import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage; -import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; -import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService; -import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; -import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.time.Instant; -import java.util.*; -import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; - -/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */ -@Service -public class DispatchLoadingDocumentService { - public static final List SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST, - PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT); - private final DispatchAggregateService workflow; - private final DispatchLoadingDepartureService departure; - private final OwnedBusinessRepository repository; - private final OwnedBusinessCommandService commands; - private final OwnedBusinessProjection projection; - private final OwnedBusinessSupport codec; - private final OwnedBusinessFileGateway files; - /** 复用现有附加表、文件归属校验和上传意图账本。 */ - public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure, - OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, - OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { - this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands; - this.projection=projection;this.codec=codec;this.files=files; - } - public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion, - String stage,boolean editable,boolean complete,List photos) {} - public record Binding(PhotoSlot slot,BindPhoto request) {} - public record Download(Photo photo,byte[] content) {} - - /** GET不写入照片或推进阶段;PDA每次重验冻结仓。 */ - @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) - public Documents get(Actor actor,String number) { - require(actor,false);var context=context(actor,number,false); - return documents(context,repository.record(context.dispatchId()),canWrite(actor)); - } - - /** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */ - public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { - require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false); - if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE) - throw bad("照片须为非空JPG/PNG,最大10MiB"); - byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} - var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); - var identity=new OwnedBusinessPhotoService.UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); - var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity); - if(!reservation.created())return uploaded(reservation.command()); - try { - var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload)); - commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; - } catch(RuntimeException failure) { - try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} - catch(RuntimeException recording){failure.addSuppressed(recording);} - throw conflict("照片上传结果未知,请保留原请求键核对"); - } - } - - /** 原上传恢复只允许原操作者和本车装车槽位。 */ - public Photo recover(Actor actor,String number,String key) { - require(actor,true);codec.key(key);var context=context(actor,number,false); - var command=commands.existing(context.dispatchId(),key,operator(actor)); - if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求"); - return uploaded(command); - } - - /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */ - @Transactional(isolation=Isolation.READ_COMMITTED) - public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { - require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失"); - codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId()); - if(fileId<=0)throw bad("文件编号无效"); - var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request); - var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator); - if(old!=null) { - commands.same(old,"BIND_LOADING_DOCUMENT",version,identity); - if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认"); - return codec.read(old.getResultJson(),Documents.class); - } - // 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。 - var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId)); - var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId()); - seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); - var row=repository.lock(seed); - if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); - var photos=new EnumMap(PhotoSlot.class); - for(var value:projection.photos(row))photos.put(value.slot(),value); - photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); - row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1)); - row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); - if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); - var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(context.dispatchId()); - history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId()); - history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt()); - history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history); - if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) { - departure.departAfterDocuments(actor,number,request.idempotencyKey()); - context=workflow.preview(actor,number); - } - var result=documents(context,row,true); - commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result); - return result; - } - - /** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */ - @Transactional(readOnly=true) - public Download content(Actor actor,String number,PhotoSlot slot,long fileId) { - require(actor,false);slot(slot);var context=context(actor,number,false); - var photo=projection.photos(repository.record(context.dispatchId())).stream() - .filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst() - .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); - return new Download(photo,files.content(context.dispatchId(),slot,fileId)); - } - - /** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */ - private Context context(Actor actor,String number,boolean lock) { - var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number); - if(value.status()!=Status.ACTIVE || !Set.of(Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) - || value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料"); - return value; - } - /** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */ - private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) { - var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList(); - return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()), - context.stage().name(),editable,complete(photos),photos); - } - /** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */ - static boolean complete(List photos) { - return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); - } - /** 平台读写权限分离,PDA沿既有仓作业身份授权。 */ - private void require(Actor actor,boolean write) { - if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); - } - /** 上传按钮只反映当前主体的实际写权限。 */ - private boolean canWrite(Actor actor){return allowed(actor,true);} - /** 权限直接读取当前会话,不给PDA补造平台权限。 */ - private boolean allowed(Actor actor,boolean write) { - var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false; - var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList(); - return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate") - && authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR") - :authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list")); - } - /** 账本只消费可信操作者,权限由本服务独立校验。 */ - private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());} - /** 油耗照片不能替代装车五框。 */ - private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");} - /** 恢复回执仍须属于上传动作及装车五框。 */ - private Photo uploaded(OwnedBusinessRows.Command command) { - if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null) - throw conflict("原上传结果尚未确认,请保留原请求"); - var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result; - } - /** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */ - private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { - return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java new file mode 100644 index 000000000..9712823d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 在原照片表和命令账本实现装车资料端口,不反向调用排车履约服务。 */ +@Service +public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** 复用现有照片、版本、历史及文件归属校验。 */ + public DispatchLoadingPhotoAdapter(OwnedBusinessRepository repository,OwnedBusinessCommandService commands, + OwnedBusinessProjection projection,OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; + } + public record Binding(Slot slot,BindPhoto request) {} + /** GET只读;首次保存前资料版本为0。 */ + @Override public State read(long id) { + var row=repository.record(id); + return new State(row==null?"0":row.getRecordVersion().toString(),projection.photos(row).stream() + .filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList()); + } + /** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */ + @Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) { + codec.key(key);var originalSlot=slot(slot); + if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); + byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} + var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); + HttpOwnedBusinessFileGateway.validate(payload); + var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(id,operator(operator),key,identity); + if(!reservation.created())return uploaded(reservation.command()); + try { + var result=photo(originalSlot,files.upload(id,originalSlot,operator,payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return publicPhoto(result); + } catch(RuntimeException failure) { + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} + catch(RuntimeException recording){failure.addSuppressed(recording);} + throw conflict("照片上传结果未知,请保留原请求键核对"); + } + } + /** 上传结果仅限本车原操作者及装车槽位。 */ + @Override public Photo recover(long id,String operator,String key) { + codec.key(key);var command=commands.existing(id,key,operator(operator)); + if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");return uploaded(command); + } + /** 完整原结果先于当前资料版本比较,防止重复推进。 */ + @Override public Documents replayBind(long id,String operator,Slot slot,BindPhoto request) { + validate(slot,request);var old=commands.existing(id,request.idempotencyKey(),operator(operator)); + if(old==null)return null; + commands.same(old,"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request)); + if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");return codec.read(old.getResultJson(),Documents.class); + } + /** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public State bind(long id,String operator,Slot slot,BindPhoto request) { + validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot); + var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId()))); + var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id); + seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); + var row=repository.lock(seed); + if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); + var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); + photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); + row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); + history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),projection.photos(row),operator)));repository.history(history); + return read(id); + } + /** 保存调用者提供的最终阶段,命令与资料、派送同事务提交。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void recordBind(long id,String operator,Slot slot,BindPhoto request,Documents result) { + commands.success(id,operator(operator),request.idempotencyKey(),"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request),result); + } + /** 下载仅允许当前已保存引用,不接受未绑定上传或其他车文件。 */ + @Override public Download content(long id,Slot slot,long fileId) { + var originalSlot=slot(slot);var photo=projection.photos(repository.record(id)).stream() + .filter(p->p.slot()==originalSlot && String.valueOf(fileId).equals(p.fileId())).findFirst() + .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); + return new Download(publicPhoto(photo),files.content(id,originalSlot,fileId)); + } + /** 原五框技术名保持文件归属,拒绝油耗框作为装车资料。 */ + private PhotoSlot slot(Slot slot){if(slot==null)throw bad("请选择有效的装车照片框");return PhotoSlot.valueOf(slot.name());} + /** 版本0合法,文件编号必须为正且请求键不得缺失。 */ + private void validate(Slot slot,BindPhoto request) { + slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); + if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); + } + /** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */ + private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());} + /** 回执必须是已知成功上传,其他类别或未明确结果不得充数。 */ + private Photo uploaded(OwnedBusinessRows.Command command) { + if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)throw conflict("原上传结果尚未确认,请保留原请求"); + var value=codec.read(command.getResultJson(),OwnedBusinessModels.Photo.class); + if(!"LOADING".equals(value.category()))throw bad("原上传不是装车照片");return publicPhoto(value); + } + /** 外部事实仅投影照片字段,不暴露持久化记录。 */ + private Photo publicPhoto(OwnedBusinessModels.Photo photo) { + return new Photo(Slot.valueOf(photo.slot().name()),photo.category(),photo.fileId(),photo.fileName(),photo.contentType(),photo.sizeBytes(),photo.uploadedAt(),photo.savedAt()); + } + /** 上传回执不伪造保存时间。 */ + private OwnedBusinessModels.Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { + return new OwnedBusinessModels.Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); + } +} 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 index 23c86c5b7..0b3087b25 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: 装车完成进入AFTER_LOADING。五个整车照片框均已保存时,在最后一次绑定事务内自动生成派送并进入DELIVERING;失败回滚该次绑定和派送,前四张与已确认实载保留。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 security: - bearerAuth: [] paths: @@ -369,6 +369,7 @@ components: stage: type: string enum: + - LOADING - AFTER_LOADING - DELIVERING - DELIVERED 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 95e6679bb..a5739f874 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1728,8 +1728,8 @@ paths: type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 确认装车完成并自动进入派送中 - description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 + summary: 确认装车完成,资料齐全时自动进入派送中 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。 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 bf9062af3..b5a5fc0bf 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 @@ -253,8 +253,8 @@ paths: type: string /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为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 + summary: 确认装车完成,资料齐全时自动进入派送中 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。 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 36e5391d6..fb9f2e1ae 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 @@ -3,8 +3,9 @@ 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.port.DispatchLoadingPhotoPort.BindPhoto; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Documents; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot; import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import java.nio.file.*; @@ -42,7 +43,7 @@ class DispatchLoadingDeparturePersistenceTest { 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"))); + var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.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"); @@ -103,13 +104,16 @@ class DispatchLoadingDeparturePersistenceTest { verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any()); } - /** 资料上传必须在装车后且属于绑定仓,平台只读权限不能保存照片。 */ + /** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */ @Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception { authorize();var f=new Fixture(); - assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("装车完成后"); + assertThat(f.documents.get(f.f.actor,f.number).stage()).isEqualTo("LOADING"); + f.f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING'"); + assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("进入装车中后"); + f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'"); 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("装车照片框"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,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("无装车资料操作权限"); @@ -124,7 +128,7 @@ class DispatchLoadingDeparturePersistenceTest { 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; + f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true; }catch(com.baiyue.common.core.exception.BizException expected){return false;} finally {SecurityContextHolder.clearContext();} })); @@ -135,6 +139,50 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); } + /** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */ + @Test void photosReadyBeforeLoadingConfirmationDepartAndReplayOnce() throws Exception { + authorize();var f=new Fixture(); + for(int i=0;i<5;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("LOADING"); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); + var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-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); + var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete"))); + assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.DELIVERING); + 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,"ready-complete")).stage()).isEqualTo(Stage.DELIVERING); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + } + + /** 资料先齐时派送失败回滚本次装车确认,五张已保存照片保留供原请求重试。 */ + @Test void readyDocumentsSurviveFailedLoadingAndOriginalRequestCanRetry() throws Exception { + authorize();var f=new Fixture();for(int i=0;i<5;i++)f.bind(i,f.f.actor); + 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("retry-ready")))).hasMessageContaining("模拟发运失败"); + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("LOADING"); + assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue(); + 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 idempotency_key='retry-ready'",Long.class)).isZero(); + doNothing().when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready"))).stage()).isEqualTo(Stage.DELIVERING); + } + + /** 最后一张照片与装车确认同时提交,不论谁先拿父锁均只发车一次。 */ + @Test void lastPhotoAndLoadingConfirmationCanFinishConcurrently() throws Exception { + authorize();var f=new Fixture();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 load=workers.submit(()->f.tryComplete(start,f.f.actor,"concurrent-load")); + var photo=workers.submit(()->{authorize();try {start.await(5,TimeUnit.SECONDS);return f.bind(4,new Actor("picker",ActorMode.PDA,"9"));}finally{SecurityContextHolder.clearContext();}}); + start.countDown();assertThat(load.get(25,TimeUnit.SECONDS)).isTrue();assertThat(photo.get(25,TimeUnit.SECONDS)).isNotNull(); + } 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()); + } + /** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */ private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("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"));} @@ -176,7 +224,6 @@ class DispatchLoadingDeparturePersistenceTest { var hashes=new DispatchCommandHasher(f.json); 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")) { @@ -187,10 +234,12 @@ class DispatchLoadingDeparturePersistenceTest { 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); + var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos); + documents=new DispatchLoadingDocumentService(f.service,service,photos); } /** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */ - DispatchLoadingDocumentService.Documents bind(int index,Actor actor) { + 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)))); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 05963953b..44a1c4eab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,34 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ + @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null); + var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11); + assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); + assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED"); + var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); + assertThat(replay.replayed()).isTrue(); + var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem, + new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any()); + assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + + /** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */ + @Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(8,11); + doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any()); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero(); + assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); + assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version); + } /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */ @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); @@ -68,6 +96,7 @@ class DispatchPickingPersistenceTest { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); final DispatchPickingService service;final String number;final long item,version;final Long secondItem; + final DispatchSourceRepository sources; final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); /** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */ Picking() throws Exception {this(false);} @@ -94,10 +123,32 @@ class DispatchPickingPersistenceTest { doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any()); doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));}) .when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull()); - var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet); + } + /** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */ + void receivedPlans(int first,int second) { + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10"); + f.db.update("UPDATE ll_dispatch_order SET package_count=20"); + var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules"); + for(int n=1;n<=2;n++) { + long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second; + f.candidates.get(plan).packageCount(count); + f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n); + doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan); + doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan); + doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan); + doReturn(plan).when(sources).lockPlan(customer,plan); + doReturn(1).when(sources).materialize(anyLong(),eq(customer),eq(plan),eq(pallet),anyLong(),any()); + } + when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{ + var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();var row=f.candidates.get(plan); + return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selected,"PLAN:"+plan,row.customerId(),row.customerName(),"INBOUND_PLAN","PLAN-"+plan,row.packageCount())),List.of(),true,List.of()); + }); } /** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */ private static String sqlType(Class type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index 12932f0d1..acb329134 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -28,15 +28,23 @@ class DispatchPlanPdaReadPersistenceTest { verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); } - /** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */ + /** 在途和来源身份变化均不成为可拣库存。 */ @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { var f=new Fixture();var row=f.f.candidates.get(1L); row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty(); - row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); } + /** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */ + @Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception { + var f=new Fixture(true);var before=f.dbState(); + var task=f.read(); + assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty()); + assertThat(task.counts().packageCount()).isEqualTo(20); + assertThat(f.dbState()).isEqualTo(before); + } /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { var f=new Fixture(); @@ -65,9 +73,13 @@ class DispatchPlanPdaReadPersistenceTest { final DispatchService rules=mock(DispatchService.class); final DispatchPdaQueryService queries;final String number; /** 原车已审核,只清除自然收货尚未补写的实托字段。 */ - Fixture() throws Exception { - var selection=f.source(1,"101",501,"ONT8",3); - var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection)))); + Fixture() throws Exception {this(false);} + /** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */ + Fixture(boolean twoPallets) throws Exception { + var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3); + var selections=new java.util.ArrayList();selections.add(selection); + if(twoPallets)selections.add(f.source(2,"101",502,"ONT8",10)); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",selections))); f.approve(created);number=created.dispatchNo(); f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'"); f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); @@ -82,6 +94,21 @@ class DispatchPlanPdaReadPersistenceTest { when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of())); + if(twoPallets) { + for(int n=1;n<=2;n++) { + long plan=n,pallet=500+n;int count=n==1?9:11; + f.candidates.get(plan).packageCount(count); + when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan))); + when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan)); + when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan)); + when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1))); + } + when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{ + var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId(); + return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of()); + }); + } } /** 读取正式PDA入口。 */ DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 3aee9acaa..91b9cac8e 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath $adminAgents = $adminEntry + "`n" + $adminIndex $adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md' $adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath +# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。 +$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?.*?)(?=^#{1,3} |\z)').Groups['body'].Value +$checked++ +if (-not $adminInboundPageRules) { Add-Failure '缺少 Admin 入仓计划页面约束正文' } Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口' Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表' $clientEntry = Read-RequiredText $clientAgentsPath @@ -245,7 +249,8 @@ $lookupSamples = @( $lookupCharacters = 0 foreach ($alias in @( @{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, - @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '转运通过'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' }, @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' }, @{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' }, @{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' }, @@ -291,7 +296,8 @@ foreach ($document in @( @{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 }, @{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 }, @{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 }, - @{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 } + @{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }, + @{ Name = 'Admin 海外仓指引'; Path = $adminOverseasAgentsPath; Text = $adminOverseasAgents; Limit = 8000 } )) { Assert-MarkdownLinks $document.Path $document.Text $document.Name $checked++ @@ -329,10 +335,43 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史 Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文' Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' +# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。 +$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue') +$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts') +$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') }) +$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') }) +$checked++ +if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) { + Add-Failure '转运审核的界面入口与 API 能力必须分别定位' +} else { + Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口' + Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论' + Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口' + Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论' + Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用' + Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论' + if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') { + Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引' + Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引' + } else { + Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作' + Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开' + } +} +Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口' +Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板' +$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value +$checked++ +if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) { + Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款' +} + $docLookupCharacters = 0 $walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户' $docLookupSamples = @( @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, + @{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' }, @{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, @{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, @{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget }, @@ -723,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间 Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口' Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口' -Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' +Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁' @@ -1069,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本' Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' -Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' +Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口' Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围' -Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' +Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围' Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI' -Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' -Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' -Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' -Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' +Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选' +Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' +Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' +Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用' Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'