fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:00 +08:00
parent f701456de1
commit a16a85e14a
85 changed files with 1518 additions and 277 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
退货预报、签收、库存、合箱与退货出库:
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量。确认必须传提交前全单累计实收量 `expectedReceivedQuantity`,在订单锁内校验;响应丢失后换新键重复提交旧快照返回冲突,刷新后重新操作,同键幂等请求不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。实物合箱 `/cartons/merge-boxes` 必须提交扫码时全部来源箱 SKU 数量 `expectedSourceQuantities`,来源行锁内核对完整快照;旧快照换新键也拒绝扣减,重新扫描后才能提交当前实物。内部退货换标调用从已锁定任务及来源库存生成快照。
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
@@ -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";
@@ -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
@@ -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
@@ -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() { }
}
@@ -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,
@@ -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); }
}
@@ -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();
@@ -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));
}
@@ -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);
}
@@ -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); }
}
@@ -6,7 +6,7 @@ import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */
/** 读取既有正式预约的确认范围,供自动带入及预约业务自身核验使用。 */
@Service
public class AppointmentDispatchAvailability {
private final FbaReceivingStore receiving;
@@ -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();
@@ -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));
}
@@ -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);
}
@@ -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) {
@@ -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; }
}
@@ -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);
}
@@ -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) { }
@@ -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());
@@ -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,
@@ -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) { }
}
@@ -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 ->
@@ -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));
@@ -3229,6 +3229,13 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryMerge' } } } }
responses: { '200': { description: 新箱号、SKU 汇总数量及可打印箱唛 }, '409': { description: 箱状态、客户或 SKU 不满足合箱条件,或幂等键冲突 } }
/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes:
post:
summary: PDA 按实物来源箱合箱
description: 同一客户、当前绑定仓内合箱;新请求必须携带扫码时全部来源箱 SKU 数量,在来源行锁内校验,过期返回冲突,重新扫描后操作。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryBoxMerge' } } } }
responses: { '200': { description: 合箱实物箱号与标签 }, '400': { description: 缺少或无效来源库存快照 }, '409': { description: 来源库存或资格已变化,或幂等键冲突 } }
/api/pda/v1/last-leg/return-inventory/packing/orders:
get:
summary: PDA 查询可合箱退货单和 SKU 库存
@@ -3426,8 +3433,9 @@ components:
expectedQuantity: { type: integer, minimum: 1 }
ReturnReceiptConfirm:
type: object
required: [returnNo, startedBy, items, idempotencyKey]
required: [returnNo, startedBy, items, idempotencyKey, expectedReceivedQuantity]
properties:
expectedReceivedQuantity: { type: integer, format: int64, minimum: 0, description: '提交前退货单全部 SKU 的累计实收数量;在订单锁内校验,过期返回冲突,刷新后重新操作' }
returnNo: { type: string, maxLength: 128 }
trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' }
startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' }
@@ -3450,6 +3458,39 @@ components:
skuCode: { type: string, maxLength: 128 }
receivedQuantity: { type: integer, minimum: 1 }
boxCount: { type: integer, nullable: true, minimum: 1, description: 托盘或箱子签收时该 SKU 对应箱数,包裹签收不填 }
ReturnInventoryBoxMerge:
type: object
required: [sourceCartonMarks, idempotencyKey, expectedSourceQuantities]
properties:
sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
targetCartonMark: { type: string, nullable: true, description: 不填时生成新箱 }
idempotencyKey: { type: string, maxLength: 255 }
expectedSourceQuantities:
type: array
minItems: 1
maxItems: 1000
description: 扫码时全部来源箱各 SKU 的数量,必须完整且不能重复
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 0 }
sourceDeductions:
type: array
maxItems: 1000
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 1 }
length: { type: number, minimum: 0, exclusiveMinimum: true }
width: { type: number, minimum: 0, exclusiveMinimum: true }
height: { type: number, minimum: 0, exclusiveMinimum: true }
weight: { type: number, minimum: 0, exclusiveMinimum: true }
ReturnInventoryMerge:
type: object
required: [returnNos, items, length, width, height, weight, idempotencyKey]
@@ -5447,7 +5488,7 @@ components:
DispatchMaintenance:
type: object
x-java-record: DispatchContracts.Maintenance
description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。
description: 保存于排车resourcesJson.maintenance;预约按本车目的仓登记,保存可暂缺,提交审核及后续执行须填写完整。
properties:
plannedDeliveryDate:
type: string
@@ -5477,10 +5518,18 @@ components:
type: string
maxLength: 128
nullable: true
appointmentNo:
type: string
maxLength: 128
nullable: true
destinationAppointments:
type: array
maxItems: 500
items: { $ref: '#/components/schemas/DispatchDestinationAppointment' }
DispatchDestinationAppointment:
type: object
x-java-record: DispatchContracts.DestinationAppointment
description: 同一排车同一FBA目的仓仅一个预约号,空白可暂存。
required: [destinationKey, appointmentNo]
properties:
destinationKey: { type: string, pattern: '^FBA:.+', maxLength: 256 }
appointmentNo: { type: string, maxLength: 128, nullable: true }
DispatchResources:
type: object
x-java-record: DispatchContracts.Resources
@@ -6363,6 +6412,14 @@ components:
type: object
x-java-record: DispatchViews.ListRow
properties:
createdAt:
type: string
format: date-time
nullable: true
description: 排车单真实创建时间,取原排车主单created_at。
fbaAppointments:
type: array
items: { $ref: '#/components/schemas/DispatchFbaAppointment' }
dispatchNo:
type: string
nullable: true
@@ -6424,6 +6481,7 @@ components:
$ref: '#/components/schemas/DispatchGate'
nullable: true
required:
- createdAt
- dispatchNo
- status
- stage
@@ -6440,6 +6498,7 @@ components:
- pickingReconciled
- arranged
- actions
- fbaAppointments
DispatchVehicleInfo:
type: object
x-java-record: DispatchViews.VehicleInfo
@@ -6463,7 +6522,14 @@ components:
DispatchFbaAppointment:
type: object
x-java-record: DispatchViews.FbaAppointment
description: 按本车目的仓汇总的预约;唯一已约编号自动带入,人工登记不冒充正式确认时间。批次及客户在目的仓汇总行为空。
properties:
customerId:
type: string
nullable: true
shipmentNumbers:
type: array
items: { type: string }
batchKey:
type: string
nullable: true
@@ -6498,6 +6564,8 @@ components:
- status
- confirmedAt
- blockReason
- customerId
- shipmentNumbers
DispatchDispatchInfo:
type: object
x-java-record: DispatchViews.DispatchInfo
@@ -116,6 +116,7 @@ paths:
- { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } }
- { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } }
- { name: dispatchNo, in: query, description: 排车维护候选,分页前排除其他车次实际占用,保留本车资源, schema: { type: string, maxLength: 64 } }
responses: { '200': { description: 业务响应,data为PageResult<Vehicle> } }
post:
summary: 创建车辆主数据
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/** 核对分页、总数、分组统计及BOL接入同一正式编号查询;JSON函数适配仅用于隔离H2测试。 */
public class DispatchReceivingSqlTest {
/** 三种列表查询必须在SQL分页前使用当前客户确认,且保留非空人工编号的覆盖条件。 */
@Test void allListQueriesUseCurrentReceivingAuthorityBeforePagination() {
var config=new MybatisConfiguration();config.addMapper(DispatchAggregateMapper.class);
var query=new DispatchAggregateMapper.Search(null,null,null,null,"ISA-CURRENT","ALL",null,null,null,0,20);
for(var method:new String[]{"page","count","summaries"}) {
var bound=config.getMappedStatement(DispatchAggregateMapper.class.getName()+"."+method).getBoundSql(Map.of("q",query));
var sql=normalize(bound.getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" =?");
assertThat(sql).doesNotContain("ll_inbound_fba_appointment ap","ap.confirmation_no");
assertThat(sql).contains("saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL");
assertThat(bound.getParameterMappings().stream().filter(p->"q.fbaAppointmentNo".equals(p.getProperty())).count()).isEqualTo(2);
if(method.equals("page"))assertThat(sql.indexOf("LIMIT")).isGreaterThan(sql.indexOf("ll_fba_receiving_member"));
}
}
/** BOL只替换编号来源,仍由原批次item核对客户、来源和订单映射。 */
@Test void bolUsesSameCurrentConfirmationAndKeepsSourceMapping() {
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);
var sql=normalize(config.getMappedStatement(DispatchBolMapper.class.getName()+".findAppointmentAllocations")
.getBoundSql(Map.of("dispatchOrderId",1L)).getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" AS confirmationNo");
assertThat(sql).contains("i.customer_id=a.customer_id","i.source_key=a.source_key","i.order_id AS orderId");
assertThat(sql).doesNotContain("p.confirmation_no");
}
/** H2没有MySQL JSON函数;仅适配标量路径读取,SQL关联、过滤和数量计算仍执行真实Mapper。 */
public static void installJsonFunctions(JdbcTemplate db) {
db.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonExtract'");
db.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonUnquote'");
}
/** 区分缺失路径的SQL NULL与JSON显式null,覆盖未填写确认时间的两种存储形态。 */
public static String jsonExtract(String document,String path) throws Exception {
if(document==null)return null;
var node=new ObjectMapper().readTree(document);
for(var key:path.substring(2).split("\\."))node=node.path(key);
return node.isMissingNode()?null:node.toString();
}
/** 与MySQL标量JSON_UNQUOTE一致,保留JSON null文本供生产SQL判断。 */
public static String jsonUnquote(String value) throws Exception {
if(value==null)return null;
var node=new ObjectMapper().readTree(value);
return node.isTextual()?node.textValue():node.toString();
}
/** 去除文本块换行差异,仅比较实际SQL表达式。 */
private static String normalize(String sql) {return sql.replaceAll("\\s+"," ").trim();}
}
@@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null);
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of(),mode==ResourceMode.MASTER && !ids.isEmpty()?ids.get(0):null)), null);
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
}
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -374,6 +374,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
@@ -403,6 +404,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
@@ -428,6 +430,7 @@ public class DispatchAggregatePersistenceTest {
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
@@ -505,7 +508,7 @@ public class DispatchAggregatePersistenceTest {
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);
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().stream().filter(a0->"FBA:ONT8".equals(a0.destinationKey())).findFirst().orElseThrow();
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())));
@@ -588,19 +591,20 @@ public class DispatchAggregatePersistenceTest {
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128),order_type VARCHAR(64))");
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),destination_warehouse_code VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(64),carton_mark VARCHAR(128),pallet_code VARCHAR(128),relabel_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),total_quantity BIGINT,status VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128),lastleg_order_id BIGINT,fba_shipment_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,carton_count INT,status VARCHAR(32),completed_at TIMESTAMP(3),gross_weight_kg DECIMAL(18,6),volume_cbm DECIMAL(18,6))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(64),pallet_id BIGINT,created_at TIMESTAMP(3),business_type VARCHAR(64))");
db.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(32),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(128),customer_id VARCHAR(128),source_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,version INT DEFAULT 0,appointment_no VARCHAR(64),confirmation_no VARCHAR(128),appointment_status VARCHAR(32),fba_warehouse_code VARCHAR(128),appointment_start_utc TIMESTAMP(3))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT,appointment_id BIGINT,source_key VARCHAR(128),fba_warehouse_code VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),fba_warehouse_code VARCHAR(128))");
json=new ObjectMapper().registerModule(new JavaTimeModule());
var fleetRepo=new FleetTripRepository(session.getMapper(FleetTripMapper.class),session.getMapper(FleetResourceMapper.class),session.getMapper(FleetTripHistoryMapper.class),session.getMapper(FleetTripDispatchMapper.class));
var warehouses=mock(FleetWarehouseDirectory.class); when(warehouses.required(9L,"OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("9","WH9","测试起运仓","OVERSEAS"));
@@ -632,7 +636,9 @@ public class DispatchAggregatePersistenceTest {
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
@@ -663,20 +669,32 @@ public class DispatchAggregatePersistenceTest {
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of(303L))),
departure,null,null,sources,List.of(), true);
}
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
prepareAppointmentNumbers(created.dispatchNo());
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null)));
}
/** 普通装车夹具预置已约编号;预约缺失及填写流程由专项测试单独验证。 */
public void prepareAppointmentNumbers(String number) {
try {
var row=repository.find(number);var resources=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(row.getResourcesJson());
var maintenance=resources.withObject("/maintenance");var entries=maintenance.withArray("destinationAppointments");
var known=new java.util.HashSet<String>();entries.forEach(e->known.add(e.path("destinationKey").asText()));
for(var item:repository.cargo(row.getId()))if("FBA".equals(item.getDestinationType()) && known.add("FBA:"+item.getFbaWarehouseCode()))
entries.addObject().put("destinationKey","FBA:"+item.getFbaWarehouseCode()).put("appointmentNo","TEST-ISA-"+item.getFbaWarehouseCode());
db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",json.writeValueAsString(resources),number);
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException(error);}
}
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
public void picked(long source,String customer,long pallet,int packages) {
long item=db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,source);
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id) VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source);
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2);
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
@@ -23,6 +24,78 @@ import static org.mockito.Mockito.*;
/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */
class DispatchBolReadPersistenceTest {
/** 同一批次仅客户A确认,批次头仍为空,BOL只给A带出当前编号且不改变两方箱数。 */
@Test void partialBatchConfirmationUsesCurrentCustomerAuthority() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-CURRENT","101","ORDER:1000",1000,"ONT8");
f.db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(2,1,'ORDER:2000','102',2000,'ONT8')");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,1,"102","ORDER:2000","FBA:ONT8",5);
var lines=f.read().bolLines();
assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-CURRENT",null);
assertThat(lines).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no='ISA-STALE'");
assertThat(f.read().bolLines()).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-CURRENT",null);
}
/** 当前覆盖退出、失效或确认资料不完整时,不借用历史编号、其他客户或其他仓的编号。 */
@Test void invalidReceivingConfirmationCannotProvideBolNumber() throws Exception {
for(var change:List.of(
"UPDATE ll_fba_receiving_member SET active_ref=NULL",
"UPDATE ll_fba_receiving_member SET customer_id='102'",
"UPDATE ll_fba_receiving_member SET batch_key='FBA_APPOINTMENT:2'",
"UPDATE ll_fba_receiving_agreement SET destination_key='FBA:LAX9'",
"UPDATE ll_fba_receiving_agreement SET status='CANCELLED'",
"UPDATE ll_fba_receiving_agreement SET status='SUPERSEDED'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"PENDING\",\"appointmentAt\":\"2026-10-22T10:00:00\"}}'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"SUCCESS\",\"appointmentAt\":null}}'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"SUCCESS\"}}'")) {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-CURRENT","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no='ISA-STALE'");f.db.update(change);
assertThat(f.read().bolLines()).as(change).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isEqualTo(3);
});
}
}
/** 同一客户改约后历史关联仍保留,打印只取新的有效关联,不重复分配货量。 */
@Test void replacedReceivingMemberUsesOnlySuccessorNumber() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-OLD","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
f.db.update("UPDATE ll_fba_receiving_member SET active_ref=NULL");
f.db.update("INSERT INTO ll_fba_receiving_agreement SELECT 'RA-NEW',status,destination_key,'ISA-NEW',snapshot_json FROM ll_fba_receiving_agreement");
f.db.update("INSERT INTO ll_fba_receiving_member VALUES('RA-NEW','FBA_APPOINTMENT:1','101','FBA_APPOINTMENT:1/101')");
assertThat(f.read().bolLines()).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isEqualTo("ISA-NEW");assertThat(line.packageCount()).isEqualTo(3);
});
}
/** 同一车目的仓填写一次编号,打印逐行归属及各客户原数量保持不变。 */
@Test void destinationNumberAppliesToAllCustomersWithoutDuplicatingCargo() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-OLD-A","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-OLD-B","102","ORDER:2000",2000,"ONT8");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:ONT8",5);
f.row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null,
new DispatchContracts.Maintenance(null,null,null,null,null,null,null,List.of(new DispatchContracts.DestinationAppointment("FBA:ONT8","ISA-CAR"))))));
var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);
assertThat(result.bolLines()).allSatisfy(line->assertThat(line.appointmentNo()).isEqualTo("ISA-CAR"));
}
/** 先保存空目的仓草稿,再取得正式预约号时,打印仍带出正式编号。 */
@Test void blankDestinationDraftDoesNotEraseLaterOfficialNumber() throws Exception {
for(var blank:Arrays.asList(null,""," ")) {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null,
new DispatchContracts.Maintenance(null,null,null,null,null,null,null,List.of(new DispatchContracts.DestinationAppointment("FBA:ONT8",blank))))));
assertThat(f.read().bolLines()).singleElement().satisfies(line->assertThat(line.appointmentNo()).isNull());
f.appointment(1,"ISA-REAL","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
assertThat(f.read().bolLines()).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isEqualTo("ISA-REAL");assertThat(line.packageCount()).isEqualTo(3);assertThat(line.customerId()).isEqualTo("101");
});
}
}
/** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */
@Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
@@ -108,7 +181,7 @@ class DispatchBolReadPersistenceTest {
f.appointment(1,"SAME","101","ORDER:1000",1000,"ONT8");f.appointment(2,"SAME","102","ORDER:2000",2000,"LAX9");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:LAX9",5);
assertThat(f.read().bolLines().stream().map(DispatchBolData.Line::groupKey).distinct().count()).isEqualTo(2);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no=' '");
f.db.update("UPDATE ll_fba_receiving_agreement SET confirmation_no=' '");
assertThat(f.read().bolLines()).allSatisfy(line->{assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isPositive();});
}
@@ -173,10 +246,13 @@ class DispatchBolReadPersistenceTest {
/** 测试表仅取完整模块DDL中实际引用列,保留来源/分配唯一键,事务覆盖两个真实Mapper查询。 */
private static final class Fixture {
final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;
final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;final DispatchAggregate row;
/** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */
Fixture() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds);
DispatchReceivingSqlTest.installJsonFunctions(db);
db.execute("CREATE TABLE ll_fba_receiving_agreement(agreement_key VARCHAR(64) PRIMARY KEY,status VARCHAR(24),destination_key VARCHAR(128),confirmation_no VARCHAR(128),snapshot_json VARCHAR(4000))");
db.execute("CREATE TABLE ll_fba_receiving_member(agreement_key VARCHAR(64),batch_key VARCHAR(128),customer_id VARCHAR(128),active_ref VARCHAR(260) UNIQUE,PRIMARY KEY(agreement_key,batch_key,customer_id))");
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(128),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),source_stage VARCHAR(32),order_id BIGINT,destination_type VARCHAR(32),delivery_address VARCHAR(1000),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT,counted_package_count INT,UNIQUE(line_id,pallet_id))");
@@ -186,7 +262,7 @@ class DispatchBolReadPersistenceTest {
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))");
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class);
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row);
var roots=mock(DispatchAggregateRepository.class);row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row);
service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
}
@@ -201,10 +277,13 @@ class DispatchBolReadPersistenceTest {
db.update("INSERT INTO ll_order VALUES(?,?,?)",order,customer,"CN-"+order);
db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,NULL,NULL,?,?, 'FBA',NULL,NULL,?,?,?,?,?,?)",item,customer,pallet,stage,order,"P-"+pallet,code,code,code,"IN-"+order,packages);
}
/** 系统预约业务号与对外确认号分开,稳定来源键原样保存。 */
/** 系统批次头可为空;正式编号来自当前客户收货确认,稳定来源键原样保存。 */
void appointment(long id,String confirmation,String customer,String source,long order,String code) {
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,?)",id,"SYSTEM-APPOINTMENT-"+id,confirmation);
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,NULL)",id,"SYSTEM-APPOINTMENT-"+id);
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(?,?,?,?,?,?)",id,id,source,customer,order,code);
db.update("INSERT INTO ll_fba_receiving_agreement VALUES(?,'ACTIVE',?,?,?)","RA-"+id,"FBA:"+code,confirmation,
"{\"details\":{\"appointmentStatus\":\"SUCCESS\",\"appointmentAt\":\"2026-10-22T10:00:00\"}}");
db.update("INSERT INTO ll_fba_receiving_member VALUES(?,?,?,?)","RA-"+id,"FBA_APPOINTMENT:"+id,customer,"FBA_APPOINTMENT:"+id+"/"+customer);
}
/** 按模块实际主键建立本车明细的真实逐票分配,不以客户目的地总量替代。 */
void allocate(long item,long pallet,long appointment,String customer,String source,String destination,int packages) {
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 真实排车持久化验证先排后约、同仓跨客户、缺约拒审及补齐后继续。 */
class DispatchDestinationAppointmentPersistenceTest {
@Test void canCreateWithoutAppointmentAndSubmitAfterOneNumberForTwoCustomers() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"ONT8",5)));
var created=f.tx.execute(t->f.service.create(f.actor,input));
assertThat(f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments()).hasSize(1);
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"missing",null)))).hasMessageContaining("ONT8");
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
var loaded=f.approve(saved);assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));
assertThat(detail.dispatchInfo().fbaAppointments()).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE"));
assertThat(detail.orderDetails()).extracting(d->d.customerId()).containsExactlyInAnyOrder("101","202");
}
@Test void savingOneOfTwoNumbersIsAllowedButReviewNeedsBoth() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("two-dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LGB8",5)));
var created=f.tx.execute(t->f.service.create(f.actor,input));
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"second-missing",null)))).hasMessageContaining("LGB8");
var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO"))))));
assertThat(f.approve(complete).stage()).isEqualTo(Stage.LOADING);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchViews.FbaAppointment;
import java.util.*;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
class DispatchDestinationAppointmentsTest {
@Test void groupsMultipleCustomersAndPalletsByDestination() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).contains("LGB8、ONT8");
}
/** 十票跨客户货物同目的仓,只带入一个编号,全部不同货件保留。 */
@Test void takesTheUniqueAlreadyConfirmedNumberAndAggregatesShipments() {
var customers=java.util.stream.IntStream.range(0,10).mapToObj(i->"A"+i).toList();
var rows=resolve(customers.stream().map(c->item(c,"ONT8")).toList(),null,customers.stream().map(c->booked(c,"ISA-1")).toList());
assertThat(rows).singleElement().satisfies(a->{assertThat(a.appointmentNo()).isEqualTo("ISA-1");assertThat(a.shipmentNumbers()).containsExactlyElementsOf(customers.stream().map(c->"SHIP-"+c).toList());assertThat(a.customerId()).isNull();});
}
@Test void conflictingExistingNumbersRequireOneExplicitDestinationNumber() {
var cargo=List.of(item("A","ONT8"),item("B","ONT8"));var booked=List.of(booked("A","ISA-A"),booked("B","ISA-B"));
assertThat(resolve(cargo,null,booked).get(0).appointmentNo()).isNull();
var rows=resolve(cargo,maintenance(new DestinationAppointment("FBA:ONT8"," ISA-CAR ")),booked);
assertThat(rows).singleElement().satisfies(a->{assertThat(a.appointmentNo()).isEqualTo("ISA-CAR");assertThat(a.confirmedAt()).isNull();});
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).isNull();
}
/** 先保存空草稿、后取得正式预约时,同仓仍自动带出一次,无需重新手填。 */
@Test void blankDraftDoesNotHideTheUniqueConfirmedNumber() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked("A","ISA-ONE"),booked("B","ISA-ONE")));
assertThat(rows).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE"));
}
@Test void missingSecondDestinationDoesNotPassWithTheFirstNumber() {
var rows=resolve(List.of(item("A","ONT8"),item("B","LGB8")),maintenance(new DestinationAppointment("FBA:ONT8","ISA-1")),List.of());
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).contains("LGB8").doesNotContain("ONT8");
assertThatThrownBy(()->DispatchDestinationAppointments.validate(maintenance(new DestinationAppointment("FBA:OTHER","ISA")),rows)).hasMessageContaining("本车已有");
}
@Test void rejectsDuplicateDestinationsAndOverlongNumbers() {
assertThatThrownBy(()->maintenance(new DestinationAppointment("FBA:ONT8","A"),new DestinationAppointment("FBA:ONT8","B"))).hasMessageContaining("一个预约号");
assertThatThrownBy(()->new DestinationAppointment("FBA:ONT8","A".repeat(129))).hasMessageContaining("128");
}
@Test void knownConfirmedWindowKeepsDepartureAndArrivalValidation() {
var at=java.time.Instant.parse("2026-11-01T18:00:00Z");
var rows=resolve(List.of(item("A","ONT8")),null,List.of(booked("A","ISA-1")));
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-1",at,at.plusSeconds(3600),"AGR-1",1);
assertThat(DispatchDestinationAppointments.blockReason(rows,at.minusSeconds(3600),at.plusSeconds(1800),List.of(window))).isNull();
assertThat(DispatchDestinationAppointments.blockReason(rows,at.plusSeconds(60),at.plusSeconds(120),List.of(window))).contains("早于");
assertThat(DispatchDestinationAppointments.blockReason(rows,at.minusSeconds(3600),at.plusSeconds(3601),List.of(window))).contains("晚于");
}
/** 将测试中的货物引用显式构造为真实货物投影,预约编号仅提供确认事实。 */
private static List<FbaAppointment> resolve(List<DispatchItem> cargo,Maintenance maintenance,List<FbaAppointment> booked) {
var shipments=booked.stream().flatMap(b->b.shipmentNumbers().stream().map(no->new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment(b.customerId(),b.destinationKey(),no))).toList();
return DispatchDestinationAppointments.resolve(cargo,maintenance,booked,shipments);
}
private static DispatchItem item(String customer,String code){var item=new DispatchItem();item.setCustomerId(customer);item.setDestinationType("FBA");item.setFbaWarehouseCode(code);return item;}
private static FbaAppointment booked(String customer,String no){return new FbaAppointment("FBA_APPOINTMENT:1","AP-1",no,"FBA:ONT8","ONT8","SUCCESS",null,null,customer,List.of("SHIP-"+customer));}
private static Maintenance maintenance(DestinationAppointment... appointments){return new Maintenance(null,null,null,null,null,null,null,List.of(appointments));}
}
@@ -0,0 +1,134 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */
class DispatchFeedbackRegressionTest {
/** 主档PDF与本趟补传图片分属各自文件域,补传不能被主档重建清空。 */
@Test void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
var driver=mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class);
var details=mock(com.baiyue.lastleg.fleet.driver.DriverModels.Draft.class);
when(driver.id()).thenReturn("71");when(driver.details()).thenReturn(details);when(driver.licenseFileId()).thenReturn("81");
when(details.region()).thenReturn("OVERSEAS");when(details.name()).thenReturn("主档司机");when(details.phone()).thenReturn("555-0100");
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1));
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3}));
var input=f.configured("update",List.of());
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))),
input.plannedDepartureAt(),null,null,true,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
f.prepareAppointmentNumbers(created.dispatchNo());
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(proof.fileIds()).containsExactly(81L,91L);assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion());
verify(drivers,never()).download(71L,91L);
}
/** 车辆候选使用真实资源占用,本车已选资源保留,其他车次占用排除。 */
@Test void vehicleAvailabilityKeepsOnlyOwnReservation() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var first=f.tx.execute(s->f.service.create(f.actor,f.create("first",List.of())));
var second=f.tx.execute(s->f.service.create(f.actor,f.create("second",List.of())));
long own=f.repository.find(first.dispatchNo()).getTripId(), other=f.repository.find(second.dispatchNo()).getTripId();
f.db.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,?,'OWN'),('VEHICLE',12,?,'OTHER')",own,other);
var guard=new com.baiyue.lastleg.fleet.trip.FleetVehicleAssignmentGuard(f.session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class));
assertThat(guard.unavailableForDispatch(first.dispatchNo())).containsExactly(12L);
assertThat(guard.unavailableForDispatch(second.dispatchNo())).containsExactly(11L);
assertThat(guard.unavailableForDispatch("UNRELATED")).containsExactlyInAnyOrder(11L,12L);
}
/** 三类各自缺失均不能提交,不能用其他类别照片顶替。 */
@ParameterizedTest @ValueSource(strings={"VEHICLE","DRIVER_PHOTO","DRIVER"})
void missingPhotoBlocksSubmission(String kind) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("missing-"+kind,List.of(f.source(1,"101",501,"ONT8",3)))));
f.prepareAppointmentNumbers(created.dispatchNo());
var row=f.repository.find(created.dispatchNo());
var json=(ObjectNode)f.json.readTree(row.getResourcesJson());
ObjectNode target=kind.equals("VEHICLE")?(ObjectNode)json.path("vehicle"):(ObjectNode)json.path("driver").path("evidence");
target.set(kind.equals("VEHICLE")?"evidenceFileIds":kind.equals("DRIVER")?"fileIds":"photoFileIds",f.json.valueToTree(List.of()));
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=?",f.json.writeValueAsString(json),row.getId());
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))))
.hasMessageContaining("至少一张有效");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(created.recordVersion());
}
/** 上传未成功不产生有效引用;草稿可先保存空照片,送审才拦截。 */
@Test void incompletePhotosCanBeSavedAsDraft() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.create("draft",List.of(f.source(1,"101",501,"ONT8",3)))));
var input=f.configured("resource",List.of());var v=input.vehicle();var d=input.driver();var e=d.evidence();
var update=new DispatchCommands.Update(created.recordVersion(),"save-draft",
new DispatchCommands.Vehicle(v.mode(),null,v.plate(),v.carrierName(),v.capacity(),List.of()),
new DispatchCommands.Driver(d.mode(),null,d.name(),d.phone(),new DispatchContracts.DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),List.of(),e.expectedTaskEndAt(),List.of())),
input.plannedDepartureAt(),null,null,true,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
f.prepareAppointmentNumbers(created.dispatchNo());
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-draft",null))))
.hasMessageContaining("车辆图片");
}
/** PDF不计为图片,失效文件在审核通过时重新核验,失败保持待审。 */
@Test void pdfAndRevokedUploadCannotPassReview() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("valid",List.of(f.source(1,"101",501,"ONT8",3)))));
f.prepareAppointmentNumbers(created.dispatchNo());
var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
when(gateway.metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq(101L)))
.thenReturn(new DispatchEvidenceGateway.File(101L,"车辆.pdf","application/pdf",10));
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"pdf",null))))
.hasMessageContaining("车辆图片");
when(gateway.metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq(101L)))
.thenReturn(new DispatchEvidenceGateway.File(101L,"车辆.png","image/png",10));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-valid",null)));
doThrow(new IllegalArgumentException("文件已作废")).when(gateway).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),eq(303L));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve-revoked",Decision.APPROVED,null,null))))
.hasMessageContaining("文件已作废");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("DISPATCH_PENDING");
}
/** 同仓预约汇总一行,多托及重复分配不重复显示,全部真实货件保留。 */
@Test void appointmentsGroupByDestinationAndKeepAllShipmentNumbers() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("projection",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"101",502,"ONT8",2)))));
long id=f.repository.find(created.dispatchNo()).getId();
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class);
f.db.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,fba_warehouse_code) VALUES(900,'AP-1','ONT8'),(901,'AP-2','ONT8')");
f.db.update("INSERT INTO ll_order(id,customer_id,order_no,order_type) VALUES(9001,'101','LM-1','FIRST_LEG'),(9002,'101','LM-2','FIRST_LEG')");
f.db.update("INSERT INTO ll_order_detail VALUES(9001,'101','SHIP-1','ONT8'),(9001,'101','SHIP-2','ONT8'),(9001,'101','SHIP-1','ONT8'),(9001,'999','FOREIGN','ONT8'),(9002,'101','SHIP-3','ONT8')");
f.db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(9001,'101',1,900,'ORDER:9001','ONT8'),(9002,'101',1,900,'ORDER:9002','ONT8'),(9001,'101',1,901,'ORDER:9001','ONT8')");
for(String source:List.of("ORDER:9001","ORDER:9001","ORDER:9002"))
f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101',?,'FBA:ONT8',1)",id,item,source);
f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:901','101','ORDER:9001','FBA:ONT8',1)",id,item);
var provider=mock(AppointmentWorkDispatchPort.class);var at=Instant.parse("2026-10-25T17:00:00Z");
when(provider.reviewEvidence(created.dispatchNo())).thenReturn(List.of(
new AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-1","101","FBA:ONT8","CONF-SAME",at,null,"AGR-1",1),
new AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:901","AP-2","101","FBA:ONT8","CONF-SAME",at.plusSeconds(3600),null,"AGR-2",1)));
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var appointments=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments();
assertThat(appointments).hasSize(1);
assertThat(appointments.get(0).shipmentNumbers()).containsExactly("SHIP-1","SHIP-2","SHIP-3");
assertThat(appointments.get(0).confirmedAt()).isEqualTo(at);
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,10,null,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
assertThat(page.records().get(0).fbaAppointments()).isEqualTo(appointments);
}
}
@@ -82,13 +82,13 @@ class DispatchInboundReceiptPersistenceTest {
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效");
}
/** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */
@Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception {
/** 本车已按目的仓维护编号,逐票预约回复不再重新引入车辆送审门禁。 */
@Test void receivingKeepsFactsWithoutReintroducingPerTicketAppointmentGate() throws Exception {
var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true);
f.confirm();
assertThat(f.quantities()).containsExactly(9,11);
assertThat(f.allocations()).containsExactly(9,11);
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认");
assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException();
}
/** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */
@@ -161,8 +161,8 @@ class DispatchInboundReceiptPersistenceTest {
doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id);
doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any());
}
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))");
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD destination_type VARCHAR(20)");
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (planned_package_count INT,counted_package_count INT)");
p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')");
for(long id=1;id<=2;id++) {
p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id);
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.fleet.driver.*;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 创建时间公共投影、实际货件来源及司机换证保存的直接回归。 */
class DispatchListAndResourceRegressionTest {
/** 各阶段、审核分区和任务类型均取同一主单创建时间,不借用计划或更新时间。 */
@Test void creationTimeIsPreservedAcrossAllListScopes() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var createdAt=LocalDateTime.of(2026,10,22,1,23,45);
for(var stage:Stage.values())for(var type:List.of("DELIVERY","PICKUP","LOCAL_TRUCK")) {
var result=f.tx.execute(s->f.service.create(f.actor,f.create(stage+type,List.of())));
f.db.update("UPDATE ll_dispatch_order SET task_type=?,stage=?,created_at=?,planned_departure_at=?,updated_at=? WHERE dispatch_no=?",
type,stage.name(),createdAt,createdAt.plusDays(1),createdAt.plusDays(2),result.dispatchNo());
}
for(var stage:Stage.values()) {
var page=f.tx.execute(s->f.service.page(f.actor,query(stage,null)));
assertThat(page.records()).hasSize(3).allSatisfy(row->assertThat(row.createdAt()).isEqualTo(createdAt.toInstant(ZoneOffset.UTC)));
}
for(var section:BeforeLoadingSection.values()) {
var state=section==BeforeLoadingSection.VEHICLE_INFO?"DRAFT":section==BeforeLoadingSection.DISPATCH_REVIEW?"DISPATCH_PENDING":"LOADING_PENDING";
f.db.update("UPDATE ll_dispatch_order SET review_state=? WHERE stage='BEFORE_LOADING'",state);
var page=f.tx.execute(s->f.service.page(f.actor,query(Stage.BEFORE_LOADING,section)));
assertThat(page.records()).hasSize(3).allSatisfy(row->assertThat(row.createdAt()).isEqualTo(createdAt.toInstant(ZoneOffset.UTC)));
}
}
/** 手填预约与内部实物引用不妨碍逐票货件读取,同仓跨客户去重且不串入别的客户。 */
@Test void manualAppointmentReadsShipmentsFromActualPlanAllocations() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var result=f.tx.execute(s->f.service.create(f.actor,f.configured("actual-shipments",List.of(f.source(1,"101",501,"ONT8",2),f.source(2,"202",502,"ONT8",1)))));
f.prepareAppointmentNumbers(result.dispatchNo());
f.db.update("INSERT INTO ll_order(id,customer_id,order_no,order_type) VALUES(9001,'101','LM-1','FIRST_LEG'),(9002,'101','LM-2','FIRST_LEG'),(9003,'202','LM-3','FIRST_LEG')");
f.db.update("INSERT INTO ll_order_detail VALUES(9001,'101','SHIP-1','ONT8'),(9001,'101','SHIP-1','ONT8'),(9001,'999','FOREIGN','ONT8'),(9001,'101','OTHER-WAREHOUSE','LGB8'),(9002,'101','SHIP-2','ONT8'),(9003,'202','SHIP-3','ONT8')");
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(11,9001,'101','ONT8'),(12,9002,'101',' ont8 '),(13,9003,'202','ONT8')");
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,11,'101'),(1,12,'101'),(2,13,'202')");
f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,CONCAT('DISPATCH:',dispatch_order_id),customer_id,CONCAT('ORDER:',order_id),'FBA:ONT8',package_count FROM ll_dispatch_item");
var appointments=f.service.detail(f.actor,result.dispatchNo()).dispatchInfo().fbaAppointments();
assertThat(appointments).singleElement().satisfies(a->{
assertThat(a.appointmentNo()).isEqualTo("TEST-ISA-ONT8");
assertThat(a.shipmentNumbers()).containsExactly("SHIP-1","SHIP-2","SHIP-3");
});
assertThat(f.service.page(f.actor,query(Stage.BEFORE_LOADING,null)).records().get(0).fbaAppointments()).isEqualTo(appointments);
}
/** 换证后排除服务端冻结的旧主档引用,本车补传图保留,请求不能伪造文件来源来移除补传图。 */
@Test void savingAfterMasterReplacementKeepsOnlyTheNewMasterAndDispatchImages() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("driver-replacement",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
var drivers=(DriverService)ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);
when(driver.id()).thenReturn("71");when(driver.details()).thenReturn(details);when(driver.licenseFileId()).thenReturn("81");
when(details.region()).thenReturn("OVERSEAS");when(details.name()).thenReturn("主档司机");when(details.phone()).thenReturn("555-0100");
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(LocalDate.of(2099,1,1));
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
var input=f.configured("input",List.of());
var requestDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L)));
var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,true,null)));
var snapshot=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver();var proof=snapshot.evidence();
when(driver.licenseFileId()).thenReturn("82");
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");clearInvocations(evidence);
doThrow(new IllegalArgumentException("旧主档证照不能按本车附件读取")).when(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
var changedDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,snapshot.name(),snapshot.phone(),new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),91L));
f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,true,null)));
var saved=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(saved.fileIds()).containsExactly(82L,91L);assertThat(saved.masterLicenseFileId()).isEqualTo(82L);
verify(evidence,never()).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
verify(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(91L));
}
/** 只读列表查询保持正式阶段与审核分区组合。 */
private static DispatchViews.Query query(Stage stage,BeforeLoadingSection section) {
return new DispatchViews.Query(1,20,null,null,null,null,null,StatusFilter.ACTIVE,stage,section,null);
}
}
@@ -29,7 +29,7 @@ class DispatchMaintenancePersistenceTest {
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var vehicle = vehicle(f, mode, List.of(301L));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装",
" fleet@example.test ", "厢式货车", " account ", " appointment ");
" fleet@example.test ", "厢式货车", " account ", List.of(new com.baiyue.lastleg.dispatch.port.DispatchContracts.DestinationAppointment("FBA:ONT8", " appointment ")));
var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(),
input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance);
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
@@ -73,7 +73,7 @@ class DispatchMaintenancePersistenceTest {
@Test void emptyMaintenanceCanBeSavedAndCleared() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var created = f.tx.execute(t -> f.service.create(f.actor, f.create("empty-maintenance", List.of())));
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"appointmentNo\":\"\"}", Maintenance.class);
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"destinationAppointments\":[]}", Maintenance.class);
assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty)));
@@ -91,7 +91,7 @@ class DispatchMaintenancePersistenceTest {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) {
for (String field : List.of("fleetEmail", "appointmentAccount")) {
int max = field.equals("fleetEmail") ? 254 : 128;
assertThat(json.treeToValue(json.createObjectNode().put(field, "a".repeat(max)), Maintenance.class)).isNotNull();
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "a".repeat(max + 1)), Maintenance.class))
@@ -110,7 +110,7 @@ class DispatchMaintenancePersistenceTest {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("projection-maintenance-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, "AP-LOCAL");
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, List.of());
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(),
"projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance)));
var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository,
@@ -147,7 +147,8 @@ class DispatchMaintenancePersistenceTest {
assertThat(fields.size()).isEqualTo(Maintenance.class.getRecordComponents().length);
assertThat(schema.path("required").isMissingNode()).isTrue();
for (var field : Maintenance.class.getRecordComponents()) {
assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue();
if(field.getName().equals("destinationAppointments")) assertThat(fields.path(field.getName()).path("type").asText()).isEqualTo("array");
else assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue();
for (var value : fields.path(field.getName()).path("enum")) {
assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull();
}
@@ -155,7 +156,8 @@ class DispatchMaintenancePersistenceTest {
assertThat(fields.path("plannedDeliveryDate").path("format").asText()).isEqualTo("date");
assertThat(fields.path("fleetEmail").path("maxLength").asInt()).isEqualTo(254);
assertThat(fields.path("appointmentAccount").path("maxLength").asInt()).isEqualTo(128);
assertThat(fields.path("appointmentNo").path("maxLength").asInt()).isEqualTo(128);
assertThat(fields.path("destinationAppointments").path("items").path("$ref").asText()).isEqualTo("#/components/schemas/DispatchDestinationAppointment");
assertThat(schemas.path("DispatchDestinationAppointment").path("properties").path("appointmentNo").path("maxLength").asInt()).isEqualTo(128);
}
/** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */
@@ -142,6 +142,7 @@ class DispatchReviewWorkflowPersistenceTest {
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
f.prepareAppointmentNumbers(prior.dispatchNo());
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
}
@@ -119,7 +119,7 @@ class DispatchWholeSourcePersistenceTest {
for(long id:List.of(1L,2L)) {
var row=f.candidates.get(id);f.db.update("INSERT INTO ll_order(id,customer_id,order_no)VALUES(?,?,?)",row.orderId(),row.customerId(),row.orderNo());
f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?,?)",id,row.customerId(),"9",row.orderId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?,?)",id,id,row.palletNo(),row.warehousePalletId(),row.customerId());
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?)",id,id);
f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id) VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation(pallet_id,line_id) VALUES(?,?)",id,id);
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount());
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
}
@@ -65,11 +65,10 @@ class AppointmentWorkDispatchBindingTest {
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.
// 目的仓编号及时间由整车入口校验;此端口只核实真实来源分配。
assertNull(service.reviewBlockReason("D-20",departure,null));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("分配量"));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(batch.appointmentStatus()).thenReturn("PENDING");
assertDoesNotThrow(()->service.requireDeparture("D-20")); // The current receiving agreement, not the old header, authorizes receipt.
@@ -77,16 +76,16 @@ class AppointmentWorkDispatchBindingTest {
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(true);
clearInvocations(mapper);
assertTrue(service.departureBlockReason("D-20").contains("待仓库确认"));verify(mapper,never()).lockAppointment(anyLong());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertNull(service.departureBlockReason("D-20"));verify(mapper,never()).lockAppointment(anyLong());
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(false);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("OTHER",4));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertTrue(service.reviewEvidence("D-20").isEmpty());
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",3));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertDoesNotThrow(()->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("尚无有效收货确认"));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
@@ -133,7 +132,7 @@ class AppointmentWorkDispatchBindingTest {
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,one).sourceKey());
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,two).sourceKey());
}
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
@Test void prearrangementBuildsRealSourceRowsWithoutMandatoryAppointmentBatches() {
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
@@ -147,9 +146,10 @@ class AppointmentWorkDispatchBindingTest {
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
for(var status:List.of("SUCCESS","CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
assertDoesNotThrow(()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
}
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
verify(mapper,times(3)).insert(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","FBA:ONT8",4));
verify(changes,times(3)).requirementsChanged();
}
}
@@ -182,13 +182,22 @@ class AppointmentWorkDispatchPersistenceTest {
assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"));
}
/** 正式确认未完成时,候选和写入都阻止提前排车,不留下部分分配。 */
@Test void unconfirmedAppointmentBlocksBothCandidateAndBindingBeforeReview() {
/** 正式确认未完成也允许先排车;不自动带入尚未确认的预约引用。 */
@Test void unconfirmedAppointmentAllowsPrearrangementWithoutAutomaticConfirmedNumber() {
when(receiving.currentAgreement(batch,"101")).thenReturn(null);
when(receiving.snapshots(anyList())).thenReturn(List.of());
assertFalse(candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L)).blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
var candidate=candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L));
assertTrue(candidate.blockReasons().isEmpty());assertTrue(candidate.appointmentBatches().isEmpty());
assertDoesNotThrow(()->tx.executeWithoutResult(s->bind(11)));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
}
/** 先排后约沿本车真实来源记账,不能省略客户或箱数,也不占用尚未确认的预约批次。 */
@Test void fbaPrearrangementKeepsWholePhysicalSourcesWithoutBatchSelection() {
var physical=mapper.dispatchPhysicalById(1).stream().filter(p->p.itemId()==11).findFirst().orElseThrow();
tx.executeWithoutResult(s->{binding.lockBatches(Map.of());binding.bindItems(1,List.of(new PhysicalItem(physical.itemId(),physical.customerId(),physical.planPalletId(),physical.sourceStage(),physical.packages())),List.of());});
var rows=jdbc.queryForList("SELECT batch_key,customer_id,package_count FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=11");
assertEquals(1,rows.size());assertEquals("DISPATCH:1",rows.get(0).get("BATCH_KEY"));
assertEquals("101",rows.get(0).get("CUSTOMER_ID"));assertEquals(physical.packages(),rows.get(0).get("PACKAGE_COUNT"));
}
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
@@ -27,6 +27,19 @@ class VehicleApplicationServiceTest {
service = new VehicleApplicationService(repository, mock(VehicleAssignmentGuard.class));
}
/** 可用性排除集传入仓储查询,分页不先取再过滤。 */
@Test void availabilityIsAppliedBeforePaging() {
var assignments=mock(VehicleAssignmentGuard.class);
service=new VehicleApplicationService(repository,assignments);
when(assignments.unavailableForDispatch("PC-1")).thenReturn(java.util.List.of(12L));
when(repository.page(org.mockito.ArgumentMatchers.any())).thenReturn(new VehicleRepository.PageSlice(java.util.List.of(),1,20,0,0));
service.page(new VehicleModels.Query(1,20,null,null,"OVERSEAS","IN_SERVICE","PC-1"));
var criteria=org.mockito.ArgumentCaptor.forClass(VehicleRepository.SearchCriteria.class);
org.mockito.Mockito.verify(repository).page(criteria.capture());
assertThat(criteria.getValue().excludedIds()).containsExactly(12L);
assertThat(criteria.getValue().status()).isEqualTo("IN_SERVICE");
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
@@ -32,6 +32,10 @@ class ReturnRelabelPackingServiceTest {
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1");
var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B");
var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW");
var physical = mock(ReturnInventoryCartonItemMapper.class);
var stock = new ReturnInventoryCartonItem(); stock.setSkuCode("SKU"); stock.setQuantity(6L);
when(physical.lockCarton(anyLong())).thenReturn(List.of(stock));
ReflectionTestUtils.setField(service, "cartonItems", physical);
var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "histories", history);
if (inheritedCompletion) {
@@ -42,7 +46,6 @@ class ReturnRelabelPackingServiceTest {
var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1);
when(facts.selectList(any())).thenReturn(List.of(fact));
ReflectionTestUtils.setField(service, "facts", facts);
var physical = mock(ReturnInventoryCartonItemMapper.class);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L);
when(physical.lockCarton(20L)).thenReturn(List.of(line));
ReflectionTestUtils.setField(service, "cartonItems", physical);
@@ -57,13 +60,15 @@ class ReturnRelabelPackingServiceTest {
var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1);
assertEquals(List.of("A", "B"), request.sourceCartonMarks());
assertNull(request.targetCartonMark());
assertEquals(List.of(new ReturnInventoryRequests.SourceQuantity("A", "SKU", 6),
new ReturnInventoryRequests.SourceQuantity("B", "SKU", 6)), request.expectedSourceQuantities());
assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3),
new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions());
return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null);
});
var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second),
new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null,
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE));
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE, null));
assertEquals("NEW", result.cartonMark());
assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId());
assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark());
@@ -87,9 +92,9 @@ class ReturnRelabelPackingServiceTest {
var app = new RelabelApplication();
var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER");
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong")));
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong", null, null, null, null, null, null)));
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest")));
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest", null, null, null, null, null, null)));
verifyNoInteractions(inventory);
}
private RelabelItem item(long id, long cartonId, String mark) {
@@ -647,13 +647,59 @@ class ReturnInventoryServiceTest {
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5)));
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing",
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null);
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null,
List.of(new ReturnInventoryRequests.SourceQuantity("RC-SOURCE", "SKU-A", 5)));
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request));
assertThat(error.getReason()).contains("独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void freshKeyCannotRepeatPartialMergeFromAStaleSourceSnapshot() {
var source = prepareConcurrentSource(); source.setTotalQuantity(10L);
var line = item(41L, "SKU-A", 10L);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var saved = new java.util.HashMap<String, ReturnInventoryOperation>();
org.mockito.Mockito.doAnswer(call -> {
ReturnInventoryOperation operation = call.getArgument(0); saved.put(operation.getIdempotencyKey(), operation); return 1;
}).when(operations).insert(any(ReturnInventoryOperation.class));
when(operations.lockKey(anyString(), anyString())).thenAnswer(call -> saved.get(call.getArgument(1)));
var identity = new PdaWarehouseIdentity("worker", "作业员", 42L);
var first = boxMerge("first-click", 10L);
service.mergeBoxes(identity, first);
assertThat(line.getQuantity()).isEqualTo(7L);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(identity, boxMerge("next-click", 10L)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
assertThat(error.getReason()).contains("库存已变化");
assertThat(line.getQuantity()).isEqualTo(7L);
verify(cartons, org.mockito.Mockito.times(1)).insert(any(ReturnInventoryCarton.class));
verify(operations, org.mockito.Mockito.times(1)).insert(any(ReturnInventoryOperation.class));
service.mergeBoxes(identity, boxMerge("rescanned", 7L));
assertThat(line.getQuantity()).isEqualTo(4L);
verify(cartons, org.mockito.Mockito.times(2)).insert(any(ReturnInventoryCarton.class));
}
@Test
void boxMergeRejectsMissingSnapshotBeforeInventoryAccess() {
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), null, "missing", null,
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, null);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("worker", "作业员", 42L), request));
assertThat(error.getStatusCode().value()).isEqualTo(400);
verifyNoInteractions(cartons, cartonItems, balances);
}
/** 创建同一实物扣三件的请求,快照与请求键按场景独立变化。 */
private ReturnInventoryRequests.BoxMerge boxMerge(String key, long expectedQuantity) {
return new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), null, key,
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 3L)),
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE,
List.of(new ReturnInventoryRequests.SourceQuantity("RC-SOURCE", "SKU-A", expectedQuantity)));
}
@Test
void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
@@ -60,7 +60,7 @@ class ReturnReceiptServiceTest {
for (String code : List.of("中文包裹", "PK-中文")) {
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null,
List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code,
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true, 0L);
assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason())
.contains("实物编码无法生成条码");
}
@@ -435,7 +435,7 @@ class ReturnReceiptServiceTest {
ReturnOrderItem second = item(12L, "SKU-B", 2, 0);
stubNewReceipt(order, List.of(first, second), "RC-1");
var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", 1,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))));
assertFalse(result.fullyMatched());
@@ -464,7 +464,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
stubNewReceipt(order, List.of(line), "RC-AUTO");
var request = confirm("SKU", null, "RC-AUTO", "key-final",
var request = confirm("SKU", null, "RC-AUTO", "key-final", 1,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var first = service.confirm(identity, request);
@@ -489,7 +489,7 @@ class ReturnReceiptServiceTest {
stubNewReceipt(order, List.of(line), "SP-SOURCE");
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key",
"PARCEL", null, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
"PARCEL", null, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true, 0L);
var result = service.confirm(identity, request);
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
@@ -514,7 +514,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(line));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-over", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2)))));
confirm("RETURN_NO", null, null, "key-over", 1, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2)))));
assertEquals(409, error.getStatusCode().value());
verify(receipts, never()).insert(any(ReturnReceipt.class));
@@ -527,7 +527,7 @@ class ReturnReceiptServiceTest {
when(orders.lockNo("RT-1")).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-cross", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
confirm("RETURN_NO", null, null, "key-cross", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(404, error.getStatusCode().value());
verify(orderItems, never()).lockOrder(any());
@@ -539,7 +539,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 2, 0);
ReturnOrderItem line = item(11L, "SKU-A", 2, 0);
stubNewReceipt(order, List.of(line), "RC-1");
var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", 0,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
service.confirm(identity, original);
ArgumentCaptor<ReturnReceipt> savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
@@ -547,7 +547,7 @@ class ReturnReceiptServiceTest {
when(receipts.lockKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1",
confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1", 0,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
@@ -555,6 +555,29 @@ class ReturnReceiptServiceTest {
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
}
@Test
void freshKeyAfterLostResponseCannotCountTheSameSnapshotTwice() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 10, 0);
ReturnOrderItem line = item(11L, "SKU-A", 10, 0);
stubNewReceipt(order, List.of(line), "RC-1");
var items = List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2));
service.confirm(identity, confirm("SKU", null, null, "first-click", 0, items));
var stale = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("SKU", null, null, "new-click", 0, items)));
assertEquals(409, stale.getStatusCode().value());
assertThat(stale.getReason()).contains("实收数量已变化");
assertEquals(2, line.getReceivedQuantity());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
var next = service.confirm(identity, confirm("SKU", null, null, "after-refresh", 2, items));
assertEquals(4, next.receivedQuantity());
verify(inventory, times(2)).createReceiptCarton(any(), any(), any(), any());
}
private void stubNewReceipt(ReturnOrder order, List<ReturnOrderItem> items, String cartonMark) {
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(items);
@@ -568,7 +591,7 @@ class ReturnReceiptServiceTest {
@Test
void sameKeyCommittedWhileWaitingForOrderLockReplaysCompletedReceipt() throws Exception {
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var request = confirm("RETURN_NO", null, null, "concurrent-key", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
ReturnReceipt committed = new ReturnReceipt();
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED");
@@ -597,7 +620,7 @@ class ReturnReceiptServiceTest {
ReturnReceipt committed = new ReturnReceipt(); committed.setRequestHash("different-content");
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
var error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
confirm("RETURN_NO", null, null, "concurrent-key", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
verifyNoInteractions(inventory, batches, orderItems);
}
@@ -640,13 +663,12 @@ class ReturnReceiptServiceTest {
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
String key, List<PdaReturnReceiptRequests.Item> items) {
String key, long expectedReceivedQuantity, List<PdaReturnReceiptRequests.Item> items) {
List<PdaReturnReceiptRequests.Item> boxed = items.stream()
.map(item -> new PdaReturnReceiptRequests.Item(item.skuCode(), item.receivedQuantity(), 1L)).toList();
// These focused tests exercise receipt/idempotency behavior at the
// carton level. Pallet receipts have a separate multi-carton contract.
// 本组测试验证单箱签收和幂等行为;托盘多箱由独立合同覆盖。
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key,
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true, expectedReceivedQuantity);
}
}