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