Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea7b4ff12 | ||
|
|
0976b6ef0d | ||
|
|
0c6c55f6f1 | ||
|
|
4a0e86bd42 |
@@ -77,7 +77,7 @@
|
||||
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
|
||||
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
|
||||
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
|
||||
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||
|
||||
### 装车资料触发派送边界(2026-10-21)
|
||||
|
||||
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
|
||||
|
||||
### 外配业务原排车详情读取边界(2026-10-21)
|
||||
|
||||
`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.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|
||||
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
|
||||
'com.baiyue.lastleg.dispatch.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',
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||
|
||||
+60
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+5
@@ -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")
|
||||
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")
|
||||
Long lockReturn(@Param("customer") String customer, @Param("id") Long id);
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class DispatchPdaController {
|
||||
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
|
||||
}
|
||||
|
||||
/** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */
|
||||
/** 真实扫码核平后确认装车进入装车后,两端共用同一命令并等待五张装车照片。 */
|
||||
@PostMapping("/{dispatchNo}/loading-complete")
|
||||
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));
|
||||
|
||||
+60
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -74,6 +74,8 @@ public class DispatchSourceRepository {
|
||||
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 boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;}
|
||||
/** 锁定实际退回收据根。 */
|
||||
public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); }
|
||||
/** 真实库存数量及版本。 */
|
||||
|
||||
+1
-1
@@ -540,7 +540,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
|
||||
}
|
||||
|
||||
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */
|
||||
/** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
|
||||
|
||||
+28
-13
@@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
|
||||
/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */
|
||||
@Service
|
||||
public class DispatchLoadingDepartureService {
|
||||
private final DispatchAggregateService workflow;
|
||||
@@ -17,38 +18,52 @@ public class DispatchLoadingDepartureService {
|
||||
private final DispatchDepartureService departure;
|
||||
private final DispatchCommandLedger commands;
|
||||
private final DispatchCommandHasher hashes;
|
||||
private final DispatchLoadingPhotoPort documents;
|
||||
|
||||
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
|
||||
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
|
||||
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes,
|
||||
DispatchLoadingPhotoPort documents) {
|
||||
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
|
||||
this.documents=documents;
|
||||
}
|
||||
|
||||
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
|
||||
/** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
|
||||
workflow.requireLoadingPermission(actor);
|
||||
var context=workflow.lockForArrangement(actor,number);
|
||||
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
|
||||
delivery.departureFacts(context,true);
|
||||
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
|
||||
context=workflow.preview(actor,number);
|
||||
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
|
||||
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
|
||||
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
|
||||
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
|
||||
@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 arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
|
||||
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
|
||||
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
|
||||
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark()));
|
||||
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
|
||||
stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中"));
|
||||
if(departed.stage()!=Stage.DELIVERING)
|
||||
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
|
||||
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
|
||||
commands.append(context.dispatchId(),actor,identity,result);return result;
|
||||
return departed;
|
||||
}
|
||||
|
||||
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
|
||||
|
||||
+85
@@ -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"));
|
||||
}
|
||||
}
|
||||
+6
-31
@@ -87,7 +87,7 @@ public class DispatchPdaQueryService {
|
||||
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
|
||||
if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平"));
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
}
|
||||
@@ -95,36 +95,11 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return List.of();
|
||||
var actual=rows.get(0);
|
||||
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||
return stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
|
||||
if(received.isEmpty())return List.of();
|
||||
// 只修改本次投影对象,不在GET更新排车、版本或库存。
|
||||
item.setPackageCount(received.get().source().packageCount());
|
||||
return received.get().positions();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
|
||||
+33
-21
@@ -27,6 +27,7 @@ public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -101,33 +102,44 @@ public class DispatchPickingService {
|
||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */
|
||||
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托");
|
||||
var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(orders.isEmpty())throw conflict("原计划缺少真实订单组成");
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0);
|
||||
if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对");
|
||||
if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单");
|
||||
item.setWarehousePalletId(actual.warehousePalletId());
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
|
||||
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
|
||||
.ifPresent(fact->received.put(candidate,fact));
|
||||
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
|
||||
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
|
||||
}
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对");
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true);
|
||||
var pallets=new ArrayList<Long>();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId());
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId());
|
||||
try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");}
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存");
|
||||
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|
||||
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
|
||||
}
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var candidate=entry.getKey();var actual=entry.getValue().source();
|
||||
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
|
||||
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
|
||||
|
||||
+56
@@ -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());
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+124
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,379 +1,242 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "排车车辆资料",
|
||||
"version": "1.0.0",
|
||||
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
|
||||
"get": {
|
||||
"operationId": "getDispatchVehicleDocuments",
|
||||
"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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
|
||||
"post": {
|
||||
"operationId": "uploadDispatchVehiclePhoto",
|
||||
"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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
|
||||
"put": {
|
||||
"operationId": "bindDispatchVehiclePhoto",
|
||||
"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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
|
||||
"get": {
|
||||
"operationId": "readDispatchVehiclePhoto",
|
||||
"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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
|
||||
"get": {
|
||||
"operationId": "recoverDispatchVehiclePhoto",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 排车车辆资料
|
||||
version: 1.0.0
|
||||
description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders
|
||||
documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents:
|
||||
get:
|
||||
operationId: getDispatchVehicleDocuments
|
||||
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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads:
|
||||
post:
|
||||
operationId: uploadDispatchVehiclePhoto
|
||||
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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}:
|
||||
put:
|
||||
operationId: bindDispatchVehiclePhoto
|
||||
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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content:
|
||||
get:
|
||||
operationId: readDispatchVehiclePhoto
|
||||
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/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}:
|
||||
get:
|
||||
operationId: recoverDispatchVehiclePhoto
|
||||
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'
|
||||
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:
|
||||
- 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
|
||||
|
||||
@@ -1728,8 +1728,8 @@ paths:
|
||||
type: string
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
summary: 确认装车完成,资料齐全时自动进入派送中
|
||||
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
|
||||
@@ -253,8 +253,8 @@ paths:
|
||||
type: string
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||
post:
|
||||
summary: 确认装车完成并自动进入派送中
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
|
||||
summary: 确认装车完成,资料齐全时自动进入派送中
|
||||
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
|
||||
+135
-21
@@ -1,6 +1,15 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.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.port.*;
|
||||
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.ArgumentMatchers.*;
|
||||
|
||||
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
|
||||
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
|
||||
class DispatchLoadingDeparturePersistenceTest {
|
||||
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
||||
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
||||
|
||||
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
|
||||
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
|
||||
/** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
|
||||
@Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING");
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
|
||||
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
|
||||
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,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_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
|
||||
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING");
|
||||
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
|
||||
}
|
||||
|
||||
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
|
||||
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
|
||||
/** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */
|
||||
@Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception {
|
||||
authorize();
|
||||
for(boolean failPreparation:List.of(true,false)) {
|
||||
var f=new Fixture();
|
||||
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
||||
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
|
||||
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
|
||||
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
|
||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
|
||||
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
||||
assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
|
||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
|
||||
assertThat(row.getLoadingCompletionHash()).isNotNull();
|
||||
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
}
|
||||
@@ -68,12 +87,12 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
||||
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
|
||||
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
|
||||
}
|
||||
|
||||
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
|
||||
/** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
|
||||
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
|
||||
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
@@ -81,20 +100,99 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
|
||||
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
} finally {workers.shutdownNow();}
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
|
||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
|
||||
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
|
||||
/** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */
|
||||
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
|
||||
authorize();var f=new Fixture();
|
||||
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","",
|
||||
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
|
||||
|
||||
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
|
||||
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
|
||||
final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
|
||||
final List<LoadingMeasurement> measurements;
|
||||
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
||||
Fixture() throws Exception {
|
||||
@@ -126,7 +224,23 @@ class DispatchLoadingDeparturePersistenceTest {
|
||||
var hashes=new DispatchCommandHasher(f.json);
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
|
||||
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
|
||||
f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
|
||||
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for(String suffix:List.of("record","command","history")) {
|
||||
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,"实装发车");}
|
||||
|
||||
+63
-1
@@ -23,6 +23,44 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
|
||||
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或创建第二条真实扫码。 */
|
||||
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
|
||||
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
|
||||
@@ -68,6 +106,7 @@ class DispatchPickingPersistenceTest {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
|
||||
final DispatchSourceRepository sources;
|
||||
final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
|
||||
Picking() throws Exception {this(false);}
|
||||
@@ -94,10 +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->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
|
||||
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull());
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
void receivedPlans(int first,int second) {
|
||||
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10");
|
||||
f.db.update("UPDATE ll_dispatch_order SET package_count=20");
|
||||
var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules");
|
||||
for(int n=1;n<=2;n++) {
|
||||
long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second;
|
||||
f.candidates.get(plan).packageCount(count);
|
||||
f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n);
|
||||
doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan);
|
||||
doReturn(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。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
|
||||
|
||||
+58
-7
@@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
|
||||
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 {
|
||||
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.rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
|
||||
/** 在途和来源身份变化均不成为可拣库存。 */
|
||||
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
|
||||
var f=new Fixture();var row=f.f.candidates.get(1L);
|
||||
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
|
||||
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
|
||||
var f=new Fixture(true);var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
|
||||
assertThat(task.counts().packageCount()).isEqualTo(20);
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
}
|
||||
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
|
||||
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
|
||||
var f=new Fixture();
|
||||
@@ -53,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
|
||||
var f=new Fixture();
|
||||
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));
|
||||
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,模拟原计划来源快照。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -65,9 +96,13 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
final DispatchService rules=mock(DispatchService.class);
|
||||
final DispatchPdaQueryService queries;final String number;
|
||||
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
|
||||
Fixture() throws Exception {
|
||||
var selection=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
|
||||
Fixture() throws Exception {this(false);}
|
||||
/** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */
|
||||
Fixture(boolean twoPallets) throws Exception {
|
||||
var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3);
|
||||
var selections=new java.util.ArrayList<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.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)");
|
||||
@@ -76,12 +111,28 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
|
||||
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",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(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
|
||||
if(twoPallets) {
|
||||
for(int n=1;n<=2;n++) {
|
||||
long plan=n,pallet=500+n;int count=n==1?9:11;
|
||||
f.candidates.get(plan).packageCount(count);
|
||||
when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan)));
|
||||
when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan));
|
||||
when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan));
|
||||
when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1)));
|
||||
}
|
||||
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
|
||||
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();
|
||||
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of());
|
||||
});
|
||||
}
|
||||
}
|
||||
/** 读取正式PDA入口。 */
|
||||
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
|
||||
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath
|
||||
$adminAgents = $adminEntry + "`n" + $adminIndex
|
||||
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
|
||||
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
|
||||
# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。
|
||||
$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?<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-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
|
||||
$clientEntry = Read-RequiredText $clientAgentsPath
|
||||
@@ -245,7 +249,8 @@ $lookupSamples = @(
|
||||
$lookupCharacters = 0
|
||||
foreach ($alias in @(
|
||||
@{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' },
|
||||
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Term = '转运通过'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
|
||||
@{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' },
|
||||
@{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' },
|
||||
@{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' },
|
||||
@@ -291,7 +296,8 @@ foreach ($document in @(
|
||||
@{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 },
|
||||
@{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 },
|
||||
@{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 },
|
||||
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }
|
||||
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 },
|
||||
@{ Name = 'Admin 海外仓指引'; Path = $adminOverseasAgentsPath; Text = $adminOverseasAgents; Limit = 8000 }
|
||||
)) {
|
||||
Assert-MarkdownLinks $document.Path $document.Text $document.Name
|
||||
$checked++
|
||||
@@ -306,11 +312,21 @@ foreach ($document in @(
|
||||
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
|
||||
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
|
||||
$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 + ' |'
|
||||
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) })
|
||||
# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。
|
||||
$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?<name>[^\r\n]+)\r?\n(?<body>.*?)(?=^## |\z)')
|
||||
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++
|
||||
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" }
|
||||
if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" }
|
||||
}
|
||||
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
|
||||
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
|
||||
@@ -319,20 +335,68 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史
|
||||
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
|
||||
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
|
||||
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
|
||||
# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。
|
||||
$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
|
||||
$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts')
|
||||
$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') })
|
||||
$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') })
|
||||
$checked++
|
||||
if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) {
|
||||
Add-Failure '转运审核的界面入口与 API 能力必须分别定位'
|
||||
} else {
|
||||
Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口'
|
||||
Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论'
|
||||
Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口'
|
||||
Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论'
|
||||
Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用'
|
||||
Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论'
|
||||
if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') {
|
||||
Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引'
|
||||
Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引'
|
||||
} else {
|
||||
Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作'
|
||||
Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开'
|
||||
}
|
||||
}
|
||||
Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口'
|
||||
Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板'
|
||||
$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?<body>.*?)(?=^## |\z)').Groups['body'].Value
|
||||
$checked++
|
||||
if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) {
|
||||
Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款'
|
||||
}
|
||||
|
||||
$docLookupCharacters = 0
|
||||
$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户'
|
||||
$docLookupSamples = @(
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' },
|
||||
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
|
||||
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' },
|
||||
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' },
|
||||
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' }
|
||||
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
|
||||
@{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
|
||||
@{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
|
||||
@{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
|
||||
@{ 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) {
|
||||
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
|
||||
$checked++
|
||||
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
|
||||
}
|
||||
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
|
||||
@@ -698,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
|
||||
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
|
||||
Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
|
||||
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
|
||||
Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁'
|
||||
@@ -1044,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
|
||||
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
|
||||
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
|
||||
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
|
||||
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
|
||||
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
|
||||
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
|
||||
|
||||
Reference in New Issue
Block a user