fix: complete October 22 workflow feedback and request failure recovery
This commit is contained in:
+14
-1
@@ -19,7 +19,20 @@ public interface DispatchAggregateMapper {
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
|
||||
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND (
|
||||
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.destination_type='FBA'
|
||||
AND JSON_CONTAINS(COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
JSON_OBJECT('destinationKey',CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(i.destination_warehouse_code,''),i.fba_warehouse_code)))),'appointmentNo',#{q.fbaAppointmentNo}))=1)
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a
|
||||
WHERE a.dispatch_id=d.id AND a.batch_key LIKE 'FBA_APPOINTMENT:%' AND
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
=#{q.fbaAppointmentNo}
|
||||
AND NOT EXISTS(SELECT 1 FROM JSON_TABLE(
|
||||
COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
'$[*]' COLUMNS(destination_key VARCHAR(256) PATH '$.destinationKey',
|
||||
appointment_no VARCHAR(128) PATH '$.appointmentNo' NULL ON EMPTY)) saved
|
||||
WHERE saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL))
|
||||
)</if>
|
||||
""";
|
||||
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
|
||||
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
|
||||
|
||||
+4
-2
@@ -49,11 +49,13 @@ public interface DispatchBolMapper {
|
||||
""")
|
||||
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
|
||||
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
|
||||
/** 原batch+客户+稳定来源+目的地定位货物,编号取当前客户收货确认;BOOKING保留持久化order_id。 */
|
||||
@Select("""
|
||||
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
|
||||
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
AS confirmationNo
|
||||
FROM ll_dispatch_appointment_allocation a
|
||||
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
|
||||
|
||||
+18
@@ -55,6 +55,24 @@ public interface DispatchReadMapper {
|
||||
""")
|
||||
List<BatchRow> batches(@Param("id") long dispatchId);
|
||||
|
||||
record CargoShipment(String customerId, String destinationKey, String shipmentNo) { }
|
||||
/** 逐票实际货物决定货件号;手填预约号或没有正式批次引用也能读取,客户严格随来源关联。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT i.customer_id AS customerId,
|
||||
CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))) AS destinationKey,
|
||||
CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentNo
|
||||
FROM ll_dispatch_item i
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
|
||||
AND i.return_receipt_id IS NULL AND COALESCE(i.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=i.customer_id
|
||||
JOIN ll_order o ON o.id=CASE WHEN l.id IS NULL THEN i.order_id ELSE l.order_id END AND o.customer_id=i.customer_id
|
||||
LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND UPPER(TRIM(d.destination_warehouse_code))=UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))
|
||||
LEFT JOIN ll_warehouse_transfer_order wt ON wt.lastleg_order_id=o.id AND wt.customer_id=o.customer_id
|
||||
WHERE i.dispatch_order_id=#{id} AND i.destination_type='FBA'
|
||||
""")
|
||||
List<CargoShipment> cargoShipmentNumbers(@Param("id") long dispatchId);
|
||||
|
||||
/** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
/** 排车列表和BOL共用当前客户收货确认;调用方的a必须是逐票预约分配。 */
|
||||
final class DispatchReceivingSql {
|
||||
/** 只读当前有效覆盖,不使用多客户批次头汇总值,历史退出记录不能提供编号。 */
|
||||
static final String CONFIRMATION_NO = """
|
||||
(SELECT rg.confirmation_no
|
||||
FROM ll_fba_receiving_member rm
|
||||
JOIN ll_fba_receiving_agreement rg ON rg.agreement_key=rm.agreement_key
|
||||
WHERE rm.batch_key=a.batch_key AND rm.customer_id=a.customer_id AND rm.active_ref IS NOT NULL
|
||||
AND rg.destination_key=a.destination_key AND rg.status='ACTIVE'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentStatus'))='SUCCESS'
|
||||
AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentAt')),'null') IS NOT NULL)
|
||||
""";
|
||||
|
||||
/** 仅提供Mapper编译期SQL片段。 */
|
||||
private DispatchReceivingSql() { }
|
||||
}
|
||||
+17
-2
@@ -49,7 +49,7 @@ public final class DispatchContracts {
|
||||
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
|
||||
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
|
||||
String loadType, String fleetEmail, String vehicleType,
|
||||
String appointmentAccount, String appointmentNo) {
|
||||
String appointmentAccount, List<DestinationAppointment> destinationAppointments) {
|
||||
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
|
||||
public Maintenance {
|
||||
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
|
||||
@@ -58,7 +58,10 @@ public final class DispatchContracts {
|
||||
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
|
||||
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
|
||||
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
|
||||
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
|
||||
if (destinationAppointments != null && destinationAppointments.stream().anyMatch(java.util.Objects::isNull)) throw new IllegalArgumentException("预约目的仓不能空缺");
|
||||
destinationAppointments = destinationAppointments == null ? List.of() : List.copyOf(destinationAppointments);
|
||||
if (destinationAppointments.size() > 500 || destinationAppointments.stream().map(DestinationAppointment::destinationKey).distinct().count() != destinationAppointments.size())
|
||||
throw new IllegalArgumentException("同一目的仓只能填写一个预约号");
|
||||
}
|
||||
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
|
||||
private static String option(String value, String label, String... allowed) {
|
||||
@@ -74,6 +77,18 @@ public final class DispatchContracts {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一排车按目的仓登记约好的编号,不要求客服逐票或逐托分配。 */
|
||||
public record DestinationAppointment(String destinationKey, String appointmentNo) {
|
||||
/** 草稿允许暂缺编号,目的仓身份及文本长度仍须明确。 */
|
||||
public DestinationAppointment {
|
||||
destinationKey = destinationKey == null ? null : destinationKey.trim();
|
||||
appointmentNo = appointmentNo == null || appointmentNo.isBlank() ? null : appointmentNo.trim();
|
||||
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4 || destinationKey.length() > 256)
|
||||
throw new IllegalArgumentException("预约目的仓无效");
|
||||
if (appointmentNo != null && appointmentNo.length() > 128) throw new IllegalArgumentException("预约号最多128字符");
|
||||
}
|
||||
}
|
||||
|
||||
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record Destination(String key, String type, String warehouseId, String warehouseCode,
|
||||
|
||||
@@ -32,15 +32,15 @@ public final class DispatchViews {
|
||||
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
ReviewState reviewState, Reviews reviews, Resources resources,
|
||||
String warehouseId, String warehouseCode, String warehouseName,
|
||||
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
Instant createdAt, Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
boolean pickingReconciled, boolean arranged,
|
||||
Map<Action, Gate> actions) { }
|
||||
Map<Action, Gate> actions, List<FbaAppointment> fbaAppointments) { }
|
||||
|
||||
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
|
||||
String destinationKey, String destinationWarehouseCode,
|
||||
String status, Instant confirmedAt, String blockReason) { }
|
||||
String status, Instant confirmedAt, String blockReason, String customerId, List<String> shipmentNumbers) { }
|
||||
|
||||
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
||||
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
||||
|
||||
+2
@@ -65,6 +65,8 @@ public class DispatchAggregateRepository {
|
||||
public List<DispatchReadMapper.DeliveryRow> deliveries(long id) { return reads.deliveries(id); }
|
||||
/** 已有关联预约批次只读事实。 */
|
||||
public List<DispatchReadMapper.BatchRow> batches(long id) { return reads.batches(id); }
|
||||
/** 本车实际货物的权威货件号,不依赖预约分配,也不把重复托盘当作新货件。 */
|
||||
public List<DispatchReadMapper.CargoShipment> cargoShipmentNumbers(long id) { return reads.cargoShipmentNumbers(id); }
|
||||
/** 是否必须走受保护提供者核验批次分配。 */
|
||||
public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); }
|
||||
}
|
||||
|
||||
+62
-20
@@ -177,7 +177,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var fact=type!=SourceType.INBOUND_PLAN || row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
|
||||
reasons.addAll(group.blockReasons());
|
||||
if(type==SourceType.INBOUND_PLAN && "FBA".equals(destination.type()) && fact==null)reasons.add("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
|
||||
if(fact!=null) {
|
||||
reasons.addAll(fact.blockReasons());
|
||||
// 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。
|
||||
@@ -269,14 +268,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
return evidence.download(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
|
||||
/** 主档驾驶证按明确来源标记读取,本趟补传照片保持排车文件归属。 */
|
||||
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
|
||||
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
// 只有车队解析并冻结的主档引用才走司机归属,不能把补传图片当作主档文件。
|
||||
if(!Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
@@ -442,8 +441,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
var vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
|
||||
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
|
||||
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
|
||||
var savedResources=resources(actual,photos,request.maintenance());
|
||||
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
|
||||
var savedDriver=savedResources.driver();
|
||||
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
|
||||
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
|
||||
}
|
||||
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
@@ -613,7 +619,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);
|
||||
String appointmentBlock=reviewAppointmentBlock(row);
|
||||
if(appointmentBlock==null)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("托盘卸货站尚未匹配,请调度核对路线");
|
||||
@@ -698,7 +705,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
|
||||
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
|
||||
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));
|
||||
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",reviewAppointments(row));
|
||||
}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
@@ -747,20 +754,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
|
||||
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
|
||||
if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
|
||||
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
|
||||
verifyReviewPhotos(row,r);
|
||||
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */
|
||||
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
|
||||
var driver=resources.driver();var proof=driver.evidence();
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
|
||||
}
|
||||
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */
|
||||
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
|
||||
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
|
||||
boolean image=false;
|
||||
for(var id:ids) {
|
||||
var master=masterEvidenceDriverId(row,kind,id);
|
||||
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
|
||||
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true;
|
||||
}
|
||||
if(!image)throw bad("请上传至少一张有效"+label);
|
||||
}
|
||||
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
|
||||
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()));
|
||||
String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo()));
|
||||
if(reason==null)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;
|
||||
@@ -822,20 +846,26 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
statistics(repository.summaries(filter)),Instant.now());
|
||||
}
|
||||
|
||||
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
|
||||
/** Admin与PDA共用本车目的仓预约,每个目的仓仅返回一行。 */
|
||||
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
|
||||
var resources=read(row.getResourcesJson(),Resources.class);
|
||||
return DispatchDestinationAppointments.resolve(repository.cargo(row.getId()),resources==null?null:resources.maintenance(),bookedAppointments(row),repository.cargoShipmentNumbers(row.getId()));
|
||||
}
|
||||
/** 原预约仅用于带出已有确认号,客服按目的仓维护后不再逐票录入。 */
|
||||
private List<DispatchViews.FbaAppointment> bookedAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId()).stream().filter(b->b.batchKey().startsWith("FBA_APPOINTMENT:")).toList();
|
||||
if(batches.isEmpty())return List.of();
|
||||
var confirmedAppointments=this.appointments.reviewEvidence(number);
|
||||
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
return batches.stream().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);
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
|
||||
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
|
||||
/** 四页签读取同一资源快照,维护资料及本趟照片保持直接业务归属。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Detail detail(Actor actor, String number) {
|
||||
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
|
||||
@@ -1055,10 +1085,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
|
||||
}
|
||||
|
||||
/** 列表不逐行读取明细、附件或派送。 */
|
||||
/** 列表带出与详情相同的正式预约投影,不读取附件或派送。 */
|
||||
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
|
||||
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
|
||||
}
|
||||
|
||||
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
|
||||
@@ -1079,7 +1109,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var v=shell.vehicle(); var d=shell.driver();
|
||||
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
|
||||
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos,
|
||||
"MASTER".equals(d.mode()) && !d.evidenceFileIds().isEmpty()?d.evidenceFileIds().get(0):null)),maintenance);
|
||||
}
|
||||
|
||||
/** 主档选择不接受请求覆盖其车牌和容量。 */
|
||||
@@ -1089,6 +1120,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds()));
|
||||
}
|
||||
|
||||
/** 旧主档证照由服务端保存的来源标记识别;换证后只向车队传本趟补传文件,不能信任请求自报来源。 */
|
||||
private static FleetExecutionModels.DispatchDriver updatedFleetDriver(DispatchCommands.Driver requested,Resources previous) {
|
||||
var driver=fleetDriver(requested);
|
||||
var old=previous==null?null:previous.driver();
|
||||
if(driver==null || old==null || old.mode()!=ResourceMode.MASTER || requested.mode()!=ResourceMode.MASTER
|
||||
|| !Objects.equals(old.driverId(),requested.driverId()) || old.evidence()==null || old.evidence().masterLicenseFileId()==null)return driver;
|
||||
var oldMaster=old.evidence().masterLicenseFileId();
|
||||
return new FleetExecutionModels.DispatchDriver(driver.mode(),driver.driverId(),driver.name(),driver.phone(),driver.licenseNo(),driver.licenseExpiresOn(),
|
||||
driver.evidenceFileIds().stream().filter(id->!Objects.equals(id,oldMaster)).toList(),driver.expectedTaskEndAt());
|
||||
}
|
||||
|
||||
/** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */
|
||||
private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) {
|
||||
if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence();
|
||||
|
||||
+6
@@ -58,6 +58,12 @@ public class DispatchBolReadService {
|
||||
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
|
||||
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
|
||||
if(!printedTotals.equals(totals))throw invalid();
|
||||
// 仅已填写编号覆盖同仓来源;空草稿保留后来取得的正式预约号,不改变各客户货量。
|
||||
if(resources.maintenance()!=null) {
|
||||
var numbers=new HashMap<String,String>();resources.maintenance().destinationAppointments().stream()
|
||||
.filter(a->a.appointmentNo()!=null).forEach(a->numbers.put(a.destinationKey(),a.appointmentNo()));
|
||||
result.replaceAll(line->numbers.containsKey(line.destinationKey())?copy(line,line.packageCount(),numbers.get(line.destinationKey())):line);
|
||||
}
|
||||
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Maintenance;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews.FbaAppointment;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** 本车送仓预约按目的仓汇总;货物的客户归属及真实数量仍由原明细维护。 */
|
||||
final class DispatchDestinationAppointments {
|
||||
/** 纯规则容器不持有数据访问或单据状态。 */
|
||||
private DispatchDestinationAppointments() { }
|
||||
|
||||
/** 先预约的唯一确认号自动带入;同仓多个不同号时交由客服填写本车实际使用的一个号。 */
|
||||
static List<FbaAppointment> resolve(List<DispatchItem> cargo, Maintenance maintenance, List<FbaAppointment> booked, List<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment> cargoShipments) {
|
||||
var destinations = new TreeMap<String,String>();
|
||||
for (var item : cargo) if ("FBA".equals(item.getDestinationType())) {
|
||||
var key = com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
|
||||
if (key == null) throw new BizException(ResultCode.CONFLICT,"本车FBA货物缺少目的仓码");
|
||||
destinations.put(key, key.substring(4));
|
||||
}
|
||||
var entered = new HashMap<String,String>();
|
||||
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
|
||||
var result = new ArrayList<FbaAppointment>();
|
||||
destinations.forEach((key,code) -> {
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
|
||||
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
|
||||
var enteredNumber = entered.get(key);
|
||||
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
|
||||
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
|
||||
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
|
||||
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
|
||||
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
|
||||
});
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
|
||||
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
|
||||
if (maintenance == null) return;
|
||||
var keys = destinations.stream().map(FbaAppointment::destinationKey).toList();
|
||||
if (maintenance.destinationAppointments().stream().anyMatch(a -> !keys.contains(a.destinationKey())))
|
||||
throw new BizException(ResultCode.BAD_REQUEST,"预约只能填写本车已有的目的仓");
|
||||
}
|
||||
|
||||
/** 预约完整性只在提交审核及后续执行检查;明确已知的正式时间继续参与核对。 */
|
||||
static String blockReason(List<FbaAppointment> destinations, Instant departure, Instant arrival, List<ReceivingWindow> windows) {
|
||||
var missing = destinations.stream().filter(a -> a.appointmentNo() == null).map(FbaAppointment::destinationWarehouseCode).toList();
|
||||
if (!missing.isEmpty()) return "请填写目的仓 " + String.join("、",missing) + " 的预约号后提交审核";
|
||||
if (destinations.isEmpty()) return null;
|
||||
if (departure != null && arrival != null && arrival.isBefore(departure)) return "预计送达时间不能早于计划发车时间";
|
||||
var first = windows.stream().filter(w -> destinations.stream().anyMatch(a -> w.destinationKey().equals(a.destinationKey()) && Objects.equals(w.confirmationNo(),a.appointmentNo())))
|
||||
.min(Comparator.comparing(ReceivingWindow::startAt)).orElse(null);
|
||||
if (first != null) {
|
||||
if (departure == null || arrival == null) return "请先明确计划发车时间和预计送达时间,再提交审核";
|
||||
if (!departure.isBefore(first.startAt())) return "计划发车时间须早于本车最早的FBA预约送达时间";
|
||||
if (arrival.isAfter(first.endAt() == null ? first.startAt() : first.endAt())) return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,12 @@ public interface FleetResourceMapper {
|
||||
int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo);
|
||||
@Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}")
|
||||
int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId);
|
||||
/** 排除其他车次占用;当前排车绑定的原资源允许保留。 */
|
||||
@Select("""
|
||||
SELECT a.resource_id FROM ll_fleet_resource_assignment a
|
||||
WHERE a.resource_kind='VEHICLE' AND NOT EXISTS (
|
||||
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
|
||||
""")
|
||||
List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo);
|
||||
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
|
||||
}
|
||||
|
||||
@@ -323,8 +323,12 @@ public class FleetTripService {
|
||||
if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务");
|
||||
var assignment=repository.resource("DRIVER",driver.driverId());
|
||||
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用");
|
||||
// 主档证照固定在首位,本趟补传的驾驶证照片独立保留;排车域负责逐个核验本车文件归属。
|
||||
var licenseFiles=new java.util.LinkedHashSet<Long>();
|
||||
if(actual.licenseFileId()!=null)licenseFiles.add(Long.valueOf(actual.licenseFileId()));
|
||||
if(driver.evidenceFileIds()!=null)licenseFiles.addAll(driver.evidenceFileIds());
|
||||
driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(),
|
||||
actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt());
|
||||
actual.details().licenseNumber(),actual.details().licenseExpiresOn(),List.copyOf(licenseFiles),driver.expectedTaskEndAt());
|
||||
} else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案");
|
||||
return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus);
|
||||
}
|
||||
|
||||
+3
@@ -8,9 +8,12 @@ import org.springframework.stereotype.Component;
|
||||
public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard {
|
||||
private final FleetResourceMapper resources;
|
||||
public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; }
|
||||
/** 主档变更仍要求车辆没有真实车次占用。 */
|
||||
@Override public void requireUnassigned(long vehicleId) {
|
||||
var assignment = resources.find("VEHICLE", vehicleId);
|
||||
if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT,
|
||||
"车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排");
|
||||
}
|
||||
/** 由车队权威占用记录提供候选排除集。 */
|
||||
@Override public java.util.List<Long> unavailableForDispatch(String dispatchNo) { return resources.unavailableVehicles(dispatchNo); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */
|
||||
/** 读取既有正式预约的确认范围,供自动带入及预约业务自身核验使用。 */
|
||||
@Service
|
||||
public class AppointmentDispatchAvailability {
|
||||
private final FbaReceivingStore receiving;
|
||||
|
||||
+5
-41
@@ -104,7 +104,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
for(var item:items) if(item.planPalletId()!=null) {
|
||||
var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList();
|
||||
if(selected.isEmpty()) {
|
||||
requireNoBatch(item.customerId(),item.planPalletId());
|
||||
next.addAll(privateAllocation(item.customerId(),dispatch,item));
|
||||
} else {
|
||||
validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false);
|
||||
@@ -122,8 +121,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) {
|
||||
requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId()))
|
||||
.map(a->allocation(a,appointment.version().longValue())).toList());
|
||||
// FBA确认前不能把货物加入车辆;提交审核仍复验运输时间。
|
||||
requireReceiving(batch,customer,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())).toList());
|
||||
// 建单允许先排车;本车预约号在客服提交审核时按目的仓核对。
|
||||
}
|
||||
}
|
||||
// 所有校验结束后才按明确item删除;没有整车清空操作。
|
||||
@@ -160,14 +158,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
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;
|
||||
@@ -210,9 +200,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList();
|
||||
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("客户仓排车缺少逐票实物批次关联,请重新核对排车");
|
||||
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && (p.destinationKey().startsWith("FBA:") || p.destinationKey().startsWith("CUSTOMER:"))))
|
||||
throw conflict("本车货物缺少真实来源明细,请重新核对排车");
|
||||
}
|
||||
for(var batch:batches) {
|
||||
var a=appointments.batchContext(batch).appointment();
|
||||
@@ -221,7 +210,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
var dispatchIds=allocated.stream().map(AppointmentWorkDispatchMapper.Bound::dispatchId).distinct().toList();
|
||||
if(dispatchIds.size()!=1) throw conflict("排车批次关联不一致,请重新核对实物分配");
|
||||
for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) {
|
||||
requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList());
|
||||
requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId()))
|
||||
.map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList());
|
||||
}
|
||||
@@ -233,22 +221,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
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) {
|
||||
if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复");
|
||||
var agreement=receiving.currentAgreement(batch,customer);
|
||||
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus())
|
||||
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先预约成功并明确送仓时间,再加入排车或发车");
|
||||
receivingInstant(agreement.details().appointmentAt(),agreement.timezone(),agreement.details().startOffset());
|
||||
var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList();
|
||||
if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对");
|
||||
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) {
|
||||
var first=group.get(0);
|
||||
var sources=members.get(0).sources().stream().filter(s->first.sourceKey().equals(s.sourceKey()) && first.destinationKey().equals(s.destinationKey())).toList();
|
||||
if(sources.size()!=1 || mapper.allocated(batch,customer,first.sourceKey(),first.destinationKey(),first.dispatchId())
|
||||
+group.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()>sources.get(0).packageCount())
|
||||
throw conflict("当前收货确认未完整覆盖本次逐票实物,请重新核对");
|
||||
}
|
||||
}
|
||||
private void requireCurrentAllocation(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse appointment,String customer,long dispatch,List<Allocation> allocations) {
|
||||
var grouped=allocations.stream().collect(Collectors.groupingBy(a->List.of(a.sourceKey(),a.destinationKey())));
|
||||
for(var group:grouped.values()) {
|
||||
@@ -262,14 +234,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
throw conflict("所选实物超过当前批次逐票分配量,请重新核对");
|
||||
}
|
||||
}
|
||||
private void requireNoBatch(String customer,long pallet) {
|
||||
var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList();
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
|
||||
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
|
||||
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
|
||||
if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车");
|
||||
}
|
||||
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
|
||||
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
|
||||
|
||||
@@ -296,8 +260,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
}
|
||||
private List<AppointmentWorkDispatchMapper.Bound> privateAllocation(String customer,long dispatch,PhysicalItem item) {
|
||||
var physical=mapper.physical(item.planPalletId(),customer);
|
||||
if(physical.isEmpty() || physical.stream().noneMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) return List.of();
|
||||
if(physical.stream().anyMatch(p->p.destinationKey()==null || !p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓实物不能混入FBA批次,请先核对托盘目的地");
|
||||
if(physical.isEmpty()) return List.of();
|
||||
if(physical.stream().anyMatch(p->p.destinationKey()==null)) throw conflict("实物目的地尚未核实");
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
|
||||
for(var part:physical) {
|
||||
var count="RECEIVED".equals(item.sourceStage())?part.countedPackages():part.plannedPackages();
|
||||
|
||||
+8
-7
@@ -40,7 +40,7 @@ public class AppointmentWorkDispatchCandidates {
|
||||
for(var identity:identities) {
|
||||
var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList();
|
||||
var reasons=new LinkedHashSet<String>();var references=new LinkedHashMap<List<String>,List<BatchSource>>();
|
||||
boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked);
|
||||
boolean unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked);
|
||||
if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化");
|
||||
for(var row:selected) {
|
||||
var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages();
|
||||
@@ -48,18 +48,19 @@ public class AppointmentWorkDispatchCandidates {
|
||||
if(!row.destinationKey().startsWith("FBA:")) continue;
|
||||
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey();
|
||||
var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList();
|
||||
if(matched.isEmpty()) {unassigned=true;reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");continue;}
|
||||
related=true;long available=0;var seen=new HashSet<String>();
|
||||
if(matched.isEmpty()) {unassigned=true;continue;}
|
||||
long available=0;var seen=new HashSet<String>();
|
||||
for(var b:matched) {
|
||||
if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;}
|
||||
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;}
|
||||
if(!source.equals(b.sourceKey())) {unassigned=true;continue;}
|
||||
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {unassigned=true;continue;}
|
||||
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey());
|
||||
int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining);
|
||||
if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count)));
|
||||
}
|
||||
if(available<count) reasons.add("FBA已确认预约及逐票剩余量不足,请先确认送仓时间和本票箱数再排车");
|
||||
if(available<count || matched.size()!=1) unassigned=true;
|
||||
}
|
||||
if(related && unassigned) reasons.add("托盘含未归属当前预约批次的货物,请先核对逐票分配");
|
||||
// 未约、未确认或多个批次均允许先排;只有完整且唯一的已约来源才自动携带。
|
||||
if(unassigned)references.clear();
|
||||
var result=references.entrySet().stream().map(e->new BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList();
|
||||
output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied));
|
||||
}
|
||||
|
||||
+2
@@ -103,6 +103,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
|
||||
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
|
||||
}
|
||||
|
||||
/** 可用性排除在数据库分页前执行,保持候选总数与结果一致。 */
|
||||
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
|
||||
return new LambdaQueryWrapper<VehicleRecord>()
|
||||
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
|
||||
@@ -111,6 +112,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
|
||||
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
|
||||
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
|
||||
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
|
||||
.notIn(criteria.excludedIds()!=null && !criteria.excludedIds().isEmpty(), VehicleRecord::getId, criteria.excludedIds())
|
||||
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -100,11 +100,13 @@ public class VehicleApplicationService {
|
||||
return detail(id);
|
||||
}
|
||||
|
||||
/** 候选资格在分页前排除真实占用,不按显示文案推断可用。 */
|
||||
private SearchCriteria criteria(VehicleModels.Query query) {
|
||||
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
|
||||
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
|
||||
optional(query.region(), REGIONS, "车辆区域"),
|
||||
optional(query.status(), STATUSES, "车辆状态"));
|
||||
optional(query.status(), STATUSES, "车辆状态"),
|
||||
trim(query.dispatchNo())==null?java.util.List.of():assignments.unavailableForDispatch(trim(query.dispatchNo())));
|
||||
}
|
||||
|
||||
private VehicleSnapshot required(long id) {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public final class VehicleModels {
|
||||
private VehicleModels() { }
|
||||
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Size(max = 128) String keyword, String ownership, String region, String status) {
|
||||
@Size(max = 128) String keyword, String ownership, String region, String status, @Size(max = 64) String dispatchNo) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
|
||||
/** Called while the vehicle row is locked; fleet owns the active assignment. */
|
||||
public interface VehicleAssignmentGuard {
|
||||
void requireUnassigned(long vehicleId);
|
||||
/** 排车候选排除其他车次的真实占用,本车已选车辆仍可维护。 */
|
||||
java.util.List<Long> unavailableForDispatch(String dispatchNo);
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public interface VehicleRepository {
|
||||
|
||||
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
|
||||
|
||||
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
|
||||
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status, List<Long> excludedIds) { }
|
||||
|
||||
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
|
||||
|
||||
|
||||
+7
-2
@@ -25,6 +25,7 @@ public class ReturnRelabelPackingService {
|
||||
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks;
|
||||
@Resource private com.fasterxml.jackson.databind.ObjectMapper json;
|
||||
|
||||
/** 换标任务锁定来源后生成当前库存快照,随精确换标数量提交合箱。 */
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app,
|
||||
List<RelabelItem> selected, ReturnInventoryRequests.BoxMerge request) {
|
||||
@@ -37,17 +38,21 @@ public class ReturnRelabelPackingService {
|
||||
if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions())))
|
||||
throw conflict("合箱数量必须等于本次已完成换标的明细数量");
|
||||
var inheritedTransfers = new ArrayList<Map<String, Object>>();
|
||||
var expectedSourceQuantities = new ArrayList<ReturnInventoryRequests.SourceQuantity>();
|
||||
for (String mark : marks) {
|
||||
var source = cartons.lockMark(mark);
|
||||
if (source == null || !app.getCustomerId().equals(source.getCustomerId())
|
||||
|| !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus()))
|
||||
throw conflict("来源包裹/箱已变化,请刷新");
|
||||
var sourceContents = cartonItems.lockCarton(source.getId());
|
||||
sourceContents.forEach(line -> expectedSourceQuantities.add(
|
||||
new ReturnInventoryRequests.SourceQuantity(mark, line.getSkuCode(), line.getQuantity())));
|
||||
var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId());
|
||||
if (!prior.isEmpty()) {
|
||||
var ids = prior.stream().map(i -> i.getId().toString()).toList();
|
||||
var raw = inheritedQuantities(prior);
|
||||
var physical = new HashMap<String, Long>();
|
||||
cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact));
|
||||
sourceContents.forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact));
|
||||
var moved = new ArrayList<Map<String, Object>>();
|
||||
for (var entry : totals(deductions).entrySet()) {
|
||||
if (!mark.equals(entry.getKey().get(0))) continue;
|
||||
@@ -66,7 +71,7 @@ public class ReturnRelabelPackingService {
|
||||
}
|
||||
var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null,
|
||||
"RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions,
|
||||
request.length(), request.width(), request.height(), request.weight()));
|
||||
request.length(), request.width(), request.height(), request.weight(), expectedSourceQuantities));
|
||||
for (String mark : marks) {
|
||||
var source = cartons.lockMark(mark);
|
||||
cartons.clearRelabel(source.getId());
|
||||
|
||||
+4
-14
@@ -3,7 +3,9 @@ package com.baiyue.lastleg.returnreceipt.dto;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
@@ -25,20 +27,8 @@ public final class PdaReturnReceiptRequests {
|
||||
@Size(max = 16) String unitType,
|
||||
@Size(max = 128) String unitCode,
|
||||
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
|
||||
Long totalBoxCount, Boolean originalPackaging) {
|
||||
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
|
||||
List<@Valid Item> items, String idempotencyKey) {
|
||||
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey,
|
||||
null, null, null, null, null, null, null, null);
|
||||
}
|
||||
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
|
||||
List<@Valid Item> items, String idempotencyKey, String unitType, String unitCode,
|
||||
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
|
||||
Long totalBoxCount) {
|
||||
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, unitType, unitCode,
|
||||
length, width, height, weight, totalBoxCount, null);
|
||||
}
|
||||
}
|
||||
Long totalBoxCount, Boolean originalPackaging,
|
||||
@NotNull @PositiveOrZero Long expectedReceivedQuantity) { }
|
||||
public record Item(@NotBlank @Size(max = 128) String skuCode,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
|
||||
@Positive long receivedQuantity,
|
||||
|
||||
+4
-5
@@ -73,6 +73,8 @@ public final class ReturnInventoryRequests {
|
||||
public PackingPlanComplete(List<PlanBox> boxes, String idempotencyKey) { this(boxes, idempotencyKey, null); }
|
||||
}
|
||||
public record SourceDeduction(@NotBlank String cartonMark, @NotBlank String skuCode, @Positive long quantity) { }
|
||||
/** 扫码时来源箱各 SKU 的库存快照,用于拒绝过期页面重复扣减。 */
|
||||
public record SourceQuantity(@NotBlank String cartonMark, @NotBlank String skuCode, @Min(0) long quantity) { }
|
||||
|
||||
public record PackingExecution(@NotEmpty @Size(max = 100) List<@NotBlank String> returnNos,
|
||||
@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
|
||||
@@ -99,9 +101,6 @@ public final class ReturnInventoryRequests {
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions,
|
||||
@Positive BigDecimal length, @Positive BigDecimal width,
|
||||
@Positive BigDecimal height, @Positive BigDecimal weight) {
|
||||
public BoxMerge(List<String> sourceCartonMarks, String targetCartonMark, String idempotencyKey) {
|
||||
this(sourceCartonMarks, targetCartonMark, idempotencyKey, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
@Positive BigDecimal height, @Positive BigDecimal weight,
|
||||
@Size(max = 1000) List<@NotNull @Valid SourceQuantity> expectedSourceQuantities) { }
|
||||
}
|
||||
|
||||
+13
-1
@@ -928,16 +928,26 @@ public class ReturnInventoryService {
|
||||
return cartonDetail(carton);
|
||||
}
|
||||
|
||||
/** 校验全部来源箱的扫码库存快照,再执行单事务合箱。 */
|
||||
@Transactional
|
||||
public ReturnInventoryResponses.MergeResult mergeBoxes(PdaWarehouseIdentity identity, ReturnInventoryRequests.BoxMerge request) {
|
||||
// Bind replay to the complete scan command before looking up emptied sources.
|
||||
// 同键结果先返回;新键必须在来源行锁内校验扫码库存,避免重复扣减。
|
||||
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
|
||||
String digest = hash(request);
|
||||
if (replay != null) return replayMerge(replay, digest);
|
||||
if (request.expectedSourceQuantities() == null || request.expectedSourceQuantities().isEmpty())
|
||||
throw badRequest("缺少来源箱库存快照,请重新扫描来源箱");
|
||||
Map<List<String>, Long> expectedQuantities = new LinkedHashMap<>();
|
||||
for (var snapshot : request.expectedSourceQuantities()) {
|
||||
if (snapshot == null || snapshot.quantity() < 0 || !StringUtils.hasText(snapshot.skuCode()) || !request.sourceCartonMarks().contains(snapshot.cartonMark())
|
||||
|| expectedQuantities.putIfAbsent(List.of(snapshot.cartonMark(), snapshot.skuCode()), snapshot.quantity()) != null)
|
||||
throw badRequest("来源箱库存快照无效,请重新扫描来源箱");
|
||||
}
|
||||
java.util.stream.Stream.concat(request.sourceCartonMarks().stream(), java.util.stream.Stream.of(request.targetCartonMark()))
|
||||
.filter(StringUtils::hasText).distinct().sorted().forEach(cartons::lockMark);
|
||||
Map<String, Map<String, Long>> limits = new LinkedHashMap<>();
|
||||
Map<String, Long> quantities = new LinkedHashMap<>();
|
||||
Map<List<String>, Long> currentQuantities = new LinkedHashMap<>();
|
||||
LinkedHashSet<String> returnNos = new LinkedHashSet<>();
|
||||
String customer = null;
|
||||
for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) {
|
||||
@@ -950,6 +960,7 @@ public class ReturnInventoryService {
|
||||
customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(",")));
|
||||
Map<String, Long> items = new LinkedHashMap<>();
|
||||
for (var item : cartonItems.lockCarton(carton.getId())) {
|
||||
currentQuantities.put(List.of(mark, item.getSkuCode()), value(item.getQuantity()));
|
||||
if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已被合箱计划占用");
|
||||
long selected = request.sourceDeductions() == null ? value(item.getQuantity()) : request.sourceDeductions().stream()
|
||||
.filter(d -> mark.equals(d.cartonMark()) && item.getSkuCode().equals(d.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum();
|
||||
@@ -958,6 +969,7 @@ public class ReturnInventoryService {
|
||||
}
|
||||
limits.put(mark, items);
|
||||
}
|
||||
if (!expectedQuantities.equals(currentQuantities)) throw conflict("来源箱库存已变化,请重新扫描来源箱后操作");
|
||||
if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱");
|
||||
String targetMark = text(request.targetCartonMark());
|
||||
if (targetMark != null && request.sourceCartonMarks().stream().anyMatch(mark ->
|
||||
|
||||
+9
-3
@@ -571,7 +571,7 @@ public class ReturnReceiptService {
|
||||
return detail(order.getCustomerId(), order.getReturnNo());
|
||||
}
|
||||
|
||||
/** 确认退货实收,新增实物箱码沿用原编码,不使用单据日流水。 */
|
||||
/** 锁定退货单并校验提交前实收量,避免响应丢失后换新请求重复入账。 */
|
||||
@Transactional
|
||||
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
|
||||
String key = text(request.idempotencyKey());
|
||||
@@ -591,8 +591,7 @@ public class ReturnReceiptService {
|
||||
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
|
||||
throw notFound("当前绑定仓没有该退货单");
|
||||
}
|
||||
// Lock the order before any consistent read: a snapshot opened before
|
||||
// waiting would hide the winner's cartons when rendering replay labels.
|
||||
// 先锁订单再读取幂等记录,避免等待锁前的快照遗漏已提交的实物箱。
|
||||
ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key);
|
||||
if (concurrentReplay != null) {
|
||||
if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
|
||||
@@ -602,6 +601,13 @@ public class ReturnReceiptService {
|
||||
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
|
||||
}
|
||||
List<ReturnOrderItem> lockedItems = orderItems.lockOrder(order.getId());
|
||||
if (request.expectedReceivedQuantity() == null || request.expectedReceivedQuantity() < 0) {
|
||||
throw badRequest("必须提供提交前的退货单实收数量");
|
||||
}
|
||||
long currentReceivedQuantity = lockedItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
|
||||
if (request.expectedReceivedQuantity() != currentReceivedQuantity) {
|
||||
throw conflict("退货单实收数量已变化,请刷新退货单后重新操作");
|
||||
}
|
||||
Map<String, Long> receivedBySku = aggregate(request.items());
|
||||
Map<String, Long> boxCountBySku = request.items().stream().filter(item -> item.boxCount() != null)
|
||||
.collect(java.util.stream.Collectors.toMap(item -> text(item.skuCode()), PdaReturnReceiptRequests.Item::boxCount, Math::addExact, LinkedHashMap::new));
|
||||
|
||||
Reference in New Issue
Block a user