feat(lastleg): connect appointment followups and fleet intake
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Read-only loaded cargo, independent of inbound-plan materialization. */
|
||||
@Data
|
||||
public class AppointmentCargo {
|
||||
private String sourceKey;
|
||||
private Long orderId;
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String inboundCode;
|
||||
private String containerBookingId;
|
||||
private String containerNo;
|
||||
private String originWarehouseId;
|
||||
private String originWarehouseCode;
|
||||
private String originWarehouseName;
|
||||
private String destinationWarehouseCode;
|
||||
private Integer packages;
|
||||
private Integer receivedPackages;
|
||||
private Integer shippedPackages;
|
||||
private Integer sourcePalletCount;
|
||||
private BigDecimal volumeCbm;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private LocalDateTime estimatedWarehouseAt;
|
||||
private Integer orderVersion;
|
||||
private String standardNode;
|
||||
private Long shippingVersion;
|
||||
private LocalDateTime shippingObservedAt;
|
||||
private String shippingProvider;
|
||||
private boolean hasInboundPlan;
|
||||
private boolean hasActiveAppointment;
|
||||
private boolean detained;
|
||||
private boolean mixedDestinations;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentCargoMapper {
|
||||
String FILTER = """
|
||||
FROM ll_order o JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id
|
||||
WHERE o.order_type='FIRST_LEG' AND o.destination_type='FBA' AND g.node_sequence >=40
|
||||
AND o.current_status NOT IN ('CANCELLED','COMPLETED')
|
||||
AND o.overseas_warehouse_id IN
|
||||
<foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
AND o.fba_warehouse_code IN
|
||||
<foreach collection='destinations' item='code' open='(' separator=',' close=')'>#{code}</foreach>
|
||||
<if test='warehouseId != null'>AND o.overseas_warehouse_id=#{warehouseId}</if>
|
||||
<if test='destination != null'>AND o.fba_warehouse_code=#{destination}</if>
|
||||
<if test='keyword != null'>AND (o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.container_no LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))</if>
|
||||
""";
|
||||
|
||||
@Select("<script>SELECT COUNT(*) " + FILTER + "</script>")
|
||||
long count(@Param("origins") List<String> origins, @Param("destinations") List<String> destinations,
|
||||
@Param("warehouseId") String warehouseId, @Param("destination") String destination, @Param("keyword") String keyword);
|
||||
|
||||
@Select("<script>" + """
|
||||
SELECT CONCAT('ORDER:',o.id) AS source_key,o.id AS order_id,o.customer_id,o.customer_name,o.inbound_code,
|
||||
o.source_container_booking_id AS container_booking_id,o.container_no,
|
||||
o.overseas_warehouse_id AS origin_warehouse_id,o.overseas_warehouse_code AS origin_warehouse_code,
|
||||
o.overseas_warehouse_name AS origin_warehouse_name,o.fba_warehouse_code AS destination_warehouse_code,
|
||||
o.package_count AS packages,o.received_package_count AS received_packages,o.shipped_package_count AS shipped_packages,
|
||||
o.pallet_count AS source_pallet_count,o.volume_cbm,o.estimated_arrival_at,o.estimated_warehouse_at,
|
||||
o.version AS order_version,g.standard_node,g.aggregate_version AS shipping_version,
|
||||
g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
|
||||
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
|
||||
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixed_destinations
|
||||
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
|
||||
List<AppointmentCargo> page(@Param("origins") List<String> origins, @Param("destinations") List<String> destinations,
|
||||
@Param("warehouseId") String warehouseId, @Param("destination") String destination,
|
||||
@Param("keyword") String keyword, @Param("size") int size, @Param("offset") long offset);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
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.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AppointmentCargoService {
|
||||
private final AppointmentCargoMapper mapper;
|
||||
private final AppointmentWarehouseClient warehouses;
|
||||
private final String token;
|
||||
|
||||
public AppointmentCargoService(AppointmentCargoMapper mapper, AppointmentWarehouseClient warehouses,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.mapper = mapper; this.warehouses = warehouses; this.token = token;
|
||||
}
|
||||
|
||||
public PageResult<AppointmentCargo> page(int page, int size, String warehouseId, String destination, String keyword) {
|
||||
if (page < 1 || size < 1 || size > 100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每页数量必须为1至100");
|
||||
if (keyword != null && keyword.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "搜索词过长");
|
||||
var scope = scope();
|
||||
var origins = scope.origins();
|
||||
var destinations = scope.destinations();
|
||||
warehouseId = clean(warehouseId); destination = clean(destination); keyword = clean(keyword);
|
||||
if (warehouseId != null && !origins.contains(warehouseId) || destination != null && !destinations.contains(destination)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择已启用的美国接仓点及FBA目的仓");
|
||||
}
|
||||
if (origins.isEmpty() || destinations.isEmpty()) return new PageResult<>(List.of(), page, size, 0, 0);
|
||||
long total = mapper.count(origins, destinations, warehouseId, destination, keyword);
|
||||
var rows = mapper.page(origins, destinations, warehouseId, destination, keyword, size, (long) (page - 1) * size);
|
||||
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
|
||||
}
|
||||
|
||||
private record Scope(List<String> origins, List<String> destinations) { }
|
||||
private Scope scope() {
|
||||
final List<AppointmentWarehouseClient.Warehouse> catalog;
|
||||
try {
|
||||
var result = warehouses.catalog(token);
|
||||
if (result == null || !result.successful() || result.data() == null) throw new IllegalStateException();
|
||||
catalog = result.data();
|
||||
} catch (RuntimeException error) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,无法核实美国货物范围", error);
|
||||
}
|
||||
var us = catalog.stream().filter(w -> w != null && w.id() != null && w.id() > 0 && "US".equals(w.countryCode())).toList();
|
||||
var origins = us.stream().filter(w -> Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(w.warehouseType() == null ? "" : w.warehouseType()))
|
||||
.map(w -> w.id().toString()).distinct().toList();
|
||||
var destinations = us.stream().filter(w -> "FBA".equals(w.warehouseType()) && clean(w.warehouseCode()) != null)
|
||||
.map(AppointmentWarehouseClient.Warehouse::warehouseCode).distinct().toList();
|
||||
return new Scope(origins, destinations);
|
||||
}
|
||||
|
||||
private static String clean(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentCoverageMapper {
|
||||
record OrderRef(String customerId, Long orderId) { }
|
||||
record Source(Long orderId, String customerId, String destinationType, String fbaWarehouseCode,
|
||||
Integer packages, Integer version, LocalDateTime updatedAt, boolean shipmentReferencesMissing) { }
|
||||
record Fact(Long orderId, String customerId, String appointmentNo, String status,
|
||||
LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt,
|
||||
Long lineId, Integer packages, String warehouseCode) { }
|
||||
|
||||
@Select({"<script>SELECT o.id AS orderId,o.customer_id AS customerId,o.destination_type AS destinationType,",
|
||||
"o.fba_warehouse_code AS fbaWarehouseCode,o.package_count AS packages,o.version,o.updated_at AS updatedAt, ",
|
||||
"(NOT EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id) OR ",
|
||||
"EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id ",
|
||||
"AND (d.shipment_no IS NULL OR TRIM(d.shipment_no)='') AND (d.fba_tracking_no IS NULL OR TRIM(d.fba_tracking_no)=''))) AS shipmentReferencesMissing ",
|
||||
"FROM ll_order o WHERE <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
|
||||
"(o.id=#{s.orderId} AND o.customer_id=#{s.customerId})</foreach></script>"})
|
||||
List<Source> sources(@Param("sources") List<OrderRef> sources);
|
||||
|
||||
@Select({"<script>SELECT i.order_id AS orderId,i.customer_id AS customerId,a.appointment_no AS appointmentNo,",
|
||||
"a.appointment_status AS status,a.appointment_at AS appointmentAt,a.appointment_end_at AS appointmentEndAt,",
|
||||
"a.updated_at AS updatedAt,i.line_id AS lineId,l.required_package_count AS packages,l.fba_warehouse_code AS warehouseCode ",
|
||||
"FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id ",
|
||||
"JOIN ll_inbound_plan_line l ON l.id=i.line_id AND l.plan_id=i.plan_id AND l.order_id=i.order_id AND l.customer_id=i.customer_id ",
|
||||
"WHERE a.appointment_status NOT IN ('FAILED','CANCELLED') AND ",
|
||||
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
|
||||
"(i.order_id=#{s.orderId} AND i.customer_id=#{s.customerId})</foreach>",
|
||||
" ORDER BY a.appointment_at,a.id,i.id</script>"})
|
||||
List<Fact> appointments(@Param("sources") List<OrderRef> sources);
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Shared read projection. Callers authorize the customer/source before invoking; never confirms bookings. */
|
||||
@Service
|
||||
public class AppointmentCoverageService {
|
||||
public record SourceRef(String customerId, String sourceKey, String destinationKey) { }
|
||||
public record Appointment(String appointmentNo, String confirmationNo, String status,
|
||||
LocalDateTime confirmedAt, LocalDateTime confirmedEndAt, String timezone) { }
|
||||
public record Coverage(String customerId, String sourceKey, String destinationKey, String status,
|
||||
List<Appointment> appointments, List<String> missingFields,
|
||||
String sourceVersion, boolean changeReviewRequired) { }
|
||||
private final AppointmentCoverageMapper mapper;
|
||||
private final AppointmentExternalSourceGateway external;
|
||||
public AppointmentCoverageService(AppointmentCoverageMapper mapper, AppointmentExternalSourceGateway external) {
|
||||
this.mapper=mapper; this.external=external;
|
||||
}
|
||||
|
||||
public List<Coverage> query(List<SourceRef> input) {
|
||||
if (input==null || input.isEmpty()) return List.of();
|
||||
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
|
||||
throw new IllegalArgumentException("每次最多核对100个完整的预约来源");
|
||||
}
|
||||
var requested=input.stream().distinct().toList();
|
||||
var externalRefs=requested.stream().filter(s -> s.sourceKey().startsWith("BOOKING:"))
|
||||
.map(s -> new AppointmentExternalSourceGateway.SourceRef(s.customerId(),s.sourceKey().substring(8))).toList();
|
||||
var bookings=externalRefs.isEmpty() ? List.<AppointmentExternalSourceGateway.Source>of() : external.resolve(externalRefs);
|
||||
var bookingMap=bookings.stream().collect(Collectors.toMap(s -> key(s.customerId(),"BOOKING:"+s.bookingNo()),s -> s));
|
||||
var refs=new LinkedHashMap<SourceRef,AppointmentCoverageMapper.OrderRef>();
|
||||
for (var source:requested) {
|
||||
if (source.sourceKey().startsWith("ORDER:")) {
|
||||
try {
|
||||
long id=Long.parseLong(source.sourceKey().substring(6));
|
||||
if (id>0) refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),id));
|
||||
} catch (NumberFormatException ignored) { /* Invalid source is returned explicitly below. */ }
|
||||
} else if (source.sourceKey().startsWith("BOOKING:")) {
|
||||
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
|
||||
if (booking!=null && booking.orderId()!=null && Objects.equals(source.destinationKey(),booking.destinationKey())) {
|
||||
refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),booking.orderId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
var unique=refs.values().stream().distinct().toList();
|
||||
if (unique.size()!=refs.size()) throw new IllegalArgumentException("同一货物存在重复来源,请保留原外接登记来源后再核对");
|
||||
var orders=unique.isEmpty() ? List.<AppointmentCoverageMapper.Source>of() : mapper.sources(unique);
|
||||
var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s));
|
||||
var facts=unique.isEmpty() ? List.<AppointmentCoverageMapper.Fact>of() : mapper.appointments(unique);
|
||||
var byOrder=facts.stream().collect(Collectors.groupingBy(f -> new AppointmentCoverageMapper.OrderRef(f.customerId(),f.orderId())));
|
||||
var output=new ArrayList<Coverage>();
|
||||
for (var source:requested) {
|
||||
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
|
||||
if (booking!=null && !source.destinationKey().equals(booking.destinationKey())) {
|
||||
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(booking.recordVersion()),true)); continue;
|
||||
}
|
||||
if (booking!=null && !Set.of("CONFIRMED","RECEIVED","BOUND").contains(booking.bookingStatus()==null ? "" : booking.bookingStatus())) {
|
||||
output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue;
|
||||
}
|
||||
var order=orderMap.get(refs.get(source));
|
||||
if (order==null) {
|
||||
output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue;
|
||||
}
|
||||
if (!"FBA".equals(order.destinationType()) || !source.destinationKey().equals("FBA:"+order.fbaWarehouseCode())) {
|
||||
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue;
|
||||
}
|
||||
var relevant=byOrder.getOrDefault(refs.get(source),List.of());
|
||||
var distinct=new LinkedHashMap<String,Appointment>();
|
||||
var seen=new HashSet<Long>(); long confirmed=0, covered=0; boolean invalid=false, changed=false;
|
||||
for (var fact:relevant) {
|
||||
if (!Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.lineId())) { invalid=true; continue; }
|
||||
covered+=fact.packages();
|
||||
if ("SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null) confirmed+=fact.packages();
|
||||
boolean success="SUCCESS".equals(fact.status());
|
||||
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.appointmentNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,null));
|
||||
changed|=order.updatedAt()==null || fact.updatedAt()==null || order.updatedAt().isAfter(fact.updatedAt());
|
||||
}
|
||||
var missing=new ArrayList<String>();
|
||||
String status;
|
||||
if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); }
|
||||
else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); }
|
||||
else if (covered<order.packages()) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
|
||||
else if (confirmed<order.packages()) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
|
||||
else { status="CONFIRMED"; }
|
||||
if (confirmed>0) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
|
||||
if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES");
|
||||
if (changed) missing.add("SOURCE_CHANGED_REVIEW");
|
||||
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
|
||||
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
|
||||
}
|
||||
return List.copyOf(output);
|
||||
}
|
||||
private static Coverage empty(SourceRef s,String status,String missing,String version,boolean review) {
|
||||
return new Coverage(s.customerId(),s.sourceKey(),s.destinationKey(),status,List.of(),List.of(missing),version,review);
|
||||
}
|
||||
private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; }
|
||||
private static String version(Number version) { return version==null ? null : version.toString(); }
|
||||
private static boolean blank(String value) { return value==null || value.isBlank(); }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Implemented by the external-booking owner; planning must not query Fleet's persistence directly. */
|
||||
public interface AppointmentExternalSourceGateway {
|
||||
record SourceRef(String customerId, String bookingNo) { }
|
||||
record Source(String customerId, String bookingNo, String tripNo, String bookingStatus,
|
||||
String warehouseId, String destinationKey, Long orderId, Integer packageCount, Long recordVersion) { }
|
||||
List<Source> resolve(List<SourceRef> sources);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_appointment_followup")
|
||||
public class AppointmentFollowup {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long sourceOrderId;
|
||||
private String sourceKey;
|
||||
private String customerId;
|
||||
private String stage;
|
||||
private Integer version;
|
||||
private String contactedParty;
|
||||
private String result;
|
||||
private String nextAction;
|
||||
private LocalDate followupDate;
|
||||
private String operatorId;
|
||||
private String operatorName;
|
||||
private String commandKey;
|
||||
private String requestHash;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentFollowupMapper extends BaseMapper<AppointmentFollowup> {
|
||||
@Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage} AND command_key=#{key}")
|
||||
AppointmentFollowup command(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage, @Param("key") String key);
|
||||
@Select("SELECT COALESCE(MAX(version),0) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage}")
|
||||
int version(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage);
|
||||
@Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} ORDER BY created_at DESC,id DESC LIMIT #{size} OFFSET #{offset}")
|
||||
List<AppointmentFollowup> page(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("size") int size, @Param("offset") long offset);
|
||||
@Select("SELECT COUNT(*) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId}")
|
||||
long count(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId);
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AppointmentFollowupService {
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentFollowupMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) {
|
||||
this.sources = sources; this.mapper = mapper; this.json = json;
|
||||
}
|
||||
public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion,
|
||||
@NotBlank @Size(max=64) String idempotencyKey,
|
||||
@Size(max=200) String contactedParty, @NotBlank @Size(max=1000) String result,
|
||||
@Size(max=500) String nextAction, LocalDate followupDate) { }
|
||||
public record Record(String stage, int version, String contactedParty, String result, String nextAction,
|
||||
LocalDate followupDate, String operatorName, Instant createdAt) { }
|
||||
public record History(PageResult<Record> history, java.util.Map<String,Integer> versions) { }
|
||||
private static final Set<String> STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING");
|
||||
|
||||
public History history(String sourceKey, String customerId, int page, int size) {
|
||||
if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效");
|
||||
long total = mapper.count(sourceKey,customerId);
|
||||
if (total == 0) sources.resolve(sourceKey,customerId,null);
|
||||
var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList();
|
||||
var versions = new java.util.LinkedHashMap<String,Integer>();
|
||||
STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage)));
|
||||
return new History(new PageResult<>(records,page,size,total,(total+size-1)/size),versions);
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) {
|
||||
if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项");
|
||||
if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排");
|
||||
}
|
||||
final String hash;
|
||||
try { hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customerId+"\n"+operatorId+"\n"+json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (JsonProcessingException | NoSuchAlgorithmException error) { throw new IllegalStateException("无法保存跟进请求",error); }
|
||||
var existing = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey());
|
||||
if (existing!=null) {
|
||||
if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录");
|
||||
return view(existing);
|
||||
}
|
||||
var source = sources.resolve(sourceKey,customerId,null);
|
||||
if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态");
|
||||
if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交");
|
||||
var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(source.sourceKey()); value.setCustomerId(source.customerId());
|
||||
value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty()));
|
||||
value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate());
|
||||
value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey());
|
||||
value.setRequestHash(hash); value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
try { mapper.insert(value); }
|
||||
catch (org.springframework.dao.DuplicateKeyException conflict) {
|
||||
var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey());
|
||||
if (raced!=null && hash.equals(raced.getRequestHash())) return view(raced);
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新或请求内容冲突,请重新读取",conflict);
|
||||
}
|
||||
return view(value);
|
||||
}
|
||||
private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); }
|
||||
private static Record view(AppointmentFollowup row) {
|
||||
return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/appointment-planning")
|
||||
public class AppointmentPlanningController {
|
||||
private final AppointmentCargoService service;
|
||||
private final AppointmentFollowupService followups;
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentCoverageService coverage;
|
||||
public AppointmentPlanningController(AppointmentCargoService service, AppointmentFollowupService followups,
|
||||
AppointmentSourceService sources, AppointmentCoverageService coverage) {
|
||||
this.service = service; this.followups = followups; this.sources = sources; this.coverage = coverage;
|
||||
}
|
||||
public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { }
|
||||
|
||||
@GetMapping("/sources/resolve")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<Resolved> resolve(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String sourceKey, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) {
|
||||
requirePlatform(user);
|
||||
var source = sources.resolve(sourceKey,customerId,destinationKey);
|
||||
var result = coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())));
|
||||
return ApiResponse.success(new Resolved(source,result.get(0)));
|
||||
}
|
||||
|
||||
@GetMapping("/cargo")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String warehouseId, @RequestParam(required = false) String destination,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(page, size, warehouseId, destination, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/sources/{sourceKey}/follow-ups")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<AppointmentFollowupService.History> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String sourceKey, @RequestParam String customerId, @RequestParam(defaultValue="1") int page, @RequestParam(defaultValue="20") int size) {
|
||||
requirePlatform(user); return ApiResponse.success(followups.history(sourceKey,customerId,page,size));
|
||||
}
|
||||
|
||||
@PostMapping("/sources/{sourceKey}/follow-ups")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')")
|
||||
public ApiResponse<AppointmentFollowupService.Record> followup(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String sourceKey, @RequestParam String customerId, @Valid @RequestBody AppointmentFollowupService.Save request) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(followups.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null ? user.username() : user.displayName()));
|
||||
}
|
||||
private static void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Stage-specific planning rules. Planning never creates stock, vehicles or carrier confirmations. */
|
||||
public final class AppointmentPlanningPolicy {
|
||||
private AppointmentPlanningPolicy() { }
|
||||
|
||||
public record Cargo(String sourceKey, String customerId, String originWarehouseId,
|
||||
String destinationKey, String originCountry, String destinationCountry,
|
||||
Integer packages, Instant readyFrom, Instant readyTo,
|
||||
boolean eligibleForPlanning, String blockReason) { }
|
||||
public record Group(String originWarehouseId, String destinationKey, long packages,
|
||||
int sourceCount, int customerCount, Instant readyFrom, Instant readyTo,
|
||||
boolean readinessIncomplete) { }
|
||||
public record Forecast(String sourceKey, String sourceVersion, Instant eta, Instant readyAt) { }
|
||||
public record Impact(String sourceKey, String reason, Instant previousEta, Instant latestEta,
|
||||
Instant confirmedAt, boolean reviewRequired) { }
|
||||
|
||||
public static Group group(List<Cargo> cargo) {
|
||||
if (cargo == null || cargo.isEmpty()) throw new IllegalArgumentException("请选择需要安排的真实货物");
|
||||
if (cargo.size() > 500) throw new IllegalArgumentException("每批最多选择500条来源货物");
|
||||
Set<String> sources = new HashSet<>();
|
||||
Set<String> customers = new HashSet<>();
|
||||
String origin = null, destination = null;
|
||||
long packages = 0;
|
||||
Instant from = null, to = null;
|
||||
boolean incomplete = false;
|
||||
for (Cargo row : cargo) {
|
||||
if (row == null || blank(row.sourceKey()) || blank(row.customerId())) {
|
||||
throw new IllegalArgumentException("货物缺少稳定来源或客户归属");
|
||||
}
|
||||
if (!sources.add(row.sourceKey())) throw new IllegalArgumentException("同一来源货物不能重复选择");
|
||||
if (!row.eligibleForPlanning()) throw new IllegalArgumentException(
|
||||
blank(row.blockReason()) ? "所选货物暂不具备筹划资格" : row.blockReason());
|
||||
if (!"US".equals(row.originCountry()) || !"US".equals(row.destinationCountry())) {
|
||||
throw new IllegalArgumentException("请选择已确认美国起运点及目的地的货物");
|
||||
}
|
||||
if (blank(row.originWarehouseId()) || blank(row.destinationKey())) {
|
||||
throw new IllegalArgumentException("货物缺少实际接仓点或目的地");
|
||||
}
|
||||
if (origin == null) { origin = row.originWarehouseId(); destination = row.destinationKey(); }
|
||||
if (!origin.equals(row.originWarehouseId()) || !destination.equals(row.destinationKey())) {
|
||||
throw new IllegalArgumentException("同批货物必须具有相同实际起运仓和目的地");
|
||||
}
|
||||
if (row.packages() == null || row.packages() <= 0) throw new IllegalArgumentException("货物箱数待核实,不能填默认数量");
|
||||
packages = Math.addExact(packages, row.packages().longValue());
|
||||
customers.add(row.customerId());
|
||||
if (row.readyFrom() == null || row.readyTo() == null) incomplete = true;
|
||||
if (row.readyFrom() != null && row.readyTo() != null && row.readyTo().isBefore(row.readyFrom())) {
|
||||
throw new IllegalArgumentException("货物预计备妥窗口无效");
|
||||
}
|
||||
if (row.readyFrom() != null && (from == null || row.readyFrom().isAfter(from))) from = row.readyFrom();
|
||||
if (row.readyTo() != null && (to == null || row.readyTo().isBefore(to))) to = row.readyTo();
|
||||
}
|
||||
if (from != null && to != null && to.isBefore(from)) throw new IllegalArgumentException("所选货物预计备妥窗口不相容,请分别安排");
|
||||
return new Group(origin, destination, packages, sources.size(), customers.size(), from, to, incomplete);
|
||||
}
|
||||
|
||||
/** A new forecast only requests review; it never changes an externally confirmed appointment. */
|
||||
public static Impact compare(Forecast acknowledged, Forecast latest, Instant confirmedAt) {
|
||||
if (acknowledged == null || latest == null || blank(latest.sourceKey())
|
||||
|| !Objects.equals(acknowledged.sourceKey(), latest.sourceKey())) {
|
||||
throw new IllegalArgumentException("只能比较同一真实货源的预测");
|
||||
}
|
||||
boolean changed = !Objects.equals(acknowledged.eta(), latest.eta())
|
||||
|| !Objects.equals(acknowledged.readyAt(), latest.readyAt());
|
||||
String reason = "UNCHANGED";
|
||||
if (changed) {
|
||||
reason = latest.eta() == null ? "FORECAST_MISSING" : "FORECAST_CHANGED";
|
||||
if (confirmedAt != null && latest.readyAt() != null && latest.readyAt().isAfter(confirmedAt)) {
|
||||
reason = "READINESS_AFTER_APPOINTMENT";
|
||||
}
|
||||
}
|
||||
return new Impact(latest.sourceKey(), reason, acknowledged.eta(), latest.eta(), confirmedAt, changed);
|
||||
}
|
||||
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
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.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AppointmentSourceService {
|
||||
public record Source(String sourceKey, String customerId, Long orderId, String inboundCode, String bookingNo,
|
||||
String containerNo, String originWarehouseId, String originWarehouseName,
|
||||
String destinationKey, String destinationWarehouseCode, Integer packages,
|
||||
String sourceVersion, boolean canFollowup, List<String> blockReasons) { }
|
||||
private final LastlegOrderMapper orders;
|
||||
private final ContainerShippingGateMapper shipping;
|
||||
private final AppointmentExternalSourceGateway external;
|
||||
private final AppointmentWarehouseClient warehouses;
|
||||
private final String token;
|
||||
public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping,
|
||||
AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.orders=orders; this.shipping=shipping; this.external=external; this.warehouses=warehouses; this.token=token;
|
||||
}
|
||||
public Source resolve(String sourceKey,String customerId,String destinationKey) {
|
||||
if (blank(sourceKey) || sourceKey.length()>160 || blank(customerId) || customerId.length()>128) throw bad("请选择完整的真实货源");
|
||||
com.baiyue.lastleg.entity.LastlegOrder order=null;
|
||||
String bookingNo=null,origin,destination,version;
|
||||
Integer packages;
|
||||
boolean followup=true;
|
||||
List<String> reasons=List.of();
|
||||
if (sourceKey.startsWith("ORDER:")) {
|
||||
long id;
|
||||
try { id=Long.parseLong(sourceKey.substring(6)); if (id<=0 || !sourceKey.equals("ORDER:"+id)) throw new NumberFormatException(); }
|
||||
catch (NumberFormatException error) { throw bad("货源标识无效"); }
|
||||
order=orders.selectById(id);
|
||||
if (order==null || !customerId.equals(order.getCustomerId())) throw missing();
|
||||
if (!"FIRST_LEG".equals(order.getOrderType())) throw bad("请从原外接登记继续跟进,避免重复建立货源");
|
||||
var gate=order.getSourceContainerBookingId()==null ? null : shipping.selectById(order.getSourceContainerBookingId());
|
||||
if (gate==null || gate.getNodeSequence()==null || gate.getNodeSequence()<40) throw bad("货物尚无已开船或后续实际船节点");
|
||||
origin=order.getOverseasWarehouseId(); destination="FBA:"+order.getFbaWarehouseCode();
|
||||
if (!"FBA".equals(order.getDestinationType())) throw bad("当前货源不是FBA送仓货物");
|
||||
packages=order.getPackageCount(); version=order.getVersion()==null ? null : "ORDER:"+order.getVersion();
|
||||
} else if (sourceKey.startsWith("BOOKING:")) {
|
||||
bookingNo=sourceKey.substring(8);
|
||||
if (blank(bookingNo)) throw bad("外接登记号不能为空");
|
||||
var matches=external.resolve(List.of(new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo)));
|
||||
if (matches.size()!=1) throw missing();
|
||||
var source=matches.get(0);
|
||||
if (!Objects.equals(source.customerId(),customerId) || !Objects.equals(source.bookingNo(),bookingNo)) throw missing();
|
||||
origin=source.warehouseId(); destination=source.destinationKey(); packages=source.packageCount();
|
||||
version=source.recordVersion()==null ? null : "BOOKING:"+source.recordVersion();
|
||||
followup=Set.of("CONFIRMED","RECEIVED","BOUND").contains(source.bookingStatus()==null ? "" : source.bookingStatus());
|
||||
if (!followup) reasons=List.of("当前登记尚未确认承运或已取消,仅可查看跟进记录");
|
||||
if (source.orderId()!=null) {
|
||||
order=orders.selectById(source.orderId());
|
||||
if (order==null || !customerId.equals(order.getCustomerId())) throw missing();
|
||||
if (!Objects.equals(order.getOverseasWarehouseId(),origin)) throw bad("来货的接仓点与原登记不一致,请先核对");
|
||||
if (!"FBA".equals(order.getDestinationType()) || !Objects.equals(destination,"FBA:"+order.getFbaWarehouseCode())) throw bad("来货目的仓与原登记不一致,请先核对");
|
||||
packages=order.getPackageCount();
|
||||
}
|
||||
} else throw bad("不支持的货源类型");
|
||||
if (order!=null && Set.of("CANCELLED","COMPLETED").contains(order.getCurrentStatus()==null ? "" : order.getCurrentStatus())) {
|
||||
followup=false; reasons=List.of("该货物已取消或完成,仅可查看跟进记录");
|
||||
}
|
||||
if (!blank(destinationKey) && !Objects.equals(destination,destinationKey)) throw bad("目的地已变化,请返回原业务重新核对");
|
||||
if (destination==null || !destination.startsWith("FBA:")) throw bad("当前仅支持FBA收货跟进来源");
|
||||
var catalog=catalog(); final String originId=origin; final String code=destination.substring(4);
|
||||
var warehouse=catalog.stream().filter(w -> w.id()!=null && w.id().toString().equals(originId) && "US".equals(w.countryCode())
|
||||
&& Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(w.warehouseType()==null ? "" : w.warehouseType())).findFirst().orElseThrow(() -> bad("接仓点不在启用的美国仓目录中"));
|
||||
if (catalog.stream().noneMatch(w -> "FBA".equals(w.warehouseType()) && "US".equals(w.countryCode()) && code.equals(w.warehouseCode()))) throw bad("目的仓不在启用的美国FBA目录中");
|
||||
return new Source(sourceKey,customerId,order==null ? null : order.getId(),order==null ? null : order.getInboundCode(),bookingNo,
|
||||
order==null ? null : order.getContainerNo(),origin,warehouse.warehouseName(),destination,code,packages,version,followup,reasons);
|
||||
}
|
||||
private List<AppointmentWarehouseClient.Warehouse> catalog() {
|
||||
try { var result=warehouses.catalog(token); if (result==null || !result.successful() || result.data()==null) throw new IllegalStateException(); return result.data().stream().filter(Objects::nonNull).toList(); }
|
||||
catch (RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对",error); }
|
||||
}
|
||||
private static boolean blank(String value) { return value==null || value.isBlank(); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
|
||||
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到当前客户的货物来源"); }
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
/** Resolves a location's appointment window without using the operator's system time zone. */
|
||||
public final class AppointmentTimePolicy {
|
||||
private AppointmentTimePolicy() { }
|
||||
|
||||
public record Window(Instant start, Instant end, String timezone) { }
|
||||
|
||||
public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone,
|
||||
ZoneOffset startOffset, ZoneOffset endOffset) {
|
||||
if (start == null) throw new IllegalArgumentException("请填写预约开始时间");
|
||||
if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区");
|
||||
final ZoneId zone;
|
||||
try { zone = ZoneId.of(timezone); }
|
||||
catch (DateTimeException error) { throw new IllegalArgumentException("预约地点时区无效", error); }
|
||||
if (zone instanceof ZoneOffset) throw new IllegalArgumentException("预约地点需要地区时区,不能仅填写固定时差");
|
||||
Instant first = instant(start, zone, startOffset);
|
||||
Instant last = end == null ? null : instant(end, zone, endOffset);
|
||||
if (end == null && endOffset != null) throw new IllegalArgumentException("未填写结束时间,不能指定结束时差");
|
||||
if (last != null && !last.isAfter(first)) throw new IllegalArgumentException("预约结束时间必须晚于开始时间");
|
||||
return new Window(first, last, zone.getId());
|
||||
}
|
||||
|
||||
private static Instant instant(LocalDateTime value, ZoneId zone, ZoneOffset selected) {
|
||||
List<ZoneOffset> offsets = zone.getRules().getValidOffsets(value);
|
||||
if (offsets.isEmpty()) throw new IllegalArgumentException("该当地时间因夏令时切换不存在,请重新选择");
|
||||
if (selected == null && offsets.size() > 1) {
|
||||
throw new IllegalArgumentException("该当地时间在夏令时切换期间出现两次,请明确时差");
|
||||
}
|
||||
if (selected != null && !offsets.contains(selected)) {
|
||||
throw new IllegalArgumentException("所选时差与预约地点及日期不符");
|
||||
}
|
||||
return value.toInstant(selected == null ? offsets.get(0) : selected);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "appointmentWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface AppointmentWarehouseClient {
|
||||
@GetMapping("/internal/warehouses/order-catalog")
|
||||
ApiResponse<List<Warehouse>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
|
||||
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.baiyue.lastleg.dispatch.fleet;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.util.*;
|
||||
|
||||
@Component
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway {
|
||||
private final FleetDispatchPlanningMapper mapper;
|
||||
private final DispatchService dispatches;
|
||||
private final FleetDispatchGateway cargo;
|
||||
public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) {
|
||||
this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo;
|
||||
}
|
||||
public PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size) {
|
||||
var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword);
|
||||
return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) {
|
||||
if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty())
|
||||
throw conflict("请先完成真实收货和实收核对");
|
||||
var existing=mapper.sourceDispatches(customerId,receipt.inboundCode());
|
||||
if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing));
|
||||
String number;
|
||||
if(existing.isEmpty()) {
|
||||
var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode());
|
||||
if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘");
|
||||
number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo();
|
||||
} else number=existing.get(0);
|
||||
validateIncomingDispatch(number,customerId,receipt);
|
||||
return number;
|
||||
}
|
||||
public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) {
|
||||
var snapshot=cargo.lockForPlanning(number);
|
||||
if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致");
|
||||
var expected=new TreeMap<String,Integer>();
|
||||
for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact);
|
||||
var selected=new TreeMap<String,Integer>();
|
||||
for(var item:cargo.sourceCargo(number)) {
|
||||
if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null)
|
||||
throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number);
|
||||
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
|
||||
}
|
||||
if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.dispatch.fleet;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetDispatchPlanningMapper {
|
||||
@Select({"<script>SELECT d.dispatch_no,d.customer_id,d.overseas_warehouse_id AS warehouse_id,d.package_count,d.route",
|
||||
"FROM ll_dispatch_order d WHERE d.overseas_warehouse_id=#{warehouseId} AND d.status='PENDING_DEPARTURE'",
|
||||
"AND d.package_count>0 AND COALESCE(d.picked_package_count,0)=0 AND COALESCE(d.shipped_package_count,0)=0",
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_fleet_trip_dispatch b WHERE b.dispatch_id=d.id)",
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_fleet_external_booking eb ON eb.incoming_no=di.inbound_code",
|
||||
"WHERE di.dispatch_order_id=d.id AND (eb.trip_no!=#{tripNo} OR eb.status!='RECEIVED'))",
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_task t WHERE t.dispatch_order_id=d.id AND t.status!='PDA_PENDING')",
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_outbound_order o WHERE o.dispatch_order_id=d.id AND o.status!='WAIT_OUTBOUND')",
|
||||
"<if test='keyword != null and keyword != ""'>AND (d.dispatch_no LIKE CONCAT('%',#{keyword},'%') OR d.route LIKE CONCAT('%',#{keyword},'%'))</if>",
|
||||
"ORDER BY d.updated_at DESC,d.id DESC</script>"})
|
||||
IPage<FleetDispatchPlanningGateway.Candidate> candidates(IPage<?> page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword);
|
||||
|
||||
@Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " +
|
||||
"WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no")
|
||||
List<String> sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id")
|
||||
List<Long> receivedPlanPallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.List;
|
||||
|
||||
/** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */
|
||||
@Component
|
||||
public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway {
|
||||
private final FleetPoolSourceResolver sources;
|
||||
public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;}
|
||||
@Override
|
||||
public List<Source> resolve(List<SourceRef> refs) {
|
||||
return sources.resolve(refs.stream().map(r->new FleetPoolSourceResolver.SourceRef(r.customerId(),r.bookingNo())).toList())
|
||||
.stream().map(s->new Source(s.customerId(),s.bookingNo(),s.tripNo(),s.bookingStatus(),s.warehouseId(),s.destinationKey(),s.lastlegOrderId(),s.packageCount(),s.recordVersion())).toList();
|
||||
}
|
||||
}
|
||||
+7
@@ -39,6 +39,13 @@ public class FleetPoolOrderBridge {
|
||||
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
|
||||
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
|
||||
if(bookingIds.isEmpty())return Map.of();
|
||||
var result=new HashMap<Long,String>();
|
||||
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
|
||||
validate(in);
|
||||
|
||||
+9
@@ -7,6 +7,15 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
|
||||
@Select({"<script>SELECT b.customer_id,b.booking_no,b.trip_no,b.status AS booking_status,",
|
||||
"t.warehouse_id,b.destination_key,b.destination_label,b.incoming_no,",
|
||||
"l.lastleg_order_id,l.business_order_no,l.fulfillment_stage,b.record_version,",
|
||||
"b.cargo_json,b.expected_pallet_count,b.estimated_arrival_at",
|
||||
"FROM ll_fleet_external_booking b JOIN ll_fleet_trip t ON t.id=b.trip_id",
|
||||
"LEFT JOIN ll_fleet_pool_order_link l ON l.booking_id=b.id AND l.customer_id=b.customer_id",
|
||||
"WHERE b.offer_id IS NOT NULL AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
|
||||
"(b.customer_id=#{s.customerId} AND b.booking_no=#{s.bookingNo})</foreach></script>"})
|
||||
List<FleetPoolSourceResolver.SourceRow> resolveSources(@Param("sources") List<FleetPoolSourceResolver.SourceRef> sources);
|
||||
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE")
|
||||
FleetPoolOrderLink lockBooking(long id);
|
||||
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE")
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Internal read boundary for appointment planning; caller must authorize every customer/source. */
|
||||
@Service
|
||||
public class FleetPoolSourceResolver {
|
||||
public record SourceRef(String customerId, String bookingNo) { }
|
||||
public record Source(String customerId, String bookingNo, String tripNo, String bookingStatus,
|
||||
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
|
||||
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, Integer packageCount, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
|
||||
public record SourceRow(String customerId, String bookingNo, String tripNo, String bookingStatus,
|
||||
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
|
||||
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion,
|
||||
String cargoJson, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper json;
|
||||
private final FleetPoolOrderMapper links;
|
||||
public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Source> resolve(List<SourceRef> sources) {
|
||||
if (sources == null || sources.isEmpty()) return List.of();
|
||||
if (sources.size() > 100 || sources.stream().anyMatch(s -> s == null || s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank()))
|
||||
throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条");
|
||||
return links.resolveSources(sources.stream().distinct().toList()).stream().map(row -> {
|
||||
Integer packages;
|
||||
try {
|
||||
var value=json.readTree(row.cargoJson()).get("packageCount");
|
||||
if(value==null||value.isNull()) packages=null;
|
||||
else { if(!value.isIntegralNumber()||!value.canConvertToInt())throw new IllegalStateException("预报箱数格式无效"); packages=value.intValue(); }
|
||||
} catch(java.io.IOException e){throw new IllegalStateException("货物预报读取失败",e);}
|
||||
return new Source(row.customerId(),row.bookingNo(),row.tripNo(),row.bookingStatus(),row.warehouseId(),row.destinationKey(),row.destinationLabel(),row.incomingNo(),row.lastlegOrderId(),row.businessOrderNo(),row.fulfillmentStage(),row.recordVersion(),packages,row.expectedPalletCount(),row.estimatedArrivalAt());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
+2
@@ -11,6 +11,8 @@ public interface FleetBookingIncomingGateway {
|
||||
record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { }
|
||||
record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status,
|
||||
String receiptHash,List<Pallet> pallets,int receivedPackages) { }
|
||||
record Source(String customerId,String incomingNo) { }
|
||||
java.util.Map<Source,String> statuses(List<Source> sources);
|
||||
Evidence create(String operator, Incoming incoming);
|
||||
Evidence inspect(String customerId,String incomingNo,boolean lock);
|
||||
Evidence confirm(String customerId,String incomingNo,String operator,String key);
|
||||
|
||||
+46
-2
@@ -35,12 +35,17 @@ public class FleetBookingService {
|
||||
private final FleetBookingQuoteContext quoteContext;
|
||||
private final FleetQuoteCandidateService quotes;
|
||||
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
|
||||
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
|
||||
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
|
||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
||||
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
|
||||
this.quoteContext=quoteContext;this.quotes=quotes;
|
||||
}
|
||||
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
|
||||
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
@@ -125,9 +130,12 @@ public class FleetBookingService {
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作");
|
||||
customers.required(Long.parseLong(row.getCustomerId()));
|
||||
var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt();
|
||||
var arrival=request.estimatedArrivalAt()!=null?request.estimatedArrivalAt():row.getEstimatedArrivalAt()==null?null:row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC);
|
||||
if(arrival==null)throw conflict("请确认预计到仓时间,不自动推算到仓安排");
|
||||
if(arrival.isAfter(trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC)))throw conflict("预计到仓不得晚于计划发车时间");
|
||||
var instructions=trim(request.deliveryInstructions())!=null?request.deliveryInstructions():reservations.cargo(row).remark();
|
||||
var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row),
|
||||
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
|
||||
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),instructions,request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
|
||||
requireEvidence(trip,row,evidence);
|
||||
row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash);
|
||||
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
|
||||
@@ -169,12 +177,48 @@ public class FleetBookingService {
|
||||
if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);}
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费");
|
||||
if(row.getOfferId()!=null) {
|
||||
var accepted=reservations.receipt(row);
|
||||
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
|
||||
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
|
||||
planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence);
|
||||
}
|
||||
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
|
||||
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
|
||||
var result=booking(trip,bookingNo);
|
||||
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
|
||||
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) {
|
||||
var trip=trip(tripNo,true);String hash=hash(request);
|
||||
var previous=commands.find(trip.getId(),operator,"BOOKING_ARRANGE",bookingNo,request.idempotencyKey());
|
||||
if(previous!=null) {
|
||||
if(!hash.equals(previous.getRequestHash()))throw conflict("排入本车请求内容不一致");
|
||||
try {var saved=json.readValue(previous.getResultJson(),FleetBookingModels.Result.class);return new FleetBookingModels.Result(saved.tripRecordVersion(),saved.booking(),true);}
|
||||
catch(java.io.IOException e){throw new IllegalStateException("原排入结果读取失败",e);}
|
||||
}
|
||||
String resourceBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);
|
||||
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
|
||||
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认全部实收货量和运费");
|
||||
var accepted=reservations.receipt(row);
|
||||
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
|
||||
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
|
||||
if(resourceBlock!=null)throw conflict(resourceBlock);
|
||||
var plan=execution.capacityPlan(tripNo);
|
||||
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入");
|
||||
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
|
||||
String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator);
|
||||
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo));
|
||||
row=booking(trip,bookingNo);
|
||||
if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源");
|
||||
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
|
||||
var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false);
|
||||
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator);
|
||||
command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash);
|
||||
command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command);
|
||||
return result;
|
||||
}
|
||||
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
|
||||
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
|
||||
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
|
||||
|
||||
+5
-2
@@ -21,7 +21,7 @@ public final class FleetCapacityOfferModels {
|
||||
}
|
||||
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
|
||||
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
|
||||
@Pattern(regexp="MINE|ALL") String scope) {
|
||||
@Pattern(regexp="MINE|ALL|TODO") String scope) {
|
||||
public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;}
|
||||
}
|
||||
public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion,
|
||||
@@ -50,12 +50,15 @@ public final class FleetCapacityOfferModels {
|
||||
String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments,
|
||||
boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { }
|
||||
public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { }
|
||||
public record Progress(String stage,String label,String reservation,String owner,String primaryAction,
|
||||
String message,List<String> missingFields) { }
|
||||
public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion,
|
||||
long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode,
|
||||
String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
|
||||
FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount,
|
||||
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo,
|
||||
Instant updatedAt,InquiryActions actions) { }
|
||||
Instant updatedAt,InquiryActions actions,Progress progress,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage appointmentCoverage) { }
|
||||
public record Fulfillment(FleetBookingModels.Response booking,FleetBookingModels.IncomingPreview preview,Instant plannedDepartureAt) { }
|
||||
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
|
||||
public record OfferResult(Offer offer,boolean replayed) { }
|
||||
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
|
||||
|
||||
+66
-9
@@ -37,6 +37,7 @@ public class FleetCapacityOfferService {
|
||||
private final FleetPoolOrderBridge orders;
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage;
|
||||
@Autowired
|
||||
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
|
||||
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
|
||||
@@ -67,23 +68,52 @@ public class FleetCapacityOfferService {
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
|
||||
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden();
|
||||
readPermission();if(!"MINE".equals(query.scope())&&!manage())throw forbidden();
|
||||
var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId);
|
||||
if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
|
||||
if("MINE".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
|
||||
if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());}
|
||||
if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status());
|
||||
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()));
|
||||
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()).or().like(FleetBookingEntity::getTripNo,query.keyword()));
|
||||
boolean todo="TODO".equals(query.scope());
|
||||
if(todo)filter.in(FleetBookingEntity::getStatus,"DRAFT","CONFIRMED","RECEIVED");
|
||||
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
|
||||
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter);
|
||||
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(todo?1:query.page(),todo?100:query.size()),filter);
|
||||
if(todo&&result.getTotal()>100)throw bad("待办实时核对最多100条,请按客户或登记号缩小范围");
|
||||
var incomingStatuses=bookingService.incomingStatuses(result.getRecords());
|
||||
var coverage=coverageByBooking(result.getRecords());
|
||||
var stages=orders.fulfillmentStages(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
|
||||
var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
|
||||
var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>();
|
||||
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList();
|
||||
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}),incomingStatuses.get(new FleetBookingIncomingGateway.Source(row.getCustomerId(),row.getIncomingNo())),stages.get(row.getId()),coverage.get(row.getBookingNo()))).filter(v->!todo||"调度".equals(v.progress().owner())).toList();
|
||||
if(todo)return page(values,query.page(),query.size());
|
||||
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Inquiry inquiry(String number){
|
||||
readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId()));
|
||||
}
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public FleetCapacityOfferModels.Fulfillment fulfillment(String number) {
|
||||
readPermission();var row=inquiryRow(number);own(row);
|
||||
var trip=trip(row.getTripNo(),false);
|
||||
return new FleetCapacityOfferModels.Fulfillment(reservations.response(row),
|
||||
bookingService.incoming(row.getTripNo(),number),instant(trip.getPlannedDepartureAt()));
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result arrangeIncoming(String number,FleetBookingModels.StartIncoming request) {
|
||||
requireManage();var row=inquiryRow(number);
|
||||
return bookingService.startIncoming(row.getTripNo(),number,operator(),request);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result acceptReceipt(String number,FleetBookingModels.AcceptReceipt request) {
|
||||
requireManage();var row=inquiryRow(number);
|
||||
return bookingService.acceptReceipt(row.getTripNo(),number,operator(),request);
|
||||
}
|
||||
@Transactional
|
||||
public FleetBookingModels.Result arrangeDispatch(String number,FleetBookingModels.Command request) {
|
||||
requireManage();var row=inquiryRow(number);
|
||||
return bookingService.arrangeDispatch(row.getTripNo(),number,operator(),request);
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
|
||||
writePermission("inquiry-create");
|
||||
@@ -165,6 +195,16 @@ public class FleetCapacityOfferService {
|
||||
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult saveAndConfirm(String number,FleetCapacityOfferModels.InquiryWrite request) {
|
||||
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
|
||||
var replay=replay(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
|
||||
if(replay!=null)return replayed(replay);
|
||||
var saved=updateInquiry(number,request);
|
||||
var result=confirm(number,new FleetCapacityOfferModels.InquiryCommand(saved.offer().tripRecordVersion(),saved.offer().recordVersion(),saved.inquiry().recordVersion(),request.idempotencyKey(),"核对资料并确认承运"));
|
||||
saveCommand(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,result);
|
||||
return result;
|
||||
}
|
||||
@Transactional
|
||||
public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){
|
||||
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
|
||||
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);
|
||||
@@ -172,7 +212,10 @@ public class FleetCapacityOfferService {
|
||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);
|
||||
if(vehicleBlock!=null)throw conflict(vehicleBlock);
|
||||
var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason());
|
||||
var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo);
|
||||
var cargo=reservations.cargo(booking);
|
||||
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
|
||||
if(!missing.isEmpty())throw conflict("确认前还需:"+String.join("、",missing));
|
||||
FleetBookingService.requireCompleteCargo(cargo);
|
||||
if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息");
|
||||
if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代");
|
||||
if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间");
|
||||
@@ -197,18 +240,32 @@ public class FleetCapacityOfferService {
|
||||
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
|
||||
}
|
||||
|
||||
private Map<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage> coverageByBooking(List<FleetBookingEntity> rows) {
|
||||
var refs=rows.stream().filter(r->r.getDestinationKey()!=null&&r.getDestinationKey().startsWith("FBA:"))
|
||||
.map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.getCustomerId(),"BOOKING:"+r.getBookingNo(),r.getDestinationKey())).toList();
|
||||
var result=new HashMap<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage>();
|
||||
if(refs.isEmpty())return result;
|
||||
for(var c:appointmentCoverage.query(refs))result.put(c.sourceKey().substring("BOOKING:".length()),c);
|
||||
return result;
|
||||
}
|
||||
private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){
|
||||
return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false);
|
||||
}
|
||||
private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){
|
||||
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)));
|
||||
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)),bookingService.incomingStatuses(List.of(booking)).get(new FleetBookingIncomingGateway.Source(booking.getCustomerId(),booking.getIncomingNo())),orders.fulfillmentStages(List.of(booking.getId())).get(booking.getId()),coverageByBooking(List.of(booking)).get(booking.getBookingNo()));
|
||||
}
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){
|
||||
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer,String incomingStatus,String fulfillmentStage,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage coverage){
|
||||
boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus());
|
||||
boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null);
|
||||
var cargo=reservations.cargo(booking);
|
||||
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
|
||||
String block=draft?FleetInquiryProgress.capacityBlock(cargo,offer):null;
|
||||
boolean canEdit=owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit")));
|
||||
boolean canConfirm=manage()&&draft&&offer.canConfirm()&&missing.isEmpty()&&block==null;
|
||||
return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()),
|
||||
new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason()));
|
||||
new FleetCapacityOfferModels.InquiryActions(canEdit,canConfirm,canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),block),
|
||||
FleetInquiryProgress.project(booking,missing,block,canEdit,canConfirm,incomingStatus,fulfillmentStage),coverage);
|
||||
}
|
||||
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
|
||||
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
|
||||
/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */
|
||||
public interface FleetDispatchPlanningGateway {
|
||||
record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { }
|
||||
PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size);
|
||||
void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt);
|
||||
String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Derived handling guidance, never a second writable booking state machine. */
|
||||
final class FleetInquiryProgress {
|
||||
private FleetInquiryProgress() { }
|
||||
|
||||
static List<String> missing(FleetBookingEntity booking, FleetBookingModels.Cargo cargo,
|
||||
FleetCapacityOfferModels.Offer offer) {
|
||||
var fields = new ArrayList<String>();
|
||||
if (cargo.packageCount() == null || cargo.packageCount() < 1) fields.add("预计箱数");
|
||||
if (cargo.palletSpaces() == null || cargo.palletSpaces() < 1) fields.add("占用标准托位");
|
||||
if (!positive(cargo.weightKg())) fields.add("预计重量");
|
||||
if (!positive(cargo.volumeCbm())) fields.add("预计体积");
|
||||
if ("US_TRUCK".equals(booking.getBusinessType()) &&
|
||||
(booking.getExpectedPalletCount() == null || booking.getExpectedPalletCount() < 1)) fields.add("预计实托数");
|
||||
if (cargo.unitPrice() == null || cargo.unitPrice().signum() < 0) fields.add("约定价格");
|
||||
if (!List.of("CNY", "USD").contains(cargo.currency() == null ? "" : cargo.currency())) fields.add("币种(CNY或USD)");
|
||||
if (blank(cargo.cargoName())) fields.add("货物名称");
|
||||
if (blank(cargo.contactName())) fields.add("联系人");
|
||||
if (blank(cargo.contactPhone())) fields.add("联系方式");
|
||||
if (booking.getEstimatedArrivalAt() == null) fields.add("预计到仓时间");
|
||||
else if (booking.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC).isAfter(offer.cutoffAt())) fields.add("预计到仓时间(不得晚于最晚送仓)");
|
||||
return List.copyOf(fields);
|
||||
}
|
||||
|
||||
static String capacityBlock(FleetBookingModels.Cargo cargo, FleetCapacityOfferModels.Offer offer) {
|
||||
if (!offer.canConfirm()) return offer.blockReason();
|
||||
var destination = offer.destinations().stream().filter(d -> d.destinationKey().equals(cargo.destinationKey())).findFirst();
|
||||
if (destination.isEmpty()) return "卸货站已变化,请调度核对路线";
|
||||
var remaining = destination.get().remaining();
|
||||
if (remaining == null) return "余位待调度核对";
|
||||
if (cargo.palletSpaces() != null && cargo.palletSpaces() > remaining.palletSpaces()
|
||||
|| cargo.weightKg() != null && cargo.weightKg().compareTo(remaining.weightKg()) > 0
|
||||
|| cargo.volumeCbm() != null && cargo.volumeCbm().compareTo(remaining.volumeCbm()) > 0)
|
||||
return "本票预报超过所选目的地余位,请调度核对";
|
||||
return null;
|
||||
}
|
||||
|
||||
static FleetCapacityOfferModels.Progress project(FleetBookingEntity booking, List<String> missing,
|
||||
String block, boolean canEdit, boolean canConfirm, String incomingStatus, String fulfillmentStage) {
|
||||
if ("IN_TRANSIT".equals(fulfillmentStage)) return progress("IN_TRANSIT", "已发车", "运输中", "派送", "VIEW", "按实际派送进度跟进", List.of());
|
||||
if ("COMPLETED".equals(fulfillmentStage)) return progress("COMPLETED", "已完成", "运输已完成", "无待办", "VIEW", "实际货物已全部签收", List.of());
|
||||
return switch (booking.getStatus()) {
|
||||
case "DRAFT" -> {
|
||||
if (!canEdit && !canConfirm && block != null)
|
||||
yield progress("BLOCKED", "登记暂不可处理", "尚未保位", "调度", "VIEW", block, missing);
|
||||
if (!missing.isEmpty()) yield progress("NEEDS_DETAILS", "待补资料", "尚未保位", "销售", canEdit ? "EDIT" : "VIEW", "确认前还需:" + String.join("、", missing), missing);
|
||||
if (block != null) yield progress("RECHECK", "待调度核对", "尚未保位", "调度", "VIEW", block, missing);
|
||||
yield progress("AWAITING_CONFIRMATION", "待调度确认", "尚未保位", "调度", canConfirm ? "CONFIRM" : "VIEW", "保存后调度可见,确认承运成功后才保位", missing);
|
||||
}
|
||||
case "CONFIRMED" -> booking.getIncomingNo() == null
|
||||
? progress("AWAITING_INCOMING", "尚未安排来货", "已保留货位", "调度", "INCOMING", "确定来货安排后再生成来货单", List.of())
|
||||
: incomingStatus != null && List.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(incomingStatus)
|
||||
? progress("AWAITING_RECEIPT_CHECK", "实收完成待核量", "已保留货位", "调度", "RECEIPT", "核对预报与实收差异、容量及约定运费", List.of())
|
||||
: progress("RECEIVING", "已安排来货", "已保留货位", "仓库", "VIEW", incomingStatus == null ? "收货进度暂不可确认,请打开本票进度核对" : "等待仓库实际收货,销售跟进客户送仓", List.of());
|
||||
case "RECEIVED" -> progress("READY_TO_BIND", "实收已核对", "已保留货位", "调度", "BIND", "确认排入原车次,仍需后续审核和实际作业", List.of());
|
||||
case "BOUND" -> progress("BOUND", "已排入本车", "已转为本车货物", "审核 / 仓库", "VIEW", "排入不等于已装车或发车,请查看关联排车作业", List.of());
|
||||
default -> progress("CANCELLED", "已取消", "未保留货位", "无待办", "VIEW", "原登记保留供追溯", List.of());
|
||||
};
|
||||
}
|
||||
|
||||
private static FleetCapacityOfferModels.Progress progress(String stage, String label, String reservation,
|
||||
String owner, String action, String message, List<String> missing) {
|
||||
return new FleetCapacityOfferModels.Progress(stage, label, reservation, owner, action, message, missing);
|
||||
}
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; }
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** Shared by dispatch and appointment consumers; all business writes commit together. */
|
||||
@Service
|
||||
public class FleetTripPreparationService {
|
||||
public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion,
|
||||
@Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId,
|
||||
@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
private final FleetTripService service;
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetCapacityOfferCommandMapper commands;
|
||||
private final ObjectMapper json;
|
||||
public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) {
|
||||
this.service=service;this.trips=trips;this.commands=commands;this.json=json;
|
||||
}
|
||||
@Transactional
|
||||
public FleetTripModels.Detail prepare(String operator,Prepare request) {
|
||||
boolean create=request.tripNo()==null||request.tripNo().isBlank();
|
||||
if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); }
|
||||
permission("platform:fleet:trip:edit");
|
||||
if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择");
|
||||
if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机");
|
||||
if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划");
|
||||
var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo());
|
||||
if(!create&&initial==null)throw conflict("所选车次不存在");
|
||||
String payload=write(request);
|
||||
if(initial!=null) {
|
||||
var locked=trips.lockByNo(initial.getTripNo());
|
||||
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
|
||||
if(previous!=null) {
|
||||
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
|
||||
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次");
|
||||
}
|
||||
FleetTripModels.Detail detail;
|
||||
if(create) {
|
||||
var p=request.plan();
|
||||
detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey()));
|
||||
// Concurrent same-key create may have committed while waiting for its unique constraint.
|
||||
var locked=trips.lockByNo(detail.trip().tripNo());
|
||||
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
|
||||
if(previous!=null) {
|
||||
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
|
||||
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源"));
|
||||
} else {
|
||||
detail=service.detail(request.tripNo());
|
||||
if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对");
|
||||
}
|
||||
for(String number:request.dispatchNos().stream().sorted().toList())
|
||||
detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次"));
|
||||
var row=trips.findByNo(detail.trip().tripNo());
|
||||
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator);
|
||||
command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload));
|
||||
command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command);
|
||||
return detail;
|
||||
}
|
||||
private static void permission(String name) {
|
||||
var auth=SecurityContextHolder.getContext().getAuthentication();
|
||||
if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限");
|
||||
}
|
||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
@@ -190,6 +190,15 @@ public class FleetTripService {
|
||||
return detail(trip);
|
||||
}
|
||||
|
||||
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
|
||||
var trip=trips.findByNo(tripNo);
|
||||
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
|
||||
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
|
||||
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
|
||||
var trip = planned(tripNo, request.recordVersion());
|
||||
|
||||
+12
@@ -45,4 +45,16 @@ public class PlatformFleetCapacityOfferController {
|
||||
public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));}
|
||||
@GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));}
|
||||
@GetMapping("/inquiries/{bookingNo}/fulfillment") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.Fulfillment> fulfillment(@PathVariable String bookingNo){return ApiResponse.success(service.fulfillment(bookingNo));}
|
||||
@PostMapping("/inquiries/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit')")
|
||||
public ApiResponse<FleetBookingModels.Result> incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')")
|
||||
public ApiResponse<FleetBookingModels.Result> receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));}
|
||||
@PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')")
|
||||
public ApiResponse<FleetBookingModels.Result> arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));}
|
||||
|
||||
@PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<FleetCapacityOfferModels.InquiryResult> saveAndConfirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.saveAndConfirm(bookingNo,request));}
|
||||
|
||||
}
|
||||
|
||||
+14
@@ -17,6 +17,13 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
||||
public class PlatformFleetTripController {
|
||||
@jakarta.annotation.Resource private FleetTripPreparationService preparation;
|
||||
@PostMapping("/prepare")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetTripModels.Detail> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody FleetTripPreparationService.Prepare request) {
|
||||
return ApiResponse.success(preparation.prepare(operator(user),request));
|
||||
}
|
||||
private final FleetTripService service;
|
||||
private final FleetTripExecutionService execution;
|
||||
private final FleetTripCompletionService completion;
|
||||
@@ -50,6 +57,13 @@ public class PlatformFleetTripController {
|
||||
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
|
||||
return ApiResponse.success(service.cancel(tripNo, operator(user), request));
|
||||
}
|
||||
@GetMapping("/{tripNo}/dispatch-candidates")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<PageResult<FleetDispatchPlanningGateway.Candidate>> dispatchCandidates(@PathVariable String tripNo,
|
||||
@RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page,
|
||||
@RequestParam(defaultValue="20") @Min(1) @Max(100) int size) {
|
||||
return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size));
|
||||
}
|
||||
@PostMapping("/{tripNo}/dispatches/bind")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
|
||||
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
|
||||
|
||||
+10
@@ -24,6 +24,16 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
|
||||
private final ObjectMapper json;
|
||||
public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;}
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||
public Map<Source,String> statuses(List<Source> sources) {
|
||||
if(sources.isEmpty())return Map.of();
|
||||
if(sources.size()>100)throw conflict("每次最多读取100条来货进度");
|
||||
var query=new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder>();
|
||||
query.and(q->{for(var source:sources)q.or(c->c.eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getCustomerId,source.customerId()).eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getTransferNo,source.incomingNo()));});
|
||||
var result=new HashMap<Source,String>();
|
||||
for(var row:orders.selectList(query))result.put(new Source(row.getCustomerId(),row.getTransferNo()),row.getStatus());
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||
public Evidence create(String operator,Incoming input){
|
||||
boolean fba=input.cargo().destinationKey().startsWith("FBA:");
|
||||
String code=fba?input.cargo().destinationKey().substring(4):null;
|
||||
|
||||
Reference in New Issue
Block a user