This commit is contained in:
2026-10-21 17:22:19 +08:00
parent 0c6c55f6f1
commit 0976b6ef0d
23 changed files with 577 additions and 286 deletions
+1 -1
View File
@@ -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`及平台/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,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService',
'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController',
'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source)
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort'
|| [
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
@@ -1,10 +1,10 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
@@ -25,21 +25,21 @@ public class DispatchLoadingDocumentController {
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<DispatchLoadingDocumentService.Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
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 PhotoSlot slot,
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<DispatchLoadingDocumentService.Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
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));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@@ -51,7 +51,7 @@ public class DispatchLoadingDocumentController {
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) {
var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(value.content());
@@ -98,7 +98,7 @@ public interface DispatchAggregateMapper {
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
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}
""")
@@ -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));
@@ -1,10 +1,10 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
@@ -25,21 +25,21 @@ public class PdaDispatchLoadingDocumentController {
/** 读取本车已保存照片,不以设备缓存作为上传事实。 */
@GetMapping
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ApiResponse<DispatchLoadingDocumentService.Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
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 PhotoSlot slot,
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<DispatchLoadingDocumentService.Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
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));
}
/** 原上传仅允许当前有权限的原操作者恢复。 */
@@ -51,7 +51,7 @@ public class PdaDispatchLoadingDocumentController {
/** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
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());
@@ -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);
@@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 装车完成停在装车后;资料齐全时由资料服务在保存事务中推进派送。 */
/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */
@Service
public class DispatchLoadingDepartureService {
private final DispatchAggregateService workflow;
@@ -17,18 +18,32 @@ public class DispatchLoadingDepartureService {
private final DispatchDepartureService departure;
private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes;
private final DispatchLoadingPhotoPort documents;
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes,
DispatchLoadingPhotoPort documents) {
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
this.documents=documents;
}
/** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */
/** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
workflow.requireLoadingPermission(actor);
return workflow.loadingComplete(actor,number,request);
var context=workflow.lockForArrangement(actor,number);
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);
return result;
}
/** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */
@Service
public class DispatchLoadingDocumentService {
public static final List<Slot> SLOTS=List.of(Slot.values());
private final DispatchAggregateService workflow;
private final DispatchLoadingDepartureService departure;
private final DispatchLoadingPhotoPort photos;
/** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) {
this.workflow=workflow;this.departure=departure;this.photos=photos;
}
/** GET只读,PDA每次按可信主体重验冻结仓。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Documents get(Actor actor,String number) {
require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true));
}
/** 上传先记原请求意图,不提前绑定或推进派送。 */
public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) {
require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file);
}
/** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */
public Photo recover(Actor actor,String number,String key) {
require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key);
}
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) {
require(actor,true);var context=context(actor,number,true);
var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay;
var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request);
if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) {
departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number);
}
var result=documents(context,state,true);
photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result;
}
/** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */
@Transactional(readOnly=true)
public Download content(Actor actor,String number,Slot slot,long fileId) {
require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId);
}
/** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */
private Context context(Actor actor,String number,boolean lock) {
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|| value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null))
throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料");
return value;
}
/** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */
private Documents documents(Context context,State state,boolean editable) {
return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos());
}
/** 按五个固定框核齐,不能用总数或重复槽位代替。 */
static boolean complete(List<Photo> photos) {
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
}
/** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */
private void require(Actor actor,boolean write) {
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
}
/** 权限取当前会话,不为PDA补造平台权限。 */
private boolean allowed(Actor actor,boolean write) {
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
}
}
@@ -87,7 +87,7 @@ public class DispatchPdaQueryService {
if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对"));
if(!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作业");}
@@ -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());}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
final class DispatchReceivedPlan {
record Fact(DispatchPalletCandidate source, List<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();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)return Optional.empty();
var actual=rows.get(0);
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(original.isEmpty() || !new HashSet<>(original).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
&& !Objects.equals(Long.valueOf(selected.getPackageCount()),member.packages()))return Optional.empty();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
}
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) {
return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null
&& Objects.equals(actual.planPalletId(),item.getPlanPalletId())
&& Objects.equals(actual.customerId(),item.getCustomerId())
&& Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
&& Objects.equals(actual.destinationType(),item.getDestinationType())
&& Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
&& Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress());
}
}
@@ -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);
}
@@ -1,179 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */
@Service
public class DispatchLoadingDocumentService {
public static final List<PhotoSlot> SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST,
PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT);
private final DispatchAggregateService workflow;
private final DispatchLoadingDepartureService departure;
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** 复用现有附加表、文件归属校验和上传意图账本。 */
public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands;
this.projection=projection;this.codec=codec;this.files=files;
}
public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,
String stage,boolean editable,boolean complete,List<Photo> photos) {}
public record Binding(PhotoSlot slot,BindPhoto request) {}
public record Download(Photo photo,byte[] content) {}
/** GET不写入照片或推进阶段;PDA每次重验冻结仓。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Documents get(Actor actor,String number) {
require(actor,false);var context=context(actor,number,false);
return documents(context,repository.record(context.dispatchId()),canWrite(actor));
}
/** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false);
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)
throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new OwnedBusinessPhotoService.UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity);
if(!reservation.created())return uploaded(reservation.command());
try {
var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload));
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
} catch(RuntimeException failure) {
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");}
catch(RuntimeException recording){failure.addSuppressed(recording);}
throw conflict("照片上传结果未知,请保留原请求键核对");
}
}
/** 原上传恢复只允许原操作者和本车装车槽位。 */
public Photo recover(Actor actor,String number,String key) {
require(actor,true);codec.key(key);var context=context(actor,number,false);
var command=commands.existing(context.dispatchId(),key,operator(actor));
if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");
return uploaded(command);
}
/** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId());
if(fileId<=0)throw bad("文件编号无效");
var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request);
var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator);
if(old!=null) {
commands.same(old,"BIND_LOADING_DOCUMENT",version,identity);
if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");
return codec.read(old.getResultJson(),Documents.class);
}
// 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。
var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId));
var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId());
seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);
for(var value:projection.photos(row))photos.put(value.slot(),value);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));
row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(context.dispatchId());
history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId());
history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history);
if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) {
departure.departAfterDocuments(actor,number,request.idempotencyKey());
context=workflow.preview(actor,number);
}
var result=documents(context,row,true);
commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result);
return result;
}
/** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */
@Transactional(readOnly=true)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId) {
require(actor,false);slot(slot);var context=context(actor,number,false);
var photo=projection.photos(repository.record(context.dispatchId())).stream()
.filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst()
.orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片"));
return new Download(photo,files.content(context.dispatchId(),slot,fileId));
}
/** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */
private Context context(Actor actor,String number,boolean lock) {
var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number);
if(value.status()!=Status.ACTIVE || !Set.of(Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage())
|| value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料");
return value;
}
/** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */
private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) {
var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList();
return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()),
context.stage().name(),editable,complete(photos),photos);
}
/** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */
static boolean complete(List<Photo> photos) {
return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank()));
}
/** 平台读写权限分离,PDA沿既有仓作业身份授权。 */
private void require(Actor actor,boolean write) {
if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限");
}
/** 上传按钮只反映当前主体的实际写权限。 */
private boolean canWrite(Actor actor){return allowed(actor,true);}
/** 权限直接读取当前会话,不给PDA补造平台权限。 */
private boolean allowed(Actor actor,boolean write) {
var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false;
var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList();
return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate")
&& authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")
:authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list"));
}
/** 账本只消费可信操作者,权限由本服务独立校验。 */
private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());}
/** 油耗照片不能替代装车五框。 */
private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");}
/** 恢复回执仍须属于上传动作及装车五框。 */
private Photo uploaded(OwnedBusinessRows.Command command) {
if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)
throw conflict("原上传结果尚未确认,请保留原请求");
var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result;
}
/** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) {
return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null);
}
}
@@ -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);
}
}
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: 装车完成进入AFTER_LOADING。五个整车照片框均已保存时,在最后一次绑定事务内自动生成派送并进入DELIVERING;失败回滚该次绑定和派送,前四张与已确认实载保留。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
@@ -369,6 +369,7 @@ components:
stage:
type: string
enum:
- LOADING
- AFTER_LOADING
- DELIVERING
- DELIVERED
@@ -1728,8 +1728,8 @@ paths:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。
summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -253,8 +253,8 @@ paths:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。
summary: 确认装车完成,资料齐全时自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -3,8 +3,9 @@ package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.BindPhoto;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.BindPhoto;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Documents;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import java.nio.file.*;
@@ -42,7 +43,7 @@ class DispatchLoadingDeparturePersistenceTest {
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,PhotoSlot.RECEIPT,new BindPhoto("4","photo-bind-4","1004")));
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004")));
assertThat(replayPhoto).isEqualTo(fifth);
assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
@@ -103,13 +104,16 @@ class DispatchLoadingDeparturePersistenceTest {
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
}
/** 资料上传必须在装车后且属于绑定仓,平台只读权限不能保存照片。 */
/** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
authorize();var f=new Fixture();
assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("装车完成后");
assertThat(f.documents.get(f.f.actor,f.number).stage()).isEqualTo("LOADING");
f.f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING'");
assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("进入装车中后");
f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'");
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,PhotoSlot.FUEL_START,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框");
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list"));
assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse();
assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限");
@@ -124,7 +128,7 @@ class DispatchLoadingDeparturePersistenceTest {
var results=new ArrayList<Future<Boolean>>();
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
f.f.tx.execute(s->f.documents.bind(actor,f.number,PhotoSlot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
}catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}));
@@ -135,6 +139,50 @@ class DispatchLoadingDeparturePersistenceTest {
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
}
/** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */
@Test void photosReadyBeforeLoadingConfirmationDepartAndReplayOnce() throws Exception {
authorize();var f=new Fixture();
for(int i=0;i<5;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("LOADING");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);
assertThat(result.stage()).isEqualTo(Stage.DELIVERING);assertThat(result.counts().shippedPackageCount()).isEqualTo(8);
assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.DELIVERING);
assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"ready-complete")).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 资料先齐时派送失败回滚本次装车确认,五张已保存照片保留供原请求重试。 */
@Test void readyDocumentsSurviveFailedLoadingAndOriginalRequestCanRetry() throws Exception {
authorize();var f=new Fixture();for(int i=0;i<5;i++)f.bind(i,f.f.actor);
doThrow(new IllegalStateException("模拟发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready")))).hasMessageContaining("模拟发运失败");
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("LOADING");
assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='retry-ready'",Long.class)).isZero();
doNothing().when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready"))).stage()).isEqualTo(Stage.DELIVERING);
}
/** 最后一张照片与装车确认同时提交,不论谁先拿父锁均只发车一次。 */
@Test void lastPhotoAndLoadingConfirmationCanFinishConcurrently() throws Exception {
authorize();var f=new Fixture();for(int i=0;i<4;i++)f.bind(i,f.f.actor);
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var load=workers.submit(()->f.tryComplete(start,f.f.actor,"concurrent-load"));
var photo=workers.submit(()->{authorize();try {start.await(5,TimeUnit.SECONDS);return f.bind(4,new Actor("picker",ActorMode.PDA,"9"));}finally{SecurityContextHolder.clearContext();}});
start.countDown();assertThat(load.get(25,TimeUnit.SECONDS)).isTrue();assertThat(photo.get(25,TimeUnit.SECONDS)).isNotNull();
} finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
@@ -176,7 +224,6 @@ class DispatchLoadingDeparturePersistenceTest {
var hashes=new DispatchCommandHasher(f.json);
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("record","command","history")) {
@@ -187,10 +234,12 @@ class DispatchLoadingDeparturePersistenceTest {
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
documents=new DispatchLoadingDocumentService(f.service,service,records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos);
documents=new DispatchLoadingDocumentService(f.service,service,photos);
}
/** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */
DispatchLoadingDocumentService.Documents bind(int index,Actor actor) {
Documents bind(int index,Actor actor) {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
@@ -23,6 +23,34 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest {
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
assertThat(replay.replayed()).isTrue();
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
}
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(8,11);
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
}
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
@@ -68,6 +96,7 @@ class DispatchPickingPersistenceTest {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
final DispatchSourceRepository sources;
final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
Picking() throws Exception {this(false);}
@@ -94,10 +123,32 @@ class DispatchPickingPersistenceTest {
doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any());
doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull());
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
}
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
void receivedPlans(int first,int second) {
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10");
f.db.update("UPDATE ll_dispatch_order SET package_count=20");
var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules");
for(int n=1;n<=2;n++) {
long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second;
f.candidates.get(plan).packageCount(count);
f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n);
doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan);
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan);
doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan);
doReturn(plan).when(sources).lockPlan(customer,plan);
doReturn(1).when(sources).materialize(anyLong(),eq(customer),eq(plan),eq(pallet),anyLong(),any());
}
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();var row=f.candidates.get(plan);
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selected,"PLAN:"+plan,row.customerId(),row.customerName(),"INBOUND_PLAN","PLAN-"+plan,row.packageCount())),List.of(),true,List.of());
});
}
/** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
@@ -28,15 +28,23 @@ class DispatchPlanPdaReadPersistenceTest {
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
}
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
/** 在途和来源身份变化均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
var f=new Fixture(true);var before=f.dbState();
var task=f.read();
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
assertThat(task.counts().packageCount()).isEqualTo(20);
assertThat(f.dbState()).isEqualTo(before);
}
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture();
@@ -65,9 +73,13 @@ class DispatchPlanPdaReadPersistenceTest {
final DispatchService rules=mock(DispatchService.class);
final DispatchPdaQueryService queries;final String number;
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
Fixture() throws Exception {
var selection=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
Fixture() throws Exception {this(false);}
/** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */
Fixture(boolean twoPallets) throws Exception {
var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3);
var selections=new java.util.ArrayList<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)");
@@ -82,6 +94,21 @@ class DispatchPlanPdaReadPersistenceTest {
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
if(twoPallets) {
for(int n=1;n<=2;n++) {
long plan=n,pallet=500+n;int count=n==1?9:11;
f.candidates.get(plan).packageCount(count);
when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan)));
when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan));
when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan));
when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1)));
}
when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{
var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();
return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of());
});
}
}
/** 读取正式PDA入口。 */
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
+48 -9
View File
@@ -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++
@@ -329,10 +335,43 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。
$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue')
$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts')
$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') })
$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') })
$checked++
if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) {
Add-Failure '转运审核的界面入口与 API 能力必须分别定位'
} else {
Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口'
Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论'
Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口'
Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论'
Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用'
Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论'
if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') {
Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引'
Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引'
} else {
Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作'
Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开'
}
}
Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口'
Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板'
$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?<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; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
@{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' },
@{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' },
@{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget },
@{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget },
@@ -723,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁'
@@ -1069,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'