修复费用记录、装柜完成及海外履约资料

This commit is contained in:
2026-10-21 23:20:09 +08:00
parent 5356a8c898
commit 2c3435adaf
62 changed files with 1622 additions and 298 deletions
+3 -3
View File
@@ -22,10 +22,10 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` |
| 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
@@ -112,7 +112,7 @@
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
record Request(List<Long> containerBookingIds) { }
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { }
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { }
@PostMapping("/internal/firstleg/appointment-forecasts/batch")
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
}
@@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper {
@Select("""
<script>
SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
SELECT o.source_container_booking_id sourceContainerBookingId,
MAX(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo')),'null')) cabinetNo,
MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
AND MAX(o.arrival_time_version)>0
THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
FALSE canEdit,
(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1
THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) plannedDate,
FALSE isDefault
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id
) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
OR (c.plannedDate &gt;= DATE(#{from}) AND c.plannedDate &lt; DATE(#{to}))
<if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
public record Entry(String sourceContainerBookingId, String cabinetNo, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit) { }
long planCount, boolean canEdit, LocalDate plannedDate, boolean isDefault) { }
}
@@ -10,8 +10,10 @@ import java.util.List;
@Service
public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository;
private final com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals;
/** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
public WarehouseCalendarService(WarehouseCalendarRepository repository,
com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals) { this.repository = repository; this.arrivals=arrivals; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
@@ -20,8 +22,16 @@ public class WarehouseCalendarService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
var resolved=arrivals.resolve(rows.stream().map(WarehouseCalendarModels.Entry::sourceContainerBookingId).toList())
.stream().collect(java.util.stream.Collectors.toMap(
com.baiyue.lastleg.dto.ContainerArrivalTimeView::sourceContainerBookingId,java.util.function.Function.identity()));
return rows.stream().map(row -> {
var arrival=resolved.get(row.sourceContainerBookingId());
if(arrival==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到仓日期读取不完整");
return new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.cabinetNo(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null,
arrival.plannedDate(),arrival.isDefault());
}).toList();
}
}
@@ -30,7 +30,7 @@ public final class DispatchCommands {
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt,
String remark, Boolean publishLooseboardDemand) {
String remark, Boolean publishLooseboardDemand, Maintenance maintenance) {
/** 缺版本与有效版本零必须区分。 */
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
}
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
@@ -42,7 +44,37 @@ public final class DispatchContracts {
public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone,
DriverEvidence evidence) { }
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { }
/** 本趟可空维护资料,不替代正式预约或实际发车事实。 */
public record Maintenance(@JsonFormat(shape = JsonFormat.Shape.STRING)
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
String loadType, String fleetEmail, String vehicleType,
String appointmentAccount, String appointmentNo) {
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
public Maintenance {
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
pickupMethod = option(pickupMethod, "提卸方式", "一提一卸", "一提多卸");
loadType = option(loadType, "装载类型", "托盘", "地板", "混装");
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
}
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
private static String option(String value, String label, String... allowed) {
String normalized = limited(value, label, 128);
if (normalized != null && !List.of(allowed).contains(normalized)) throw new IllegalArgumentException(label + "无效");
return normalized;
}
/** 统一文本空值及长度边界,不把补充资料写入备注或车牌。 */
private static String limited(String value, String label, int max) {
String normalized = value == null || value.isBlank() ? null : value.trim();
if (normalized != null && normalized.length() > max) throw new IllegalArgumentException(label + "最多" + max + "字符");
return normalized;
}
}
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record Destination(String key, String type, String warehouseId, String warehouseCode,
String warehouseName, String address, String postalCode,
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/** 本趟补充日期允许清空,但不能将时间串或不存在的日期截断、修正为有效日期。 */
public final class DispatchMaintenanceDateDeserializer extends StdDeserializer<LocalDate> {
/** 仅绑定维护资料的可空日期,不改变其他业务日期解析规则。 */
public DispatchMaintenanceDateDeserializer() { super(LocalDate.class); }
/** 空白归空,其余只接受四位年、两位月日的严格ISO日期。 */
@Override
public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (!parser.hasToken(JsonToken.VALUE_STRING)) return (LocalDate) context.handleUnexpectedToken(LocalDate.class, parser);
String value = parser.getText().trim();
if (value.isEmpty()) return null;
try {
if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) throw new DateTimeParseException("日期格式无效", value, 0);
return LocalDate.parse(value);
} catch (DateTimeParseException failure) {
return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, "计划派送日期须为有效的YYYY-MM-DD");
}
}
}
@@ -36,7 +36,7 @@ public final class DispatchViews {
boolean pickingReconciled, boolean arranged,
Map<Action, Gate> actions) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
String destinationKey, String destinationWarehouseCode,
@@ -310,7 +310,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -379,7 +379,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason());
}
/** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
requirePlatform(actor);
@@ -391,8 +391,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
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)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));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,"车辆、司机或计划信息已修改");
@@ -675,12 +677,12 @@ 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)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
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());
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()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
void requireReviewAppointment(DispatchAggregate row) {
@@ -763,6 +765,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
}
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
@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());
@@ -778,7 +781,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
}
@@ -1000,12 +1003,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return List.copyOf(result);
}
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos,Maintenance maintenance) {
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)));
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -25,7 +25,7 @@ public class TransportReviewPhotos {
}
/** 读取已保存资源;空排车还没有照片。 */
public Resources resources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null);
if(row.getResourcesJson()==null)return new Resources(null,null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
}
@@ -109,21 +109,21 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
}
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
/** 主单展示真实主档分配,并保留本趟维护资料及同一资源的照片。 */
private void resources(DispatchAggregate row,FleetTripEntity trip) {
var old=readResources(row);
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null))));
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null),old.maintenance())));
}
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */
/** 照片变更纳入主单版本和审核依据,保留原维护资料,失败由外层事务一起回滚。 */
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos));
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos),old.maintenance());
if(next.equals(old))return;
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
@@ -134,7 +134,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
}
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
private Resources readResources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null);
if(row.getResourcesJson()==null)return new Resources(null,null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
}
@@ -8,5 +8,7 @@ public record ContainerArrivalTimeView(
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt,
int version,
long planCount
long planCount,
java.time.LocalDate plannedDate,
boolean isDefault
) { }
@@ -301,7 +301,7 @@ public class FleetTripService {
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
}
/** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */
/** 复验主档资格与名称容量;本趟已由排车核验归属的车辆照片保持独立,不被主档重建清空。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle,
FleetExecutionModels.DispatchDriver driver) {
@@ -315,7 +315,7 @@ public class FleetTripService {
var assignment=repository.resource("VEHICLE",actual.id());
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用");
vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(),
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status();
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),vehicle.evidenceFileIds()==null?List.of():List.copyOf(vehicle.evidenceFileIds()));vehicleStatus=actual.status();
} else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案");
if(driver!=null && "MASTER".equals(driver.mode())) {
if(driver.driverId()==null)throw bad("请选择司机档案");
@@ -79,11 +79,6 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT estimated_warehouse_at FROM ll_order " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
List<LocalDateTime> containerEstimatedWarehouseTimes(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@@ -69,6 +69,7 @@ public class InboundPlanService {
@Resource private LastlegOrderCommandRecordMapper commandMapper;
@Resource private LastlegSourcePalletMapper sourcePalletMapper;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
return "ONE_CLICK_DROPSHIP".equals(orderType)
@@ -775,20 +776,13 @@ public class InboundPlanService {
}
}
/** 计划日期使用与清关和预约日历相同的权威解析,不以约仓时间代替。 */
private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间");
}
List<LocalDateTime> values = orderMapper.containerEstimatedWarehouseTimes(bookingId);
if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间");
}
LocalDateTime expected = values.get(0);
if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间");
}
return expected.toLocalDate();
return warehouseArrival.requiredDate(bookingId);
}
private void requirePlanContainer(InboundPlan plan, String containerNo) {
@@ -210,19 +210,26 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
/** 仅查询已冻结来源柜的头程交接尾程号,调用方先取得柜级互斥锁。 */
@Select("SELECT DISTINCT order_no FROM ll_order WHERE source_container_booking_id=#{bookingId} " +
"AND source_region='FIRSTLEG' ORDER BY order_no")
List<String> containerOrderNumbers(@Param("bookingId") String bookingId);
/** 人工时间与计划冻结日期分别返回;默认日期由共用业务解析器读取权威到港后生成。 */
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
"AND (MAX(o.arrival_time_version)&gt;0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
"AND MAX(o.arrival_time_version)&gt;0 " +
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount," +
"(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1 " +
"THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS plannedDate,FALSE AS isDefault " +
"FROM ll_order o WHERE o.source_container_booking_id IN " +
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY o.source_container_booking_id</script>")
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 柜级人工到仓与已有入库计划日期的唯一数据库读取入口。 */
@Repository
public class ContainerWarehouseArrivalRepository {
private final LastlegOrderMapper mapper;
/** 复用本库柜级事实查询。 */
public ContainerWarehouseArrivalRepository(LastlegOrderMapper mapper) { this.mapper=mapper; }
/** 保留同柜版本及计划数量,不把缺失时间转换成零点。 */
public List<ContainerArrivalTimeView> find(List<String> ids) { return mapper.findContainerArrivalTimes(ids); }
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 读取来源柜已经分配的业务尾程号,不按客户拆分柜级身份。 */
@Repository
public class LastlegOrderNumberRepository {
private final LastlegOrderMapper mapper;
/** 复用所属服务订单 Mapper。 */
public LastlegOrderNumberRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
/** 调用方已持有来源柜门禁行锁,同柜的后续客户事件沿用首个尾程号。 */
public String findContainerOrderNo(String bookingId) {
List<String> values = mapper.containerOrderNumbers(bookingId);
if (values.size() > 1) throw new IllegalStateException("同一来源柜存在多个尾程业务编号");
return values.isEmpty() ? null : values.get(0);
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient;
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
/** 清关列表、预约日历与入库计划共用的到仓日期解析。 */
@Service
public class ContainerWarehouseArrivalService {
private final ContainerWarehouseArrivalRepository repository;
private final AppointmentForecastClient forecasts;
private final String token;
/** 到港事实只从 Firstleg 权威来源读取,人工与计划日期只访问本库。 */
public ContainerWarehouseArrivalService(ContainerWarehouseArrivalRepository repository,
AppointmentForecastClient forecasts,@Value("${baiyue.security.internal-service-token:}") String token) {
this.repository=repository; this.forecasts=forecasts; this.token=token;
}
/** 人工时间及计划冻结日期优先,其余按最新到港当地日期加两个自然日。 */
public List<ContainerArrivalTimeView> resolve(List<String> ids) {
if(ids.isEmpty()) return List.of();
var rows=repository.find(ids);
var pending=rows.stream().filter(row->row.version()==0 && row.planCount()==0
&& row.estimatedWarehouseArrivalAt()==null).map(row->Long.valueOf(row.sourceContainerBookingId())).toList();
Map<Long,LocalDate> defaults=new HashMap<>();
for(int start=0;start<pending.size();start+=200) {
var batch=pending.subList(start,Math.min(start+200,pending.size()));
var response=forecasts.batch(token,new AppointmentForecastClient.Request(batch));
if(response==null || !response.successful() || response.data()==null || response.data().size()!=batch.size())
throw unavailable();
var seen=new java.util.HashSet<Long>();
for(var fact:response.data()) {
if(fact==null || !batch.contains(fact.containerBookingId()) || !seen.add(fact.containerBookingId())) throw unavailable();
if(fact.portArrivalAt()!=null) defaults.put(fact.containerBookingId(),fact.portArrivalAt().toLocalDate().plusDays(2));
}
}
return rows.stream().map(row->{
boolean automatic=row.version()==0 && row.planCount()==0 && row.estimatedWarehouseArrivalAt()==null;
LocalDate date=row.estimatedWarehouseArrivalAt()!=null?row.estimatedWarehouseArrivalAt().toLocalDate()
:automatic?defaults.get(Long.valueOf(row.sourceContainerBookingId())):row.plannedDate();
return new ContainerArrivalTimeView(row.sourceContainerBookingId(),row.estimatedWarehouseArrivalAt(),
row.actualWarehouseArrivalAt(),row.version(),row.planCount(),date,automatic && date!=null);
}).toList();
}
/** 生成计划沿用同一解析结果,缺失或不一致时阻断,不改写来源事实。 */
public LocalDate requiredDate(String id) {
var rows=resolve(List.of(id));
if(rows.size()!=1 || rows.get(0).version()<0 || rows.get(0).plannedDate()==null)
throw new ResponseStatusException(HttpStatus.CONFLICT,"来源柜预计到仓日期缺失或不一致,请先核对柜级时间");
return rows.get(0).plannedDate();
}
/** 来源失败必须明确提示,不能用本地旧快照伪造默认日期。 */
private ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到港日期暂不可读取,请稍后重试");
}
}
@@ -10,7 +10,7 @@ import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component
public class LastlegDocumentNumberGenerator {
private static final Set<String> PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
private static final Set<String> PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
private final DocumentSequenceRepository sequences;
private final Clock clock;
@@ -1,14 +1,9 @@
package com.baiyue.lastleg.service;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */
/** 海外单据类型映射;业务单号统一使用本服务日流水。 */
@Component
public class LastlegOrderNoGenerator {
private final LastlegDocumentNumberGenerator documents;
@@ -21,6 +16,7 @@ public class LastlegOrderNoGenerator {
/** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */
public String next(String businessType) {
String prefix = switch (businessType) {
case "LASTLEG" -> "WC";
case "ONE_CLICK_DROPSHIP" -> "DFCK";
case "OUTBOUND" -> "CK";
case "RELABEL_OUTBOUND" -> "HBCK";
@@ -45,16 +41,4 @@ public class LastlegOrderNoGenerator {
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT);
}
/** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */
public String forContainerNo(String containerNo) {
if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required");
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8));
long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong();
return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
+ String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
@@ -83,6 +83,7 @@ public class LastlegOrderService {
@Resource private InventoryCommandService inventory;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private ContainerWarehouseArrivalService warehouseArrival;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
@@ -142,6 +143,7 @@ public class LastlegOrderService {
orderMapper.findContainerProgressItems(bookingId));
}
/** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */
@Transactional(readOnly = true)
public List<ContainerArrivalTimeView> containerArrivalTimes(
LastlegOrderRequests.ContainerArrivalTimeQuery request) {
@@ -150,7 +152,7 @@ public class LastlegOrderService {
if (bookingIds.size() != request.sourceContainerBookingIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复");
}
return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds));
return warehouseArrival.resolve(List.copyOf(bookingIds));
}
@Transactional
@@ -194,8 +196,9 @@ public class LastlegOrderService {
return requireContainerArrivalTime(bookingId);
}
/** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */
private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) {
List<ContainerArrivalTimeView> values = orderMapper.findContainerArrivalTimes(List.of(bookingId));
List<ContainerArrivalTimeView> values = warehouseArrival.resolve(List.of(bookingId));
if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
if (values.get(0).version() < 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
@@ -44,12 +44,14 @@ public class LoadingCompletedConsumer {
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private com.baiyue.lastleg.repository.LastlegOrderNumberRepository orderNumbers;
@Resource private DropshipOrderService dropshipOrders;
@Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets;
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
/** 校验交接身份并在柜级锁内为各客户的冻结入仓单建立同柜尾程记录。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public DeliveryAck consume(EventEnvelope envelope) {
envelope.requireValid();
@@ -94,7 +96,9 @@ public class LoadingCompletedConsumer {
// Seeding the shipping version from the loading-plan version makes a valid first node
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
JsonNode rawOrders = envelope.payload().path("orders");
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
// 来源柜行锁覆盖跨客户交接事件,首票取号,后续票沿用同柜已落库编号。
String orderNo = orderNumbers.findContainerOrderNo(sourceContainerBookingId);
if (orderNo == null) orderNo = orderNoGenerator.next("LASTLEG");
for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index);
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
@@ -8,8 +8,8 @@ paths:
description: >-
仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。
未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0时才有值;仅日期的冻结计划通过plannedDate返回。
plannedDate/isDefault由与清关提柜和入库计划共用的服务端解析器返回,默认按权威到港当地日期加2个自然日;此只读接口不写入库计划、不补造时刻。
actualAt只读PDA首次收货时间,不替换计划日期。
具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。
保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。
@@ -41,10 +41,13 @@ paths:
type: object
properties:
sourceContainerBookingId: {type: string}
cabinetNo: {type: string, nullable: true, description: 来源交接冻结的柜次号,作为日历主标识}
containerNo: {type: string, nullable: true}
warehouseName: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true}
plannedAt: {type: string, format: date-time, nullable: true}
plannedDate: {type: string, format: date, nullable: true, description: 人工日期、已生成计划冻结日期或权威默认日期}
isDefault: {type: boolean, description: 仅当前未维护未建计划且有默认日期时为true}
actualAt: {type: string, format: date-time, nullable: true}
version: {type: integer, minimum: -1}
planCount: {type: integer, minimum: 0}
@@ -3582,7 +3582,9 @@ components:
required: [sourceContainerBookingId, version, planCount]
properties:
sourceContainerBookingId: { type: string }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护的整柜时间;默认日期及只有日期的冻结计划不补造时间 }
plannedDate: { type: string, format: date, nullable: true, description: 与日历和入库计划共用的服务端日期,人工或冻结日期优先,否则按权威到港当地日期加两个自然日 }
isDefault: { type: boolean, description: 未人工维护未建计划时使用权威到港推算日期 }
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
@@ -4022,6 +4024,11 @@ components:
remark:
type: string
nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required:
- publishLooseboardDemand
- recordVersion
@@ -4558,7 +4565,7 @@ components:
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest:
type: object
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由共用服务端解析器返回 plannedDate:人工时间或已有计划冻结日期优先,其余按 Firstleg 权威到港当地日期加两个自然日;同柜所有计划保持一致,不接受人工提交,不补造默认时间。
required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
@@ -5390,6 +5397,43 @@ components:
- name
- phone
- evidence
DispatchMaintenance:
type: object
x-java-record: DispatchContracts.Maintenance
description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。
properties:
plannedDeliveryDate:
type: string
format: date
nullable: true
deliveryMethod:
type: string
enum: [FBA自提, 客户自提, 卡派打托, 卡派地板, 快递, 外配]
nullable: true
pickupMethod:
type: string
enum: [一提一卸, 一提多卸]
nullable: true
loadType:
type: string
enum: [托盘, 地板, 混装]
nullable: true
fleetEmail:
type: string
maxLength: 254
nullable: true
vehicleType:
type: string
enum: [厢式货车, 平板车, 集装箱卡车]
nullable: true
appointmentAccount:
type: string
maxLength: 128
nullable: true
appointmentNo:
type: string
maxLength: 128
nullable: true
DispatchResources:
type: object
x-java-record: DispatchContracts.Resources
@@ -5402,6 +5446,11 @@ components:
allOf:
- $ref: '#/components/schemas/DispatchDriverSnapshot'
nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required:
- vehicle
- driver
@@ -6356,6 +6405,11 @@ components:
allOf:
- $ref: '#/components/schemas/DispatchDriverSnapshot'
nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required:
- vehicle
- driver
@@ -26,7 +26,7 @@ class AppointmentImpactPersistenceTest {
return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null);
}
static void observe(AnnotationConfigApplicationContext c,long revision,int day) {
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED"))));
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED",null))));
}
static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
@@ -11,11 +11,21 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class WarehouseCalendarMapperTest {
public class WarehouseCalendarMapperTest {
/** H2 测试替身按真实 JSON 内容读取柜次,不把样例值写死到 SQL。 */
public static String jsonExtract(String json,String path) throws Exception {
return json==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).path(path.substring(2)).toString();
}
/** 测试替身复用 JSON 解码语义。 */
public static String jsonUnquote(String value) throws Exception {
return value==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).asText();
}
/** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */
@Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection=ds.getConnection(); var sql=connection.createStatement()) {
sql.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'");
sql.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'");
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," +
"overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," +
"estimated_warehouse_at TIMESTAMP,arrival_time_version INT)");
@@ -26,6 +36,12 @@ class WarehouseCalendarMapperTest {
"(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," +
"(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)");
sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')");
sql.execute("ALTER TABLE ll_order ADD source_event_id VARCHAR");
sql.execute("ALTER TABLE ll_inbound_plan ADD planned_arrival_date DATE");
sql.execute("UPDATE ll_inbound_plan SET planned_arrival_date='2026-11-12'");
sql.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR,payload_json VARCHAR)");
sql.execute("INSERT INTO ll_loading_event_inbox VALUES('source-1','{\"cabinetNo\":\"D0001\"}')");
sql.execute("UPDATE ll_order SET source_event_id='source-1' WHERE source_container_booking_id='1'");
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
@@ -34,9 +50,11 @@ class WarehouseCalendarMapperTest {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals("D0001",rows.get(0).cabinetNo());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
assertEquals(1,rows.get(2).planCount()); assertNull(rows.get(2).plannedAt());
assertEquals(java.time.LocalDate.of(2026,11,12),rows.get(2).plannedDate());
// 清关提柜与日历读取相同冻结事实,默认日期由共用服务端解析器返回。
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size());
for(var calendar:rows) {
@@ -10,10 +10,15 @@ import static org.mockito.Mockito.*;
class WarehouseCalendarServiceTest {
/** 读取资格不代替写权限,已有入库计划仍冻结时间。 */
@Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() {
var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository);
var repository=mock(WarehouseCalendarRepository.class);
var arrivals=mock(com.baiyue.lastleg.service.ContainerWarehouseArrivalService.class);
var service=new WarehouseCalendarService(repository,arrivals);
var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1"));
var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false);
var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false);
var open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false);
var frozen=new WarehouseCalendarModels.Entry("2","D0002","CONT2","Warehouse","W",null,null,1,1,false,null,false);
when(arrivals.resolve(List.of("1","2"))).thenReturn(List.of(
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("1",null,null,0,0,LocalDate.of(2026,11,2),true),
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("2",null,null,1,1,LocalDate.of(2026,11,3),false)));
when(repository.calendar(query)).thenReturn(List.of(open,frozen));
assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit());
assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit));
@@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest {
var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class);
var d=resources.driver();var proof=d.evidence();
var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)));
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)), null);
e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number);
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L);
@@ -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())));
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), 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网关合同分别覆盖。 */
@@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true);
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
}
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true);
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -186,7 +186,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)));when(roots.find("DP-ONE")).thenReturn(row);
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);
service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
}
@@ -17,7 +17,7 @@ class DispatchCapacityDemandPersistenceTest {
var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false);
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero();
@@ -0,0 +1,178 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
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.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 维护资料与车辆照片经真实聚合、Fleet资源解析、持久化及审核往返。 */
class DispatchMaintenancePersistenceTest {
/** 主档和临时车辆均保留本趟照片与八项资料,不能被Fleet主档重建吞掉。 */
@ParameterizedTest
@ValueSource(strings = {"MASTER", "EXTERNAL"})
void maintenanceAndVehiclePhotosSurviveReplayAndBothReviews(String mode) throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("maintenance-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
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 ");
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));
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
f.approve(saved);
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().maintenance()).isEqualTo(maintenance);
assertThat(detail.vehicleInfo().maintenance().appointmentAccount()).isEqualTo("account");
assertThat(detail.vehicleInfo().vehicle().evidenceFileIds()).containsExactly(301L);
assertThat(detail.dispatchInfo().plannedDepartureAt()).isEqualTo(input.plannedDepartureAt());
assertThat(detail.dispatchInfo().departedAt()).isNull();
var persisted = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class);
assertThat(persisted.maintenance()).isEqualTo(maintenance);
assertThat(persisted.vehicle().evidenceFileIds()).containsExactly(301L);
assertThat(f.repository.find(created.dispatchNo()).getRemark()).isEqualTo("保留原备注");
verify(files(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.VEHICLE, 301L);
if (mode.equals("MASTER")) {
assertThat(detail.vehicleInfo().vehicle().plate()).isEqualTo("MASTER-71");
assertThat(detail.vehicleInfo().vehicle().capacity().palletSpaces()).isEqualTo(15);
}
}
/** 跨车、跨分类及失效照片的提供者拒绝必须整体回滚,MASTER同样不能绕过。 */
@ParameterizedTest
@ValueSource(strings = {"跨车", "错分类", "已失效"})
void invalidVehiclePhotoCannotChangeDraft(String reason) throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("invalid-vehicle-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo());
var dispatchId = f.repository.find(created.dispatchNo()).getId();
doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L);
var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)),
input.driver(), input.plannedDepartureAt(), null, null, true, null);
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason);
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero();
}
/** 维护整体为空或八字段为空均可保存空草稿,清空资料不会制造实际发车或正式预约。 */
@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);
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)));
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null)));
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().maintenance()).isNull();
assertThat(detail.dispatchInfo().departedAt()).isNull();
}
/** 文本仅限约定选项和长度,ISO日期不能接受时间串或不存在的日期。 */
@Test void maintenanceRejectsUnknownOptionsAndOverlongText() throws Exception {
var json = new DispatchAggregatePersistenceTest.Fixture().json;
for (String field : List.of("deliveryMethod", "pickupMethod", "loadType", "vehicleType")) {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) {
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))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String date : List.of("2026-10-32", "2026-02-29", "2026-10-21T10:00:00", "2026/10/21")) {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", date), Maintenance.class)).isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
}
var parsed = json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", "2026-10-21"), Maintenance.class);
assertThat(parsed.plannedDeliveryDate()).isEqualTo(LocalDate.of(2026, 10, 21));
assertThat(json.valueToTree(parsed).path("plannedDeliveryDate").asText()).isEqualTo("2026-10-21");
}
/** 取货资源投影和照片保存同样保留正式维护资料,不能在重建快照时掉字段。 */
@Test void transportResourceRebuildAndPhotoSaveKeepMaintenance() throws Exception {
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");
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,
mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class),
mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json);
var resources = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class);
f.tx.executeWithoutResult(t -> projection.savePhotos(created.dispatchNo(), f.actor.operatorId(), List.of(302L), resources.driver().evidence()));
var row = f.repository.find(created.dispatchNo());
assertThat(f.json.readValue(row.getResourcesJson(), Resources.class).maintenance()).isEqualTo(maintenance);
var trip = new com.baiyue.lastleg.fleet.trip.FleetTripEntity();
trip.setVehicleId(71L); trip.setVehiclePlate("MASTER-71");
ReflectionTestUtils.invokeMethod(projection, "resources", row, trip);
var rebuilt = f.json.readValue(row.getResourcesJson(), Resources.class);
assertThat(rebuilt.maintenance()).isEqualTo(maintenance);
assertThat(rebuilt.vehicle().evidenceFileIds()).containsExactly(302L);
}
/** 请求、资源与详情共用可空维护结构;文档中的每个选项都必须被正式DTO接受。 */
@Test void openApiPublishesSymmetricNullableMaintenanceContract() throws Exception {
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.JsonNode root;
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
root = json.valueToTree(new org.yaml.snakeyaml.Yaml().load(input));
}
var schemas = root.path("components").path("schemas");
for (String name : List.of("DispatchUpdate", "DispatchResources", "DispatchVehicleInfo")) {
var field = schemas.path(name).path("properties").path("maintenance");
assertThat(field.path("nullable").asBoolean()).isTrue();
assertThat(field.path("allOf").get(0).path("$ref").asText()).isEqualTo("#/components/schemas/DispatchMaintenance");
for (var required : schemas.path(name).path("required")) assertThat(required.asText()).isNotEqualTo("maintenance");
}
var schema = schemas.path("DispatchMaintenance");
var fields = schema.path("properties");
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();
for (var value : fields.path(field.getName()).path("enum")) {
assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull();
}
}
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);
}
/** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */
private static DispatchCommands.Vehicle vehicle(DispatchAggregatePersistenceTest.Fixture f, String mode, List<Long> photos) {
if (mode.equals("MASTER")) {
var vehicles = (VehicleRepository) ReflectionTestUtils.getField(f.nativeFleetService, "vehicles");
var actual = new VehicleRepository.VehicleSnapshot(71L, "MASTER-71", null, null, 0L, "主档车队", null,
"OVERSEAS", "OWNED", "IN_SERVICE", null, 0L, null, null, 15, new BigDecimal("10000"), new BigDecimal("50"));
when(vehicles.findById(71L)).thenReturn(Optional.of(actual));
return new DispatchCommands.Vehicle(ResourceMode.MASTER, 71L, "不能覆盖主档", null, null, photos);
}
return new DispatchCommands.Vehicle(ResourceMode.EXTERNAL, null, "EXTERNAL-71", "本趟车队",
new Capacity(15, new BigDecimal("10000"), new BigDecimal("50")), photos);
}
/** 附件提供者仍执行本车及分类边界;只隔离外部File服务。 */
private static DispatchEvidenceGateway files(DispatchAggregatePersistenceTest.Fixture f) {
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
}
}
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true);
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null);
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true)));
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null)));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -55,13 +55,13 @@ class TransportReviewPhotosTest {
/** 换司机后旧人像不允许重复绑定。 */
@Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000",
new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null)));
assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传");
}
/** 审核复验已保存证据,照片后续失效也不能通过。 */
@Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)),
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null)));
service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L);
when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效"));
assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效");
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true);
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver(), null),null,null,List.of(),true);
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
@@ -67,7 +67,7 @@ class OwnedBusinessScopePersistenceTest {
var original=OwnedBusinessFixtures.source();
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver(), null));
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
@@ -58,12 +58,14 @@ class InboundPlanServiceTest {
@Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper;
@Mock LastlegSourcePalletMapper sourcePalletMapper;
@Mock com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service,"warehouseArrival",warehouseArrival);
ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
@@ -95,8 +97,8 @@ class InboundPlanServiceTest {
"100", 2L, "BOOKING-1", "193000000000200003")));
org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong()))
.thenReturn("BOOKING-1");
org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any()))
.thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0)));
org.mockito.Mockito.lenient().when(warehouseArrival.requiredDate(any()))
.thenReturn(java.time.LocalDate.of(2026, 9, 15));
}
@Test
@@ -176,15 +178,16 @@ class InboundPlanServiceTest {
void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() {
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
2, "STANDARD", true, true, true, "优先收货");
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of());
when(warehouseArrival.requiredDate("BOOKING-1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间缺失,请先补齐柜级时间"));
ResponseStatusException missing = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-missing-arrival", "900"));
assertEquals(409, missing.getStatusCode().value());
assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason());
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of(
LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0)));
org.mockito.Mockito.doThrow(new ResponseStatusException(
org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间不一致,请先修正柜级时间"))
.when(warehouseArrival).requiredDate("BOOKING-1");
ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-inconsistent-arrival", "900"));
assertEquals(409, inconsistent.getStatusCode().value());
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient;
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ContainerWarehouseArrivalServiceTest {
private final ContainerWarehouseArrivalRepository repository=mock(ContainerWarehouseArrivalRepository.class);
private final AppointmentForecastClient forecasts=mock(AppointmentForecastClient.class);
private final ContainerWarehouseArrivalService service=new ContainerWarehouseArrivalService(repository,forecasts,"internal-test");
/** 默认日期跨月按当地自然日推进,展示和计划创建必须读取同一结果。 */
@Test void defaultsAcrossMonthAndUsesExactlyTheSameDateForPlanning() {
when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false)));
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(
new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",LocalDateTime.of(2026,10,30,23,30)))));
var result=service.resolve(List.of("1")).get(0);
assertEquals(LocalDate.of(2026,11,1),result.plannedDate());
assertNull(result.estimatedWarehouseArrivalAt());
assertTrue(result.isDefault());
assertEquals(result.plannedDate(),service.requiredDate("1"));
}
/** 人工维护优先,已生成计划后的日期不再随到港变化重新计算。 */
@Test void manualAndFrozenDatesNeverReadNewForecasts() {
var manual=LocalDateTime.of(2026,11,3,9,30);
when(repository.find(List.of("1","2"))).thenReturn(List.of(
new ContainerArrivalTimeView("1",manual,null,1,0,null,false),
new ContainerArrivalTimeView("2",null,null,0,1,LocalDate.of(2026,11,4),false)));
var result=service.resolve(List.of("1","2"));
assertEquals(manual.toLocalDate(),result.get(0).plannedDate());
assertEquals(LocalDate.of(2026,11,4),result.get(1).plannedDate());
assertFalse(result.get(1).isDefault());
verifyNoInteractions(forecasts);
}
/** 真实未知日期不补零点,提供方漏行也不能当作完整空事实。 */
@Test void unknownAndIncompleteProviderResultsBlockPlanCreation() {
when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false)));
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(
new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",null))));
assertNull(service.resolve(List.of("1")).get(0).plannedDate());
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value());
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of()));
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value());
}
/** 同柜版本或已建计划日期冲突时不能被默认到港算法覆盖。 */
@Test void inconsistentOrMissingFrozenDatesRemainBlocked() {
for(var row:List.of(new ContainerArrivalTimeView("1",null,null,-1,0,null,false),
new ContainerArrivalTimeView("1",null,null,1,0,null,false),
new ContainerArrivalTimeView("1",null,null,0,2,null,false))) {
when(repository.find(List.of("1"))).thenReturn(List.of(row));
assertThrows(ResponseStatusException.class,()->service.requiredDate("1"));
}
verifyNoInteractions(forecasts);
}
}
@@ -8,7 +8,7 @@ class LastlegOrderNoGeneratorTest {
/** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */
@Test void routesEveryOperationalTypeToItsDailySequence() {
var generator=TestDocumentNumbers.orders();
Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.ofEntries(Map.entry("LASTLEG","WC"),Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"),
Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"),
@@ -19,12 +19,4 @@ class LastlegOrderNoGeneratorTest {
});
assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN"));
}
/** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */
@Test void actualContainerNumberRemainsStableAndShared() {
var generator=TestDocumentNumbers.orders();
assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870"));
assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383"));
assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" "));
}
}
@@ -117,7 +117,7 @@ class LastlegOrderServiceTest {
void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class);
service = new LastlegOrderService();
service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"warehouseArrival", new ContainerWarehouseArrivalService(new com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository(orders), org.mockito.Mockito.mock(com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient.class), "test"));
ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders);
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0)));
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0, estimatedAt.toLocalDate(), false)));
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
@@ -2,11 +2,42 @@ package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
/** 两个来源柜复用同一实箱时仍各自取号,不能把历史箱号当本次柜身份。 */
@Test void reusedPhysicalContainerKeepsDistinctSourceBookingNumbers() {
when(sequences.next("WC")).thenReturn("WC202610210001","WC202610210002");
EventEnvelope first=event(); service.consume(first);
com.fasterxml.jackson.databind.node.ObjectNode payload=first.payload().deepCopy();
payload.put("containerBookingId","book-2");
EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",first.eventType(),first.eventVersion(),
first.occurredAt(),first.tenantId(),first.aggregateType(),first.aggregateId(),first.aggregateVersion(),
first.handoverVersion(),first.traceId(),first.correlationId(),null,payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
service.consume(second);
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(4)).insert(captured.capture());
assertEquals(2,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count());
verify(orders).containerOrderNumbers("book-1"); verify(orders).containerOrderNumbers("book-2");
}
/** 同柜后续客户交接沿用首票编号,不再次占用日流水。 */
@Test void reusesTheExistingContainerNumberWithoutAllocatingAgain() {
when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001"));
service.consume(event());
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(2)).insert(captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(order->"WC202610200001".equals(order.getOrderNo())));
verify(sequences,never()).next(anyString());
}
/** 同柜身份不一致必须停止,不能挑选任一旧号继续生成订单。 */
@Test void rejectsConflictingContainerBusinessNumbers() {
when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001","WC202610200002"));
assertThrows(IllegalStateException.class,()->service.consume(event()));
verify(orders,never()).insert(any(LastlegOrder.class));
}
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); }
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"orderNumbers",new com.baiyue.lastleg.repository.LastlegOrderNumberRepository(orders)); lenient().when(sequences.next("WC")).thenReturn("WC202610210001"); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("WC[0-9]{8}[0-9]{4}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^WC\\d{8}\\d{4}$")); verify(sequences).next("WC"); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }