Compare commits

...
Author SHA1 Message Date
liuyihe ae361879e5 Merge remote-tracking branch 'origin/test' into test 2026-10-21 17:27:17 +08:00
liuyihe c442c9eaa7 提交 2026-10-21 17:26:43 +08:00
ouyangxq 1ea7b4ff12 优化 2026-10-21 17:26:41 +08:00
ouyangxq 0976b6ef0d 优化 2026-10-21 17:22:19 +08:00
ouyangxq 0c6c55f6f1 优化 2026-10-21 17:08:52 +08:00
ouyangxq 4a0e86bd42 优化 2026-10-21 16:58:30 +08:00
56 changed files with 2144 additions and 544 deletions
+1 -1
View File
@@ -77,7 +77,7 @@
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 整车调度与理货审核 | `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`核对实收来源 | | 外接询价、确认占位及取消 | `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,不把计划值当实测值,资源/计划/关联变更后须重新核对 | | 拣货前计划容量与逐段余位 | `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权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
@@ -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引用、其他消费者借用端口许可及任何包循环。 `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`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
### 外配业务原排车详情读取边界(2026-10-21) ### 外配业务原排车详情读取边界(2026-10-21)
`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。 `OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。
@@ -72,6 +72,11 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService',
'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', 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
@@ -0,0 +1,60 @@
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.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
public class DispatchLoadingDocumentController {
private final DispatchLoadingDocumentService service;
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Documents> 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<Photo> 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<Documents> 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));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
}
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> 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());
}
}
@@ -98,7 +98,7 @@ public interface DispatchAggregateMapper {
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, 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}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, 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} shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
WHERE id=#{r.id} AND record_version=#{version} WHERE id=#{r.id} AND record_version=#{version}
""") """)
@@ -93,6 +93,11 @@ public interface DispatchSourceMapper {
@Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE")
Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id);
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
@Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
Long lockReturn(@Param("customer") String customer, @Param("id") Long id); Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
@@ -57,7 +57,7 @@ public class DispatchPdaController {
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
} }
/** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */ /** 真实扫码核平后确认装车进入装车后,两端共用同一命令并等待五张装车照片。 */
@PostMapping("/{dispatchNo}/loading-complete") @PostMapping("/{dispatchNo}/loading-complete")
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request)); return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
@@ -0,0 +1,60 @@
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.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */
@RestController
@Validated
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents")
public class PdaDispatchLoadingDocumentController {
private final DispatchLoadingDocumentService service;
/** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */
public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;}
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Documents> 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<Photo> 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<Documents> 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));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
}
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ResponseEntity<byte[]> 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());
}
}
@@ -25,7 +25,10 @@ public final class DispatchContracts {
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
List<Long> fileIds, Instant expectedTaskEndAt) { } List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds) {
/** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */
public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); }
}
public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate,
String carrierName, String vehicleStatus, Capacity capacity, String carrierName, String vehicleStatus, Capacity capacity,
@@ -74,6 +74,8 @@ public class DispatchSourceRepository {
public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); }
/** 锁定真实计划托盘根。 */ /** 锁定真实计划托盘根。 */
public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); }
/** 只读核对原计划已经结束实际收货。 */
public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;}
/** 锁定实际退回收据根。 */ /** 锁定实际退回收据根。 */
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
/** 真实库存数量及版本。 */ /** 真实库存数量及版本。 */
@@ -51,6 +51,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership; @jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
/** 领域编排只访问Repository和提供者应用入口。 */ /** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -304,7 +305,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L); row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L); row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
Resources resources=resources(shell); row.setResourcesJson(write(resources)); var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate()); row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt())); row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0); row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -382,8 +385,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion()); requireEditing(row,request.recordVersion());
String before=basis(row); String before=basis(row);
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
@@ -398,7 +404,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
requireEditing(row,request.recordVersion()); requireEditing(row,request.recordVersion());
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row); verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row); long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
@@ -415,7 +421,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(actor!=null && actor.mode()==ActorMode.PDA) { if(actor!=null && actor.mode()==ActorMode.PDA) {
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action)) if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核"); throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true); lockReviewRoot(actor,number);row=required(actor,number,true);
} else row=lockPlatform(actor,number); } else row=lockPlatform(actor,number);
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
@@ -540,7 +546,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
} }
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */ /** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
@@ -610,7 +616,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
} }
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); } private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
private void lockReviewRoot(Actor actor,String number) {
var row=required(actor,number,false);
if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number);
}
/** 严格请求身份不接收前端摘要或操作者。 */ /** 严格请求身份不接收前端摘要或操作者。 */
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
/** 每次状态变更、历史及不可变命令结果同事务保存。 */ /** 每次状态变更、历史及不可变命令结果同事务保存。 */
@@ -627,7 +638,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */ /** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */
private String basis(DispatchAggregate row) { private String basis(DispatchAggregate row) {
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts); facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));}
return hashes.source(facts);
} }
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) { private void invalidate(DispatchAggregate row,String reason) {
@@ -652,6 +666,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) { void verifyReviewResources(DispatchAggregate row) {
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
@@ -659,9 +674,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
} }
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) { private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
@@ -848,8 +864,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class); var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) {
boolean state=pickup && switch(action) {
case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState());
case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState());
case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState());
default -> false;
};
boolean allowed=state && hasPermission(actor,action);
blocked.put(action,new Gate(allowed,allowed?null:"TRANSPORT_TASK",allowed?null:"请按取货运输审核流程操作"));
}
return Collections.unmodifiableMap(blocked); return Collections.unmodifiableMap(blocked);
} }
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
@@ -940,12 +966,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return List.copyOf(result); return List.copyOf(result);
} }
/** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */ /** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell) { private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
var v=shell.vehicle(); var d=shell.driver(); var v=shell.vehicle(); var d=shell.driver();
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt()))); new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)));
} }
/** 主档选择不接受请求覆盖其车牌和容量。 */ /** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -19,7 +19,7 @@ import java.util.TreeMap;
@Component @Component
public final class DispatchCommandHasher { public final class DispatchCommandHasher {
private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services", private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services",
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds"); "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds", "photoFileIds");
private final ObjectMapper json; private final ObjectMapper json;
/** 复用已注册的时间、数值和公开字段序列化配置。 */ /** 复用已注册的时间、数值和公开字段序列化配置。 */
@@ -60,7 +60,12 @@ public final class DispatchCommandHasher {
if (node == null || node.isNull()) return "null"; if (node == null || node.isNull()) return "null";
if (node.isObject()) { if (node.isObject()) {
var values = new TreeMap<String, String>(); var values = new TreeMap<String, String>();
node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()))); node.fields().forEachRemaining(entry -> {
// 新增可选照片为空时沿用旧摘要,保留历史命令重放与已通过审核的依据。
if (entry.getKey().equals("photoFileIds") && (entry.getValue().isNull()
|| entry.getValue().isArray() && entry.getValue().isEmpty())) return;
values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()));
});
var result = new ArrayList<String>(); var result = new ArrayList<String>();
values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); values.forEach((key, value) -> result.add(quoted(key) + ":" + value));
return "{" + String.join(",", result) + "}"; return "{" + String.join(",", result) + "}";
@@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*; import org.springframework.transaction.annotation.*;
import java.util.List; import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */ /** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */
@Service @Service
public class DispatchLoadingDepartureService { public class DispatchLoadingDepartureService {
private final DispatchAggregateService workflow; private final DispatchAggregateService workflow;
@@ -17,38 +18,52 @@ public class DispatchLoadingDepartureService {
private final DispatchDepartureService departure; private final DispatchDepartureService departure;
private final DispatchCommandLedger commands; private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes; private final DispatchCommandHasher hashes;
private final DispatchLoadingPhotoPort documents;
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */ /** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery, 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.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
this.documents=documents;
} }
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */ /** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
workflow.requireLoadingPermission(actor); workflow.requireLoadingPermission(actor);
var context=workflow.lockForArrangement(actor,number); var context=workflow.lockForArrangement(actor,number);
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
context=workflow.preview(actor,number); var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE"); 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;
}
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
@Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public CommandResult departAfterDocuments(Actor actor,String number,String key) {
var context=workflow.lockForArrangement(actor,number);
if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE)
throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送");
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
String arrangementKey=stepKey(number,key,"ARRANGE");
var fact=delivery.prepareForDeparture(context,actor,arrangementKey); var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(), var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash()))); hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false); var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(), var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark())); stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded)) if(departed.stage()!=Stage.DELIVERING)
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平"); throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(), return departed;
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);return result;
} }
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */ /** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
@@ -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<Slot> 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<Photo> 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"));
}
}
@@ -87,7 +87,7 @@ public class DispatchPdaQueryService {
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); 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只在服务端审计保存。 // 整车作业直接以排车业务号识别;真实客户子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))); 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锁内重验。 */ /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) { private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of(); var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(received.isEmpty())return List.of();
if(rows.size()!=1)return List.of(); // 只修改本次投影对象,不在GET更新排车、版本或库存。
var actual=rows.get(0); item.setPackageCount(received.get().source().packageCount());
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null return received.get().positions();
|| !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();
} }
/** 仓和操作者均来自受信身份,不接收请求自报。 */ /** 仓和操作者均来自受信身份,不接收请求自报。 */
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作业");} 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作业");}
@@ -27,6 +27,7 @@ public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules; @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 DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; 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); 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; commands.append(row.getId(),actor,identity,result);return result;
} }
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ /** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) { private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
long dispatch=row.getId(); long dispatch=row.getId();
if(item.getWarehousePalletId()!=null) { if(item.getWarehousePalletId()!=null) {
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
} }
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); .ifPresent(fact->received.put(candidate,fact));
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
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()) for(var entry:received.entrySet()) {
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); entry.getKey().setPackageCount(entry.getValue().source().packageCount());
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); }
item.setWarehousePalletId(actual.warehousePalletId());
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对"); var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true); if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId()); || !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId()); || !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");} }
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); 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());} 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());}
@@ -0,0 +1,56 @@
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<DispatchViews.Position> positions) { }
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
DispatchSourceRepository sources, DispatchService rules) {
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))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)
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=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());
}
}
@@ -18,12 +18,13 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */ /** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
@Service @Service
public class TransportTaskProjection { public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.PickupDepartureReview {
private final DispatchAggregateRepository roots; private final DispatchAggregateRepository roots;
private final TransportTaskRepository links; private final TransportTaskRepository links;
private final UsTruckOrderRepository trucks; private final UsTruckOrderRepository trucks;
private final DispatchNumberGenerator numbers; private final DispatchNumberGenerator numbers;
private final ObjectMapper json; private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickupFacts;
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */ /** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) { public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json; this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
@@ -50,11 +51,22 @@ public class TransportTaskProjection {
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return; var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
var found=links.byTrip(trip.getId());if(found==null)return; var found=links.byTrip(trip.getId());if(found==null)return;
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion(); var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt()); row.setRecordVersion(version+1);row.setUpdatedAt(trip.getUpdatedAt());
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE"); row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";}); row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";case "PLANNED"->row.getStage();default->"BEFORE_LOADING";});
// 旧发车入口只记录出发事实,取货照片尚未齐全时仍在装货中。
if("PICKUP".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus())
&& pickupFacts.tasks(trip.getTripNo()).stream().anyMatch(task->"ASSIGNED".equals(task.getStatus())))row.setStage("LOADING");
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt();
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
// 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。
if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus())
&& (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) {
row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
}
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败"); if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt()); roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
var link=links.truck(row.getDispatchNo());if(link==null)return; var link=links.truck(row.getDispatchNo());if(link==null)return;
@@ -62,6 +74,30 @@ public class TransportTaskProjection {
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion()); transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo()); if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
} }
/** 旧取货发车入口也必须锁定并核验同轮两级批准,不允许无主单发车。 */
@Override
public void requireApproved(long tripId) {
var found=links.byTrip(tripId);
if(found==null)throw new IllegalStateException("取货排车缺失,请先接入排车审核");
var row=roots.lock(found.getDispatchNo());
if(!"PICKUP".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || !"APPROVED".equals(row.getReviewState()) || row.getReviewBasisHash()==null)
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"请先完成调度审核和理货员审核");
for(String value:List.of(row.getDispatchReviewJson(),row.getLoadingReviewJson())) {
try {
var review=json.readValue(value,Review.class);
if(review.status()!=ReviewStatus.APPROVED || review.reviewRound()!=row.getReviewRound() || !Objects.equals(review.basisHash(),row.getReviewBasisHash()))
throw new IllegalStateException("取货审核依据已失效,请重新提交审核");
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
}
}
/** 保留驳回事实,同时明确此前批准已因资源变更失效。 */
private String invalidated(String value,long round) {
try {
var old=json.readValue(value,Review.class);
return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED,
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机或计划已变更,请重新提交两级审核",old.attachments()));
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
}
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */ /** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
@@ -51,6 +51,22 @@ public class TransportTaskService {
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator); var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
return detail(row.getDispatchNo()); return detail(row.getDispatchNo());
} }
/** 审核通过后按原单锁幂等生成待发车排车;重复审核保留原安排及取消结果。 */
@Transactional
public void ensureApprovedPickup(String customer,String number,String operator) {
var source=pickupFacts.source(customer,number,true);
if(source==null)throw missing("转运单不存在");
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !number.equals(source.getInboundCode())
|| !"WAIT_PDA_RECEIVING".equals(source.getStatus()))return;
var task=pickupFacts.lockTask(source.getId());
// 已安排、执行或主动取消的取货任务均不因审核重放重新建单。
if(task!=null)return;
var departure=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
createPickup(operator,new FleetPickupModels.Create(List.of(new FleetPickupModels.Source(customer,number)),
departure,departure.plus(Duration.ofHours(24)),
"审核自动生成到仓取货排车;时间为暂定计划,调度确认后分配车辆司机",
"transfer-approval:"+source.getId()));
}
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */ /** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
@Transactional @Transactional
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) { public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
@@ -97,6 +113,15 @@ public class TransportTaskService {
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request); var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number); if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
} }
/** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */
public void verifyPickupReview(String number) {
var trip=transport(number,true);
if(!"PICKUP".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).isEmpty())throw conflict("仅有真实取货任务的未发车排车可以审核");
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
requireResources(trip);
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
}
/** 取消运输并保留原单、排车及资源审计。 */ /** 取消运输并保留原单、排车及资源审计。 */
@Transactional @Transactional
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
@@ -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<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> 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);
}
@@ -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,OwnedBusinessModels.Photo>(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);
}
}
@@ -39,6 +39,9 @@ public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
/** 解除计划中的取货关联,保留历史后允许再次安排。 */ /** 解除计划中的取货关联,保留历史后允许再次安排。 */
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'") @Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
int unassign(@Param("tripNo") String tripNo); int unassign(@Param("tripNo") String tripNo);
/** 原单同时取消时保留车次关联供详情和审计追溯,不再回到待安排。 */
@Update("UPDATE ll_fleet_pickup_task SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
int cancelTasks(@Param("tripNo") String tripNo);
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */ /** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})") @Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip, int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
@@ -24,7 +24,10 @@ public final class FleetPickupModels {
@NotBlank @Size(min=8,max=128) String idempotencyKey) { } @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion, public record Command(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { } @NotBlank @Size(max=1000) String reason,List<@NotNull @Positive Long> evidenceFileIds) {
/** 无照片的非送仓命令保持原调用形式。 */
public Command(Long recordVersion,String idempotencyKey,String reason){this(recordVersion,idempotencyKey,reason,null);}
}
public record Pickup(@NotNull @Min(0) Long recordVersion, public record Pickup(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotBlank @Size(min=8,max=128) String idempotencyKey,
@Min(1) @Max(5000) int actualPackages, @Min(1) @Max(5000) int actualPackages,
@@ -35,6 +38,9 @@ public final class FleetPickupModels {
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone, String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt, Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
String remark, List<Evidence> evidence) { } String remark, List<Evidence> evidence) { }
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { } public record Evidence(String fileId, String fileName, String contentType, long sizeBytes,String phase) {
/** 旧取货凭证明确归入取货阶段,不冒充到仓照片。 */
public Evidence(String fileId,String fileName,String contentType,long sizeBytes){this(fileId,fileName,contentType,sizeBytes,"PICKUP");}
}
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { } public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
} }
@@ -34,6 +34,8 @@ public class FleetPickupRepository {
public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); } public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); }
/** 释放未开始的成员关联。 */ /** 释放未开始的成员关联。 */
public int unassign(String number) { return mapper.unassign(number); } public int unassign(String number) { return mapper.unassign(number); }
/** 联动取消保留原车次与转运单的可追溯关联。 */
public int cancelTasks(String number) { return mapper.cancelTasks(number); }
/** 保存不可变业务操作记录。 */ /** 保存不可变业务操作记录。 */
public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); } public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); }
/** 核对旧拼车承运关系,防止同票走两条运输链。 */ /** 核对旧拼车承运关系,防止同票走两条运输链。 */
@@ -28,11 +28,13 @@ public class FleetPickupService {
private final VehicleRepository vehicles; private final VehicleRepository vehicles;
private final TripOperationsEvidenceGateway files; private final TripOperationsEvidenceGateway files;
private final ObjectMapper json; private final ObjectMapper json;
private final PickupDepartureReview departureReview;
/** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */ /** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */
public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses, public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses,
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) { DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json,PickupDepartureReview departureReview) {
this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers; this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers;
this.vehicles=vehicles;this.files=files;this.json=json; this.vehicles=vehicles;this.files=files;this.json=json;
this.departureReview=departureReview;
} }
/** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */ /** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
@@ -120,6 +122,7 @@ public class FleetPickupService {
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number); if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
version(trip,request.recordVersion()); version(trip,request.recordVersion());
if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车"); if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车");
departureReview.requireApproved(trip.getId());
requireResources(trip); requireResources(trip);
for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true)); for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true));
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
@@ -142,28 +145,47 @@ public class FleetPickupService {
var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request); var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo); version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货"); if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("该单尚未处于可确认取货阶段");
if("PLANNED".equals(trip.getStatus()))departureReview.requireApproved(trip.getId());
if(request.actualPackages()<=0 || request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请填写实提箱数并上传取货照片");
eligible(source(customer,transferNo,true)); eligible(source(customer,transferNo,true));
var evidence=request.evidenceFileIds().stream().distinct().map(id->{ var evidence=request.evidenceFileIds().stream().distinct().map(id->{
if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次"); if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次");
var file=files.metadata(trip.getId(),id); var file=files.metadata(trip.getId(),id);
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效取货照片");
return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes()); return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes());
}).toList(); }).toList();
task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now()); task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now());
task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task); task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task);
repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),"")); repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),""));
// 全车照片和实提数量核齐后自动进入派送中,不能以发车按钮代替取货照片。
if("PLANNED".equals(trip.getStatus()) && repository.tasks(number).stream().allMatch(row->"PICKED".equals(row.getStatus()))) {
requireResources(trip);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
}
trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator); trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number); repository.command(number,request.idempotencyKey(),hash);return tasks(number);
} }
/** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */ /** 到仓照片独立归档,全部送仓后自动完成本次取货派送并开放PDA清点。 */
@Transactional @Transactional
public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) { public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request); var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo); version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货"); if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货");
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task); if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请上传到仓照片");
var old=evidence(task);var arrival=new ArrayList<FleetPickupModels.Evidence>(old);
for(long id:request.evidenceFileIds().stream().distinct().toList()) {
if(!repository.ownsUpload(task.getTransferOrderId(),number,id) || old.stream().anyMatch(file->file.fileId().equals(String.valueOf(id))))throw bad("请上传本票新的到仓照片,不能复用取货照片");
var file=files.metadata(trip.getId(),id);
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效到仓照片");
arrival.add(new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes(),"ARRIVAL"));
}
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setEvidenceJson(write(arrival));task.setUpdatedAt(now());repository.update(task);
repository.history(task,"ARRIVED",operator,request.reason()); repository.history(task,"ARRIVED",operator,request.reason());
if(repository.tasks(number).stream().allMatch(row->"ARRIVED".equals(row.getStatus()))) {
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
}
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator); trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number); repository.command(number,request.idempotencyKey(),hash);return tasks(number);
} }
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.pickup;
/** 取货发车必须通过排车主单的本轮两级审核。 */
public interface PickupDepartureReview {
/** 调用方已持车次锁,校验并锁定对应排车审核事实。 */
void requireApproved(long tripId);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.pickup;
import java.util.List;
/** 原单所属域负责取消未执行取货的转运业务,失败时整车取消回滚。 */
public interface PickupOrderCancellation {
/** 调用方持有车次锁,按稳定来源顺序取消全部成员。 */
void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason);
}
@@ -34,6 +34,7 @@ public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripRepository repository; private final FleetTripRepository repository;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation;
private final FleetDispatchGateway dispatches; private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles; private final VehicleRepository vehicles;
private final DriverService drivers; private final DriverService drivers;
@@ -467,18 +468,20 @@ public class FleetTripService {
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */ /** 取消未执行车次,取货同时取消全部转运原单并释放资源。 */
@Transactional @Transactional
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) { public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion()); FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason()); requireReason(request.reason());
if ("PICKUP".equals(trip.getTripPurpose())) { if ("PICKUP".equals(trip.getTripPurpose())) {
// 取消车次与释放原单关联同事务,失败时全部回滚。 // 取消车次与释放原单关联同事务,失败时全部回滚。
for (var task : pickups.tasks(tripNo)) { var tasks=pickups.tasks(tripNo);
for (var task : tasks) {
if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次"); if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次");
pickups.history(task,"UNASSIGN",operator,request.reason());
} }
pickups.unassign(tripNo); pickupCancellation.cancel(tasks.stream().map(task->new com.baiyue.lastleg.fleet.pickup.FleetPickupModels.Source(task.getCustomerId(),task.getTransferNo())).toList(),tripNo,operator,request.reason());
for(var task:tasks)pickups.history(task,"CANCEL",operator,"取消排车及转运单:"+request.reason());
if(pickups.cancelTasks(tripNo)!=tasks.size())throw conflict("取货关联已变化,取消未完成");
} }
if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次"); if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
@@ -554,6 +557,7 @@ public class FleetTripService {
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
if("PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(number).stream().anyMatch(task->!"ASSIGNED".equals(task.getStatus())))throw conflict("已开始取货,不能修改资源、计划或取消排车");
return trip; return trip;
} }
/** 复核创建请求摘要,同键不同内容不允许复用。 */ /** 复核创建请求摘要,同键不同内容不允许复用。 */
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.*;
/** 仅取消本客户外来取货的待执行原单,不释放或回滚任何已入库库存。 */
@Mapper
public interface TransferPickupCancellationMapper {
/** 与PDA共用转运原单锁,串行核验收货事实。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lock(@Param("customer") String customer,@Param("number") String number);
/** 任何收货、箱清点或托盘测量开始后均禁止普通取消。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customer} AND transfer_order_id=#{id}) + (SELECT COUNT(*) FROM ll_warehouse_transfer_carton WHERE customer_id=#{customer} AND transfer_order_id=#{id} AND counted_at IS NOT NULL) + (SELECT COUNT(*) FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customer} AND transfer_order_id=#{id})")
int started(@Param("customer") String customer,@Param("id") long id);
/** 同一取消事务只接受待PDA收货的转运单。 */
@Update("UPDATE ll_warehouse_transfer_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='WAIT_PDA_RECEIVING'")
int cancelTransfer(@Param("customer") String customer,@Param("id") long id);
/** 写入主单取消事实并递增版本,防止旧页面继续写入。 */
@Update("UPDATE ll_order SET current_status='CANCELLED',sub_order_status='WAREHOUSE_TRANSFER:CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND current_status='INBOUND_MEASURING'")
int cancelOrder(@Param("customer") String customer,@Param("id") long id);
/** 只取消尚未接货的外来入仓单。 */
@Update("UPDATE ll_inbound_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='PENDING_INBOUND'")
int cancelInbound(@Param("customer") String customer,@Param("id") long id);
/** 只取消原转运生成且仍待PDA的唯一计划。 */
@Update("UPDATE ll_inbound_plan SET status='CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND source_order_id=#{order} AND creation_mode='WAREHOUSE_TRANSFER' AND status='PDA_PENDING'")
int cancelPlan(@Param("customer") String customer,@Param("order") long order);
/** 不可变历史保留取消前状态、原车次与操作者。 */
@Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,event_id,remark,created_at) SELECT customer_id,id,current_status,'CANCELLED',#{trip},CONCAT('取消取货排车及转运单;操作人:',#{operator},';原因:',#{reason}),CURRENT_TIMESTAMP FROM ll_order WHERE customer_id=#{customer} AND id=#{id}")
int history(@Param("customer") String customer,@Param("id") long id,@Param("trip") String trip,@Param("operator") String operator,@Param("reason") String reason);
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.transfer.repository;
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
import org.springframework.stereotype.Repository;
/** 未执行取货取消的原单、入仓与历史持久化边界。 */
@Repository
public class TransferPickupCancellationRepository {
private final TransferPickupCancellationMapper mapper;
/** 注入本业务取消映射器。 */
public TransferPickupCancellationRepository(TransferPickupCancellationMapper mapper){this.mapper=mapper;}
/** 锁定真实客户所属转运来源。 */
public com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder lock(String customer,String number){return mapper.lock(customer,number);}
/** 已存在清点、托盘或收货事实时禁止普通取消。 */
public boolean started(String customer,long id){return mapper.started(customer,id)>0;}
/** 取消转运并检查当前状态。 */
public int cancelTransfer(String customer,long id){return mapper.cancelTransfer(customer,id);}
/** 取消尾程主单并记录当前状态来源。 */
public int cancelOrder(String customer,long id,String trip,String operator,String reason){mapper.history(customer,id,trip,operator,reason);return mapper.cancelOrder(customer,id);}
/** 取消原入仓单,保留不可删除的业务关联。 */
public int cancelInbound(String customer,long id){return mapper.cancelInbound(customer,id);}
/** 取消待收货计划,已执行的计划导致整车取消失败。 */
public int cancelPlan(String customer,long order){return mapper.cancelPlan(customer,order);}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 取货排车取消时原子取消其全部转运、尾程、入仓单及待执行入仓计划。 */
@Service
public class TransferPickupCancellationService implements PickupOrderCancellation {
private final TransferPickupCancellationRepository repository;
/** 取消只访问本域持久化边界,不反向调用排车服务。 */
public TransferPickupCancellationService(TransferPickupCancellationRepository repository){this.repository=repository;}
/** 任一来源已经执行或归属不符时抛错,使外层整车取消全部回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason) {
if(sources==null || sources.isEmpty())throw conflict();
for(var source:sources) {
var row=repository.lock(source.customerId(),source.transferNo());
if(row==null || !"WAREHOUSE_PICKUP".equals(row.getDeliveryMethod()) || !row.getTransferNo().equals(row.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(row.getStatus()) || repository.started(source.customerId(),row.getId()))throw conflict();
if(repository.cancelTransfer(source.customerId(),row.getId())!=1
|| repository.cancelOrder(source.customerId(),row.getLastlegOrderId(),tripNo,operator,reason)!=1
|| repository.cancelInbound(source.customerId(),row.getInboundOrderId())!=1
|| repository.cancelPlan(source.customerId(),row.getLastlegOrderId())!=1)throw conflict();
}
}
/** 保留用户上下文,不把部分取消作为成功返回。 */
private static ResponseStatusException conflict(){return new ResponseStatusException(HttpStatus.CONFLICT,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");}
}
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
public class WarehouseTransferService { public class WarehouseTransferService {
@Resource private TransferQuantityService transferQuantityService; @Resource private TransferQuantityService transferQuantityService;
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
@Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService;
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@@ -814,6 +815,7 @@ public class WarehouseTransferService {
return new WarehouseTransferResponses.Label(transferNo, cartons, html); return new WarehouseTransferResponses.Label(transferNo, cartons, html);
} }
/** 审核与入仓计划、上门取货排车原子提交,重放复用原业务关联。 */
@Transactional @Transactional
public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Review request) { WarehouseTransferRequests.Review request) {
@@ -836,6 +838,7 @@ public class WarehouseTransferService {
.contains(transfer.getStatus())) { .contains(transfer.getStatus())) {
if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) {
activateTransferInbound(customerId, transfer, LocalDateTime.now()); activateTransferInbound(customerId, transfer, LocalDateTime.now());
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
} }
return detail(customerId, transferNo); return detail(customerId, transferNo);
} }
@@ -865,6 +868,9 @@ public class WarehouseTransferService {
"Warehouse transfer reviewed: " + next "Warehouse transfer reviewed: " + next
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now); + (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
} }
if (APPROVED.equals(next) && incomingTransit) {
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
}
return detail(customerId, transferNo); return detail(customerId, transferNo);
} }
@@ -0,0 +1,394 @@
openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocuments
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocumentsPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhoto
summary: 上传整车装车照片,尚未绑定
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhotoPda
summary: 上传整车装车照片,尚未绑定
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhoto
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhotoPda
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
dispatchNo:
name: dispatchNo
in: path
required: true
schema:
type: string
slot:
name: slot
in: path
required: true
schema:
type: string
enum: &id001
- LOADING_SCENE
- LOADING_LIST
- PRE_DEPARTURE_CHECK
- DRIVER_LICENSE
- RECEIPT
fileId:
name: fileId
in: path
required: true
schema:
type: string
idempotencyKey:
name: idempotencyKey
in: path
required: true
schema:
type: string
schemas:
Photo:
type: object
required:
- slot
- fileId
- fileName
- contentType
- sizeBytes
- uploadedAt
properties:
slot:
type: string
enum: *id001
category:
type: string
enum:
- LOADING
fileId:
type: string
fileName:
type: string
contentType:
type: string
sizeBytes:
type: string
uploadedAt:
type: string
savedAt:
type: string
nullable: true
Documents:
type: object
required:
- dispatchNo
- recordVersion
- editable
- photos
- dispatchRecordVersion
- stage
- complete
properties:
dispatchNo:
type: string
recordVersion:
type: string
editable:
type: boolean
photos:
type: array
items:
$ref: '#/components/schemas/Photo'
dispatchRecordVersion:
type: string
stage:
type: string
enum:
- LOADING
- AFTER_LOADING
- DELIVERING
- DELIVERED
complete:
type: boolean
description: 五个固定框各有一张已保存照片
BindPhoto:
type: object
required:
- recordVersion
- idempotencyKey
- fileId
properties:
recordVersion:
type: string
pattern: ^(0|[1-9][0-9]*)$
idempotencyKey:
type: string
minLength: 8
maxLength: 128
fileId:
type: string
@@ -8,6 +8,10 @@ info:
list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。 list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。
创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。 创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。
到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。 到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。
仓储转运上门取货入仓单审核通过时自动生成待发车排车并绑定原转运单;重复审核不重复建单,主动取消排车时对应转运、尾程、入仓单和待收货计划一并取消。
PICKUP 维护车辆后复用 dispatches/{dispatchNo}/submit-review、dispatch-review、loading-review 两级审核入口及版本化命令。
调度通过后待理货审核,任一级驳回返回车辆信息修改重提;两级通过后进入装货中;逐票上传取货照片并确认后自动进入派送中,逐票上传到仓照片并确认后自动完成取货派送,再走原仓库收货流程,不走仓内拣货扣库存。
车辆、司机或计划时刻变化作废本轮审核;运输版本与排车审核版本各自递增,命令使用对应详情的当前版本。
servers: servers:
- url: /api/platform/v1/last-leg/dispatches/transport-tasks - url: /api/platform/v1/last-leg/dispatches/transport-tasks
security: security:
@@ -1,379 +1,242 @@
{ openapi: 3.0.3
"openapi": "3.0.3", info:
"info": { title: 排车车辆资料
"title": "排车车辆资料", version: 1.0.0
"version": "1.0.0", description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。" documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。
}, security:
"security": [ - bearerAuth: []
{ paths:
"bearerAuth": [] /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents:
} get:
], operationId: getDispatchVehicleDocuments
"paths": { summary: 读取本车油耗图片;复用车队附加记录
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": { x-permission-code: platform:overseas-warehouse:dispatch:list
"get": { parameters:
"operationId": "getDispatchVehicleDocuments", - $ref: '#/components/parameters/dispatchNo'
"summary": "读取本车油耗图片;复用车队附加记录", responses:
"x-permission-code": "platform:overseas-warehouse:dispatch:list", '200':
"parameters": [ description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
{ content:
"$ref": "#/components/parameters/dispatchNo" application/json:
} schema:
], type: object
"responses": { properties:
"200": { code:
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", type: string
"content": { message:
"application/json": { type: string
"schema": { data:
"type": "object", $ref: '#/components/schemas/Documents'
"properties": { /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads:
"code": { post:
"type": "string" operationId: uploadDispatchVehiclePhoto
}, summary: 准备本车油耗图片;尚未绑定保存
"message": { x-permission-code: platform:overseas-warehouse:dispatch:edit
"type": "string" parameters:
}, - $ref: '#/components/parameters/dispatchNo'
"data": { - $ref: '#/components/parameters/slot'
"$ref": "#/components/schemas/Documents" responses:
} '200':
} description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
} content:
} application/json:
} schema:
} type: object
} properties:
} code:
}, type: string
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": { message:
"post": { type: string
"operationId": "uploadDispatchVehiclePhoto", data:
"summary": "准备本车油耗图片;尚未绑定保存", $ref: '#/components/schemas/Photo'
"x-permission-code": "platform:overseas-warehouse:dispatch:edit", requestBody:
"parameters": [ required: true
{ content:
"$ref": "#/components/parameters/dispatchNo" multipart/form-data:
}, schema:
{ type: object
"$ref": "#/components/parameters/slot" required:
} - file
], - idempotencyKey
"responses": { properties:
"200": { file:
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", type: string
"content": { format: binary
"application/json": { description: 非空JPG/PNG,最大10MiB
"schema": { idempotencyKey:
"type": "object", type: string
"properties": { minLength: 8
"code": { maxLength: 128
"type": "string" /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}:
}, put:
"message": { operationId: bindDispatchVehiclePhoto
"type": "string" summary: 按附加版本绑定原上传文件,不改变排车或签收状态
}, x-permission-code: platform:overseas-warehouse:dispatch:edit
"data": { parameters:
"$ref": "#/components/schemas/Photo" - $ref: '#/components/parameters/dispatchNo'
} - $ref: '#/components/parameters/slot'
} responses:
} '200':
} description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
} content:
} application/json:
}, schema:
"requestBody": { type: object
"required": true, properties:
"content": { code:
"multipart/form-data": { type: string
"schema": { message:
"type": "object", type: string
"required": [ data:
"file", $ref: '#/components/schemas/Documents'
"idempotencyKey" requestBody:
], required: true
"properties": { content:
"file": { application/json:
"type": "string", schema:
"format": "binary", $ref: '#/components/schemas/BindPhoto'
"description": "非空JPG/PNG,最大10MiB" /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content:
}, get:
"idempotencyKey": { operationId: readDispatchVehiclePhoto
"type": "string", summary: 只读取本车当前绑定图片
"minLength": 8, x-permission-code: platform:overseas-warehouse:dispatch:list
"maxLength": 128 parameters:
} - $ref: '#/components/parameters/dispatchNo'
} - $ref: '#/components/parameters/slot'
} - $ref: '#/components/parameters/fileId'
} responses:
} '200':
} description: no-store图片内容
} content:
}, image/jpeg:
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": { schema:
"put": { type: string
"operationId": "bindDispatchVehiclePhoto", format: binary
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态", image/png:
"x-permission-code": "platform:overseas-warehouse:dispatch:edit", schema:
"parameters": [ type: string
{ format: binary
"$ref": "#/components/parameters/dispatchNo" /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}:
}, get:
{ operationId: recoverDispatchVehiclePhoto
"$ref": "#/components/parameters/slot" summary: 只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传
} x-permission-code: platform:overseas-warehouse:dispatch:edit
], parameters:
"responses": { - $ref: '#/components/parameters/dispatchNo'
"200": { - $ref: '#/components/parameters/idempotencyKey'
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", responses:
"content": { '200':
"application/json": { description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
"schema": { content:
"type": "object", application/json:
"properties": { schema:
"code": { type: object
"type": "string" properties:
}, code:
"message": { type: string
"type": "string" message:
}, type: string
"data": { data:
"$ref": "#/components/schemas/Documents" $ref: '#/components/schemas/Photo'
} components:
} securitySchemes:
} bearerAuth:
} type: http
} scheme: bearer
} bearerFormat: JWT
}, parameters:
"requestBody": { dispatchNo:
"required": true, name: dispatchNo
"content": { in: path
"application/json": { required: true
"schema": { schema:
"$ref": "#/components/schemas/BindPhoto" type: string
} slot:
} name: slot
} in: path
} required: true
} schema:
}, type: string
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": { enum:
"get": { - FUEL_START
"operationId": "readDispatchVehiclePhoto", - FUEL_END
"summary": "只读取本车当前绑定图片", fileId:
"x-permission-code": "platform:overseas-warehouse:dispatch:list", name: fileId
"parameters": [ in: path
{ required: true
"$ref": "#/components/parameters/dispatchNo" schema:
}, type: string
{ idempotencyKey:
"$ref": "#/components/parameters/slot" name: idempotencyKey
}, in: path
{ required: true
"$ref": "#/components/parameters/fileId" schema:
} type: string
], schemas:
"responses": { Photo:
"200": { type: object
"description": "no-store图片内容", required:
"content": { - slot
"image/jpeg": { - fileId
"schema": { - fileName
"type": "string", - contentType
"format": "binary" - sizeBytes
} - uploadedAt
}, properties:
"image/png": { slot:
"schema": { type: string
"type": "string", enum:
"format": "binary" - FUEL_START
} - FUEL_END
} category:
} type: string
} enum:
} - FUEL
} fileId:
}, type: string
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": { fileName:
"get": { type: string
"operationId": "recoverDispatchVehiclePhoto", contentType:
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传", type: string
"x-permission-code": "platform:overseas-warehouse:dispatch:edit", sizeBytes:
"parameters": [ type: string
{ uploadedAt:
"$ref": "#/components/parameters/dispatchNo" type: string
}, savedAt:
{ type: string
"$ref": "#/components/parameters/idempotencyKey" nullable: true
} Documents:
], type: object
"responses": { required:
"200": { - dispatchNo
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", - recordVersion
"content": { - editable
"application/json": { - photos
"schema": { properties:
"type": "object", dispatchNo:
"properties": { type: string
"code": { recordVersion:
"type": "string" type: string
}, editable:
"message": { type: boolean
"type": "string" photos:
}, type: array
"data": { items:
"$ref": "#/components/schemas/Photo" $ref: '#/components/schemas/Photo'
} BindPhoto:
} type: object
} required:
} - recordVersion
} - idempotencyKey
} - fileId
} properties:
} recordVersion:
} type: string
}, pattern: ^(0|[1-9][0-9]*)$
"components": { idempotencyKey:
"securitySchemes": { type: string
"bearerAuth": { minLength: 8
"type": "http", maxLength: 128
"scheme": "bearer", fileId:
"bearerFormat": "JWT" type: string
}
},
"parameters": {
"dispatchNo": {
"name": "dispatchNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"slot": {
"name": "slot",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
}
},
"fileId": {
"name": "fileId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"idempotencyKey": {
"name": "idempotencyKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"schemas": {
"Photo": {
"type": "object",
"required": [
"slot",
"fileId",
"fileName",
"contentType",
"sizeBytes",
"uploadedAt"
],
"properties": {
"slot": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
},
"category": {
"type": "string",
"enum": [
"FUEL"
]
},
"fileId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"sizeBytes": {
"type": "string"
},
"uploadedAt": {
"type": "string"
},
"savedAt": {
"type": "string",
"nullable": true
}
}
},
"Documents": {
"type": "object",
"required": [
"dispatchNo",
"recordVersion",
"editable",
"photos"
],
"properties": {
"dispatchNo": {
"type": "string"
},
"recordVersion": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"photos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Photo"
}
}
}
},
"BindPhoto": {
"type": "object",
"required": [
"recordVersion",
"idempotencyKey",
"fileId"
],
"properties": {
"recordVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"fileId": {
"type": "string"
}
}
}
}
}
}
@@ -3,12 +3,16 @@ info:
title: 仓储转运上门取货与车辆排车 title: 仓储转运上门取货与车辆排车
version: 1.0.0 version: 1.0.0
description: | description: |
审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。 WAREHOUSE_PICKUP 外来转运单审核通过后,同事务自动生成绑定原单的 PICKUP 排车单(PC号),待分配车辆司机;客户自送、库存转运不自动排车。
自动计划暂定为审核时刻发车、24小时后结束,并在备注标明待调度确认;不填写实际发车、实提或送仓事实。
取货排车先维护车辆信息、提交调度审核,经调度和理货员同轮审核通过后进入装货中。逐票上传取货照片并确认实提,全车齐全后自动进入派送中;逐票上传独立到仓照片,全部到仓后自动完成取货派送,再继续原仓库收货流程。
修改车辆、司机或计划时刻后本轮审核失效,须重新提交;审核不触发仓内拣货或库存扣减。
重复审核沿原单锁复用已有任务,不重复建单,也不恢复已主动取消的任务;取消排车时对应转运单、尾程单、入仓单及待PDA计划同事务取消,不再进入取货候选。
入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。 入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。
同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。 同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。
取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。 取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。
司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。 司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。
取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。 取消 PLANNED 车次原子取消本车全部对应转运单、尾程单、入仓单及待PDA计划并释放资源;任一成员已有清点或收货事实则全部回滚,已发车不可取消。
写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。 写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -79,7 +83,7 @@ paths:
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
post: post:
summary: 逐票确认取货 summary: 逐票确认取货
description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。 description: 仅平台主体,权限 platform:fleet:trip:edit。两级审核通过的计划车次或已出发车次,任务 ASSIGNED;核对本票本车图片上传归属后转 PICKED,实提箱数不覆盖预报。全车取货照片齐全后自动进入 IN_TRANSIT,排车阶段为 DELIVERING。
requestBody: requestBody:
required: true required: true
content: content:
@@ -98,7 +102,7 @@ paths:
parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}]
post: post:
summary: 确认本票送达海外仓 summary: 确认本票送达海外仓
description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。 description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;evidenceFileIds 必填且为本票本车新上传的图片,不得复用取货照片。Evidence.phase 为 PICKUP 或 ARRIVAL 区分两次照片。全车均 ARRIVED 后自动完成运输,开放原 PDA 清点,不直接完成测量或入库。
requestBody: {$ref: '#/components/requestBodies/command'} requestBody: {$ref: '#/components/requestBodies/command'}
responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}} responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}}
/api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete: /api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete:
@@ -157,3 +161,4 @@ components:
recordVersion: {type: integer, format: int64, minimum: 0} recordVersion: {type: integer, format: int64, minimum: 0}
idempotencyKey: {type: string, minLength: 8, maxLength: 128} idempotencyKey: {type: string, minLength: 8, maxLength: 128}
reason: {type: string, maxLength: 1000} reason: {type: string, maxLength: 1000}
evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string}, description: 送仓确认必填,必须为本票本车新上传的到仓图片}
@@ -1728,8 +1728,8 @@ paths:
type: string type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post: post:
summary: 确认装车完成并自动进入派送中 summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security: security:
- bearerAuth: [] - bearerAuth: []
responses: responses:
@@ -5253,6 +5253,14 @@ components:
type: object type: object
x-java-record: DispatchContracts.DriverEvidence x-java-record: DispatchContracts.DriverEvidence
properties: properties:
photoFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 维护车辆司机时保存的本趟司机照片,按DRIVER_PHOTO类别校验,与驾驶证附件独立。
items:
type: string
pattern: ^[1-9][0-9]*$
licenseNo: licenseNo:
type: string type: string
nullable: true nullable: true
@@ -253,8 +253,8 @@ paths:
type: string type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post: post:
summary: 确认装车完成并自动进入派送中 summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security: security:
- bearerAuth: [] - bearerAuth: []
responses: responses:
@@ -148,7 +148,7 @@ public class DispatchAggregatePersistenceTest {
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception { String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z")))); new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())));
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
} }
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -591,7 +591,7 @@ public class DispatchAggregatePersistenceTest {
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方", return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))), new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
departure,null,null,sources,List.of()); departure,null,null,sources,List.of());
} }
@@ -1,6 +1,15 @@
package com.baiyue.lastleg.dispatch.service; package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*; import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.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.*;
import java.util.regex.Pattern;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*; import com.baiyue.lastleg.dispatch.repository.*;
@@ -19,43 +28,53 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */ /** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
class DispatchLoadingDeparturePersistenceTest { class DispatchLoadingDeparturePersistenceTest {
/** 每例恢复隔离身份,不影响其他服务权限测试。 */ /** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */ /** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception { @Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception {
authorize();var f=new Fixture(); authorize();var f=new Fixture();
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING); assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,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"); assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING"); assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING"); assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING");
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result()); assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING); assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART); assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段"); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
} }
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */ /** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception { @Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception {
authorize(); authorize();
for(boolean failPreparation:List.of(true,false)) { for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture(); var f=new Fixture();
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString()); if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class); assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version); var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull(); assertThat(row.getLoadingCompletionHash()).isNotNull();
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
} }
@@ -68,12 +87,12 @@ class DispatchLoadingDeparturePersistenceTest {
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null); var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化"); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING); var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限"); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
} }
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */ /** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception { @Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try { try {
@@ -81,20 +100,99 @@ class DispatchLoadingDeparturePersistenceTest {
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda")); var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();} } finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
} }
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */ /** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
authorize();var f=new Fixture();
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,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("无装车资料操作权限");
}
/** 两端同版本竞争第五张,仅一个绑定和一次真实派送成功。 */
@Test void concurrentFifthPhotoCannotDispatchTwice() throws Exception {
authorize();var f=new Fixture();f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var results=new ArrayList<Future<Boolean>>();
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
}catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}));
start.countDown();assertThat(List.of(results.get(0).get(25,TimeUnit.SECONDS),results.get(1).get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
}
/** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */
@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","", private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */ /** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
private static final class Fixture { private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class); final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest; final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements; final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception { Fixture() throws Exception {
@@ -126,7 +224,23 @@ class DispatchLoadingDeparturePersistenceTest {
var hashes=new DispatchCommandHasher(f.json); var hashes=new DispatchCommandHasher(f.json);
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json); var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes); f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("record","command","history")) {
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
assertThat(matcher.find()).isTrue();
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
}
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
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);
}
/** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */
Documents bind(int index,Actor actor) {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
} }
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");} DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
@@ -23,6 +23,44 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest { class DispatchPickingPersistenceTest {
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
verify(fixture.inventory,never()).ship(any(),any(),any());
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
}
/** 预排两托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或创建第二条真实扫码。 */ /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
@@ -68,6 +106,7 @@ class DispatchPickingPersistenceTest {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); 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 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"); final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */ /** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
Picking() throws Exception {this(false);} Picking() throws Exception {this(false);}
@@ -94,10 +133,33 @@ 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->{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()));}) 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()); .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)); 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); 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)); 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(true).when(sources).receivedPlanCompleted(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。 */ /** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";} private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
@@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*;
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */ /** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
class DispatchPlanPdaReadPersistenceTest { class DispatchPlanPdaReadPersistenceTest {
/** 完成状态查询走真实Mapper,未完成或其他客户的计划不能被当作本托实收。 */
@Test void completionGateUsesOriginalCustomerAndPlan() throws Exception {
var f=new Fixture();
f.f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),status VARCHAR(64))");
f.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(64))");
f.f.db.update("INSERT INTO ll_inbound_plan VALUES(1,'101','IN_PROGRESS')");
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
}
}
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
var f=new Fixture();var before=f.dbState(); var f=new Fixture();var before=f.dbState();
@@ -28,15 +44,23 @@ class DispatchPlanPdaReadPersistenceTest {
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
} }
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */ /** 在途和来源身份变化均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L); var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); 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.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.packageCount(3);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(); row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); 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 { @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture(); var f=new Fixture();
@@ -53,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest {
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
var f=new Fixture(); var f=new Fixture();
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) { new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
assertThat(f.read().items().get(0).positions()).isEmpty(); assertThat(f.read().items().get(0).positions()).isEmpty();
} }
} }
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
private static final class Fixture { private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
@@ -65,9 +96,13 @@ class DispatchPlanPdaReadPersistenceTest {
final DispatchService rules=mock(DispatchService.class); final DispatchService rules=mock(DispatchService.class);
final DispatchPdaQueryService queries;final String number; final DispatchPdaQueryService queries;final String number;
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */ /** 原车已审核,只清除自然收货尚未补写的实托字段。 */
Fixture() throws Exception { Fixture() throws Exception {this(false);}
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(boolean twoPallets) throws Exception {
var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3);
var selections=new java.util.ArrayList<DispatchCommands.CargoSelection>();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.approve(created);number=created.dispatchNo();
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'"); 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)"); 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)");
@@ -76,12 +111,28 @@ class DispatchPlanPdaReadPersistenceTest {
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1))); when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of())); 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入口。 */ /** 读取正式PDA入口。 */
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));} DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
@@ -157,7 +157,7 @@ class DispatchRepairWorkflowTest {
assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); assertThat(created.dispatchNo()).isNotEqualTo("DP-1");
var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo()); var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo());
assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue(); assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue();
assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse(); assertThat(current.actions()).doesNotContainKey(DispatchTypes.Action.ARRANGE_DELIVERY);
assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING); assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId()); assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId());
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/** 车辆维护保存司机照片后,真实资源快照、审核和原键恢复均保留相同引用。 */
class DispatchResourcePhotosPersistenceTest {
/** 新字段缺省不改变升级前命令及审核依据的摘要,实际照片变化仍必须识别。 */
@Test void optionalPhotosPreserveLegacyHashesButBindActualReferences() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("legacy-photos", List.of());
var request = update(input, 0L, "legacy-edit", List.of());
var oldJson = f.json.valueToTree(request);
((com.fasterxml.jackson.databind.node.ObjectNode) oldJson.path("driver").path("evidence")).remove("photoFileIds");
var hashes = new DispatchCommandHasher(f.json);
var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.EDIT;
var oldHash = hashes.command(f.actor, action, "PC-OLD", 0L, oldJson);
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, request)).isEqualTo(oldHash);
assertThat(hashes.source(request)).isEqualTo(hashes.source(oldJson));
var withPhotos = update(input, 0L, "legacy-edit", List.of(401L, 402L));
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, withPhotos)).isNotEqualTo(oldHash)
.isEqualTo(hashes.command(f.actor, action, "PC-OLD", 0L, update(input, 0L, "legacy-edit", List.of(402L, 401L))));
}
/** 使用真实资源持久化与两级审核,防止车队资源规范化吞掉独立司机照片。 */
@Test void savedPhotosSurviveReplayAndBothReviews() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("photos-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var request = update(input, created.recordVersion(), "photos-save", List.of(401L));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
var approved = f.approve(saved);
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().driver().evidence().photoFileIds()).containsExactly(401L);
assertThat(detail.vehicleInfo().driver().evidence().fileIds()).containsExactly(202L);
verify(gateway(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
assertThat(approved.recordVersion()).isGreaterThan(saved.recordVersion());
}
/** 错车或错类附件失败时,事务不得覆盖原资源、推进版本或留下成功命令。 */
@Test void foreignPhotoCannotChangeSavedResources() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("foreign-photo-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo());
doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway(f))
.metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
var request = update(input, created.recordVersion(), "foreign-photo-save", List.of(401L));
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)))
.hasMessageContaining("归属不匹配");
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='foreign-photo-save'", Long.class)).isZero();
}
/** 编辑清空照片必须真实持久化;历史资源缺该字段时仍按空集合读取。 */
@Test void photosCanBeRemovedAndOldSnapshotsRemainReadable() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("remove-photo-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, created.recordVersion(), "add-photo", List.of(401L))));
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, saved.recordVersion(), "remove-photo", List.of())));
assertThat(f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())).vehicleInfo().driver().evidence().photoFileIds()).isEmpty();
var legacy = f.json.readValue("{\"licenseNo\":\"TEST\",\"fileIds\":[202]}", DispatchContracts.DriverEvidence.class);
assertThat(legacy.photoFileIds()).isEmpty();
assertThat(legacy.fileIds()).containsExactly(202L);
}
/** 仅替换司机照片,保留车辆、驾驶证和计划字段。 */
private static DispatchCommands.Update update(DispatchCommands.Create input, long version, String key, List<Long> photos) {
var d = input.driver();
var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片");
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) {
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
}
}
@@ -11,6 +11,32 @@ import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest { class DispatchReviewWorkflowPersistenceTest {
/** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */
@Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-review",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",created.dispatchNo());
var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class);
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]");
org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport);
var submitted=submit(f,created,"pickup-submit");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
assertThatThrownBy(()->review(f,f.actor,submitted,true,"skip",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
var approved=review(f,f.actor,submitted,false,"pickup-dispatch",Decision.APPROVED,null);
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
var rejected=review(f,f.actor,approved,true,"pickup-reject",Decision.REJECTED,"车辆不符");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var next=submit(f,rejected,"pickup-resubmit");
var nextApproved=review(f,f.actor,next,false,"pickup-dispatch-2",Decision.APPROVED,null);
var loaded=review(f,f.actor,nextApproved,true,"pickup-loading-2",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
var row=f.repository.find(created.dispatchNo());
assertThat(row.getReviewState()).isEqualTo("APPROVED");
assertThat(f.service.actions(f.actor,row).get(Action.PICK).allowed()).isFalse();
assertThat(f.service.actions(f.actor,row).get(Action.LOADING_COMPLETE).allowed()).isFalse();
assertThat(f.repository.outbounds(row.getId())).isEmpty();
org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(5)).verifyPickupReview(created.dispatchNo());
}
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */ /** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception { @Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture(); var f=new DispatchAggregatePersistenceTest.Fixture();
@@ -15,6 +15,35 @@ import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class TransportTaskProjectionTest { class TransportTaskProjectionTest {
/** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */
@Test void oldDepartureDoesNotSkipPickupPhotosStage() {
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",pickups);
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setRecordVersion(10L);root.setStage("LOADING");
var task=new com.baiyue.lastleg.fleet.pickup.FleetPickupTask();task.setStatus("ASSIGNED");
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(eq(root),anyLong())).thenReturn(1);when(pickups.tasks("TR1")).thenReturn(java.util.List.of(task));
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");
projection.changed(new TransportTripChanged(trip,"PICKUP_DEPART","出发","OP"));assertThat(root.getStage()).isEqualTo("LOADING");
task.setStatus("PICKED");projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERING");
trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERED");
}
/** 审核推进后车次版本可能较小,换车仍须递增主单版本并作废两级审核。 */
@Test void pickupResourceChangeInvalidatesApprovalWithoutRegressingVersion() throws Exception {
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);
var json=new ObjectMapper().findAndRegisterModules();
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json);
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setStatus("ACTIVE");root.setStage("LOADING");root.setRecordVersion(10L);root.setReviewRound(1L);root.setReviewState("APPROVED");root.setReviewBasisHash("basis");root.setResourcesJson("{}");
var review=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null);
root.setDispatchReviewJson(json.writeValueAsString(review));root.setLoadingReviewJson(json.writeValueAsString(review));
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,10L)).thenReturn(1);
assertThatCode(()->projection.requireApproved(1L)).doesNotThrowAnyException();
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
projection.changed(new TransportTripChanged(trip,"ASSIGN","换车","OP"));
assertThat(root.getRecordVersion()).isEqualTo(11L);assertThat(root.getReviewState()).isEqualTo("DRAFT");assertThat(root.getStage()).isEqualTo("BEFORE_LOADING");
assertThatThrownBy(()->projection.requireApproved(1L)).hasMessageContaining("调度审核和理货员审核");
verify(roots).saveState(root,10L);
}
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */ /** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){ @Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class); var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
@@ -58,6 +58,30 @@ class TransportTaskServiceTest {
@Test void evidenceDownloadRequiresPersistedConfirmation(){ @Test void evidenceDownloadRequiresPersistedConfirmation(){
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
} }
/** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */
@Test void approvalCreatesOnlyEligibleUnassignedPickup() {
var facts=mock(FleetPickupRepository.class);
var subject=spy(new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,
mock(FleetPickupService.class),facts,trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),
mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()));
var source=new FleetPickupSource();source.setId(42L);source.setTransferNo("ZY1");source.setInboundCode("ZY1");
source.setDeliveryMethod("WAREHOUSE_PICKUP");source.setStatus("WAIT_PDA_RECEIVING");
when(facts.source("C1","ZY1",true)).thenReturn(source);
doReturn(null).when(subject).createPickup(anyString(),any());
subject.ensureApprovedPickup("C1","ZY1","OP");
var request=org.mockito.ArgumentCaptor.forClass(FleetPickupModels.Create.class);
verify(subject).createPickup(eq("OP"),request.capture());
assertThat(request.getValue().sources()).containsExactly(new FleetPickupModels.Source("C1","ZY1"));
assertThat(request.getValue().idempotencyKey()).isEqualTo("transfer-approval:42");
assertThat(request.getValue().expectedDriverEndAt()).isAfter(request.getValue().plannedDepartureAt());
clearInvocations(subject);
when(facts.lockTask(42L)).thenReturn(new FleetPickupTask());
subject.ensureApprovedPickup("C1","ZY1","OP2");
verify(subject,never()).createPickup(anyString(),any());
source.setDeliveryMethod("CUSTOMER_DELIVERY");clearInvocations(facts);
subject.ensureApprovedPickup("C1","ZY1","OP");
verify(facts,never()).lockTask(anyLong());
}
/** 构造固定版本的测试执行命令。 */ /** 构造固定版本的测试执行命令。 */
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);} private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
} }
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.controller.DispatchVehicleDocumentController;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 排车资料复用同一车辆照片记录,不扩大旧入口或改变履约状态。 */
class DispatchVehicleDocumentTest {
/** 无拼车和实际拼车都能在派送后读取;原车队分类和冻结装车门禁继续生效。 */
@Test void dispatchScopeAcceptsBothCargoGroupsOnlyAfterDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var queries=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source(source.getDispatchNo())).thenReturn(source);
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
for(boolean pooled:List.of(false,true))for(String stage:List.of("DELIVERING","DELIVERED")) {
source.setPooledCargo(pooled);source.setStage(stage);
assertSame(source,queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
}
assertThrows(BizException.class,()->queries.requireOwned(source.getDispatchNo()));
source.setOwnedLoadingCompletionHash("other-load");
assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH));
verify(repo,never()).update(any(),anyLong());
}
/** 排车入口只接受油耗图片;签收和装车文件仍走派送单,旧外配仍不能上传油耗。 */
@Test void scopesKeepIndependentWritePermissionsAndPhotoCategories() {
var scope=OwnedBusinessScope.DISPATCH;
assertEquals("platform:overseas-warehouse:dispatch:list",scope.listPermission());
assertEquals("platform:overseas-warehouse:dispatch:edit",scope.uploadPermission());
for(var slot:PhotoSlot.values()) {
if(slot.category().equals("FUEL")) { assertDoesNotThrow(()->scope.requireSlot(slot));assertThrows(BizException.class,()->OwnedBusinessScope.OUTSOURCED.requireSlot(slot)); }
else assertThrows(BizException.class,()->scope.requireSlot(slot));
}
var actor=new Actor("reader","只读",Set.of(scope.listPermission()));
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor,scope);
assertFalse(row.actions().upload().allowed());
}
/** 油耗与车队装车照片共存,排车补充油耗不破坏外配业务读取。 */
@Test void sharedFuelPhotosDoNotInvalidateOutsourcedProjection() {
var codec=OwnedBusinessFixtures.codec();var record=new OwnedBusinessRows.Record();record.setRecordVersion(3L);record.setArrivalsJson("[]");
record.setPhotosJson(codec.write(List.of(new Photo(PhotoSlot.FUEL_START,"FUEL","77","fuel.png","image/png","9","2026-10-21T00:00:00Z","2026-10-21T00:00:00Z"))));
var projection=new OwnedBusinessProjection(codec);var source=OwnedBusinessFixtures.source();source.setPooledCargo(true);source.setStage("DELIVERED");
var row=projection.row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor(),OwnedBusinessScope.OUTSOURCED);
assertEquals(1,row.photoSummary().fuelCount());assertEquals("3",row.recordVersion());assertEquals(8L,source.getRecordVersion());assertEquals("DELIVERED",source.getStage());
assertEquals("77",projection.photos(record).get(0).fileId());
var queries=mock(OwnedBusinessQueryService.class);
when(queries.detail(any(),eq(source.getDispatchNo()),eq(OwnedBusinessScope.OUTSOURCED)))
.thenReturn(new Detail(row,null,List.of(),projection.photos(record)));
var outsourced=new OutsourcedBusinessService(queries,mock(OwnedBusinessRecordService.class),mock(OwnedBusinessPhotoService.class),projection,codec);
assertTrue(outsourced.detail(OwnedBusinessFixtures.actor(),source.getDispatchNo()).photos().isEmpty());
}
/** 新入口读写权限独立;服务层同样拒绝仅查看账号和车队权限绕入。 */
@Test void controllerAndPhotoServiceKeepDispatchWriteBoundary() throws Exception {
assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:list')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
.filter(m->m.getName().equals("get")).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
for(String name:List.of("upload","bind","recover"))assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:edit')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods())
.filter(m->m.getName().equals(name)).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value());
var queries=mock(OwnedBusinessQueryService.class);var repo=mock(OwnedBusinessRepository.class);var files=mock(OwnedBusinessFileGateway.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessPhotoService(queries,mock(OwnedBusinessRecordService.class),repo,mock(OwnedBusinessCommandService.class),new OwnedBusinessProjection(codec),codec,files);
for(var actor:List.of(new Actor("read","只读",Set.of(OwnedBusinessScope.DISPATCH.listPermission())),OwnedBusinessFixtures.actor()))
assertThrows(ResponseStatusException.class,()->service.upload(actor,"PC-1",PhotoSlot.FUEL_START,"upload-original",null,OwnedBusinessScope.DISPATCH));
verifyNoInteractions(files,repo,queries);
}
}
@@ -60,4 +60,12 @@ class FleetPickupMapperTest {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)"); db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1"); assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");
} }
/** 联动取消保留原车次身份,已取货成员不能被取消SQL覆盖。 */
@Test void cancellationKeepsSourceLinkAndDoesNotRewritePickedFacts() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)");
db.update("INSERT INTO ll_fleet_pickup_task VALUES(6,'C6','WT-6','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.cancelTasks("TR-1")).isEqualTo(1);
assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");assertThat(mapper.task(1L).getStatus()).isEqualTo("CANCELLED");
assertThat(mapper.task(6L).getStatus()).isEqualTo("PICKED");assertThat(mapper.cancelTasks("TR-1")).isZero();
}
} }
@@ -15,11 +15,15 @@ import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest { class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
PickupDepartureReview departureReview;
DriverService drivers;VehicleRepository vehicles;
TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task; TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>(); final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() { @BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules()); departureReview=mock(PickupDepartureReview.class);
drivers=mock(DriverService.class);vehicles=mock(VehicleRepository.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),drivers,vehicles,files,new ObjectMapper().findAndRegisterModules(),departureReview);
trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L); trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L);
source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED"); source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED");
when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip); when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip);
@@ -29,6 +33,29 @@ class FleetPickupServiceTest {
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
} }
/** 审核完成后直接在装货阶段上传取货照片,全车核齐自动进入派送。 */
@Test void pickupPhotosStartDeliveryWithoutSeparateDeparture() {
trip.setStatus("PLANNED");trip.setVehicleId(20L);trip.setDriverId(30L);trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1));
when(repository.ownsResources(9L,20L,30L)).thenReturn(true);
var vehicle=mock(VehicleRepository.VehicleSnapshot.class);when(vehicle.region()).thenReturn("OVERSEAS");when(vehicle.status()).thenReturn("IN_SERVICE");when(vehicles.lockById(20L)).thenReturn(Optional.of(vehicle));
var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);when(details.region()).thenReturn("OVERSEAS");when(driver.details()).thenReturn(details);when(drivers.requireDispatchable(eq(30L),any())).thenReturn(driver);
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货照片.jpg","image/jpeg",500));
var request=new FleetPickupModels.Pickup(2L,"pickup-photo-key",5,List.of(88L),null);
service.picked("TR-1","C1","WT-1","OP",request);
assertThat(task.getStatus()).isEqualTo("PICKED");assertThat(trip.getStatus()).isEqualTo("IN_TRANSIT");assertThat(trip.getDepartedAt()).isNotNull();
verify(departureReview).requireApproved(9L);verify(trips,times(1)).recordExecution(any(),eq(2L),eq("PICKUP_PICKED"),anyString(),eq("OP"));
service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
}
/** 原车队发车接口不能在两级审核未通过时写入实际发车事实。 */
@Test void legacyDepartureCannotBypassReview() {
trip.setStatus("PLANNED");
doThrow(new IllegalStateException("请先完成两级审核")).when(departureReview).requireApproved(9L);
assertThatThrownBy(()->service.depart("TR-1","OP",new FleetPickupModels.Command(2L,"depart-review-key","发车"))).hasMessageContaining("两级审核");
assertThat(trip.getStatus()).isEqualTo("PLANNED");assertThat(trip.getDepartedAt()).isNull();
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
assertThat(commands).isEmpty();
}
/** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */ /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */
@Test void pickupMustArriveBeforePda() { @Test void pickupMustArriveBeforePda() {
assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓"); assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓");
@@ -79,10 +106,23 @@ class FleetPickupServiceTest {
} }
@Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() { @Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() {
task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC)); task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC));
service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台")); when(repository.ownsUpload(1L,"TR-1",89L)).thenReturn(true);
when(files.metadata(9L,89L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("89","到仓照片.jpg","image/jpeg",500));
var request=new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台",List.of(89L));
service.arrive("TR-1","C1","WT-1","OP",request);
assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓")); assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("arrive-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); assertThat(task.getEvidenceJson()).contains("ARRIVAL","到仓照片");
service.arrive("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
}
/** 到仓必须上传独立的新照片,不能用取货照片冒充或提前开放清点。 */
@Test void arrivalRequiresFreshPhotoEvidence() {
task.setStatus("PICKED");
task.setEvidenceJson("[{\"fileId\":\"88\",\"fileName\":\"取货.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":1,\"phase\":\"PICKUP\"}]");
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"no-photo","送仓"))).hasMessageContaining("上传到仓照片");
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"same-photo","送仓",List.of(88L)))).hasMessageContaining("不能复用取货照片");
assertThat(task.getStatus()).isEqualTo("PICKED");verify(repository,never()).update(any());
} }
@Test void staleVersionAndWrongTripPurposeAreRejected() { @Test void staleVersionAndWrongTripPurposeAreRejected() {
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化"); assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化");
@@ -122,6 +122,8 @@ class FleetTripAuthorizationTest {
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); } @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
/** 取消原单使用所属域替身,权限测试不写业务数据。 */
@Bean com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation() { return mock(com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation.class); }
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); } @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); }
@Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); }
@@ -75,7 +75,6 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
"/api/pda/v1/last-leg/outbound-tasks:", "/api/pda/v1/last-leg/outbound-tasks:",
"/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:", "/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
@@ -83,7 +82,8 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"); "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
for (String path : paths) assertTrue(yaml.contains(path), path); for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(36, paths.size()); assertEquals(35, paths.size());
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:"));
assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 通过真实SQL验证联动取消、客户隔离以及多票失败回滚。 */
class TransferPickupCancellationPersistenceTest {
JdbcTemplate db;TransactionTemplate tx;TransferPickupCancellationService service;
/** 每例建立隔离库,保留取消所需真实字段和状态约束。 */
@BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_cancel_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_id BIGINT,inbound_order_id BIGINT,delivery_method VARCHAR(40),status VARCHAR(32),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),current_status VARCHAR(40),sub_order_status VARCHAR(255),version INT,updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),status VARCHAR(40),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_order_id BIGINT,creation_mode VARCHAR(40),status VARCHAR(40),version INT,updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_loading_order_status_history(customer_id VARCHAR(64),order_id BIGINT,from_status VARCHAR(40),to_status VARCHAR(40),event_id VARCHAR(64),remark VARCHAR(1000),created_at TIMESTAMP)");
db.execute("CREATE TABLE ll_warehouse_transfer_receipt(customer_id VARCHAR(64),transfer_order_id BIGINT)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR(64),transfer_order_id BIGINT,counted_at TIMESTAMP)");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(customer_id VARCHAR(64),transfer_order_id BIGINT)");
for(int i=1;i<=2;i++) {
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,?,?,'WAREHOUSE_PICKUP','WAIT_PDA_RECEIVING',CURRENT_TIMESTAMP)",i,"C"+i,"WT"+i,"WT"+i,i,i);
db.update("INSERT INTO ll_order VALUES(?,?,'INBOUND_MEASURING','WAREHOUSE_TRANSFER:APPROVED',0,CURRENT_TIMESTAMP)",i,"C"+i);
db.update("INSERT INTO ll_inbound_order VALUES(?,?,'PENDING_INBOUND',CURRENT_TIMESTAMP)",i,"C"+i);
db.update("INSERT INTO ll_inbound_plan VALUES(?, ?,?,'WAREHOUSE_TRANSFER','PDA_PENDING',0,CURRENT_TIMESTAMP)",i,"C"+i,i);
}
var config=new MybatisConfiguration();config.addMapper(TransferPickupCancellationMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
service=new TransferPickupCancellationService(new TransferPickupCancellationRepository(new SqlSessionTemplate(factory.getObject()).getMapper(TransferPickupCancellationMapper.class)));
}
/** 释放本例数据库资源。 */
@AfterEach void close(){db.execute("SHUTDOWN");}
/** 全车跨客户成员分别取消自己的原单与计划,不影响其他客户。 */
@Test void cancelsOnlyTheLinkedBusinessAndKeepsAudit() {
tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1")),"TR1","OP","客户取消"));
for(String table:List.of("ll_warehouse_transfer_order","ll_inbound_order","ll_inbound_plan")) {
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=1",String.class)).isEqualTo("CANCELLED");
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=2",String.class)).isNotEqualTo("CANCELLED");
}
assertThat(db.queryForObject("SELECT current_status FROM ll_order WHERE id=1",String.class)).isEqualTo("CANCELLED");
assertThat(db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT remark FROM ll_loading_order_status_history",String.class)).contains("OP","客户取消");
}
/** 后一票已经清点时,前一票取消及历史写入全部回滚。 */
@Test void oneStartedMemberRollsBackAllOtherCancellations() {
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('C2',2,CURRENT_TIMESTAMP)");
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1"),new FleetPickupModels.Source("C2","WT2")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_order WHERE status='CANCELLED'",Integer.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_loading_order_status_history",Integer.class)).isZero();
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C2","WT1")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
}
}
@@ -27,7 +27,7 @@ class WarehouseTransferCartonCountTest {
service = spy(new WarehouseTransferService()); service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders); ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items); ReflectionTestUtils.setField(service, "transferItems", items);
ReflectionTestUtils.setField(service, "transferPallets", palletMapper); ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
@@ -202,6 +202,7 @@ class WarehouseTransferServiceTest {
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service; private WarehouseTransferService service;
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator; @Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@Mock com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@@ -209,6 +210,7 @@ class WarehouseTransferServiceTest {
void setUp() { void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new WarehouseTransferService(); service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "transportTaskService", transportTasks);
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);
ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository);
@@ -233,7 +235,7 @@ class WarehouseTransferServiceTest {
}); });
ReflectionTestUtils.setField(service, "transfers", transfers); ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
@@ -724,6 +726,7 @@ class WarehouseTransferServiceTest {
assertEquals(5, line.getValue().getRequiredPackageCount()); assertEquals(5, line.getValue().getRequiredPackageCount());
assertEquals("PENDING_INBOUND", inbound.getStatus()); assertEquals("PENDING_INBOUND", inbound.getStatus());
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1");
} }
@Test @Test
+84 -20
View File
@@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath
$adminAgents = $adminEntry + "`n" + $adminIndex $adminAgents = $adminEntry + "`n" + $adminIndex
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md' $adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath $adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。
$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?<body>.*?)(?=^#{1,3} |\z)').Groups['body'].Value
$checked++
if (-not $adminInboundPageRules) { Add-Failure '缺少 Admin 入仓计划页面约束正文' }
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口' Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表' Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
$clientEntry = Read-RequiredText $clientAgentsPath $clientEntry = Read-RequiredText $clientAgentsPath
@@ -245,7 +249,8 @@ $lookupSamples = @(
$lookupCharacters = 0 $lookupCharacters = 0
foreach ($alias in @( foreach ($alias in @(
@{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, @{ 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/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' },
@{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' }, @{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' },
@{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' }, @{ 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 = $businessIndexPath; Text = $businessIndex; Limit = 12500 },
@{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 }, @{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 },
@{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 }, @{ 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 Assert-MarkdownLinks $document.Path $document.Text $document.Name
$checked++ $checked++
@@ -306,11 +312,21 @@ foreach ($document in @(
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name } )) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表' Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0] $businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0]
foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?<name>.+?)\r?$')) { # 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。
$prefix = '| ' + $topic.Groups['name'].Value + ' |' $businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?<name>[^\r\n]+)\r?\n(?<body>.*?)(?=^## |\z)')
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) }) foreach ($topic in $businessRuleSections) {
$name = $topic.Groups['name'].Value
$slug = [regex]::Replace($name.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-'
$target = '业务实现边界.md#' + $slug
$hasRoute = $businessRouteSection.Contains('](' + $target + ')')
if (-not $hasRoute) {
# 已有正式正文时允许索引直接跳转,避免为覆盖率强加一层摘要。
$sourceLine = [regex]::Match($topic.Groups['body'].Value, '(?m)^范围依据:[^\r\n]+')
$sourceLink = [regex]::Match($sourceLine.Value, '\[[^\]]+\]\((?<target>[^)]+)\)')
$hasRoute = $sourceLink.Success -and $businessRouteSection.Contains('](' + $sourceLink.Groups['target'].Value + ')')
}
$checked++ $checked++
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" } if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" }
} }
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单' Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件' Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
@@ -319,20 +335,68 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文' Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' 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(?<body>.*?)(?=^## |\z)').Groups['body'].Value
$checked++
if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) {
Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款'
}
$docLookupCharacters = 0 $docLookupCharacters = 0
$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户'
$docLookupSamples = @( $docLookupSamples = @(
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' }, @{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' }, @{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' }, @{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' }, @{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' } @{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/warehouses/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '海外库位、托盘与资源锁'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse-overseas-service/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' },
@{ Key = '客户统计与运营指标'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/customers/AGENTS.md' },
@{ Key = '平台日志、操作日志、审计日志与操作人缺失'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/audit/AGENTS.md' },
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Column = 2; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Column = 1; Entry = '财务对账结算P1闭环.md#客户资金账户' },
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Column = 1; Entry = '排车列表统一作业改造契约.md' },
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Column = 1; Entry = '业务规则.md#本土散板简化下单' }
) )
foreach ($sample in $docLookupSamples) { foreach ($sample in $docLookupSamples) {
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') }) $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
$checked++ $checked++
if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue } if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue }
Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)" # 代码入口与规则正文分别核对,不能靠其他列碰巧包含目标路径通过。
$cells = @($hits[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
if ($sample.Column -ge $cells.Count) { Add-Failure "文档路由样本缺少目标列:$($sample.Key)"; continue }
Assert-Contains $cells[$sample.Column] $sample.Entry "文档路由样本 $($sample.Key)"
if ($sample.Rules) {
if ($cells.Count -lt 3) { Add-Failure "文档路由样本缺少规则正文:$($sample.Key)"; continue }
Assert-Contains $cells[2] $sample.Rules "文档正文样本 $($sample.Key)"
}
$docLookupCharacters += $hits[0].Length $docLookupCharacters += $hits[0].Length
} }
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。" Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
@@ -698,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' 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)}/hold' 'Admin 尾程暂扣入口'
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' '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 $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁' Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁'
@@ -1044,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本' Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希' Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' '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 $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围' Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围' Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI' Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用' Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'