feat(lastleg): connect appointment followups and fleet intake

This commit is contained in:
2026-10-09 22:04:49 +08:00
parent 1202cafadf
commit 0d75e311d0
54 changed files with 2315 additions and 25 deletions
@@ -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;
}
@@ -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&lt;&gt;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);
}
@@ -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(); }
}
@@ -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);
}
@@ -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(); }
}
@@ -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);
}
@@ -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;
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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(); }
}
@@ -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,"未找到当前客户的货物来源"); }
}
@@ -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);
}
}
@@ -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) { }
}
@@ -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);}
}
@@ -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 != &quot;&quot;'>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);
}
@@ -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();
}
}
@@ -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);
@@ -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")
@@ -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();
}
}
@@ -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);
@@ -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("来货单引用不一致");
@@ -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) { }
@@ -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());
@@ -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; }
}
@@ -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());
@@ -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));}
}
@@ -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,
@@ -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;