fix(dispatch): 在审核及拣货前核对预约与运输时间
This commit is contained in:
+5
@@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort {
|
||||
void requireDeparture(String dispatchNo);
|
||||
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
void requireTripBinding(String dispatchNo,long tripId);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ public final class DispatchViews {
|
||||
|
||||
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
|
||||
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments) { }
|
||||
|
||||
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
|
||||
Destination destination, long scheduledPackages, long pickedPackages,
|
||||
|
||||
+40
-6
@@ -434,8 +434,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name();
|
||||
if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化");
|
||||
if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空");
|
||||
if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading) {
|
||||
if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading && request.decision()==Decision.APPROVED) {
|
||||
var dispatch=reviews(row).dispatchReview();
|
||||
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
|
||||
}
|
||||
@@ -562,6 +562,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
||||
lockExecutionSources(row,items);
|
||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||
String appointmentBlock=appointments.departureBlockReason(number);
|
||||
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
||||
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
|
||||
@@ -640,7 +642,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));}
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
@@ -667,6 +669,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
|
||||
void verifyReviewResources(DispatchAggregate row) {
|
||||
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
|
||||
requireReviewAppointment(row);
|
||||
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
|
||||
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
@@ -679,6 +682,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
|
||||
void requireReviewAppointment(DispatchAggregate row) {
|
||||
String reason=reviewAppointmentBlock(row);
|
||||
if(reason!=null)throw conflict(reason);
|
||||
}
|
||||
private String reviewAppointmentBlock(DispatchAggregate row) {
|
||||
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
|
||||
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
|
||||
return reason;
|
||||
}
|
||||
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
|
||||
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据");
|
||||
@@ -736,6 +750,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
statistics(repository.summaries(filter)),Instant.now());
|
||||
}
|
||||
|
||||
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
|
||||
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
|
||||
var confirmedAppointments=this.appointments.reviewEvidence(number);
|
||||
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Detail detail(Actor actor, String number) {
|
||||
@@ -746,9 +772,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b));
|
||||
var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(),
|
||||
group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList();
|
||||
var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream()
|
||||
.map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList();
|
||||
var appointments=reviewAppointments(row);
|
||||
var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),
|
||||
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
|
||||
d.status(),d.packageCount(),d.palletCount())).toList();
|
||||
@@ -880,6 +904,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||
String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING)
|
||||
?reviewAppointmentBlock(row):null;
|
||||
for(var action:Action.values()) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
||||
boolean state=switch(action) {
|
||||
@@ -897,6 +923,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
};
|
||||
boolean permitted=hasPermission(actor,action);
|
||||
gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件"));
|
||||
if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) {
|
||||
boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW;
|
||||
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
|
||||
}
|
||||
if(action==Action.LOADING_COMPLETE && state && permitted) {
|
||||
String reason=appointments.departureBlockReason(row.getDispatchNo());
|
||||
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableMap(gates);
|
||||
}
|
||||
|
||||
+6
@@ -17,6 +17,12 @@ public class DispatchAppointmentAllocations {
|
||||
private final AppointmentWorkDispatchPort provider;
|
||||
public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;}
|
||||
|
||||
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
|
||||
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
|
||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||
|
||||
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
|
||||
var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList();
|
||||
|
||||
+3
-1
@@ -89,7 +89,9 @@ public class DispatchPdaQueryService {
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
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)));
|
||||
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),
|
||||
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
|
||||
root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ public class DispatchPickingService {
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|
||||
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
||||
workflow.requireReviewAppointment(row);
|
||||
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
|
||||
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
|
||||
+52
-5
@@ -141,15 +141,61 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致");
|
||||
if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致");
|
||||
}
|
||||
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); }
|
||||
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); }
|
||||
public String departureBlockReason(String dispatchNo) {
|
||||
try { checkDeparture(dispatchNo,false);return null; }
|
||||
try { checkDeparture(dispatchNo,false,true);return null; }
|
||||
catch(org.springframework.web.server.ResponseStatusException error) {
|
||||
if(error.getStatusCode().value()!=409) throw error;
|
||||
return error.getReason();
|
||||
}
|
||||
}
|
||||
private void checkDeparture(String dispatchNo,boolean locked) {
|
||||
/** 审核使用计划/实收分配,不要求尚未发生的拣货;发车继续核对真实已拣来源。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) {
|
||||
try {
|
||||
checkDeparture(dispatchNo,false,false);
|
||||
var windows=reviewEvidence(dispatchNo);
|
||||
if(!windows.isEmpty()) {
|
||||
if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核";
|
||||
if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间";
|
||||
var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow();
|
||||
if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间";
|
||||
if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
|
||||
}
|
||||
return null;
|
||||
} catch(org.springframework.web.server.ResponseStatusException error) {
|
||||
if(error.getStatusCode().value()!=409)throw error;
|
||||
return error.getReason();
|
||||
}
|
||||
}
|
||||
/** 审核依据取当前有效收货确认,不能用旧批次头的计划时间替代。 */
|
||||
public List<ReceivingWindow> reviewEvidence(String dispatchNo) {
|
||||
var result=new ArrayList<ReceivingWindow>();var seen=new HashSet<List<String>>();
|
||||
for(var row:mapper.bindings(dispatchNo)) {
|
||||
if(row.batchKey().startsWith("DISPATCH:") || !seen.add(List.of(row.batchKey(),row.customerId(),row.destinationKey())))continue;
|
||||
var agreement=receiving.currentAgreement(row.batchKey(),row.customerId());
|
||||
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) || agreement.details().appointmentAt()==null)continue;
|
||||
var member=agreement.members().stream().filter(m->row.batchKey().equals(m.batchKey()) && row.customerId().equals(m.customerId())).findFirst().orElse(null);
|
||||
if(member==null)continue;
|
||||
var details=agreement.details();
|
||||
result.add(new ReceivingWindow(row.batchKey(),member.appointmentNo(),row.customerId(),row.destinationKey(),details.confirmationNo(),
|
||||
receivingInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()),
|
||||
details.appointmentEndAt()==null?null:receivingInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()),agreement.agreementKey(),agreement.version()));
|
||||
}
|
||||
return result.stream().sorted(Comparator.comparing(ReceivingWindow::batchKey).thenComparing(ReceivingWindow::customerId).thenComparing(ReceivingWindow::destinationKey)).toList();
|
||||
}
|
||||
/** 保留仓库当地时区及明确偏移,不把美国预约时间按服务器时区解释。 */
|
||||
private java.time.Instant receivingInstant(java.time.LocalDateTime at,String timezone,String offset) {
|
||||
try {
|
||||
if(timezone==null)throw conflict("预约收货时间的时区尚未明确");
|
||||
var zone=java.time.ZoneId.of(timezone);
|
||||
var valid=zone.getRules().getValidOffsets(at);
|
||||
if(valid.isEmpty() || offset==null && valid.size()!=1)throw conflict("预约收货时间的时区偏移尚未明确");
|
||||
var selected=offset==null?valid.get(0):java.time.ZoneOffset.of(offset);
|
||||
if(!valid.contains(selected))throw conflict("预约收货时间的时区偏移无效");
|
||||
return at.toInstant(selected);
|
||||
} catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");}
|
||||
}
|
||||
private void checkDeparture(String dispatchNo,boolean locked,boolean picked) {
|
||||
var rows=mapper.bindings(dispatchNo);
|
||||
var physical=mapper.dispatchPhysical(dispatchNo);
|
||||
for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId())))
|
||||
@@ -158,6 +204,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
if(locked) for(var batch:batches) lock(batch);
|
||||
for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) {
|
||||
requireNoBatch(item.customerId(),item.planPalletId());
|
||||
if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核");
|
||||
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
|
||||
}
|
||||
for(var batch:batches) {
|
||||
@@ -175,8 +222,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) {
|
||||
var first=group.get(0);
|
||||
var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow();
|
||||
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()),
|
||||
group.stream().map(a->allocation(a,null)).toList(),true,true);
|
||||
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()),
|
||||
group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked);
|
||||
}
|
||||
}
|
||||
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
|
||||
|
||||
@@ -6644,6 +6644,21 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCounts'
|
||||
nullable: true
|
||||
plannedDepartureAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当前排车计划发车时间,审核必须核对。
|
||||
estimatedArrivalAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当前排车预计送达时间。
|
||||
fbaAppointments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchFbaAppointment'
|
||||
description: 按本车各客户批次读取当前有效收货确认;缺失确认显示PENDING。
|
||||
pickingReconciled:
|
||||
type: boolean
|
||||
items:
|
||||
|
||||
+8
-2
@@ -454,8 +454,14 @@ public class DispatchAggregatePersistenceTest {
|
||||
f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item);
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo()));
|
||||
assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1);
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL");
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z"));
|
||||
// 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING");
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull();
|
||||
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
|
||||
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
|
||||
var c=f.source(3,"101",503,"ONT8",2);
|
||||
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
|
||||
var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
|
||||
|
||||
+58
@@ -19,6 +19,64 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
|
||||
class DispatchLoadingEligibilityPersistenceTest {
|
||||
@org.junit.jupiter.api.BeforeEach void authorize() {
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator","",
|
||||
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete",
|
||||
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
}
|
||||
@org.junit.jupiter.api.AfterEach void clearIdentity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();}
|
||||
/** 允许预排;客服提交及两级批准均复验,缺预约时仍能驳回客服而不能开始拣货。 */
|
||||
@org.junit.jupiter.api.Test void preArrangementRemainsAllowedButReviewsCannotApproveUnconfirmedAppointments() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("review-appointment",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"blocked-submit",null)))).hasMessageContaining("尚未确认");
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
|
||||
var submitted=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
|
||||
var gate=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.DISPATCH_REVIEW);
|
||||
assertThat(gate.allowed()).isTrue();assertThat(gate.blockCode()).isEqualTo("APPROVAL_BLOCKED");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
|
||||
// 预约依据变化后仍可驳回,不让失效审核把客服修正入口锁死。
|
||||
var changedWindow=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","YY1","101","FBA:ONT8","CONF1",java.time.Instant.parse("2026-12-01T10:00:00Z"),null,"agreement-1",2);
|
||||
when(provider.reviewEvidence(anyString())).thenReturn(List.of(changedWindow));
|
||||
var rejected=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"reject",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.REJECTED,"请客服核对预约",null)));
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
|
||||
var submittedAgain=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"resubmit",null)));
|
||||
var reviewed=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submittedAgain.recordVersion(),"dispatch-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"bad-loading-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
|
||||
f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
|
||||
when(provider.reviewEvidence(anyString())).thenReturn(List.of());
|
||||
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("原审核依据失效");
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
|
||||
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
assertThat(f.tx.execute(t->f.service.detail(pda,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.PICK).allowed()).isFalse();
|
||||
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("尚未确认");
|
||||
}
|
||||
/** 照片或终端不改变预约门槛;显示与正式提交均拒绝未准备好的预约。 */
|
||||
@org.junit.jupiter.api.Test void appointmentBlockIsVisibleBeforeLoadingSubmission() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-gate",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
var approved=f.approve(created);f.picked(1,"101",501,3);
|
||||
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3");
|
||||
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
when(provider.departureBlockReason(created.dispatchNo())).thenReturn("请先约仓成功再排车或发车");
|
||||
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
|
||||
assertThat(preview.pickingReconciled()).isTrue();
|
||||
assertThat(preview.loadingComplete().allowed()).isFalse();
|
||||
assertThat(preview.loadingComplete().blockCode()).isEqualTo("APPOINTMENT_NOT_READY");
|
||||
assertThat(f.tx.execute(t->f.service.detail(actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.LOADING_COMPLETE).allowed()).isFalse();
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"blocked-loading",preview.manifestHash(),null))))
|
||||
.hasMessageContaining("先约仓成功");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("LOADING");
|
||||
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
|
||||
}
|
||||
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans={false,true})
|
||||
|
||||
+4
@@ -31,6 +31,10 @@ class DispatchPdaReadPersistenceTest {
|
||||
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null)));
|
||||
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
|
||||
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
var platform=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(task.plannedDepartureAt()).isEqualTo(platform.dispatchInfo().plannedDepartureAt());
|
||||
assertThat(task.estimatedArrivalAt()).isEqualTo(platform.dispatchInfo().estimatedArrivalAt());
|
||||
assertThat(task.fbaAppointments()).isEqualTo(platform.dispatchInfo().fbaAppointments());
|
||||
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
|
||||
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
|
||||
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null)));
|
||||
|
||||
+11
@@ -23,6 +23,17 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
|
||||
class DispatchPickingPersistenceTest {
|
||||
/** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */
|
||||
@Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fixture.f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"appointment-blocked",11L,9,0,null,null)))).hasMessageContaining("尚未确认");
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
|
||||
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
|
||||
+11
@@ -58,6 +58,16 @@ class AppointmentWorkDispatchBindingTest {
|
||||
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
assertDoesNotThrow(()->service.requireDeparture("D-20"));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4)));
|
||||
var departure=java.time.Instant.parse("2026-10-18T12:00:00Z");
|
||||
var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z");
|
||||
clearInvocations(mapper);
|
||||
assertNull(service.reviewBlockReason("D-20",departure,arrival));
|
||||
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
|
||||
assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释");
|
||||
assertNotNull(service.reviewBlockReason("D-20",departure,null));
|
||||
assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120)));
|
||||
assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60)));
|
||||
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
|
||||
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
|
||||
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
|
||||
@@ -76,6 +86,7 @@ class AppointmentWorkDispatchBindingTest {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
|
||||
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
|
||||
assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认"));
|
||||
verifyNoInteractions(changes);
|
||||
}
|
||||
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
|
||||
|
||||
Reference in New Issue
Block a user