feat(appointments): unify pre-inbound planning and confirmed FBA bookings
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches")
|
||||
public class AppointmentBatchPreviewController {
|
||||
private final AppointmentBatchPreviewService service;
|
||||
public AppointmentBatchPreviewController(AppointmentBatchPreviewService service) { this.service=service; }
|
||||
public record Request(@NotEmpty @Size(max=100) List<@NotNull SourceRef> sources) { }
|
||||
@PostMapping("/preview")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<AppointmentBatchPreviewService.Preview> preview(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Request request) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(service.preview(request.sources()));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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 AppointmentBatchPreviewMapper {
|
||||
record Flags(Long orderId, boolean detained, boolean mixedDestinations, boolean activeAppointment) { }
|
||||
@Select("<script>" + """
|
||||
SELECT o.id AS orderId,o.detained,
|
||||
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixedDestinations,
|
||||
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 activeAppointment
|
||||
FROM ll_order o WHERE o.id IN
|
||||
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
""" + "</script>")
|
||||
List<Flags> flags(@Param("ids") List<Long> ids);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AppointmentBatchPreviewService {
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentBatchPreviewMapper mapper;
|
||||
public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper) {
|
||||
this.sources=sources;this.mapper=mapper;
|
||||
}
|
||||
public record Preview(AppointmentPlanningPolicy.Group group,String originWarehouseName,
|
||||
String destinationWarehouseCode,List<AppointmentSourceService.Source> sources,
|
||||
List<String> missingFields) { }
|
||||
public Preview preview(List<AppointmentCoverageService.SourceRef> refs) {
|
||||
var resolved=sources.resolveBatch(refs);
|
||||
var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
|
||||
var flags=ids.isEmpty()?java.util.Map.<Long,AppointmentBatchPreviewMapper.Flags>of():mapper.flags(ids).stream()
|
||||
.collect(Collectors.toMap(AppointmentBatchPreviewMapper.Flags::orderId,Function.identity()));
|
||||
var cargo=resolved.stream().map(source->{
|
||||
String reason=source.canFollowup()?null:String.join(";",source.blockReasons());
|
||||
var state=source.orderId()==null?null:flags.get(source.orderId());
|
||||
if(source.orderId()!=null && state==null) reason="货源已变化,请重新查询";
|
||||
else if(state!=null) {
|
||||
if(state.detained()) reason="货物暂扣,请先处理后再合批";
|
||||
else if(state.mixedDestinations()) reason="货物含多个目的地,请先核对明细";
|
||||
else if(state.activeAppointment()) reason="货物已有有效预约,请编辑原预约";
|
||||
}
|
||||
// Arrival/warehouse ETA is not a cargo-ready window. Until an authoritative producer exists it stays unknown.
|
||||
return new AppointmentPlanningPolicy.Cargo(source.sourceKey(),source.customerId(),source.originWarehouseId(),
|
||||
source.destinationKey(),"US","US",source.packages(),null,null,reason==null,reason);
|
||||
}).toList();
|
||||
try {
|
||||
var group=AppointmentPlanningPolicy.group(cargo);
|
||||
return new Preview(group,resolved.get(0).originWarehouseName(),resolved.get(0).destinationWarehouseCode(),resolved,
|
||||
group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED"));
|
||||
} catch(IllegalArgumentException error) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,error.getMessage(),error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -8,30 +8,34 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentCoverageMapper {
|
||||
String DESTINATION_TYPE="CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.destination_type ELSE o.destination_type END";
|
||||
String DESTINATION_CODE="CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.destination_warehouse_code ELSE o.fba_warehouse_code END";
|
||||
String SHIPMENT_MISSING="CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN (wt.id IS NULL OR ((wt.fba_shipment_no IS NULL OR TRIM(wt.fba_shipment_no)='') AND (wt.tracking_no IS NULL OR TRIM(wt.tracking_no)=''))) ELSE " +
|
||||
"(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)=''))) END";
|
||||
String TRANSFER_JOIN=" LEFT JOIN ll_warehouse_transfer_order wt ON o.order_type='WAREHOUSE_TRANSFER' AND wt.lastleg_order_id=o.id AND wt.customer_id=o.customer_id ";
|
||||
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) { }
|
||||
Long itemId, Integer packages, String warehouseCode,String sourceKey,String sourceVersion,
|
||||
String confirmationNo,String timezone) { }
|
||||
|
||||
@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=')'>",
|
||||
@Select({"<script>SELECT o.id AS orderId,o.customer_id AS customerId,"+DESTINATION_TYPE+" AS destinationType,",
|
||||
DESTINATION_CODE+" AS fbaWarehouseCode,o.package_count AS packages,o.version,o.updated_at AS updatedAt, ",
|
||||
SHIPMENT_MISSING+" AS shipmentReferencesMissing ",
|
||||
"FROM ll_order o "+TRANSFER_JOIN+" 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 ",
|
||||
"a.updated_at AS updatedAt,i.id AS itemId,i.package_count AS packages,i.fba_warehouse_code AS warehouseCode, ",
|
||||
"i.source_key AS sourceKey,i.source_version AS sourceVersion,a.confirmation_no AS confirmationNo,a.timezone ",
|
||||
"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 ",
|
||||
"WHERE i.active=1 AND 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>",
|
||||
"(i.source_key=#{s.sourceKey} 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);
|
||||
List<Fact> appointments(@Param("sources") List<AppointmentCoverageService.SourceRef> sources);
|
||||
}
|
||||
|
||||
+17
-9
@@ -48,8 +48,8 @@ public class AppointmentCoverageService {
|
||||
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 facts=mapper.appointments(requested);
|
||||
var bySource=facts.stream().collect(Collectors.groupingBy(f -> key(f.customerId(),f.sourceKey())));
|
||||
var output=new ArrayList<Coverage>();
|
||||
for (var source:requested) {
|
||||
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
|
||||
@@ -60,22 +60,30 @@ public class AppointmentCoverageService {
|
||||
output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue;
|
||||
}
|
||||
var order=orderMap.get(refs.get(source));
|
||||
var relevant=bySource.getOrDefault(key(source.customerId(),source.sourceKey()),List.of());
|
||||
if (order==null) {
|
||||
if(booking!=null && !relevant.isEmpty()) {
|
||||
var planned=relevant.stream().map(f->new Appointment(f.appointmentNo(),null,f.status(),null,null,f.timezone())).distinct().toList();
|
||||
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),"PENDING",planned,List.of("REAL_CARGO_MATERIALS","RECEIVING_CONFIRMATION"),version(booking.recordVersion()),false));continue;
|
||||
}
|
||||
output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue;
|
||||
}
|
||||
if(blank(order.destinationType()) || blank(order.fbaWarehouseCode())) {
|
||||
output.add(empty(source,"SOURCE_NOT_READY","DESTINATION_UNVERIFIED",version(order.version()),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; }
|
||||
if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { 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());
|
||||
boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone());
|
||||
if (success) confirmed+=fact.packages();
|
||||
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone()));
|
||||
String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version());
|
||||
changed|=!Objects.equals(currentVersion,fact.sourceVersion());
|
||||
}
|
||||
var missing=new ArrayList<String>();
|
||||
String status;
|
||||
@@ -84,7 +92,7 @@ public class AppointmentCoverageService {
|
||||
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 (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) 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),
|
||||
|
||||
+5
@@ -5,7 +5,12 @@ 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 OrderRef(String customerId, Long orderId) { }
|
||||
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);
|
||||
/** Returns existing original bookings only, for canonicalizing post-receipt selections. Never creates a booking. */
|
||||
List<Source> resolveOrders(List<OrderRef> orders);
|
||||
/** Inside the caller transaction, lock original booking rows in stable order before validating an appointment write. */
|
||||
void lockForAppointment(List<SourceRef> sources);
|
||||
}
|
||||
|
||||
+6
-5
@@ -14,9 +14,9 @@ public final class AppointmentPlanningPolicy {
|
||||
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,
|
||||
public record Group(String originWarehouseId, String destinationKey, Long packages,
|
||||
int sourceCount, int customerCount, Instant readyFrom, Instant readyTo,
|
||||
boolean readinessIncomplete) { }
|
||||
boolean readinessIncomplete, int unknownQuantitySources) { }
|
||||
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) { }
|
||||
@@ -28,6 +28,7 @@ public final class AppointmentPlanningPolicy {
|
||||
Set<String> customers = new HashSet<>();
|
||||
String origin = null, destination = null;
|
||||
long packages = 0;
|
||||
int unknownQuantitySources = 0;
|
||||
Instant from = null, to = null;
|
||||
boolean incomplete = false;
|
||||
for (Cargo row : cargo) {
|
||||
@@ -47,8 +48,8 @@ public final class AppointmentPlanningPolicy {
|
||||
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());
|
||||
if (row.packages() == null || row.packages() <= 0) unknownQuantitySources++;
|
||||
else 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())) {
|
||||
@@ -58,7 +59,7 @@ public final class AppointmentPlanningPolicy {
|
||||
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);
|
||||
return new Group(origin, destination, unknownQuantitySources==0?packages:null, sources.size(), customers.size(), from, to, incomplete, unknownQuantitySources);
|
||||
}
|
||||
|
||||
/** A new forecast only requests review; it never changes an externally confirmed appointment. */
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
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 com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/appointments")
|
||||
public class AppointmentSourceCreateController {
|
||||
private final InboundFbaAppointmentService service;
|
||||
public AppointmentSourceCreateController(InboundFbaAppointmentService service){this.service=service;}
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')")
|
||||
public ApiResponse<FbaAppointmentResponse> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody SourceFbaAppointmentRequest request) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request));
|
||||
}
|
||||
@GetMapping("/unscheduled")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list')")
|
||||
public ApiResponse<PageResult<FbaAppointmentResponse>> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(service.unscheduled(page,size));
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
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.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Called by actual incoming materialization, never as a migration or historical data repair. */
|
||||
@Service
|
||||
public class AppointmentSourceLinker {
|
||||
private final LastlegOrderMapper orders;
|
||||
private final InboundFbaAppointmentItemMapper items;
|
||||
private final AppointmentExternalSourceGateway bookings;
|
||||
public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void materialized(String customerId,String bookingNo,Long orderId) {
|
||||
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
|
||||
bookings.lockForAppointment(List.of(ref));
|
||||
var matched=bookings.resolve(List.of(ref));
|
||||
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
|
||||
var order=orderId==null?null:orders.selectById(orderId);
|
||||
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
|
||||
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
|
||||
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
|
||||
for(var row:rows) {
|
||||
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
|
||||
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
|
||||
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
|
||||
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
|
||||
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
|
||||
}
|
||||
}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
+90
-16
@@ -9,6 +9,10 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AppointmentSourceService {
|
||||
@@ -21,47 +25,105 @@ public class AppointmentSourceService {
|
||||
private final AppointmentExternalSourceGateway external;
|
||||
private final AppointmentWarehouseClient warehouses;
|
||||
private final String token;
|
||||
@jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts;
|
||||
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("请选择完整的真实货源");
|
||||
return resolveBatch(List.of(new AppointmentCoverageService.SourceRef(customerId,sourceKey,destinationKey))).get(0);
|
||||
}
|
||||
public void lockForWrite(List<AppointmentCoverageService.SourceRef> refs) {
|
||||
if(refs==null || refs.isEmpty() || refs.size()>100 || refs.stream().anyMatch(Objects::isNull)) throw bad("每批请选择1至100票真实货源");
|
||||
var bookings=refs.stream().filter(ref->ref.sourceKey()!=null && ref.sourceKey().startsWith("BOOKING:"))
|
||||
.map(ref->new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))).toList();
|
||||
if(!bookings.isEmpty()) external.lockForAppointment(bookings);
|
||||
}
|
||||
public List<AppointmentCoverageService.SourceRef> canonicalOrders(List<AppointmentExternalSourceGateway.OrderRef> refs) {
|
||||
if(refs==null || refs.isEmpty() || refs.size()>100) throw bad("每批请选择1至100票真实货源");
|
||||
var bookings=external.resolveOrders(refs);
|
||||
var byOrder=bookings.stream().collect(Collectors.toMap(s->new AppointmentExternalSourceGateway.OrderRef(s.customerId(),s.orderId()),Function.identity()));
|
||||
return refs.stream().distinct().map(ref->{
|
||||
var booking=byOrder.get(ref);
|
||||
return new AppointmentCoverageService.SourceRef(ref.customerId(),booking==null?"ORDER:"+ref.orderId():"BOOKING:"+booking.bookingNo(),null);
|
||||
}).toList();
|
||||
}
|
||||
public String destinationTimezone(String code) {
|
||||
var destination=catalog().stream().filter(w->"US".equals(w.countryCode()) && "FBA".equals(w.warehouseType()) && code.equals(w.warehouseCode())).findFirst().orElseThrow(()->bad("目的仓不在美国FBA目录中"));
|
||||
try {
|
||||
var response=warehouses.activeDetail(token,destination.id());
|
||||
if(response==null || !response.successful() || response.data()==null || response.data().warehouse()==null
|
||||
|| !Objects.equals(response.data().warehouse().id(),destination.id()) || !Long.valueOf(0).equals(response.data().warehouse().tenantId())) throw new IllegalStateException();
|
||||
return response.data().warehouse().timezone();
|
||||
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
|
||||
}
|
||||
/** One catalog request and batched source reads, including mixed first-leg and external bookings. */
|
||||
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
|
||||
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
|
||||
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
|
||||
for(var ref:requested) {
|
||||
if(ref==null || blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源");
|
||||
if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择");
|
||||
if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey()));
|
||||
else if(ref.sourceKey().startsWith("BOOKING:") && !blank(ref.sourceKey().substring(8))) bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8)));
|
||||
else throw bad("不支持的货源类型");
|
||||
}
|
||||
var bookings=bookingRefs.isEmpty()?List.<AppointmentExternalSourceGateway.Source>of():external.resolve(bookingRefs);
|
||||
var bookingMap=new java.util.HashMap<String,AppointmentExternalSourceGateway.Source>();
|
||||
for(var booking:bookings) {
|
||||
if(bookingMap.put(booking.customerId()+"\n"+booking.bookingNo(),booking)!=null) throw bad("外接来源重复,请先核对");
|
||||
if(booking.orderId()!=null) orderIds.add(booking.orderId());
|
||||
}
|
||||
var orderRows=orderIds.isEmpty()?List.<com.baiyue.lastleg.entity.LastlegOrder>of():orders.selectBatchIds(orderIds);
|
||||
var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).toList();
|
||||
if(!directOrders.isEmpty() && !external.resolveOrders(directOrders).isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约");
|
||||
var orderMap=orderRows.stream().collect(Collectors.toMap(com.baiyue.lastleg.entity.LastlegOrder::getId,Function.identity()));
|
||||
var transferRefs=orderRows.stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList();
|
||||
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
|
||||
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
|
||||
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
|
||||
var catalog=catalog();
|
||||
var result=requested.stream().map(ref->resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations)).toList();
|
||||
var materialized=new java.util.HashSet<Long>();
|
||||
for(var source:result) if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源");
|
||||
return result;
|
||||
}
|
||||
private Source resolve(String sourceKey,String customerId,String destinationKey,
|
||||
Map<Long,com.baiyue.lastleg.entity.LastlegOrder> orderMap,
|
||||
Map<String,AppointmentExternalSourceGateway.Source> bookingMap,Map<String,Boolean> gates,
|
||||
List<AppointmentWarehouseClient.Warehouse> catalog,Map<Long,AppointmentCoverageMapper.Source> destinations) {
|
||||
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);
|
||||
order=orderMap.get(orderId(sourceKey));
|
||||
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送仓货物");
|
||||
if (!Set.of("FIRST_LEG","WAREHOUSE_TRANSFER").contains(Objects.toString(order.getOrderType(),""))) throw bad("当前货源类型不支持FBA预约");
|
||||
if ("FIRST_LEG".equals(order.getOrderType()) && (order.getSourceContainerBookingId()==null || !Boolean.TRUE.equals(gates.get(order.getSourceContainerBookingId())))) throw bad("货物尚无已开船或后续实际船节点");
|
||||
origin=order.getOverseasWarehouseId(); destination="FBA:"+destinationCode(order,destinations);
|
||||
if (!"FBA".equals(destinationType(order,destinations))) 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);
|
||||
var source=bookingMap.get(customerId+"\n"+bookingNo);
|
||||
if (source==null) throw missing();
|
||||
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());
|
||||
order=orderMap.get(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("来货目的仓与原登记不一致,请先核对");
|
||||
if (!"FBA".equals(destinationType(order,destinations)) || !Objects.equals(destination,"FBA:"+destinationCode(order,destinations))) throw bad("来货目的仓与原登记不一致,请先核对");
|
||||
packages=order.getPackageCount();
|
||||
version=version+"/ORDER:"+order.getVersion();
|
||||
}
|
||||
} else throw bad("不支持的货源类型");
|
||||
if (order!=null && Set.of("CANCELLED","COMPLETED").contains(order.getCurrentStatus()==null ? "" : order.getCurrentStatus())) {
|
||||
@@ -69,7 +131,7 @@ public class AppointmentSourceService {
|
||||
}
|
||||
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);
|
||||
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目录中");
|
||||
@@ -81,6 +143,18 @@ public class AppointmentSourceService {
|
||||
catch (RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对",error); }
|
||||
}
|
||||
private static boolean blank(String value) { return value==null || value.isBlank(); }
|
||||
private static String destinationType(com.baiyue.lastleg.entity.LastlegOrder order,Map<Long,AppointmentCoverageMapper.Source> facts) {
|
||||
if(!"WAREHOUSE_TRANSFER".equals(order.getOrderType())) return order.getDestinationType();
|
||||
var fact=facts.get(order.getId());return fact==null?null:fact.destinationType();
|
||||
}
|
||||
private static String destinationCode(com.baiyue.lastleg.entity.LastlegOrder order,Map<Long,AppointmentCoverageMapper.Source> facts) {
|
||||
if(!"WAREHOUSE_TRANSFER".equals(order.getOrderType())) return order.getFbaWarehouseCode();
|
||||
var fact=facts.get(order.getId());return fact==null?null:fact.fbaWarehouseCode();
|
||||
}
|
||||
private static long orderId(String key) {
|
||||
try { long id=Long.parseLong(key.substring(6)); if(id<=0 || !key.equals("ORDER:"+id)) throw new NumberFormatException(); return id; }
|
||||
catch(NumberFormatException error) { throw bad("货源标识无效"); }
|
||||
}
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
|
||||
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到当前客户的货物来源"); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public final class AppointmentTimePolicy {
|
||||
final ZoneId zone;
|
||||
try { zone = ZoneId.of(timezone); }
|
||||
catch (DateTimeException error) { throw new IllegalArgumentException("预约地点时区无效", error); }
|
||||
if (zone instanceof ZoneOffset) throw new IllegalArgumentException("预约地点需要地区时区,不能仅填写固定时差");
|
||||
if (zone instanceof ZoneOffset || zone.getRules().isFixedOffset()) 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("未填写结束时间,不能指定结束时差");
|
||||
|
||||
+5
@@ -4,6 +4,7 @@ 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 org.springframework.web.bind.annotation.PathVariable;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "appointmentWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
@@ -11,4 +12,8 @@ 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) { }
|
||||
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
|
||||
ApiResponse<Detail> activeDetail(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("warehouseId") long warehouseId);
|
||||
record Identity(Long id,Long tenantId,String warehouseType,String status,String timezone) { }
|
||||
record Detail(Identity warehouse) { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user