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) { }
|
||||
}
|
||||
|
||||
+11
-12
@@ -9,6 +9,15 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||
String CONFIRMED_APPOINTMENT_AT =
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_plan_pallet_allocation pa " +
|
||||
"JOIN ll_inbound_plan_line al ON al.id=pa.line_id AND al.customer_id=pa.customer_id AND al.plan_id=p.plan_id " +
|
||||
"JOIN ll_inbound_fba_appointment_item fi ON fi.order_id=al.order_id AND fi.customer_id=al.customer_id " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id " +
|
||||
"WHERE pa.pallet_id=p.id AND pa.customer_id=ip.customer_id AND fi.active=1 AND fa.appointment_status='SUCCESS' " +
|
||||
"AND fi.fba_warehouse_code=fa.fba_warehouse_code " +
|
||||
"AND fa.fba_warehouse_code=COALESCE(NULLIF(TRIM(p.destination_warehouse_code),''),NULLIF(TRIM(al.fba_warehouse_code),'')))";
|
||||
// Billing volume weight is never a substitute for physical cargo mass.
|
||||
String SOURCE_GROSS_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
@@ -40,12 +49,7 @@ public interface DispatchCandidateMapper {
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
@@ -148,12 +152,7 @@ public interface DispatchCandidateMapper {
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS appointmentAt," +
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
|
||||
+15
@@ -14,4 +14,19 @@ public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceG
|
||||
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();
|
||||
}
|
||||
@Override
|
||||
public List<Source> resolveOrders(List<OrderRef> refs) {
|
||||
if (refs == null || refs.isEmpty()) return List.of();
|
||||
if (refs.size() > 100 || refs.stream().anyMatch(java.util.Objects::isNull))
|
||||
throw new IllegalArgumentException("订单来源每次最多100条,且不能包含空项");
|
||||
return sources.resolveOrders(refs.stream().map(r -> new FleetPoolSourceResolver.OrderRef(r.customerId(), r.orderId())).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();
|
||||
}
|
||||
@Override
|
||||
public void lockForAppointment(List<SourceRef> refs) {
|
||||
if (refs == null || refs.size() > 100 || refs.stream().anyMatch(java.util.Objects::isNull))
|
||||
throw new IllegalArgumentException("预约来源须完整,每次最多100条");
|
||||
sources.lockForAppointment(refs.stream().map(r -> new FleetPoolSourceResolver.SourceRef(r.customerId(), r.bookingNo())).toList());
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -33,11 +33,14 @@ public class FleetPoolOrderBridge {
|
||||
private final LastlegOrderNoGenerator numbers;
|
||||
private final ObjectMapper json;
|
||||
private final DeliverySourceEvidenceMapper deliveryEvidence;
|
||||
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
|
||||
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
|
||||
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
|
||||
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence) {
|
||||
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence,
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
|
||||
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
|
||||
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
|
||||
this.appointmentSources=appointmentSources;
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
|
||||
@@ -100,6 +103,7 @@ public class FleetPoolOrderBridge {
|
||||
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
|
||||
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
|
||||
}
|
||||
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
|
||||
}
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void receiving(String customerId,String incomingNo,String operator) {
|
||||
|
||||
+12
@@ -7,6 +7,8 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
|
||||
@Select("SELECT customer_id,booking_no FROM ll_fleet_external_booking WHERE booking_no=#{bookingNo} AND offer_id IS NOT NULL FOR UPDATE")
|
||||
FleetPoolSourceResolver.SourceRef lockAppointmentSource(String bookingNo);
|
||||
@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,",
|
||||
@@ -16,6 +18,16 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
|
||||
"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({"<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_pool_order_link l JOIN ll_fleet_external_booking b ON b.id=l.booking_id AND b.customer_id=l.customer_id",
|
||||
"JOIN ll_fleet_trip t ON t.id=b.trip_id",
|
||||
"JOIN ll_order o ON o.id=l.lastleg_order_id AND o.customer_id=l.customer_id",
|
||||
"WHERE b.offer_id IS NOT NULL AND <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>",
|
||||
"(l.customer_id=#{o.customerId} AND l.lastleg_order_id=#{o.orderId})</foreach></script>"})
|
||||
List<FleetPoolSourceResolver.SourceRow> resolveOrderSources(@Param("orders") List<FleetPoolSourceResolver.OrderRef> orders);
|
||||
@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")
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ public class FleetPoolOrderQueries {
|
||||
row.getTripNo(),row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),row.getOriginWarehouseName(),row.getDestinationLabel(),
|
||||
row.getDeliveryMode(),row.getDeliveryInstructions(),row.getFulfillmentStage(),row.getStageOccurredAt()==null?null:row.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant(),
|
||||
row.getAgreedFreightAmount(),row.getCurrency(),row.getIncomingNo(),row.getDispatchNo(),row.getDeliveryNo(),
|
||||
signed,unsigned,returned,exception,"此订单由拼车承运实际履约推进,请从来货、排车和签收入口操作");
|
||||
signed,unsigned,returned,exception,"此订单已关联拼车承运,状态随实际收货、排车和签收更新,无需重复下单。");
|
||||
}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
|
||||
+33
-1
@@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class FleetPoolSourceResolver {
|
||||
public record SourceRef(String customerId, String bookingNo) { }
|
||||
public record OrderRef(String customerId, Long orderId) { }
|
||||
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) { }
|
||||
@@ -19,12 +20,43 @@ public class FleetPoolSourceResolver {
|
||||
private final FleetPoolOrderMapper links;
|
||||
public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; }
|
||||
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void lockForAppointment(List<SourceRef> sources) {
|
||||
if (!org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())
|
||||
throw new IllegalStateException("预约来源锁必须在调用方事务内获取");
|
||||
if (sources == null || 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条");
|
||||
// Lock the original booking, not the optional commercial link: an inquiry may have no incoming yet.
|
||||
// One unique-key lookup at a time gives concurrent batches the same lock order.
|
||||
for (var source : sources.stream().distinct().sorted(java.util.Comparator.comparing(SourceRef::bookingNo)
|
||||
.thenComparing(SourceRef::customerId)).toList()) {
|
||||
var locked = links.lockAppointmentSource(source.bookingNo());
|
||||
if (locked == null || !source.equals(locked))
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
|
||||
"预约货源不存在或客户归属不一致,请重新核对");
|
||||
}
|
||||
}
|
||||
|
||||
@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 -> {
|
||||
return project(links.resolveSources(sources.stream().distinct().toList()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Source> resolveOrders(List<OrderRef> orders) {
|
||||
if (orders == null || orders.isEmpty()) return List.of();
|
||||
if (orders.size() > 100 || orders.stream().anyMatch(o -> o == null || o.customerId() == null
|
||||
|| o.customerId().isBlank() || o.orderId() == null || o.orderId() <= 0))
|
||||
throw new IllegalArgumentException("来源须包含客户及有效订单,每次最多100条");
|
||||
return project(links.resolveOrderSources(orders.stream().distinct().toList()));
|
||||
}
|
||||
|
||||
private List<Source> project(List<SourceRow> rows) {
|
||||
return rows.stream().map(row -> {
|
||||
Integer packages;
|
||||
try {
|
||||
var value=json.readTree(row.cargoJson()).get("packageCount");
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ public class InboundPlanController {
|
||||
@PathVariable String appointmentNo,
|
||||
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.update(appointmentNo, request));
|
||||
return ApiResponse.success(appointmentService.update(String.valueOf(user.userId()), appointmentNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/fba-appointments/{appointmentNo}")
|
||||
@@ -169,12 +169,12 @@ public class InboundPlanController {
|
||||
public ApiResponse<Void> deleteFbaAppointment(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo, @RequestParam int version) {
|
||||
requireAppointmentPlatform(user);
|
||||
appointmentService.delete(appointmentNo, version);
|
||||
appointmentService.delete(String.valueOf(user.userId()), appointmentNo, version);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireAppointmentPlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user == null || user.userId() == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "预约由平台统一管理");
|
||||
}
|
||||
|
||||
+5
-7
@@ -9,12 +9,10 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchFbaAppointmentRequest(
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
||||
@NotBlank @Size(max = 64) String appointmentNo,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt
|
||||
@NotEmpty @Size(max = 100) List<@NotNull Long> lineIds,
|
||||
@NotBlank String originWarehouseId,
|
||||
@NotBlank @Size(max=64) String fbaWarehouseCode,
|
||||
@NotNull @jakarta.validation.Valid FbaAppointmentDetails details,
|
||||
@NotBlank @Size(max = 64) String idempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Planned windows and the receiving party's confirmation are separate facts. */
|
||||
public record FbaAppointmentDetails(
|
||||
java.time.LocalDate plannedDate,
|
||||
LocalDateTime plannedAt, LocalDateTime plannedEndAt,
|
||||
@Size(max=128) String confirmationNo,
|
||||
LocalDateTime appointmentAt, LocalDateTime appointmentEndAt,
|
||||
@NotBlank @Size(max=32) String appointmentStatus,
|
||||
@Size(max=1000) String appointmentRemark,
|
||||
@Size(max=6) String startOffset, @Size(max=6) String endOffset
|
||||
) { }
|
||||
+11
-4
@@ -6,20 +6,27 @@ import java.util.List;
|
||||
public record FbaAppointmentResponse(
|
||||
Long id,
|
||||
String appointmentNo,
|
||||
String confirmationNo,
|
||||
java.time.LocalDate plannedDate,
|
||||
LocalDateTime plannedAt,
|
||||
LocalDateTime plannedEndAt,
|
||||
LocalDateTime appointmentAt,
|
||||
LocalDateTime expectedLoadingAt,
|
||||
LocalDateTime appointmentEndAt,
|
||||
java.time.Instant appointmentStartUtc,
|
||||
java.time.Instant appointmentEndUtc,
|
||||
String timezone,
|
||||
String appointmentStatus,
|
||||
String appointmentRemark,
|
||||
String originWarehouseId,
|
||||
String fbaWarehouseCode,
|
||||
int selectedCount,
|
||||
String createdBy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
List<Item> items,
|
||||
LocalDateTime appointmentEndAt,
|
||||
Integer version
|
||||
) {
|
||||
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
|
||||
String fbaWarehouseCode, String customerId) {
|
||||
public record Item(Long id, String sourceKey, String sourceVersion, Long orderId, String inboundCode,
|
||||
String fbaWarehouseCode, String customerId, Integer packageCount) {
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
public record SourceFbaAppointmentRequest(
|
||||
@NotEmpty @Size(max=100) List<@NotNull SourceRef> sources,
|
||||
@NotBlank String originWarehouseId,
|
||||
@NotBlank @Size(max=64) String fbaWarehouseCode,
|
||||
@NotNull @Valid FbaAppointmentDetails details,
|
||||
@NotBlank @Size(max=64) String idempotencyKey
|
||||
) { }
|
||||
+3
-6
@@ -7,11 +7,8 @@ import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UpdateFbaAppointmentRequest(
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt,
|
||||
@NotNull @jakarta.validation.constraints.Min(0) Integer version
|
||||
@NotNull @jakarta.validation.Valid FbaAppointmentDetails details,
|
||||
@NotNull @jakarta.validation.constraints.Min(0) Integer version,
|
||||
@NotBlank @Size(max = 64) String idempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
+16
-1
@@ -13,11 +13,26 @@ public class InboundFbaAppointment {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String appointmentNo;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String confirmationNo;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private java.time.LocalDate plannedDate;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime plannedAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime plannedEndAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentEndAt;
|
||||
private Integer version;
|
||||
private LocalDateTime expectedLoadingAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentStartUtc;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentEndUtc;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String timezone;
|
||||
private String originWarehouseId;
|
||||
private String appointmentStatus;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String appointmentRemark;
|
||||
|
||||
+8
-2
@@ -14,9 +14,15 @@ public class InboundFbaAppointmentItem {
|
||||
private Long id;
|
||||
private Long appointmentId;
|
||||
private String customerId;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private String sourceKey;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String sourceVersion;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private Integer packageCount;
|
||||
private Boolean active;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private Long orderId;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String inboundCode;
|
||||
private String fbaWarehouseCode;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface FbaAppointmentCommandMapper {
|
||||
record Command(String requestHash,String responseJson) { }
|
||||
@Select("SELECT request_hash AS requestHash,response_json AS responseJson FROM ll_fba_appointment_command WHERE command_key=#{key}")
|
||||
Command find(@Param("key") String key);
|
||||
@Insert("INSERT INTO ll_fba_appointment_command(command_key,request_hash,operation,operator_id,created_at) VALUES(#{key},#{hash},#{operation},#{operator},#{createdAt})")
|
||||
int begin(@Param("key") String key,@Param("hash") String hash,@Param("operation") String operation,@Param("operator") String operator,@Param("createdAt") java.time.LocalDateTime createdAt);
|
||||
@Update("UPDATE ll_fba_appointment_command SET appointment_no=#{appointmentNo},response_json=#{response} WHERE command_key=#{key} AND response_json IS NULL")
|
||||
int finish(@Param("key") String key,@Param("appointmentNo") String appointmentNo,@Param("response") String response);
|
||||
}
|
||||
+20
-7
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@@ -9,21 +10,33 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentQueryMapper {
|
||||
@Select("<script>SELECT id FROM ll_order WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY id FOR UPDATE</script>")
|
||||
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
|
||||
List<Long> lockOrders(@Param("ids") List<Long> ids);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1 " +
|
||||
"<if test='excludedId != null'>AND appointment_id<>#{excludedId} </if>AND (source_key IN " +
|
||||
"<foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach> " +
|
||||
"<if test='orderIds != null and !orderIds.isEmpty()'>OR order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if>)</script>")
|
||||
long countActiveSources(@Param("keys") List<String> keys,@Param("orderIds") List<Long> orderIds,@Param("excludedId") Long excludedId);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_order o "+com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.TRANSFER_JOIN+
|
||||
" WHERE o.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> AND ("+
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.SHIPMENT_MISSING+") </script>")
|
||||
long countMissingShipmentReferences(@Param("ids") List<Long> ids);
|
||||
@Select("<script>" +
|
||||
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode,p.customer_id AS customerId " +
|
||||
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
|
||||
"WHERE l.id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"ORDER BY l.id FOR UPDATE" +
|
||||
"ORDER BY l.id" +
|
||||
"</script>")
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("lineIds") List<Long> lineIds);
|
||||
List<FbaAppointmentLineSnapshot> selectedLines(@Param("lineIds") List<Long> lineIds);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id " +
|
||||
"WHERE a.appointment_status NOT IN ('CANCELLED','FAILED') " +
|
||||
"<if test='excludedId != null'>AND a.id<>#{excludedId} </if>AND i.line_id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i WHERE i.active=1 " +
|
||||
"<if test='excludedId != null'>AND i.appointment_id<>#{excludedId} </if>AND i.order_id IN " +
|
||||
"(SELECT l.order_id FROM ll_inbound_plan_line l WHERE l.id IN <foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>)</script>")
|
||||
long countActiveAppointments(@Param("lineIds") List<Long> lineIds, @Param("excludedId") Long excludedId);
|
||||
|
||||
@Select("""
|
||||
|
||||
+3
-3
@@ -24,9 +24,9 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
|
||||
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
|
||||
"WHERE ai.plan_id=p.id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"WHERE ai.order_id=p.source_order_id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
|
||||
"WHERE ai.plan_id=p.id)</if>" +
|
||||
"WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND ai.active=1)</if>" +
|
||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</if>" +
|
||||
"<if test='q.createdTo != null'> AND p.created_at <=#{q.createdTo}</if>";
|
||||
|
||||
@@ -35,7 +35,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
"l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," +
|
||||
"COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," +
|
||||
"COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.plan_id=p.id " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id " +
|
||||
"ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" +
|
||||
PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InboundPlanSummary> pageRows(@Param("customerId") String customerId,
|
||||
|
||||
+4
-4
@@ -20,10 +20,10 @@ public interface InboundPlanQueryMapper {
|
||||
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
|
||||
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=p.customer_id" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}</if>" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.order_id=l.order_id AND ai.customer_id=p.customer_id" +
|
||||
" AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" +
|
||||
" JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" +
|
||||
" WHERE ai2.line_id=l.id AND ai2.customer_id=p.customer_id" +
|
||||
" WHERE ai2.order_id=l.order_id AND ai2.customer_id=p.customer_id AND ai2.fba_warehouse_code=l.fba_warehouse_code" +
|
||||
" ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" +
|
||||
" WHERE 1=1" +
|
||||
@@ -35,7 +35,7 @@ public interface InboundPlanQueryMapper {
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
|
||||
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
|
||||
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND (ai.id IS NULL OR ai.active=0)</when>" +
|
||||
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
|
||||
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND " + SCHEDULED_DISPATCH + "</when>" +
|
||||
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT " + SCHEDULED_DISPATCH + "</when></choose>" +
|
||||
@@ -57,7 +57,7 @@ public interface InboundPlanQueryMapper {
|
||||
"l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," +
|
||||
"p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," +
|
||||
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
|
||||
"a.expected_loading_at expectedLoadingAt," +
|
||||
"NULL expectedLoadingAt," +
|
||||
"CASE WHEN " + SCHEDULED_DISPATCH +
|
||||
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
|
||||
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
|
||||
+195
-239
@@ -2,274 +2,230 @@ package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.*;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.*;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class InboundFbaAppointmentService {
|
||||
@Resource private InboundFbaAppointmentMapper appointmentMapper;
|
||||
@Resource private InboundFbaAppointmentItemMapper itemMapper;
|
||||
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
|
||||
@Resource private FbaAppointmentCommandMapper commands;
|
||||
@Resource private AppointmentSourceService sources;
|
||||
@Resource private AppointmentBatchPreviewMapper flags;
|
||||
@Resource private ObjectMapper json;
|
||||
|
||||
/** A bounded calendar read uses two batch queries, never one detail query per appointment. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<FbaAppointmentResponse> calendar(LocalDate from, LocalDate to) {
|
||||
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为 1 至 42 天");
|
||||
}
|
||||
List<InboundFbaAppointment> appointments = appointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.ge(InboundFbaAppointment::getAppointmentAt, from.atStartOfDay())
|
||||
.lt(InboundFbaAppointment::getAppointmentAt, to.atStartOfDay())
|
||||
.orderByAsc(InboundFbaAppointment::getAppointmentAt, InboundFbaAppointment::getId));
|
||||
if (appointments.isEmpty()) return List.of();
|
||||
var groupedItems = itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.in(InboundFbaAppointmentItem::getAppointmentId,
|
||||
appointments.stream().map(InboundFbaAppointment::getId).toList())
|
||||
.orderByAsc(InboundFbaAppointmentItem::getLineId))
|
||||
.stream().collect(java.util.stream.Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId));
|
||||
return appointments.stream().map(appointment -> response(appointment,
|
||||
groupedItems.getOrDefault(appointment.getId(), List.of()))).toList();
|
||||
@Transactional(readOnly=true)
|
||||
public List<FbaAppointmentResponse> calendar(LocalDate from,LocalDate to) {
|
||||
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw bad("日历查询范围必须为1至42天");
|
||||
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"))
|
||||
.apply("COALESCE(appointment_at,planned_at,planned_date)>={0} AND COALESCE(appointment_at,planned_at,planned_date)<{1}",from.atStartOfDay(),to.atStartOfDay())
|
||||
.orderByAsc(InboundFbaAppointment::getId);
|
||||
return responses(appointmentMapper.selectList(query));
|
||||
}
|
||||
@Transactional(readOnly=true)
|
||||
public PageResult<FbaAppointmentResponse> unscheduled(int page,int size) {
|
||||
if(page<1 || size<1 || size>100) throw bad("分页参数无效");
|
||||
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt)
|
||||
.isNull(InboundFbaAppointment::getPlannedDate)
|
||||
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"));
|
||||
long total=appointmentMapper.selectCount(query);
|
||||
var rows=appointmentMapper.selectList(query.orderByDesc(InboundFbaAppointment::getCreatedAt)
|
||||
.last("LIMIT "+size+" OFFSET "+(long)(page-1)*size));
|
||||
return new PageResult<>(responses(rows),page,size,total,(total+size-1)/size);
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse batchCreate(String operatorId,
|
||||
BatchFbaAppointmentRequest request) {
|
||||
List<Long> lineIds = List.copyOf(request.lineIds());
|
||||
if (new HashSet<>(lineIds).size() != lineIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates");
|
||||
}
|
||||
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
|
||||
String status = normalizeStatus(request.appointmentStatus());
|
||||
String remark = trimToNull(request.appointmentRemark());
|
||||
validateWindow(request.appointmentAt(), request.appointmentEndAt());
|
||||
|
||||
InboundFbaAppointment existing = find(appointmentNo);
|
||||
if (existing != null) {
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
|
||||
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(lineIds);
|
||||
if (lines.size() != lineIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"所选入仓明细不存在,请刷新后重试");
|
||||
}
|
||||
String fbaWarehouseCode = commonFbaWarehouse(lines);
|
||||
|
||||
// The row locks serialize concurrent submissions selecting the same inbound lines.
|
||||
existing = find(appointmentNo);
|
||||
if (existing != null) {
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
|
||||
if (queryMapper.countActiveAppointments(lineIds, null) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有有效预约,请编辑原预约或先取消");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
InboundFbaAppointment appointment = new InboundFbaAppointment();
|
||||
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse batchCreate(String operatorId,BatchFbaAppointmentRequest request) {
|
||||
return command("CREATE_FROM_LINES",operatorId,request.idempotencyKey(),request,()->{
|
||||
var ids=List.copyOf(request.lineIds());
|
||||
if(ids.isEmpty() || ids.size()>100 || new HashSet<>(ids).size()!=ids.size()) throw bad("请选择不重复的入仓明细,每次最多100条");
|
||||
var lines=queryMapper.selectedLines(ids);
|
||||
if(lines.size()!=ids.size()) throw missing();
|
||||
var refs=sources.canonicalOrders(lines.stream().map(line->new AppointmentExternalSourceGateway.OrderRef(line.customerId(),line.orderId())).distinct().toList());
|
||||
return create(operatorId,refs,request.originWarehouseId(),request.fbaWarehouseCode(),request.details());
|
||||
});
|
||||
}
|
||||
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) {
|
||||
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
|
||||
}
|
||||
private FbaAppointmentResponse create(String operator,List<AppointmentCoverageService.SourceRef> refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) {
|
||||
var resolved=resolveForWrite(refs,null);
|
||||
var group=group(resolved);
|
||||
if(clean(expectedOrigin)==null || clean(expectedDestination)==null) throw bad("请选择接仓点和目的仓后重新核对货物");
|
||||
if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物");
|
||||
var appointment=new InboundFbaAppointment();
|
||||
appointment.setId(IdWorker.getId());
|
||||
appointment.setAppointmentNo(appointmentNo);
|
||||
appointment.setAppointmentAt(request.appointmentAt());
|
||||
appointment.setAppointmentEndAt(request.appointmentEndAt());
|
||||
appointment.setVersion(0);
|
||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
||||
appointment.setAppointmentStatus(status);
|
||||
appointment.setAppointmentRemark(remark);
|
||||
appointment.setFbaWarehouseCode(fbaWarehouseCode);
|
||||
appointment.setSelectedCount(lines.size());
|
||||
appointment.setCreatedBy(trimToNull(operatorId));
|
||||
appointment.setCreatedAt(now);
|
||||
appointment.setUpdatedAt(now);
|
||||
try {
|
||||
appointmentMapper.insert(appointment);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InboundFbaAppointment concurrent = find(appointmentNo);
|
||||
if (concurrent != null) {
|
||||
return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
|
||||
for (FbaAppointmentLineSnapshot line : lines) {
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setAppointmentId(appointment.getId());
|
||||
item.setCustomerId(line.customerId());
|
||||
item.setPlanId(line.planId());
|
||||
item.setLineId(line.lineId());
|
||||
item.setOrderId(line.orderId());
|
||||
item.setInboundCode(line.inboundCode());
|
||||
item.setFbaWarehouseCode(line.fbaWarehouseCode());
|
||||
item.setCreatedAt(now);
|
||||
itemMapper.insert(item);
|
||||
}
|
||||
return response(appointment, items(appointment.getId()));
|
||||
appointment.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
|
||||
appointment.setOriginWarehouseId(group.originWarehouseId());
|
||||
appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode());
|
||||
appointment.setSelectedCount(resolved.size());appointment.setVersion(0);
|
||||
appointment.setCreatedBy(operator);appointment.setCreatedAt(now());appointment.setUpdatedAt(appointment.getCreatedAt());
|
||||
apply(appointment,details,resolved);
|
||||
if(Set.of("FAILED","CANCELLED").contains(appointment.getAppointmentStatus())) throw bad("新安排请先保存待确认或登记已取得的正式确认");
|
||||
try {appointmentMapper.insert(appointment);}
|
||||
catch(DuplicateKeyException duplicate){throw conflict("该目的仓的外部约号已有记录,请继续原预约");}
|
||||
writeItems(appointment,resolved,List.of());
|
||||
return response(appointment,items(appointment.getId()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Transactional(readOnly=true)
|
||||
public FbaAppointmentResponse detail(String appointmentNo) {
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
if (appointment == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
||||
}
|
||||
return response(appointment, items(appointment.getId()));
|
||||
var appointment=find(appointmentNo,false);if(appointment==null) throw missing();
|
||||
return response(appointment,items(appointment.getId()));
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse update(String appointmentNo,
|
||||
UpdateFbaAppointmentRequest request) {
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
if (appointment == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
||||
}
|
||||
String status = normalizeStatus(request.appointmentStatus());
|
||||
validateWindow(request.appointmentAt(), request.appointmentEndAt());
|
||||
if (request.version() == null || !Objects.equals(appointment.getVersion(), request.version())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
|
||||
}
|
||||
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
|
||||
if (!lineIds.isEmpty()) {
|
||||
queryMapper.lockLines(lineIds);
|
||||
if (!Set.of("FAILED", "CANCELLED").contains(status)
|
||||
&& queryMapper.countActiveAppointments(lineIds, appointment.getId()) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有其他有效预约,不能恢复当前预约");
|
||||
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) {
|
||||
return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{
|
||||
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
|
||||
if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作");
|
||||
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约");
|
||||
var current=items(appointment.getId());
|
||||
String status=status(request.details().appointmentStatus());
|
||||
if(Set.of("FAILED","CANCELLED").contains(status)) {
|
||||
if(clean(request.details().appointmentRemark())==null) throw bad("取消或未约成时请填写原因");
|
||||
// Keep the last planned and confirmed times as historical facts.
|
||||
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(request.details().appointmentRemark().trim());
|
||||
current.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
|
||||
} else {
|
||||
var refs=current.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList();
|
||||
var resolved=resolveForWrite(refs,appointment.getId());
|
||||
if(!Objects.equals(group(resolved).originWarehouseId(),appointment.getOriginWarehouseId())) throw conflict("接仓点已变化,请重新核对安排");
|
||||
if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认,请记录取消原因后重新安排");
|
||||
apply(appointment,request.details(),resolved);
|
||||
writeItems(appointment,resolved,current);
|
||||
}
|
||||
appointment.setVersion(request.version()+1);appointment.setUpdatedAt(now());
|
||||
if(appointmentMapper.update(appointment,new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getId,appointment.getId()).eq(InboundFbaAppointment::getVersion,request.version()))!=1) throw conflict("预约版本已变化");
|
||||
return response(appointment,items(appointment.getId()));
|
||||
});
|
||||
}
|
||||
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public void delete(String operatorId,String appointmentNo,int version) {
|
||||
command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,version,()->{
|
||||
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
|
||||
if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作");
|
||||
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车");
|
||||
appointment.setAppointmentStatus("CANCELLED");appointment.setAppointmentRemark("操作员取消预约安排");
|
||||
appointment.setVersion(version+1);appointment.setUpdatedAt(now());appointmentMapper.updateById(appointment);
|
||||
var rows=items(appointment.getId());rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
|
||||
return response(appointment,rows);
|
||||
});
|
||||
}
|
||||
|
||||
private List<AppointmentSourceService.Source> resolveForWrite(List<AppointmentCoverageService.SourceRef> refs,Long excludedId) {
|
||||
sources.lockForWrite(refs);
|
||||
var resolved=sources.resolveBatch(refs);
|
||||
var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if(!ids.isEmpty()) {
|
||||
if(queryMapper.lockOrders(ids).size()!=ids.size()) throw conflict("货源已变化,请重新查询");
|
||||
// Re-read after acquiring row locks; a pre-lock snapshot must not authorize a write.
|
||||
resolved=sources.resolveBatch(refs);
|
||||
var locked=Set.copyOf(ids);
|
||||
if(resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).anyMatch(id->!locked.contains(id))) throw conflict("来货关联已变化,请重新提交安排");
|
||||
var states=flags.flags(ids);
|
||||
if(states.size()!=ids.size() || states.stream().anyMatch(s->s.detained() || s.mixedDestinations())) throw conflict("货物暂扣或含多个目的地,请先核对");
|
||||
}
|
||||
appointment.setAppointmentAt(request.appointmentAt());
|
||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
||||
appointment.setAppointmentStatus(status);
|
||||
appointment.setAppointmentEndAt(request.appointmentEndAt());
|
||||
appointment.setVersion(request.version() + 1);
|
||||
appointment.setAppointmentRemark(trimToNull(request.appointmentRemark()));
|
||||
appointment.setUpdatedAt(LocalDateTime.now());
|
||||
if (appointmentMapper.update(appointment, new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getId, appointment.getId())
|
||||
.eq(InboundFbaAppointment::getVersion, request.version())) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
|
||||
}
|
||||
return response(appointment, items(appointment.getId()));
|
||||
if(resolved.stream().anyMatch(s->!s.canFollowup())) throw conflict("所选来源不再具备安排资格");
|
||||
if(queryMapper.countActiveSources(resolved.stream().map(AppointmentSourceService.Source::sourceKey).toList(),ids,excludedId)>0) throw conflict("所选货物已有有效预约安排,请继续原单");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public void delete(String appointmentNo, int version) {
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "预约编号不能为空"));
|
||||
if (appointment == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "预约不存在");
|
||||
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
|
||||
if (!lineIds.isEmpty()) queryMapper.lockLines(lineIds);
|
||||
if (queryMapper.countActiveDispatches(appointment.getId()) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约货物已关联排车,不允许删除,请先处理关联排车");
|
||||
}
|
||||
if (appointmentMapper.delete(new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getId, appointment.getId())
|
||||
.eq(InboundFbaAppointment::getVersion, version)) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请刷新后重试");
|
||||
}
|
||||
itemMapper.delete(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.eq(InboundFbaAppointmentItem::getAppointmentId, appointment.getId()));
|
||||
private AppointmentPlanningPolicy.Group group(List<AppointmentSourceService.Source> rows) {
|
||||
try { return AppointmentPlanningPolicy.group(rows.stream().map(s->new AppointmentPlanningPolicy.Cargo(s.sourceKey(),s.customerId(),s.originWarehouseId(),s.destinationKey(),"US","US",s.packages(),null,null,s.canFollowup(),String.join(";",s.blockReasons()))).toList()); }
|
||||
catch(IllegalArgumentException error) {throw bad(error.getMessage());}
|
||||
}
|
||||
|
||||
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
|
||||
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
||||
String status, String remark, LocalDateTime appointmentEndAt) {
|
||||
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
|
||||
Set<Long> existingLineIds = existingItems.stream()
|
||||
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
|
||||
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|
||||
|| !Objects.equals(existing.getAppointmentEndAt(), appointmentEndAt)
|
||||
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|
||||
|| !Objects.equals(existing.getAppointmentStatus(), status)
|
||||
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|
||||
|| existingLineIds.size() != requestedLineIds.size()
|
||||
|| !existingLineIds.equals(new HashSet<>(requestedLineIds))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Appointment number already exists with different appointment data");
|
||||
}
|
||||
return response(existing, existingItems);
|
||||
}
|
||||
|
||||
private String commonFbaWarehouse(List<FbaAppointmentLineSnapshot> lines) {
|
||||
List<String> warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode)
|
||||
.map(InboundFbaAppointmentService::trimToNull).toList();
|
||||
if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Selected inbound lines must belong to the same non-empty FBA warehouse");
|
||||
}
|
||||
return warehouses.get(0);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment find(String appointmentNo) {
|
||||
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
|
||||
return appointmentMapper.selectOne(wrapper);
|
||||
}
|
||||
|
||||
private List<InboundFbaAppointmentItem> items(Long appointmentId) {
|
||||
return itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId)
|
||||
.orderByAsc(InboundFbaAppointmentItem::getLineId));
|
||||
}
|
||||
|
||||
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
|
||||
List<InboundFbaAppointmentItem> items) {
|
||||
return new FbaAppointmentResponse(appointment.getId(),
|
||||
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
|
||||
appointment.getAppointmentStatus(),
|
||||
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
|
||||
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
|
||||
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
|
||||
item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode(), item.getCustomerId())).toList(),
|
||||
appointment.getAppointmentEndAt(), appointment.getVersion());
|
||||
}
|
||||
|
||||
private static void validateWindow(LocalDateTime start, LocalDateTime end) {
|
||||
if (start == null || (end != null && !end.isAfter(start))) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "预约结束时间必须晚于开始时间");
|
||||
private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows) {
|
||||
String status=status(details.appointmentStatus());
|
||||
String timezone=sources.destinationTimezone(appointment.getFbaWarehouseCode());
|
||||
if(details.plannedAt()==null && details.plannedEndAt()!=null || details.plannedAt()!=null && details.plannedEndAt()!=null && !details.plannedEndAt().isAfter(details.plannedAt())) throw bad("计划结束时间必须晚于开始时间");
|
||||
if(details.plannedDate()!=null && details.plannedAt()!=null && !details.plannedDate().equals(details.plannedAt().toLocalDate())) throw bad("计划日期与开始时段不一致,请核对");
|
||||
appointment.setPlannedDate(details.plannedAt()==null?details.plannedDate():details.plannedAt().toLocalDate());
|
||||
appointment.setPlannedAt(details.plannedAt());appointment.setPlannedEndAt(details.plannedEndAt());appointment.setTimezone(timezone);
|
||||
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(clean(details.appointmentRemark()));
|
||||
if("SUCCESS".equals(status)) {
|
||||
if(clean(details.confirmationNo())==null || details.appointmentAt()==null) throw bad("登记对方确认时请填写外部约号和确认时间");
|
||||
var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
|
||||
if(ids.size()!=rows.size() || rows.stream().anyMatch(s->s.packages()==null || s.packages()<=0) || queryMapper.countMissingShipmentReferences(ids)>0) throw bad("正式确认前请核实货量并补齐真实FBA货件号或追踪号");
|
||||
final AppointmentTimePolicy.Window window;
|
||||
try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));}
|
||||
catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());}
|
||||
appointment.setConfirmationNo(details.confirmationNo().trim());appointment.setAppointmentAt(details.appointmentAt());appointment.setAppointmentEndAt(details.appointmentEndAt());
|
||||
appointment.setAppointmentStartUtc(LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));appointment.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC));
|
||||
} else {
|
||||
if(clean(details.confirmationNo())!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("待确认安排请填写计划时间;取得对方确认后再登记外部约号和确认时段");
|
||||
appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeStatus(String value) {
|
||||
String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT);
|
||||
try {
|
||||
return FbaAppointmentStatus.valueOf(normalized).name();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED");
|
||||
private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous) {
|
||||
var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item));
|
||||
for(var source:rows) {
|
||||
var item=existing.get(source.sourceKey());boolean fresh=item==null;
|
||||
if(fresh){item=new InboundFbaAppointmentItem();item.setId(IdWorker.getId());item.setAppointmentId(appointment.getId());item.setCreatedAt(now());}
|
||||
item.setSourceKey(source.sourceKey());item.setSourceVersion(source.sourceVersion());item.setCustomerId(source.customerId());item.setOrderId(source.orderId());item.setInboundCode(source.inboundCode());
|
||||
item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(source.packages());item.setActive(true);
|
||||
try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);}
|
||||
catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredTrimmed(String value, String message) {
|
||||
String normalized = trimToNull(value);
|
||||
if (normalized == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
return normalized;
|
||||
private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier<FbaAppointmentResponse> action) {
|
||||
if(clean(operator)==null || clean(key)==null || key.length()>128) throw bad("缺少操作人或有效请求标识");
|
||||
final String hash;
|
||||
try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+json.writeValueAsString(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
|
||||
catch(Exception error){throw new IllegalStateException("无法记录预约命令",error);}
|
||||
var existing=commands.find(key);
|
||||
if(existing!=null)return replay(existing,hash);
|
||||
try {commands.begin(key,hash,operation,operator,now());}
|
||||
catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);}
|
||||
var response=action.get();
|
||||
try {if(commands.finish(key,response.appointmentNo(),json.writeValueAsString(response))!=1)throw conflict("预约命令结果未能保存");}
|
||||
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);}
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) {
|
||||
if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对");
|
||||
try {return json.readValue(value.responseJson(),FbaAppointmentResponse.class);}
|
||||
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);}
|
||||
}
|
||||
private InboundFbaAppointment find(String number,boolean lock) {
|
||||
if(clean(number)==null)throw bad("预约单号不能为空");
|
||||
var query=new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getAppointmentNo,number.trim());if(lock)query.last("FOR UPDATE");return appointmentMapper.selectOne(query);
|
||||
}
|
||||
private List<InboundFbaAppointmentItem> items(Long id) {return itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getAppointmentId,id).orderByAsc(InboundFbaAppointmentItem::getId));}
|
||||
private List<FbaAppointmentResponse> responses(List<InboundFbaAppointment> rows) {
|
||||
if(rows.isEmpty())return List.of();
|
||||
var grouped=itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().in(InboundFbaAppointmentItem::getAppointmentId,rows.stream().map(InboundFbaAppointment::getId).toList()).orderByAsc(InboundFbaAppointmentItem::getId)).stream().collect(Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId));
|
||||
return rows.stream().map(row->response(row,grouped.getOrDefault(row.getId(),List.of()))).toList();
|
||||
}
|
||||
private FbaAppointmentResponse response(InboundFbaAppointment row,List<InboundFbaAppointmentItem> items) {
|
||||
return new FbaAppointmentResponse(row.getId(),row.getAppointmentNo(),row.getConfirmationNo(),row.getPlannedDate(),row.getPlannedAt(),row.getPlannedEndAt(),row.getAppointmentAt(),row.getAppointmentEndAt(),
|
||||
utc(row.getAppointmentStartUtc()),utc(row.getAppointmentEndUtc()),row.getTimezone(),row.getAppointmentStatus(),row.getAppointmentRemark(),row.getOriginWarehouseId(),row.getFbaWarehouseCode(),row.getSelectedCount(),row.getCreatedBy(),row.getCreatedAt(),row.getUpdatedAt(),
|
||||
items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount())).toList(),row.getVersion());
|
||||
}
|
||||
private static String clean(String value){return value==null || value.isBlank()?null:value.trim();}
|
||||
private static String status(String value){try{return FbaAppointmentStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)).name();}catch(RuntimeException error){throw bad("请选择有效预约状态");}}
|
||||
private static ZoneOffset offset(String value){return clean(value)==null?null:ZoneOffset.of(value);}
|
||||
private static Instant utc(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC);}
|
||||
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);}
|
||||
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
|
||||
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
private static ResponseStatusException missing(){return new ResponseStatusException(HttpStatus.NOT_FOUND,"预约或所选货物不存在");}
|
||||
}
|
||||
|
||||
+1
-1
@@ -407,6 +407,6 @@ public class LocalLooseboardService {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
private static ResponseStatusException notFound() {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, "Local looseboard order not found");
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1651,39 +1651,65 @@ CREATE TABLE IF NOT EXISTS ll_appointment_followup (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
|
||||
appointment_at DATETIME(3) NOT NULL COMMENT '预约时间',
|
||||
appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空',
|
||||
appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号',
|
||||
confirmation_no VARCHAR(128) NULL COMMENT '收货方正式外部确认号',
|
||||
planned_date DATE NULL COMMENT '计划当地日期,可只有日期而无时段',
|
||||
planned_at DATETIME(3) NULL COMMENT '计划开始当地时间,待定可空',
|
||||
planned_end_at DATETIME(3) NULL COMMENT '计划结束当地时间',
|
||||
appointment_at DATETIME(3) NULL COMMENT '对方确认开始当地时间',
|
||||
appointment_end_at DATETIME(3) NULL COMMENT '对方确认结束当地时间',
|
||||
appointment_start_utc DATETIME(3) NULL COMMENT '确认开始UTC时间点',
|
||||
appointment_end_utc DATETIME(3) NULL COMMENT '确认结束UTC时间点',
|
||||
timezone VARCHAR(64) NULL COMMENT '目的仓权威IANA时区,未维护不得确认',
|
||||
origin_warehouse_id VARCHAR(64) NOT NULL COMMENT '实际起运仓',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '并发版本',
|
||||
expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间',
|
||||
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
|
||||
appointment_remark VARCHAR(1000) NULL COMMENT '预约备注',
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
|
||||
selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING安排/SUCCESS对方确认/FAILED/CANCELLED',
|
||||
appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因',
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码',
|
||||
selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '登录操作人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||
KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code)
|
||||
) ENGINE=InnoDB COMMENT='FBA入仓预约单';
|
||||
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
|
||||
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
|
||||
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
appointment_id BIGINT NOT NULL COMMENT '预约单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID',
|
||||
plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
||||
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID',
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
id BIGINT NOT NULL COMMENT '记录主键',
|
||||
appointment_id BIGINT NOT NULL COMMENT '预约主记录标识',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING货源键',
|
||||
source_version VARCHAR(160) NULL COMMENT '保存时核实的来源版本',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本票所属客户',
|
||||
order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空',
|
||||
inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造',
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码',
|
||||
package_count INT NULL COMMENT '所选整票预报箱数,未知保持空',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
|
||||
active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键',
|
||||
active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id),
|
||||
KEY idx_ll_fba_appointment_item_cargo (line_id,appointment_id),
|
||||
KEY idx_ll_fba_appointment_item_plan (plan_id),
|
||||
KEY idx_ll_fba_appointment_item_order (order_id)
|
||||
) ENGINE=InnoDB COMMENT='FBA入仓预约明细';
|
||||
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
|
||||
UNIQUE KEY uk_ll_fba_active_source (active_source_key),
|
||||
UNIQUE KEY uk_ll_fba_active_order (active_order_id),
|
||||
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
|
||||
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fba_appointment_command (
|
||||
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要',
|
||||
operation VARCHAR(96) NOT NULL COMMENT '操作类型及目标业务号',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人',
|
||||
appointment_no VARCHAR(64) NULL COMMENT '系统预约业务单号',
|
||||
response_json LONGTEXT NULL COMMENT '原命令返回快照,与业务写入同事务',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
|
||||
PRIMARY KEY(command_key),
|
||||
KEY idx_ll_fba_command_appointment(appointment_no,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史';
|
||||
|
||||
ALTER TABLE ll_loading_order_status_history
|
||||
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
|
||||
|
||||
@@ -6,6 +6,134 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/appointment-planning/appointments:
|
||||
post:
|
||||
operationId: createAppointmentFromSources
|
||||
summary: 在入仓前按稳定货源保存跨客户预约安排
|
||||
description: >-
|
||||
仅平台;同时需要platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。
|
||||
与入仓明细入口共用同一FBA预约聚合;系统生成AP单号,不创建入仓计划、库存或车次。
|
||||
PENDING允许日期、时段、外部约号及箱数暂缺。SUCCESS必须有权威目的仓时区、真实货件材料、确定箱数及外部约号和确认时段。
|
||||
实际客户、起运仓、目的仓及原BOOKING由服务端核对;活动来源与已形成订单均唯一占用,取消后释放并保留历史。
|
||||
命令与主单、明细同事务;同键同操作人同内容重试返回原快照,不受后来编辑影响。异内容或来源占用冲突拒绝。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sources, originWarehouseId, fbaWarehouseCode, details, idempotencyKey]
|
||||
properties:
|
||||
originWarehouseId: {type: string, minLength: 1, description: 选择货源时的接仓点,保存时重新核对}
|
||||
fbaWarehouseCode: {type: string, minLength: 1, maxLength: 64, description: 选择货源时的目的仓编码,变化则拒绝保存}
|
||||
sources:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
required: [sourceKey, customerId]
|
||||
properties:
|
||||
sourceKey: {type: string, maxLength: 160}
|
||||
customerId: {type: string, maxLength: 128}
|
||||
destinationKey: {type: string}
|
||||
details: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/AppointmentDetails'}
|
||||
idempotencyKey: {type: string, minLength: 1, maxLength: 64}
|
||||
responses:
|
||||
'200': {$ref: './fba-appointment-calendar-v1.yaml#/components/responses/AppointmentResult'}
|
||||
'400': {description: 来源、时段或正式确认材料无效}
|
||||
'403': {description: 权限不足或非平台身份}
|
||||
'404': {description: 来源不存在或客户不匹配}
|
||||
'409': {description: 同键异内容、版本或活动货物占用冲突}
|
||||
'503': {description: 权威仓库目录或时区暂不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/appointments/unscheduled:
|
||||
get:
|
||||
operationId: listUnscheduledAppointments
|
||||
summary: 分页读取没有日期或时段的预约安排
|
||||
description: 仅平台,权限platform:appointment:list;排除取消/失败,不用09点或零点占位。
|
||||
parameters:
|
||||
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
|
||||
responses:
|
||||
'200':
|
||||
description: code=200时data为分页
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}}
|
||||
total: {type: integer}
|
||||
page: {type: integer}
|
||||
size: {type: integer}
|
||||
pages: {type: integer}
|
||||
'403': {description: 无列表权限或非平台身份}
|
||||
/api/platform/v1/last-leg/appointment-planning/batches/preview:
|
||||
post:
|
||||
operationId: previewAppointmentBatch
|
||||
summary: 核对跨客户同仓货源合批资格
|
||||
description: >-
|
||||
只读核对,不保存预约安排、不占货、不确认预约。仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
最多100票,批量查询货源、实际开船节点和美国仓目录,支持ORDER与已确认承运BOOKING混合;同一订单重复别名拒绝。
|
||||
必须同美国起运仓及同美国FBA目的仓;暂扣、多目的地、已有有效预约或已取消完成货源拒绝;未知箱数保留待核实,不填0。
|
||||
未建入仓计划及尚未来货的已确认BOOKING不伪造计划、库存或入仓码;正式确认前必须核实箱数。
|
||||
未接入权威备妥窗口时返回readinessIncomplete及READY_WINDOW_UNVERIFIED,不拿到港/到仓预测代替。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
required: [sourceKey, customerId]
|
||||
properties:
|
||||
sourceKey: {type: string, maxLength: 160}
|
||||
customerId: {type: string, maxLength: 128}
|
||||
destinationKey: {type: string, description: 可选预期目的地;存在时必须与来源一致}
|
||||
responses:
|
||||
'200':
|
||||
description: code=200时data为本次核对结果,不是已保存单据
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
originWarehouseName: {type: string}
|
||||
destinationWarehouseCode: {type: string}
|
||||
sources: {type: array, items: {$ref: '#/components/schemas/PlanningSource'}}
|
||||
missingFields: {type: array, items: {type: string}}
|
||||
group:
|
||||
type: object
|
||||
properties:
|
||||
originWarehouseId: {type: string}
|
||||
destinationKey: {type: string}
|
||||
packages: {type: integer, nullable: true}
|
||||
unknownQuantitySources: {type: integer}
|
||||
sourceCount: {type: integer}
|
||||
customerCount: {type: integer}
|
||||
readyFrom: {type: string, format: date-time, nullable: true}
|
||||
readyTo: {type: string, format: date-time, nullable: true}
|
||||
readinessIncomplete: {type: boolean}
|
||||
'400': {description: 来源重复、跨仓、数量不明或不满足合批资格}
|
||||
'403': {description: 权限不足或非平台身份}
|
||||
'404': {description: 来源与客户不匹配}
|
||||
'503': {description: 美国仓目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/containers:
|
||||
get:
|
||||
operationId: appointmentPlanningContainers
|
||||
@@ -152,7 +280,7 @@ paths:
|
||||
description: >-
|
||||
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
依据真实客户、货源与美国仓目录核对来源,URL参数不能授予预约资格。
|
||||
ORDER仅接受已开船头程FBA货;BOOKING保留原登记身份,确认承运后尚未生成入仓单也能跟进。
|
||||
ORDER接受已开船头程FBA货和没有外接登记的真实仓储转运;有原登记必须使用BOOKING稳定身份,确认承运后尚未生成入仓单也能跟进。
|
||||
此查询不建单、不确认预约。当前覆盖读取已有FBA预约,尚未生成货物时返回SOURCE_NOT_MATERIALIZED。
|
||||
parameters:
|
||||
- {name: sourceKey, in: query, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER:<内部关联ID>或BOOKING:<登记号>,非用户展示编号'}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 跨客户 FBA 预约与日历
|
||||
version: 1.0.0
|
||||
description: 按同一目的FBA仓合并多个客户的货物,预约号全局唯一。货物明细保留客户归属,只有平台可读取或维护完整预约;不改变库存与客户订单归属。
|
||||
version: 2.0.0
|
||||
description: 同一美国起运仓和目的FBA仓的跨客户安排。系统单号与外部约号分开;入仓前后共用稳定货源,只有平台可维护完整预约,不推进库存或现场作业。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/inbound-plans/fba-appointments/batch:
|
||||
post:
|
||||
summary: 为同一目的仓的跨客户入仓明细创建预约
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台身份限定。客户归属由服务端入仓计划确定,不接受预约主单customerId;同号同内容重放返回原预约,异内容拒绝。已有有效预约的货物不能重复建约。'
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台身份限定。入仓明细仅作选择入口,服务端转成原ORDER/BOOKING整票来源,不另建别名。系统生成appointmentNo,不接受主单客户或外部号冒充系统单号。同键、同操作者、同内容返回原命令快照;异内容拒绝。与入仓前货源入口共用同一主表及唯一占用约束。'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -18,10 +18,11 @@ paths:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/AppointmentWrite'}
|
||||
- type: object
|
||||
required: [lineIds, appointmentNo]
|
||||
required: [lineIds, originWarehouseId, fbaWarehouseCode]
|
||||
properties:
|
||||
lineIds: {type: array, minItems: 1, maxItems: 500, items: {type: string}}
|
||||
appointmentNo: {type: string, maxLength: 64}
|
||||
originWarehouseId: {type: string, minLength: 1, description: 选择时的接仓点,保存时核对}
|
||||
fbaWarehouseCode: {type: string, minLength: 1, maxLength: 64, description: 选择时的目的仓,变化则拒绝}
|
||||
lineIds: {type: array, minItems: 1, maxItems: 100, items: {type: string}}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/AppointmentResult'}
|
||||
'403': {description: 非平台身份或无写权限}
|
||||
@@ -32,8 +33,8 @@ paths:
|
||||
required: true
|
||||
schema: {type: string}
|
||||
delete:
|
||||
summary: 删除未关联有效排车的预约
|
||||
description: '平台专用,权限platform:appointment:delete。校验当前版本后原子删除预约及预约明细,不删除客户货物。已有关联有效排车时拒绝。'
|
||||
summary: 取消未关联有效排车的预约并释放占用
|
||||
description: '平台专用,权限platform:appointment:delete。校验版本后保留主单、确认事实及明细历史,设置CANCELLED并释放货源占用;不删除客户货物或回退库存。已有关联有效排车时拒绝。取消命令按系统单号、版本和操作者幂等。'
|
||||
parameters:
|
||||
- {name: version, in: query, required: true, schema: {type: integer, minimum: 0}}
|
||||
responses:
|
||||
@@ -47,7 +48,7 @@ paths:
|
||||
'403': {description: 无读取权限}
|
||||
put:
|
||||
summary: 按版本更新预约
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本冲突拒绝;取消不删除货物。恢复时重新校验货物是否已被其他有效预约占用。'
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本及持久化幂等校验;已有有效排车时先处理运输安排。取消/失败必须写原因并保留原确认时间。恢复时重新核对来源、目的仓和占用;不能直接将SUCCESS退回PENDING。'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -66,7 +67,7 @@ paths:
|
||||
get:
|
||||
operationId: fbaAppointmentCalendar
|
||||
summary: 按日期范围查询 FBA 预约
|
||||
description: '权限:platform:appointment:list。仅平台身份可读取跨客户预约。固定批量查询预约及关联明细,不逐预约查详情。'
|
||||
description: '权限:platform:appointment:list。按确认当地时间、计划时段或仅计划日期选取,排除取消/失败。仅有日期时不补造零点时段;无日期记录走未定时分页入口。固定批量查询预约及明细,不逐预约查详情。跨时区总览仍需按地点标注,不将当地值当成浏览器时区。'
|
||||
parameters:
|
||||
- name: from
|
||||
in: query
|
||||
@@ -110,22 +111,40 @@ components:
|
||||
schemas:
|
||||
AppointmentWrite:
|
||||
type: object
|
||||
required: [appointmentAt, expectedLoadingAt, appointmentStatus]
|
||||
required: [details, idempotencyKey]
|
||||
properties:
|
||||
appointmentAt: {type: string, description: 预约开始日期时间}
|
||||
details: {$ref: '#/components/schemas/AppointmentDetails'}
|
||||
idempotencyKey: {type: string, minLength: 1, maxLength: 64}
|
||||
AppointmentDetails:
|
||||
type: object
|
||||
required: [appointmentStatus]
|
||||
properties:
|
||||
plannedDate: {type: string, format: date, nullable: true, description: 计划当地日期,允许未定}
|
||||
plannedAt: {type: string, nullable: true, description: 已知的计划开始当地时段,日期必须与plannedDate一致}
|
||||
plannedEndAt: {type: string, nullable: true}
|
||||
confirmationNo: {type: string, maxLength: 128, nullable: true, description: 仅SUCCESS登记收货方的外部约号;同一FBA仓不得重复}
|
||||
appointmentAt: {type: string, nullable: true, description: 对方确认开始当地时间;SUCCESS必填,PENDING不能当作计划填写}
|
||||
appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算}
|
||||
expectedLoadingAt: {type: string}
|
||||
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]}
|
||||
appointmentRemark: {type: string, maxLength: 1000, nullable: true}
|
||||
startOffset: {type: string, maxLength: 6, nullable: true, description: 当地重叠时刻的明确时差;必须符合目的仓IANA时区}
|
||||
endOffset: {type: string, maxLength: 6, nullable: true}
|
||||
Appointment:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string, description: 内部标识,不作为展示编号}
|
||||
appointmentNo: {type: string}
|
||||
appointmentAt: {type: string, description: 业务日期时间,不含时区偏移}
|
||||
appointmentNo: {type: string, description: 系统生成AP业务单号}
|
||||
confirmationNo: {type: string, nullable: true, description: 对方正式外部确认号}
|
||||
plannedDate: {type: string, format: date, nullable: true}
|
||||
plannedAt: {type: string, nullable: true}
|
||||
plannedEndAt: {type: string, nullable: true}
|
||||
appointmentAt: {type: string, nullable: true, description: 对方确认的地点当地时间}
|
||||
appointmentEndAt: {type: string, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
expectedLoadingAt: {type: string}
|
||||
appointmentStartUtc: {type: string, format: date-time, nullable: true}
|
||||
appointmentEndUtc: {type: string, format: date-time, nullable: true}
|
||||
timezone: {type: string, nullable: true, description: 来自美国FBA仓主档的IANA时区;缺失不得正式确认}
|
||||
originWarehouseId: {type: string}
|
||||
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]}
|
||||
appointmentRemark: {type: string, nullable: true}
|
||||
fbaWarehouseCode: {type: string}
|
||||
@@ -140,8 +159,9 @@ components:
|
||||
properties:
|
||||
id: {type: string}
|
||||
customerId: {type: string, description: 本票货物客户归属,不在预约主单指定客户}
|
||||
planId: {type: string}
|
||||
lineId: {type: string}
|
||||
orderId: {type: string}
|
||||
inboundCode: {type: string}
|
||||
sourceKey: {type: string}
|
||||
sourceVersion: {type: string, nullable: true}
|
||||
orderId: {type: string, nullable: true}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
packageCount: {type: integer, nullable: true}
|
||||
fbaWarehouseCode: {type: string}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentBatchPreviewMapperTest {
|
||||
@Test void flagsUseRealCustomerOwnershipAndIgnoreCancelledAppointments() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:batch-preview-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT, customer_id VARCHAR(32),detained BOOLEAN,fba_warehouse_code VARCHAR(32))");
|
||||
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(32),destination_warehouse_code VARCHAR(32))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR(32))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A',FALSE,'ONT8'),(2,'B',TRUE,'ONT8'),(3,'C',FALSE,'ONT8')");
|
||||
s.execute("INSERT INTO ll_order_detail VALUES(1,'OTHER','LAX9'),(2,'B','ONT8'),(3,'C','LAX9')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(10,'CANCELLED'),(11,'SUCCESS')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10),(1,'OTHER',11),(3,'C',11)");
|
||||
}
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentBatchPreviewMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var flags=session.getMapper(AppointmentBatchPreviewMapper.class).flags(List.of(1L,2L,3L)).stream().collect(java.util.stream.Collectors.toMap(AppointmentBatchPreviewMapper.Flags::orderId,r->r));
|
||||
assertFalse(flags.get(1L).mixedDestinations());assertFalse(flags.get(1L).activeAppointment());
|
||||
assertTrue(flags.get(2L).detained());assertTrue(flags.get(3L).mixedDestinations());assertTrue(flags.get(3L).activeAppointment());
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentBatchPreviewServiceTest {
|
||||
private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) {
|
||||
return new AppointmentSourceService.Source(key,customer,orderId,null,null,null,origin,"美国仓",destination,destination.substring(4),10,"1",true,List.of());
|
||||
}
|
||||
@Test void firstLegAndUnmaterializedExternalCargoCanBeCheckedTogetherAcrossCustomers() {
|
||||
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","BOOKING:B-1","FBA:ONT8"));
|
||||
when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8"),source("BOOKING:B-1","B",null,"100","FBA:ONT8")));
|
||||
when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false)));
|
||||
var preview=new AppointmentBatchPreviewService(sources,mapper).preview(refs);
|
||||
assertEquals(2,preview.group().customerCount());assertEquals(20,preview.group().packages());
|
||||
assertTrue(preview.group().readinessIncomplete());assertNull(preview.group().readyFrom());
|
||||
assertEquals(List.of("READY_WINDOW_UNVERIFIED"),preview.missingFields());
|
||||
assertNull(preview.sources().get(1).orderId());
|
||||
verify(sources).resolveBatch(refs);verify(mapper).flags(List.of(1L));verifyNoMoreInteractions(sources,mapper);
|
||||
}
|
||||
@Test void missingRowsDetentionMixedDestinationsAndPriorAppointmentsBlockGrouping() {
|
||||
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
|
||||
when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8")));
|
||||
var service=new AppointmentBatchPreviewService(sources,mapper);
|
||||
for(var flags:List.of(List.<AppointmentBatchPreviewMapper.Flags>of(),List.of(new AppointmentBatchPreviewMapper.Flags(1L,true,false,false)),
|
||||
List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)),List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)))) {
|
||||
when(mapper.flags(List.of(1L))).thenReturn(flags);
|
||||
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
@Test void groupingDifferentOriginsIsRejectedEvenWhenEveryIndividualSourceIsValid() {
|
||||
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","BOOKING:B","FBA:ONT8"));
|
||||
when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8"),source("BOOKING:B","B",null,"200","FBA:ONT8")));
|
||||
assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper).preview(refs));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+19
-20
@@ -11,31 +11,30 @@ import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentCoverageMapperTest {
|
||||
@Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception {
|
||||
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
|
||||
@Test void coverageUsesStableSourcesBeforePlansAndTransferDestinationComesFromTheRealTransfer() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),order_type VARCHAR(32),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,lastleg_order_id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(128),fba_shipment_no VARCHAR(128),tracking_no VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP,confirmation_no VARCHAR(128),timezone VARCHAR(64))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),source_key VARCHAR(160),source_version VARCHAR(160),package_count INT,fba_warehouse_code VARCHAR(128),active BOOLEAN)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','FIRST_LEG','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FIRST_LEG','FBA','ONT8',8,1,CURRENT_TIMESTAMP),(3,'C','WAREHOUSE_TRANSFER',NULL,'ACY2',1,0,CURRENT_TIMESTAMP)");
|
||||
s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)");
|
||||
s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)");
|
||||
s.execute("INSERT INTO ll_warehouse_transfer_order VALUES(30,3,'C','FBA','ACY2','SHIP-C',NULL)");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'AP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-1','America/Los_Angeles'),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-2','America/Los_Angeles')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A','ORDER:1','ORDER:1',20,'ONT8',TRUE),(2,1000,2,'B','ORDER:2','ORDER:1',8,'ONT8',TRUE),(3,2000,1,'A','ORDER:1','ORDER:1',20,'ONT8',FALSE),(4,1000,NULL,'D','BOOKING:B-1','BOOKING:1',NULL,'ONT8',TRUE)");
|
||||
}
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class);
|
||||
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentCoverageMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var mapper=session.getMapper(AppointmentCoverageMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L));
|
||||
var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages());
|
||||
var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages());
|
||||
var source=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("A",1L))).get(0);assertFalse(source.shipmentReferencesMissing());assertEquals(20,source.packages());
|
||||
var facts=mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));assertEquals(1,facts.size());assertEquals("EXT-1",facts.get(0).confirmationNo());assertEquals(20,facts.get(0).packages());
|
||||
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty());
|
||||
assertTrue(mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).isEmpty());
|
||||
var external=mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("D","BOOKING:B-1","FBA:ONT8"))).get(0);assertNull(external.orderId());assertNull(external.packages());
|
||||
var transfer=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("C",3L))).get(0);assertEquals("FBA",transfer.destinationType());assertEquals("ACY2",transfer.fbaWarehouseCode());assertFalse(transfer.shipmentReferencesMissing());
|
||||
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing());
|
||||
try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); }
|
||||
session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -13,15 +13,15 @@ class AppointmentCoverageServiceTest {
|
||||
return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false);
|
||||
}
|
||||
private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) {
|
||||
return new AppointmentCoverageMapper.Fact(1L,"A","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8");
|
||||
return new AppointmentCoverageMapper.Fact(1L,"A","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles");
|
||||
}
|
||||
@Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() {
|
||||
@Test void completeQuantitySharesOneAppointmentWithItsActualConfirmationAndTimezone() {
|
||||
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
|
||||
when(mapper.sources(anyList())).thenReturn(List.of(order()));
|
||||
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS")));
|
||||
var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
|
||||
assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size());
|
||||
assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
|
||||
assertEquals("America/Los_Angeles",result.appointments().get(0).timezone()); assertEquals("EXT-1",result.appointments().get(0).confirmationNo());assertFalse(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
|
||||
assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway);
|
||||
}
|
||||
@Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() {
|
||||
@@ -40,9 +40,9 @@ class AppointmentCoverageServiceTest {
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8"));
|
||||
var service=new AppointmentCoverageService(mapper,gateway);
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L)));
|
||||
assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper);
|
||||
assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verify(mapper,never()).sources(anyList());
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L)));
|
||||
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
|
||||
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","BOOKING:B-1","BOOKING:2/ORDER:2","EXT-1","America/Los_Angeles")));
|
||||
var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status());
|
||||
assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))));
|
||||
}
|
||||
|
||||
+29
@@ -16,6 +16,31 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentPlanningAuthorizationTest {
|
||||
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
|
||||
@Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentSourceCreateController.class);
|
||||
var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")),
|
||||
"100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key");
|
||||
login(LIST,CARGO,WRITE);
|
||||
assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request));
|
||||
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
|
||||
assertThrows(ResponseStatusException.class,()->controller.create(user(1L,"CLIENT"),request));
|
||||
assertDoesNotThrow(()->controller.create(user(0L,"PLATFORM"),request));
|
||||
login(LIST);assertDoesNotThrow(()->controller.unscheduled(user(0L,"PLATFORM"),1,20));
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Test void batchPreviewIsReadOnlyButRequiresBothListPermissionsAndPlatformIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentBatchPreviewController.class);
|
||||
var request=new AppointmentBatchPreviewController.Request(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));
|
||||
login(LIST);
|
||||
assertThrows(AccessDeniedException.class,()->controller.preview(user(0L,"PLATFORM"),request));
|
||||
login(LIST,CARGO);
|
||||
assertThrows(ResponseStatusException.class,()->controller.preview(user(1L,"CLIENT"),request));
|
||||
controller.preview(user(0L,"PLATFORM"),request);
|
||||
verify(context.getBean(AppointmentBatchPreviewService.class)).preview(request.sources());
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void containerReadsAndWritesRequirePlatformIdentityAndSeparatePermissions() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentContainerController.class);
|
||||
@@ -65,6 +90,10 @@ class AppointmentPlanningAuthorizationTest {
|
||||
private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); }
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
@Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); }
|
||||
@Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); }
|
||||
@Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); }
|
||||
@Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); }
|
||||
@Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); }
|
||||
@Bean AppointmentContainerController containerController(AppointmentContainerService containers,AppointmentFollowupService followups) { return new AppointmentContainerController(containers,followups); }
|
||||
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
|
||||
|
||||
+4
-2
@@ -41,8 +41,8 @@ class AppointmentPlanningPolicyTest {
|
||||
@Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() {
|
||||
var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null);
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first)));
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
|
||||
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null))));
|
||||
var unknown=AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null)));
|
||||
assertNull(unknown.packages());assertEquals(1,unknown.unknownQuantitySources());
|
||||
}
|
||||
|
||||
@Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() {
|
||||
@@ -74,6 +74,8 @@ class AppointmentPlanningPolicyTest {
|
||||
var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null);
|
||||
assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end());
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "UTC-08:00", null, null));
|
||||
assertEquals(Instant.parse("2026-11-01T08:30:00Z"),AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-11-01T01:30:00"),null,"America/Phoenix",null,null).start());
|
||||
}
|
||||
|
||||
@Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() {
|
||||
|
||||
+17
-1
@@ -12,6 +12,22 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentSourceServiceTest {
|
||||
@Test void batchResolvesTwoCustomersWithOneCatalogAndOneOrderAndShippingQuery() {
|
||||
var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);
|
||||
var gateway=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
|
||||
var a=new LastlegOrder();a.setId(1L);a.setCustomerId("A");a.setOrderType("FIRST_LEG");a.setSourceContainerBookingId("C1");a.setOverseasWarehouseId("100");a.setDestinationType("FBA");a.setFbaWarehouseCode("ONT8");a.setPackageCount(10);
|
||||
var b=new LastlegOrder();b.setId(2L);b.setCustomerId("B");b.setOrderType("FIRST_LEG");b.setSourceContainerBookingId("C1");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20);
|
||||
when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(a,b));
|
||||
var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40);
|
||||
when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate));
|
||||
var service=new AppointmentSourceService(orders,shipping,gateway,warehouse,"token");
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8"));
|
||||
var result=service.resolveBatch(refs);assertEquals(2,result.size());assertEquals(20,result.get(1).packages());
|
||||
verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(warehouse,times(1)).catalog("token");verify(gateway,times(1)).resolveOrders(anyList());verifyNoMoreInteractions(gateway);
|
||||
assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(refs.get(0),refs.get(0))));
|
||||
assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8"))));
|
||||
}
|
||||
@Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() {
|
||||
var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
|
||||
@@ -21,7 +37,7 @@ class AppointmentSourceServiceTest {
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L)));
|
||||
var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders);
|
||||
var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8");
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order));
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L)));
|
||||
var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode());
|
||||
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9"));
|
||||
|
||||
+45
@@ -14,6 +14,51 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
@Test
|
||||
void confirmedAppointmentUsesOriginalCustomerOrderAndDestinationAndNeverInventsLoadingTime() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-confirmed-appointment;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT, customer_id VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT, plan_id BIGINT,destination_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,fba_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_at TIMESTAMP,planned_at TIMESTAMP,appointment_status VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR,active INT,fba_warehouse_code VARCHAR)");
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES ("+i+",'9')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES ("+i+","+i+",'ACY2')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_line VALUES ("+i+","+i+",'9',"+i+",'OLD')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES ("+i+","+i+",'9')");
|
||||
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES ("+i+",'2026-10-12 10:00:00','2026-10-11 09:00:00','SUCCESS','ACY2')");
|
||||
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES ("+i+","+i+",'9',1,'ACY2')");
|
||||
}
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=2");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET active=0 WHERE appointment_id=3");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET customer_id='OTHER' WHERE appointment_id=4");
|
||||
statement.execute("UPDATE ll_inbound_plan_pallet SET destination_warehouse_code='ONT8' WHERE id=5");
|
||||
statement.execute("UPDATE ll_inbound_plan_line SET customer_id='OTHER' WHERE id=6");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_at=NULL WHERE id=7");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET fba_warehouse_code='ONT8' WHERE appointment_id=8");
|
||||
try (var rows = statement.executeQuery("SELECT p.id," + DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT
|
||||
+ " AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id ORDER BY p.id")) {
|
||||
int count = 0;
|
||||
while (rows.next()) {
|
||||
count++;
|
||||
assertEquals(count == 1 ? java.sql.Timestamp.valueOf("2026-10-12 10:00:00") : null, rows.getTimestamp("appointment_at"));
|
||||
}
|
||||
assertEquals(8, count);
|
||||
}
|
||||
for (Method method : List.of(
|
||||
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class),
|
||||
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains(DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt"));
|
||||
assertFalse(sql.contains("expected_loading_at"));
|
||||
assertFalse(sql.contains("fi.line_id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalTransferCreationModeDoesNotGrantReservationOwnership() throws Exception {
|
||||
for (Method method : List.of(
|
||||
|
||||
+49
@@ -47,5 +47,54 @@ class FleetPlanningReadPersistenceTest {
|
||||
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull();
|
||||
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10);
|
||||
assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null);
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64))");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(501,'9'),(601,'9'),(701,'OTHER'),(801,'9')");
|
||||
jdbc.update("INSERT INTO ll_fleet_pool_order_link VALUES(5,'9',501,'LB-5','WAITING_INBOUND'),(6,'9',601,'LB-6','RECEIVED'),(6,'9',701,'INVALID','RECEIVED')");
|
||||
var adapter = new FleetAppointmentSourceAdapter(resolver);
|
||||
var bookingRefs = List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "FB-6"),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "FB-5"));
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(bookingRefs)).isInstanceOf(IllegalStateException.class);
|
||||
var transaction = new org.springframework.transaction.support.TransactionTemplate(new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
|
||||
transaction.executeWithoutResult(status -> {
|
||||
adapter.lockForAppointment(bookingRefs);
|
||||
adapter.lockForAppointment(List.of(bookingRefs.get(0), bookingRefs.get(0)));
|
||||
adapter.lockForAppointment(List.of());
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("OTHER", "FB-5"))))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "MISSING"))))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(java.util.Collections.nCopies(101, bookingRefs.get(0))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef(" ", "FB-5"))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
});
|
||||
var lockingMapper = org.mockito.Mockito.mock(FleetPoolOrderMapper.class);
|
||||
org.mockito.Mockito.when(lockingMapper.lockAppointmentSource("FB-5")).thenReturn(new FleetPoolSourceResolver.SourceRef("9", "FB-5"));
|
||||
org.mockito.Mockito.when(lockingMapper.lockAppointmentSource("FB-6")).thenReturn(new FleetPoolSourceResolver.SourceRef("9", "FB-6"));
|
||||
var lockingAdapter = new FleetAppointmentSourceAdapter(new FleetPoolSourceResolver(lockingMapper, new ObjectMapper()));
|
||||
transaction.executeWithoutResult(status -> lockingAdapter.lockForAppointment(List.of(bookingRefs.get(0), bookingRefs.get(1), bookingRefs.get(0))));
|
||||
var lockOrder = org.mockito.Mockito.inOrder(lockingMapper);
|
||||
lockOrder.verify(lockingMapper).lockAppointmentSource("FB-5");
|
||||
lockOrder.verify(lockingMapper).lockAppointmentSource("FB-6");
|
||||
lockOrder.verifyNoMoreInteractions();
|
||||
var refs = List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 501L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 601L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 501L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("OTHER", 601L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 701L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 801L));
|
||||
var resolved = adapter.resolveOrders(refs);
|
||||
assertThat(resolved).extracting(s -> s.bookingNo()).containsExactlyInAnyOrder("FB-5", "FB-6");
|
||||
assertThat(resolved).filteredOn(s -> s.bookingNo().equals("FB-5")).singleElement().satisfies(s -> {
|
||||
assertThat(s.packageCount()).isNull(); assertThat(s.orderId()).isEqualTo(501L);
|
||||
assertThat(s.customerId()).isEqualTo("9"); assertThat(s.destinationKey()).isEqualTo("FBA:ACY2");
|
||||
});
|
||||
assertThat(adapter.resolveOrders(List.of())).isEmpty();
|
||||
assertThat(adapter.resolveOrders(null)).isEmpty();
|
||||
assertThatThrownBy(() -> adapter.resolveOrders(java.util.Collections.nCopies(101, refs.get(0))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> adapter.resolveOrders(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 0L))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_external_booking", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -28,10 +28,11 @@ class FleetPoolOrderBridgeTest {
|
||||
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
|
||||
LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
|
||||
DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence);
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of());
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
@@ -48,6 +49,23 @@ class FleetPoolOrderBridgeTest {
|
||||
LocalDateTime.of(2030,1,1,12,0),"SELF_DELIVERY","客户自送",null,"货物",4,2,
|
||||
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
|
||||
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
|
||||
|
||||
@Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() {
|
||||
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
incoming();
|
||||
var writes=inOrder(links,loose,appointmentSources);
|
||||
writes.verify(links).updateById(row);
|
||||
writes.verify(loose).updateById(looseOrder);
|
||||
writes.verify(appointmentSources).materialized("9","FB-7",77L);
|
||||
incoming();
|
||||
verify(appointmentSources,times(1)).materialized("9","FB-7",77L);
|
||||
}
|
||||
|
||||
@Test void appointmentBindingFailurePropagatesToTheMandatoryIncomingTransaction() {
|
||||
service.confirm(input("US_TRUCK"),"op");
|
||||
doThrow(new IllegalStateException("appointment binding conflict")).when(appointmentSources).materialized("9","FB-7",77L);
|
||||
assertThatThrownBy(this::incoming).isInstanceOf(IllegalStateException.class).hasMessageContaining("appointment binding conflict");
|
||||
}
|
||||
FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of(
|
||||
new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE),
|
||||
new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);}
|
||||
|
||||
+7
-1
@@ -27,9 +27,15 @@ class InboundPlanSchemaContractTest {
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (appointment_no)"));
|
||||
assertTrue(schema.contains("expected_loading_at"));
|
||||
String appointment = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment ("), schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
|
||||
assertFalse(appointment.contains("customer_id"));
|
||||
assertFalse(appointment.contains("expected_loading_at"));
|
||||
assertTrue(appointment.contains("confirmation_no varchar(128) null"));
|
||||
assertTrue(appointment.contains("planned_date date null"));
|
||||
assertTrue(appointment.contains("appointment_start_utc datetime(3) null"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_active_source (active_source_key)"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_active_order (active_order_id)"));
|
||||
assertTrue(schema.contains("create table if not exists ll_fba_appointment_command"));
|
||||
String items = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
|
||||
assertTrue(items.substring(0, items.indexOf("engine=innodb")).contains("customer_id varchar(128) not null"));
|
||||
assertTrue(schema.contains("planned_arrival_date"));
|
||||
|
||||
+2
-3
@@ -55,12 +55,11 @@ class InboundPlanControllerTest {
|
||||
@Test
|
||||
void platformAppointmentUpdateUsesSharedAppointmentNumber() {
|
||||
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest(
|
||||
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
|
||||
"PENDING", "dock", null, 0);
|
||||
new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,LocalDateTime.of(2026,8,20,10,30),null,null,null,null,"PENDING","dock",null,null),0,"update-key");
|
||||
|
||||
controller.updateFbaAppointment(platform(), "APT-1", request);
|
||||
|
||||
verify(appointmentService).update("APT-1", request);
|
||||
verify(appointmentService).update("7", "APT-1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+4
-4
@@ -25,8 +25,8 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,plan_no VARCHAR(50),customer_id VARCHAR(20),overseas_warehouse_id BIGINT,assigned_flag BOOLEAN,merge_required BOOLEAN,urgent_flag BOOLEAN,status VARCHAR(30),created_at TIMESTAMP,version INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,order_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20),fba_warehouse_code VARCHAR(30),active BOOLEAN)");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
@@ -83,8 +83,8 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(4,'LIP-2','B',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(5,4,6,'IN-2','91','ABE4','ABE4','ABE4',1,1,1)");
|
||||
db.update("INSERT INTO ll_order VALUES(6,'B','LX-2','FIRST_LEG','LA','洛杉矶仓','客户B')");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,2,7,'A'),(9,5,7,'B')");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP)");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,3,7,'A','ABE4',TRUE),(9,6,7,'B','ABE4',TRUE)");
|
||||
assertThat(mapper.rows(null, query(null), 0, 20)).hasSize(2)
|
||||
.extracting(InboundPlanListResponse::appointmentNo).containsOnly("APT-SHARED");
|
||||
for (String customer : new String[]{"A", "B"}) {
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import javax.sql.DataSource;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FbaAppointmentPersistenceTest {
|
||||
interface OrderVersionProbe {
|
||||
@org.apache.ibatis.annotations.Select("SELECT version FROM ll_order WHERE id=#{id}")
|
||||
int version(long id);
|
||||
}
|
||||
@Test void lockingOrdersInvalidatesFactsReadBeforeTheLock() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var transaction=new org.springframework.transaction.support.TransactionTemplate(context.getBean(DataSourceTransactionManager.class));
|
||||
transaction.execute(status->{
|
||||
var probe=context.getBean(SqlSessionTemplate.class).getMapper(OrderVersionProbe.class);
|
||||
assertEquals(0,probe.version(1L));
|
||||
new JdbcTemplate(context.getBean(DataSource.class)).update("UPDATE ll_order SET version=1 WHERE id=1");
|
||||
assertEquals(0,probe.version(1L));
|
||||
context.getBean(InboundFbaAppointmentQueryMapper.class).lockOrders(List.of(1L));
|
||||
assertEquals(1,probe.version(1L));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
private static List<AppointmentSourceService.Source> cargo(Integer secondPackages) {
|
||||
return List.of(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",10,"ORDER:1",true,List.of()),
|
||||
new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓","FBA:ONT8","ONT8",secondPackages,"ORDER:1",true,List.of()));
|
||||
}
|
||||
private static SourceFbaAppointmentRequest request(String key) {
|
||||
return new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")),
|
||||
"100","ONT8",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),key);
|
||||
}
|
||||
@Test void failureAfterHeaderAndFirstItemRollsBackTheCommandAndEntireAggregate() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);assertTrue(org.springframework.aop.support.AopUtils.isAopProxy(service));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT fail_second_item CHECK(package_count<>13)");
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(cargo(13));
|
||||
assertThrows(RuntimeException.class,()->service.createFromSources("operator",request("retry-after-rollback")));
|
||||
for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command")) assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
|
||||
db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT fail_second_item");
|
||||
var saved=service.createFromSources("operator",request("retry-after-rollback"));
|
||||
assertEquals(saved,service.createFromSources("operator",request("retry-after-rollback")));
|
||||
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
|
||||
assertEquals(1,service.unscheduled(1,20).total());
|
||||
}
|
||||
}
|
||||
@Test void concurrentDifferentCommandsCannotReserveTheSameCrossCustomerCargoTwice() throws Exception {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first=executor.submit(()->attempt(service,start,"first"));var second=executor.submit(()->attempt(service,start,"second"));start.countDown();
|
||||
assertEquals(1,(first.get(10,TimeUnit.SECONDS)?1:0)+(second.get(10,TimeUnit.SECONDS)?1:0));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
|
||||
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command",Integer.class));
|
||||
} finally {executor.shutdownNow();}
|
||||
}
|
||||
}
|
||||
@Test void cancellationReleasesGeneratedClaimsWhileReplayKeepsItsOriginalSnapshot() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var first=service.createFromSources("operator",request("first"));
|
||||
service.delete("operator",first.appointmentNo(),0);
|
||||
var second=service.createFromSources("operator",request("second"));assertNotEquals(first.appointmentNo(),second.appointmentNo());
|
||||
assertEquals("CANCELLED",service.detail(first.appointmentNo()).appointmentStatus());
|
||||
assertEquals(first,service.createFromSources("operator",request("first")));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
|
||||
}
|
||||
}
|
||||
@Test void unknownQuantityOnReviewDoesNotSilentlyKeepThePreviousKnownQuantity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var saved=service.createFromSources("operator",request("create"));
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(cargo(null));
|
||||
var updated=service.update("operator",saved.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),0,"review"));
|
||||
assertNull(updated.items().get(1).packageCount());
|
||||
assertNull(new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT package_count FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2'",Integer.class));
|
||||
}
|
||||
}
|
||||
@Test void actualIncomingBindsTheOriginalBookingInItsOwnTransactionWithoutChangingPlannedFacts() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var linker=context.getBean(AppointmentSourceLinker.class);
|
||||
var before=new AppointmentSourceService.Source("BOOKING:B-1","C",null,null,"B-1",null,"100","美国仓","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of());
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(before));
|
||||
var request=new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming");
|
||||
var saved=service.createFromSources("operator",request);
|
||||
assertNull(saved.items().get(0).orderId());
|
||||
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->linker.materialized("C","B-1",100L));
|
||||
var transaction=new org.springframework.transaction.support.TransactionTemplate(context.getBean(DataSourceTransactionManager.class));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
assertThrows(IllegalStateException.class,()->transaction.execute(status->{db.update("INSERT INTO ll_order(id) VALUES(100)");linker.materialized("C","B-1",100L);throw new IllegalStateException("incoming failed after link");}));
|
||||
assertNull(service.detail(saved.appointmentNo()).items().get(0).orderId());
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=100",Integer.class));
|
||||
transaction.execute(status->{db.update("INSERT INTO ll_order(id) VALUES(100)");linker.materialized("C","B-1",100L);linker.materialized("C","B-1",100L);return null;});
|
||||
var linked=service.detail(saved.appointmentNo());assertEquals(100L,linked.items().get(0).orderId());assertEquals("WT-REAL",linked.items().get(0).inboundCode());
|
||||
assertNull(linked.items().get(0).packageCount());assertEquals("BOOKING:1",linked.items().get(0).sourceVersion());assertEquals("PENDING",linked.appointmentStatus());
|
||||
assertEquals(saved,service.createFromSources("operator",request));
|
||||
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
|
||||
}
|
||||
}
|
||||
private static boolean attempt(InboundFbaAppointmentService service,CountDownLatch start,String key) throws Exception {
|
||||
start.await();try{service.createFromSources("operator",request(key));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertEquals(409,error.getStatusCode().value());return false;}
|
||||
}
|
||||
@Configuration @EnableTransactionManagement
|
||||
static class Config {
|
||||
@Bean DataSource dataSource() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:appointment-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
|
||||
String baseline;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
|
||||
try(var c=ds.getConnection();var statement=c.createStatement()) {
|
||||
for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command")) {
|
||||
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0);
|
||||
String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql);
|
||||
}
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0)");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
|
||||
statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))");
|
||||
}return ds;
|
||||
}
|
||||
@Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);}
|
||||
@Bean SqlSessionFactory sessionFactory(DataSource dataSource) {
|
||||
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),dataSource));config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(InboundFbaAppointmentMapper.class);config.addMapper(InboundFbaAppointmentItemMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);config.addMapper(FbaAppointmentCommandMapper.class);
|
||||
config.addMapper(OrderVersionProbe.class);
|
||||
return new MybatisSqlSessionFactoryBuilder().build(config);
|
||||
}
|
||||
@Bean SqlSessionTemplate template(SqlSessionFactory factory){return new SqlSessionTemplate(factory);}
|
||||
@Bean InboundFbaAppointmentMapper appointmentMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentMapper.class);}
|
||||
@Bean InboundFbaAppointmentItemMapper itemMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentItemMapper.class);}
|
||||
@Bean InboundFbaAppointmentQueryMapper queryMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentQueryMapper.class);}
|
||||
@Bean FbaAppointmentCommandMapper commands(SqlSessionTemplate session){return session.getMapper(FbaAppointmentCommandMapper.class);}
|
||||
@Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenReturn(cargo(20));when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;}
|
||||
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false),new AppointmentBatchPreviewMapper.Flags(2L,false,false,false)));return mock;}
|
||||
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
|
||||
@Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);}
|
||||
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
|
||||
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
|
||||
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new AppointmentSourceLinker(orders,items,bookings);}
|
||||
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
|
||||
}
|
||||
}
|
||||
+144
-255
@@ -1,286 +1,175 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.*;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundFbaAppointmentServiceTest {
|
||||
@Mock InboundFbaAppointmentMapper appointmentMapper;
|
||||
@Mock InboundFbaAppointmentItemMapper itemMapper;
|
||||
@Mock InboundFbaAppointmentQueryMapper queryMapper;
|
||||
InboundFbaAppointmentMapper headers;
|
||||
InboundFbaAppointmentItemMapper items;
|
||||
InboundFbaAppointmentQueryMapper query;
|
||||
FbaAppointmentCommandMapper commands;
|
||||
AppointmentSourceService sources;
|
||||
AppointmentBatchPreviewMapper flags;
|
||||
InboundFbaAppointmentService service;
|
||||
final Map<String,AppointmentSourceService.Source> cargo=new LinkedHashMap<>();
|
||||
final List<InboundFbaAppointmentItem> storedItems=new ArrayList<>();
|
||||
final List<InboundFbaAppointment> storedHeaders=new ArrayList<>();
|
||||
final Map<String,FbaAppointmentCommandMapper.Command> journal=new HashMap<>();
|
||||
final LocalDateTime at=LocalDateTime.parse("2026-10-15T09:00:00");
|
||||
@BeforeEach void setup() {
|
||||
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
|
||||
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
|
||||
service=new InboundFbaAppointmentService();
|
||||
ReflectionTestUtils.setField(service,"appointmentMapper",headers);ReflectionTestUtils.setField(service,"itemMapper",items);ReflectionTestUtils.setField(service,"queryMapper",query);
|
||||
ReflectionTestUtils.setField(service,"commands",commands);ReflectionTestUtils.setField(service,"sources",sources);ReflectionTestUtils.setField(service,"flags",flags);ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
|
||||
when(commands.find(anyString())).thenAnswer(c->journal.get(c.getArgument(0)));
|
||||
when(commands.begin(anyString(),anyString(),anyString(),anyString(),any())).thenAnswer(c->{journal.put(c.getArgument(0),new FbaAppointmentCommandMapper.Command(c.getArgument(1),null));return 1;});
|
||||
when(commands.finish(anyString(),anyString(),anyString())).thenAnswer(c->{String key=c.getArgument(0);journal.put(key,new FbaAppointmentCommandMapper.Command(journal.get(key).requestHash(),c.getArgument(2)));return 1;});
|
||||
when(sources.resolveBatch(anyList())).thenAnswer(c->{List<AppointmentCoverageService.SourceRef> refs=c.getArgument(0);return refs.stream().map(r->cargo.get(r.sourceKey())).toList();});
|
||||
when(sources.destinationTimezone(anyString())).thenReturn("America/Los_Angeles");
|
||||
when(query.lockOrders(anyList())).thenAnswer(c->c.getArgument(0));
|
||||
when(flags.flags(anyList())).thenAnswer(c->{List<Long> ids=c.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});
|
||||
when(headers.insert(any(InboundFbaAppointment.class))).thenAnswer(c->{storedHeaders.add(c.getArgument(0));return 1;});
|
||||
when(headers.selectOne(any())).thenAnswer(c->storedHeaders.isEmpty()?null:storedHeaders.get(0));
|
||||
when(headers.update(any(InboundFbaAppointment.class),any())).thenReturn(1);
|
||||
when(items.insert(any(InboundFbaAppointmentItem.class))).thenAnswer(c->{storedItems.add(c.getArgument(0));return 1;});
|
||||
when(items.selectList(any())).thenAnswer(c->List.copyOf(storedItems));
|
||||
add("ORDER:1","A",1L,"100","ONT8",10);add("ORDER:2","B",2L,"100","ONT8",20);
|
||||
}
|
||||
void add(String key,String customer,Long order,String origin,String destination,Integer packages) {
|
||||
cargo.put(key,new AppointmentSourceService.Source(key,customer,order,order==null?null:"IN-"+order,key.startsWith("BOOKING:")?key.substring(8):null,null,origin,"美国仓","FBA:"+destination,destination,packages,"ORDER:1",true,List.of()));
|
||||
}
|
||||
List<AppointmentCoverageService.SourceRef> refs(String... keys) {return Arrays.stream(keys).map(key->new AppointmentCoverageService.SourceRef(cargo.get(key).customerId(),key,cargo.get(key).destinationKey())).toList();}
|
||||
FbaAppointmentDetails pending(String remark) {return new FbaAppointmentDetails(LocalDate.of(2026,10,15),null,null,null,null,null,"PENDING",remark,null,null);}
|
||||
FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(LocalDate.of(2026,10,14),null,null,"EXT-ONT8-1",at,at.plusHours(1),"SUCCESS","已核实",null,null);}
|
||||
SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(refs(sources),"100","ONT8",details,key);}
|
||||
FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));}
|
||||
|
||||
private InboundFbaAppointmentService service;
|
||||
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
|
||||
private final LocalDateTime expectedLoadingAt = LocalDateTime.of(2026, 8, 21, 8, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundFbaAppointmentService();
|
||||
ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper);
|
||||
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
||||
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
||||
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
|
||||
@Test void refusesOriginOrDestinationChangedAfterSelection() {
|
||||
var intent=request("origin-changed",pending(null),"ORDER:1");
|
||||
add("ORDER:1","A",1L,"200","ONT8",10);
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",intent)).getStatusCode().value());
|
||||
add("ORDER:1","A",1L,"100","LAX9",10);
|
||||
var changed=new SourceFbaAppointmentRequest(refs("ORDER:1"),"100","ONT8",pending(null),"destination-changed");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",changed)).getStatusCode().value());
|
||||
verify(headers,never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
||||
assertEquals(true, InboundFbaAppointmentService.class
|
||||
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
|
||||
.getAnnotation(Transactional.class) != null);
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
|
||||
|
||||
var result = service.batchCreate("7", request(List.of(11L, 12L), "APT-1"));
|
||||
|
||||
assertEquals("APT-1", result.appointmentNo());
|
||||
assertEquals("ONT8", result.fbaWarehouseCode());
|
||||
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
||||
assertEquals(2, result.selectedCount());
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(InboundFbaAppointmentItem.class);
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
assertEquals(List.of("CUSTOMER-11", "CUSTOMER-12"), captured.getAllValues().stream()
|
||||
.map(InboundFbaAppointmentItem::getCustomerId).toList());
|
||||
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
||||
@Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() {
|
||||
var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
|
||||
assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus());
|
||||
assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedFbaWarehouses() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void unknownQuantityAndUnmaterializedBookingRemainUnknownInPendingArrangement() {
|
||||
add("BOOKING:B-1","B",null,"100","ONT8",null);
|
||||
var result=service.createFromSources("operator",request("unknown",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"ORDER:1","BOOKING:B-1"));
|
||||
assertNull(result.plannedDate());assertNull(result.items().get(1).orderId());assertNull(result.items().get(1).inboundCode());assertNull(result.items().get(1).packageCount());
|
||||
verify(query,never()).countMissingShipmentReferences(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingSelectedLine() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
@Test void inboundEntryCanonicalizesTheOriginalBookingInsteadOfCreatingAnOrderAlias() {
|
||||
add("BOOKING:B-1","B",2L,"100","ONT8",20);
|
||||
when(query.selectedLines(List.of(11L))).thenReturn(List.of(new FbaAppointmentLineSnapshot(11L,100L,2L,"IN-2","ONT8","B")));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(refs("BOOKING:B-1"));
|
||||
var result=service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L),"100","ONT8",pending(null),"lines"));
|
||||
assertEquals("BOOKING:B-1",result.items().get(0).sourceKey());verify(sources).canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
@Test void mixedOriginsAndDestinationsAreNotSilentlyGrouped() {
|
||||
add("ORDER:2","B",2L,"200","ONT8",20);
|
||||
assertThrows(ResponseStatusException.class,()->create("mixed-origin"));
|
||||
add("ORDER:2","B",2L,"100","LAX9",20);
|
||||
assertThrows(ResponseStatusException.class,()->create("mixed-destination"));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentReplayReturnsExistingResult() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
item.setFbaWarehouseCode("ONT8");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
var result = service.batchCreate("7", request(List.of(11L), "APT-1"));
|
||||
|
||||
assertEquals(99L, result.id());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void missingAndDuplicateInboundSelectionsAreRejected() {
|
||||
assertThrows(ResponseStatusException.class,()->service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L,11L),"100","ONT8",pending(null),"duplicates")));
|
||||
verify(query,never()).selectedLines(anyList());
|
||||
when(query.selectedLines(List.of(11L))).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class,()->service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L),"100","ONT8",pending(null),"missing")));
|
||||
assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentNumberWithDifferentSelectionConflicts() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
|
||||
@Test void exactCreateReplayReturnsOriginalResultEvenAfterLaterEditing() {
|
||||
var request=request("original",pending(null),"ORDER:1","ORDER:2");var first=service.createFromSources("operator",request);
|
||||
var edited=service.update("operator",first.appointmentNo(),new UpdateFbaAppointmentRequest(pending("新备注"),0,"edit"));assertEquals(1,edited.version());
|
||||
assertEquals(first,service.createFromSources("operator",request));verify(headers,times(1)).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentNumberWithDifferentExpectedLoadingTimeConflicts() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", changed));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
@Test void sameKeyWithDifferentCargoDetailsOrOperatorIsRejected() {
|
||||
create("original");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("original",pending(null),"ORDER:1"))).getStatusCode().value());
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("original",pending("different"),"ORDER:1","ORDER:2")));
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("other",request("original",pending(null),"ORDER:1","ORDER:2")));
|
||||
verify(headers,times(1)).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
String json = "{\"lineIds\":[\"9007199254740993\"],"
|
||||
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
||||
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
||||
|
||||
BatchFbaAppointmentRequest request = new ObjectMapper().findAndRegisterModules()
|
||||
.readValue(json, BatchFbaAppointmentRequest.class);
|
||||
|
||||
assertEquals(List.of(9007199254740993L), request.lineIds());
|
||||
assertEquals(expectedLoadingAt, request.expectedLoadingAt());
|
||||
@Test void confirmationNeedsExternalNumberAndLocationTimezone() {
|
||||
var missing=new FbaAppointmentDetails(null,null,null,null,at,null,"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-number",missing,"ORDER:1")));
|
||||
when(sources.destinationTimezone("ONT8")).thenReturn(null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-zone",confirmed(),"ORDER:1")));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesAppointmentDetailsWithoutReplacingSelectedLines() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
||||
|
||||
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
|
||||
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
|
||||
|
||||
assertEquals(loadingAt, result.expectedLoadingAt());
|
||||
assertEquals("PENDING", result.appointmentStatus());
|
||||
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
|
||||
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
|
||||
@Test void confirmationRequiresActualShipmentReferencesAndKnownCargoWithoutFakingMaterialization() {
|
||||
when(query.countMissingShipmentReferences(List.of(1L))).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("missing-materials",confirmed(),"ORDER:1")));
|
||||
add("BOOKING:B-1","B",null,"100","ONT8",10);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("unmaterialized",confirmed(),"BOOKING:B-1")));
|
||||
add("ORDER:2","B",2L,"100","ONT8",null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("unknown",confirmed(),"ORDER:2")));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preventsAssigningTheSameCargoToTwoActiveAppointments() {
|
||||
when(queryMapper.lockLines(List.of(11L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), null)).thenReturn(1L);
|
||||
var exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L), "SECOND-APT")));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void confirmationPersistsExternalNumberAndUtcSeparatelyFromPlannedDate() {
|
||||
var response=service.createFromSources("operator",request("confirm",confirmed(),"ORDER:1"));
|
||||
assertEquals("EXT-ONT8-1",response.confirmationNo());assertNotEquals(response.appointmentNo(),response.confirmationNo());
|
||||
assertEquals(Instant.parse("2026-10-15T16:00:00Z"),response.appointmentStartUtc());assertEquals(LocalDate.of(2026,10,14),response.plannedDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAppointmentCannotBeReactivatedAfterItsCargoWasBookedElsewhere() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setAppointmentStatus("CANCELLED");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), 99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("APT-1",
|
||||
new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt, "SUCCESS", "", null, 0)));
|
||||
verify(queryMapper).lockLines(List.of(11L));
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
@Test void invalidWindowsAndNonexistentDaylightSavingTimeAreRejected() {
|
||||
var invalid=new FbaAppointmentDetails(null,null,null,"EXT",at,at.minusMinutes(1),"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("bad-window",invalid,"ORDER:1")));
|
||||
var gap=new FbaAppointmentDetails(null,null,null,"EXT",LocalDateTime.parse("2026-03-08T02:30:00"),null,"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("dst-gap",gap,"ORDER:1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAppointmentWindowWhoseEndIsNotAfterItsStart() {
|
||||
var request = new BatchFbaAppointmentRequest(List.of(11L), "BAD-WINDOW", appointmentAt,
|
||||
expectedLoadingAt, "PENDING", "", appointmentAt.minusMinutes(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.batchCreate("7", request));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
@Test void duplicateActiveSourceOrDatabaseClaimRaceCannotCreateAnotherAppointment() {
|
||||
when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(1L);
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("occupied")).getStatusCode().value());assertTrue(storedHeaders.isEmpty());
|
||||
when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(0L);
|
||||
when(items.insert(any(InboundFbaAppointmentItem.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("claimed"));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("raced")).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleEditBeforeWritingTheSharedAppointment() {
|
||||
var existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setVersion(3);
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
var request = new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt,
|
||||
"PENDING", "", appointmentAt.plusHours(2), 2);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.update("APT-1", request));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
@Test void staleUpdateAndStaleCancellationDoNotWrite() {
|
||||
var result=create("original");
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending(null),2,"stale-update")));
|
||||
assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),2));
|
||||
verify(headers,never()).update(any(InboundFbaAppointment.class),any());verify(headers,never()).updateById(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cannotDeleteAnAppointmentWhoseGoodsAlreadyHaveAnActiveDispatch() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(queryMapper.countActiveDispatches(99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(appointmentMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
@Test void activeDispatchBlocksChangingOrCancellingItsAppointment() {
|
||||
var result=create("original");when(query.countActiveDispatches(result.id())).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending("change"),0,"blocked-update")));
|
||||
assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),0));assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeleteKeepsAllCargoAssociations() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(appointmentMapper.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(0);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
@Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() {
|
||||
var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"));
|
||||
service.delete("operator",result.appointmentNo(),0);
|
||||
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive());
|
||||
assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1")));
|
||||
when(query.countActiveSources(anyList(),anyList(),eq(result.id()))).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore")));
|
||||
}
|
||||
|
||||
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
|
||||
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock", null);
|
||||
@Test void snowflakeLineSelectorsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
var request=new ObjectMapper().findAndRegisterModules().readValue("{\"lineIds\":[\"9007199254740993\"],\"details\":{\"appointmentStatus\":\"PENDING\"},\"idempotencyKey\":\"key\"}",BatchFbaAppointmentRequest.class);
|
||||
assertEquals(List.of(9007199254740993L),request.lineIds());assertNull(request.details().appointmentAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarRejectsInvalidOrUnboundedDateRangesBeforeQuerying() {
|
||||
LocalDate from = LocalDate.of(2026, 9, 1);
|
||||
for (LocalDate to : List.of(from, from.minusDays(1), from.plusDays(43))) {
|
||||
assertThrows(ResponseStatusException.class, () -> service.calendar(from, to));
|
||||
}
|
||||
verify(appointmentMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCalendarDoesNotQueryAppointmentItems() {
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of());
|
||||
assertEquals(List.of(), service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1)));
|
||||
verify(itemMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarLoadsAllItemsInOneBatchAndKeepsAppointmentAssociation() {
|
||||
var first = appointment(99L, "APT-1", "ONT8");
|
||||
var second = appointment(100L, "APT-2", "LAX9");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setAppointmentId(100L);
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
var result = service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1));
|
||||
assertEquals(2, result.size());
|
||||
assertEquals(0, result.get(0).items().size());
|
||||
assertEquals("IN-11", result.get(1).items().get(0).inboundCode());
|
||||
verify(itemMapper).selectList(any());
|
||||
}
|
||||
|
||||
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba, "CUSTOMER-" + id);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment appointment(Long id, String number, String fba) {
|
||||
InboundFbaAppointment value = new InboundFbaAppointment();
|
||||
value.setId(id);
|
||||
value.setAppointmentNo(number);
|
||||
value.setAppointmentAt(appointmentAt);
|
||||
value.setExpectedLoadingAt(expectedLoadingAt);
|
||||
value.setAppointmentStatus("SUCCESS");
|
||||
value.setAppointmentRemark("dock");
|
||||
value.setFbaWarehouseCode(fba);
|
||||
value.setSelectedCount(1);
|
||||
value.setVersion(0);
|
||||
return value;
|
||||
@Test void calendarRejectsInvalidRangesAndAvoidsPerRowDetailQueries() {
|
||||
var from=LocalDate.of(2026,9,1);for(var to:List.of(from,from.minusDays(1),from.plusDays(43)))assertThrows(ResponseStatusException.class,()->service.calendar(from,to));
|
||||
when(headers.selectList(any())).thenReturn(List.of());assertTrue(service.calendar(from,from.plusDays(30)).isEmpty());verify(items,never()).selectList(any());
|
||||
var one=create("first");when(headers.selectList(any())).thenReturn(List.copyOf(storedHeaders));clearInvocations(items);
|
||||
var calendar=service.calendar(from,from.plusDays(30));assertEquals(one.items(),calendar.get(0).items());verify(items,times(1)).selectList(any());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user