提交
This commit is contained in:
+20
-7
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
|
|||||||
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
|
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
|
||||||
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
|
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
|
||||||
}
|
}
|
||||||
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
|
/** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
|
||||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
|
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
|
||||||
workflow.lockForArrangement(actor,number);var row=required(number);
|
workflow.lockForArrangement(actor,number);var row=required(number);
|
||||||
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
|
|||||||
}
|
}
|
||||||
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
|
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
|
||||||
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
|
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
|
||||||
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow();
|
var members=facts.cargo(row.getId()).stream().filter(c->Objects.equals(c.item().getCustomerId(),cargo.item().getCustomerId()) && Objects.equals(c.orderNo(),cargo.orderNo())).toList();
|
||||||
|
var memberIds=members.stream().map(c->c.item().getId().toString()).collect(java.util.stream.Collectors.toSet());
|
||||||
|
var current=detail(row);var item=current.items().stream().filter(i->memberIds.contains(i.itemId())).findFirst().orElseThrow();
|
||||||
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
|
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
|
||||||
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
|
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
|
||||||
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
|
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
|
||||||
@@ -77,15 +79,18 @@ public class DispatchLooseboardExecutionService {
|
|||||||
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
|
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
|
||||||
var old=facts.history(row.getId());
|
var old=facts.history(row.getId());
|
||||||
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
||||||
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
boolean arrivalRecorded=members.stream().allMatch(c->old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,c) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()));
|
||||||
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
|
if(old.stream().filter(e->!memberIds.contains(e.itemId()) || !Objects.equals(e.customerId(),cargo.item().getCustomerId()) || !Objects.equals(e.orderNo(),cargo.orderNo()) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
|
||||||
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
|
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
|
||||||
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
|
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
|
||||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||||
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
|
|
||||||
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||||
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
|
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
|
||||||
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
|
// 一次订单操作保留逐托来源证据,原送达量统计和结案校验继续按明细精确核对。
|
||||||
|
for(var member:members){
|
||||||
|
var evidence=new Evidence(member.item().getId().toString(),member.item().getCustomerId(),member.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
|
||||||
|
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
|
||||||
|
}
|
||||||
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
|
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
|
||||||
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
|
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
|
||||||
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
|
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
|
||||||
@@ -171,7 +176,15 @@ public class DispatchLooseboardExecutionService {
|
|||||||
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
|
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
|
||||||
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
|
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
|
||||||
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
|
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
|
||||||
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed);
|
var groups=new LinkedHashMap<List<String>,List<Item>>();
|
||||||
|
for(var item:items)groups.computeIfAbsent(List.of(item.customerId(),item.orderNo()),key->new ArrayList<>()).add(item);
|
||||||
|
var orders=groups.values().stream().map(group->{
|
||||||
|
var first=group.get(0);
|
||||||
|
return new Item(first.itemId(),first.customerId(),first.orderNo(),first.destination(),group.stream().mapToInt(Item::packages).sum(),
|
||||||
|
group.stream().flatMap(i->i.pickupPhotos().stream()).distinct().toList(),group.stream().flatMap(i->i.arrivalPhotos().stream()).distinct().toList(),
|
||||||
|
group.stream().allMatch(Item::canPickup),group.stream().allMatch(Item::canArrive),group.stream().map(Item::warehouseNotice).filter(Objects::nonNull).findFirst().orElse(null),group.stream().anyMatch(Item::pickupRequired));
|
||||||
|
}).toList();
|
||||||
|
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,orders,ready,confirmed);
|
||||||
}
|
}
|
||||||
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
|
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
|
||||||
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
|
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
|
||||||
|
|||||||
+32
-5
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
import java.util.Set;
|
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||||
|
|
||||||
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
|
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
|
||||||
@Service
|
@Service
|
||||||
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
|
|||||||
private final LooseboardCargoRepository cargo;
|
private final LooseboardCargoRepository cargo;
|
||||||
private final DispatchAggregateRepository dispatches;
|
private final DispatchAggregateRepository dispatches;
|
||||||
private final DispatchAggregateService service;
|
private final DispatchAggregateService service;
|
||||||
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
|
private final DispatchCommandHasher hashes;
|
||||||
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
|
/** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
|
||||||
|
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service,DispatchCommandHasher hashes) {
|
||||||
|
this.cargo=cargo; this.dispatches=dispatches; this.service=service;this.hashes=hashes;
|
||||||
}
|
}
|
||||||
private String warehouse(String number) {
|
private String warehouse(String number) {
|
||||||
var row=dispatches.find(number);
|
var row=dispatches.find(number);
|
||||||
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
|
|||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
|
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
|
||||||
return row.getOverseasWarehouseId();
|
return row.getOverseasWarehouseId();
|
||||||
}
|
}
|
||||||
|
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
|
||||||
@Transactional(readOnly=true)
|
@Transactional(readOnly=true)
|
||||||
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
|
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
|
||||||
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
|
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
|
||||||
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
|
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
|
||||||
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
|
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
|
||||||
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL"));
|
var sources=new ArrayList<Candidate>();
|
||||||
|
for(int sourcePage=1;;sourcePage++){
|
||||||
|
var result=service.candidates(actor,new CandidateQuery(warehouse,plans,null,null,null,null,sourcePage,50,"ALL"));
|
||||||
|
sources.addAll(result.candidates());
|
||||||
|
if(sourcePage>=result.pages() || result.candidates().isEmpty())break;
|
||||||
|
}
|
||||||
|
var groups=new LinkedHashMap<List<String>,List<Candidate>>();
|
||||||
|
for(var source:sources)groups.computeIfAbsent(List.of(source.customerId(),Objects.requireNonNullElse(source.orderNo(),source.sourceKey())),key->new ArrayList<>()).add(source);
|
||||||
|
var orders=groups.values().stream().map(this::orderCandidate).toList();
|
||||||
|
long offset=(long)(page-1)*size;
|
||||||
|
return new CandidateList(orders.stream().skip(offset).limit(size).toList(),Instant.now(),orders.size()<=size,orders.size(),page,size,(orders.size()+size-1L)/size);
|
||||||
|
}
|
||||||
|
/** 合并真实来源闭包和版本,显示箱数按本单来源相加,提交仍逐来源复验。 */
|
||||||
|
private Candidate orderCandidate(List<Candidate> rows){
|
||||||
|
var first=rows.get(0);var members=new LinkedHashMap<String,SelectionGroupSource>();
|
||||||
|
var pallets=new LinkedHashMap<String,SelectionGroupPallet>();var reasons=new LinkedHashSet<String>();boolean complete=true;
|
||||||
|
for(var row:rows){
|
||||||
|
reasons.addAll(row.blockReasons());var group=row.selectionGroup();
|
||||||
|
if(group==null){complete=false;reasons.add("订单货物来源尚未完整,请重新读取");continue;}
|
||||||
|
complete&=group.complete();reasons.addAll(group.blockReasons());
|
||||||
|
for(var member:group.sources())members.putIfAbsent(member.sourceKey(),member);
|
||||||
|
for(var pallet:group.pallets())pallets.putIfAbsent(pallet.warehousePalletId()==null?group.groupKey()+":"+pallet.palletNo():pallet.warehousePalletId().toString(),pallet);
|
||||||
|
}
|
||||||
|
var group=new SelectionGroup("LOOSEBOARD_ORDER:"+hashes.source(List.of(first.customerId(),Objects.requireNonNullElse(first.orderNo(),first.sourceKey()))),hashes.source(rows.stream().map(Candidate::selectionGroup).toList()),List.copyOf(members.values()),List.copyOf(pallets.values()),complete,List.copyOf(reasons));
|
||||||
|
return new Candidate(first.selection(),first.sourceKey(),first.sourceVersion(),first.customerId(),first.customerName(),first.warehouseId(),first.warehouseCode(),first.warehouseName(),first.orderNo(),first.inboundCode(),first.planNo(),null,first.containerNo(),first.destination(),rows.stream().mapToLong(Candidate::availablePackages).sum(),null,null,"ORDER_SOURCES",rows.stream().flatMap(r->r.appointmentBatches().stream()).distinct().toList(),List.copyOf(reasons),group);
|
||||||
}
|
}
|
||||||
@Transactional
|
@Transactional
|
||||||
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
|
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
|
||||||
|
|||||||
+28
-4
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
|
|||||||
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
|
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
|
||||||
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
|
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
|
||||||
|
|
||||||
|
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
|
||||||
public Status status(long warehouseId, String dispatchNo) {
|
public Status status(long warehouseId, String dispatchNo) {
|
||||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||||
if (binding == null) throw notFound();
|
if (binding == null) throw notFound();
|
||||||
var trip = trips.findByNo(binding.getTripNo());
|
var trip = trips.findByNo(binding.getTripNo());
|
||||||
// Check token-bound warehouse before reading any customer's transport or historical data.
|
// 先校验令牌绑定仓,再读取客户运输及历史资料。
|
||||||
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
|
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
|
||||||
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
|
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
|
||||||
var evidence = dispatches.transitEvidence(dispatchNo, false);
|
var evidence = dispatches.transitEvidence(dispatchNo, false);
|
||||||
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|
||||||
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
|
|| !dispatchNo.equals(evidence.dispatchNo()) || evidence.shippedPackages()<=0) throw notFound();
|
||||||
var deliveryNumbers=new java.util.HashSet<String>();
|
var deliveryNumbers=new java.util.HashSet<String>();
|
||||||
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
|
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
|
||||||
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|
||||||
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|
||||||
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|
||||||
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound();
|
)throw notFound();
|
||||||
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
|
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
|
||||||
.eq(FleetTripHistory::getAction, "DEPART"));
|
.eq(FleetTripHistory::getAction, "DEPART"));
|
||||||
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
|
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
|
||||||
try {
|
try {
|
||||||
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
|
var originalSnapshot = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class);
|
||||||
|
var departed = originalSnapshot.trip();
|
||||||
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
|
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
|
||||||
|
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
|
||||||
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
|
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
|
||||||
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
|
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
|
||||||
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
|
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
|
||||||
@@ -71,6 +74,27 @@ public class FleetHandoverPdaQuery {
|
|||||||
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
|
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
|
||||||
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
|
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
|
||||||
}
|
}
|
||||||
|
/** 用原始逐来源实载证明客户、目的地及箱数,不能仅放宽整车数量相等判断。 */
|
||||||
|
private static void requireWarehouseCargo(String number,FleetTripDispatch binding,FleetTransitEvidence evidence,FleetTripModels.AuditSnapshot snapshot){
|
||||||
|
long packages=evidence.deliveries().stream().mapToLong(d->d.cargo().packages()).sum();
|
||||||
|
if(snapshot.confirmedLoad()==null){
|
||||||
|
if(!Objects.equals(binding.getPackageCount(),evidence.shippedPackages()) || packages!=evidence.shippedPackages())throw notFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var expected=new java.util.HashMap<List<String>,Long>();
|
||||||
|
if(snapshot.confirmedLoad().cargo()==null)throw notFound();
|
||||||
|
for(var cargo:snapshot.confirmedLoad().cargo())if(number.equals(cargo.dispatchNo())){
|
||||||
|
if(cargo.customerId()==null || cargo.destinationKey()==null || cargo.warehousePalletId()==null || cargo.packages()<=0)throw notFound();
|
||||||
|
expected.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
|
||||||
|
}
|
||||||
|
var actual=new java.util.HashMap<List<String>,Long>();
|
||||||
|
for(var delivery:evidence.deliveries()){
|
||||||
|
var cargo=delivery.cargo();if(cargo.destinationKey()==null)throw notFound();
|
||||||
|
actual.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
|
||||||
|
}
|
||||||
|
if(expected.isEmpty() || !expected.equals(actual) || binding.getPackageCount()==null || packages>evidence.shippedPackages() || evidence.shippedPackages()>binding.getPackageCount())throw notFound();
|
||||||
|
if(snapshot.dispatches()==null || snapshot.dispatches().stream().noneMatch(d->number.equals(d.dispatchNo()) && d.packageCount()==binding.getPackageCount()))throw notFound();
|
||||||
|
}
|
||||||
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
|
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
|
||||||
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
|
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ info:
|
|||||||
title: 本土散板排车照片履约
|
title: 本土散板排车照片履约
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: >-
|
description: >-
|
||||||
|
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
|
||||||
|
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
|
||||||
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
|
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
|
||||||
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
|
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
|
||||||
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
|
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
|
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
|
||||||
responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } }
|
responses: { '200': { description: 统一ApiResponse;data为CandidateList,同客户散板原单一行且按订单分页,total为订单数;availablePackages汇总本车可选的同单各托,selectionGroup包含该单所有来源及原始版本,任一来源阻断则整单不可选,加入时一次展开全部来源 } }
|
||||||
post:
|
post:
|
||||||
summary: 将已收货散板加入需求排车
|
summary: 将已收货散板加入需求排车
|
||||||
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
|
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
|
||||||
|
|||||||
+20
@@ -19,6 +19,26 @@ import static org.mockito.Mockito.*;
|
|||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
class DispatchLooseboardExecutionServiceTest {
|
class DispatchLooseboardExecutionServiceTest {
|
||||||
|
/** 同一客户散板订单多托只显示一次,保存的照片逐托原子留证并精确计量。 */
|
||||||
|
@Test void oneOrderSharesPhotosAcrossPalletsWithoutCrossingCustomers() {
|
||||||
|
var f=new Fixture(false);var cargo=f.repository.cargo(1);
|
||||||
|
cargo.add(f.item(104,"A","ZY1"));cargo.add(f.item(105,"C","ZY3"));
|
||||||
|
when(f.sourceRepo.businessSources(anyList())).thenReturn(List.of(
|
||||||
|
new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),
|
||||||
|
new BusinessSource("A","ZY1","LB1",104L,"WAREHOUSE_PICKUP"),
|
||||||
|
new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP"),
|
||||||
|
new BusinessSource("C","ZY3","LB1",105L,"WAREHOUSE_PICKUP")));
|
||||||
|
var initial=f.service.get(f.actor,"PC1");
|
||||||
|
assertThat(initial.items()).hasSize(3);
|
||||||
|
assertThat(initial.items().get(0).packages()).isEqualTo(4);
|
||||||
|
var saved=f.save(101,Phase.PICKUP,1);
|
||||||
|
assertThat(saved.items().get(0).pickupPhotos()).hasSize(1);
|
||||||
|
assertThat(saved.items().get(2).pickupPhotos()).isEmpty();
|
||||||
|
assertThat(f.facts.current(f.row)).extracting(Evidence::itemId).containsExactlyInAnyOrder("101","104");
|
||||||
|
f.replace(104,Phase.PICKUP,1L,2L);
|
||||||
|
assertThat(f.service.get(f.actor,"PC1").items().get(0).pickupPhotos()).hasSize(2);
|
||||||
|
assertThatThrownBy(()->f.save(105,Phase.PICKUP,1)).hasMessageContaining("不能重复使用");
|
||||||
|
}
|
||||||
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
|
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
|
||||||
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
|
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
|
||||||
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
|
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
|
/** 散板加货按完整订单分页,来源跨页和客户隔离不能丢失。 */
|
||||||
|
class LooseboardCargoServiceTest {
|
||||||
|
/** 同单51托跨来源页仍为一行,另一客户同号订单独立分页。 */
|
||||||
|
@Test void orderPaginationIncludesEveryPalletAndKeepsCustomersSeparate() throws Exception {
|
||||||
|
var cargo=mock(LooseboardCargoRepository.class);var repository=mock(DispatchAggregateRepository.class);
|
||||||
|
var workflow=mock(DispatchAggregateService.class);var json=new ObjectMapper();
|
||||||
|
var service=new LooseboardCargoService(cargo,repository,workflow,new DispatchCommandHasher(json));
|
||||||
|
var root=new DispatchAggregate();root.setTaskType("DELIVERY");root.setStatus("ACTIVE");root.setPublishLooseboardDemand(true);root.setOverseasWarehouseId("9");
|
||||||
|
when(repository.find("PC1")).thenReturn(root);when(cargo.readyPlans("9")).thenReturn(List.of(1L));
|
||||||
|
var sources=new ArrayList<Candidate>();for(int i=1;i<=51;i++)sources.add(candidate(json,i,"A",i==51));
|
||||||
|
sources.add(candidate(json,52,"B",false));
|
||||||
|
when(workflow.candidates(any(),any())).thenAnswer(call->{
|
||||||
|
CandidateQuery query=call.getArgument(1);int offset=(query.page()-1)*50;
|
||||||
|
return new CandidateList(sources.subList(offset,Math.min(offset+50,sources.size())),Instant.now(),false,52,query.page(),50,2);
|
||||||
|
});
|
||||||
|
var actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
|
||||||
|
var first=service.candidates(actor,"PC1",1,1);
|
||||||
|
assertThat(first.total()).isEqualTo(2);assertThat(first.pages()).isEqualTo(2);
|
||||||
|
assertThat(first.candidates()).singleElement().satisfies(order->{
|
||||||
|
assertThat(order.availablePackages()).isEqualTo(102);
|
||||||
|
assertThat(order.selectionGroup().sources()).hasSize(51);
|
||||||
|
assertThat(order.selectionGroup().pallets()).hasSize(51);
|
||||||
|
assertThat(order.blockReasons()).containsExactly("最后一托被占用");
|
||||||
|
});
|
||||||
|
assertThat(service.candidates(actor,"PC1",2,1).candidates()).singleElement().satisfies(order->{
|
||||||
|
assertThat(order.customerId()).isEqualTo("B");assertThat(order.availablePackages()).isEqualTo(2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 构造带权威选择版本、物理组成及阻断事实的来源。 */
|
||||||
|
private static Candidate candidate(ObjectMapper json,int id,String customer,boolean blocked) throws Exception {
|
||||||
|
return json.readValue("""
|
||||||
|
{"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"},"sourceKey":"K%d","sourceVersion":"v%d",
|
||||||
|
"customerId":"%s","orderNo":"LB1","warehouseId":"9","availablePackages":2,"appointmentBatches":[],"blockReasons":%s,
|
||||||
|
"selectionGroup":{"groupKey":"G%d","groupVersion":"g%d","complete":true,"blockReasons":[],
|
||||||
|
"sources":[{"sourceKey":"K%d","customerId":"%s","packages":2,"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"}}],
|
||||||
|
"pallets":[{"palletNo":"P%d","sourceKeys":["K%d"],"packageCount":2}]}}
|
||||||
|
""".formatted(id,id,id,id,customer,blocked?"[\"最后一托被占用\"]":"[]",id,id,id,customer,id,id,id,id),Candidate.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -13,6 +13,24 @@ import static org.mockito.ArgumentMatchers.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class FleetHandoverPdaQueryTest {
|
class FleetHandoverPdaQueryTest {
|
||||||
|
/** 混排整车109箱,仓内真实发运9箱时按原实载客户及目的地读取交接。 */
|
||||||
|
@Test void mixedDispatchUsesOriginalWarehouseLoadBeforeAndAfterRemotePickup() {
|
||||||
|
var binding=bindings.findByDispatchNo("PC-A");binding.setPackageCount(109);
|
||||||
|
var departed=new FleetTripHistory();departed.setSnapshotJson("""
|
||||||
|
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL"},
|
||||||
|
"dispatches":[{"dispatchNo":"PC-A","packageCount":109}],
|
||||||
|
"confirmedLoad":{"cargo":[{"dispatchNo":"PC-A","customerId":"CUSTOMER-A","warehousePalletId":"501","destinationKey":"FBA:ONE","packages":9}]}}
|
||||||
|
""");
|
||||||
|
when(history.selectList(any())).thenReturn(List.of(departed));
|
||||||
|
var delivery=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A","PC-A","DL-9","FBA:ONE",9,Instant.now(),List.of()),"DELIVERED",1,new FleetTransitEvidence.Transport("ORIGINAL","原司机",null));
|
||||||
|
for(int shipped:List.of(9,109)){
|
||||||
|
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",shipped,List.of(delivery)));
|
||||||
|
assertEquals(9,service.status(92,"PC-A").deliveries().get(0).packages());
|
||||||
|
}
|
||||||
|
var wrongCustomer=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("OTHER","PC-A","DL-9","FBA:ONE",9,null,List.of()),"DELIVERING",1,delivery.transport());
|
||||||
|
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",109,List.of(wrongCustomer)));
|
||||||
|
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));
|
||||||
|
}
|
||||||
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
|
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
|
||||||
final FleetTripMapper trips = mock(FleetTripMapper.class);
|
final FleetTripMapper trips = mock(FleetTripMapper.class);
|
||||||
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
|
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
|
||||||
|
|||||||
Reference in New Issue
Block a user