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

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
@@ -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();