feat(appointments): unify pre-inbound planning and confirmed FBA bookings

This commit is contained in:
2026-10-10 10:38:06 +08:00
parent 24f5ccf7ff
commit c4c1bddeec
54 changed files with 1623 additions and 744 deletions
@@ -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()));
}
}
@@ -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&lt;&gt;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);
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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,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);
}
@@ -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. */
@@ -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));
}
}
@@ -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);}
}
@@ -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,"未找到当前客户的货物来源"); }
}
@@ -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("未填写结束时间,不能指定结束时差");
@@ -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) { }
}
@@ -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(*)&gt;0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)&gt;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 &gt; 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 &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
@@ -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());
}
}
@@ -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) {
@@ -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")
@@ -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);}
}
@@ -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");
@@ -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, "预约由平台统一管理");
}
@@ -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
) {
}
@@ -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
) { }
@@ -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) {
}
}
@@ -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
) { }
@@ -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
) {
}
@@ -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;
@@ -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;
@@ -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);
}
@@ -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&lt;&gt;#{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&lt;&gt;#{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&lt;&gt;#{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("""
@@ -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 &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{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,
@@ -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>")
@@ -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,"预约或所选货物不存在");}
}
@@ -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}
@@ -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());
}
}
}
@@ -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);
}
}
@@ -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());
}
}
}
}
@@ -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"))));
}
@@ -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); }
@@ -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() {
@@ -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"));
@@ -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(
@@ -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);
}
}
@@ -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);}
@@ -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"));
@@ -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
@@ -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"}) {
@@ -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();}
}
}
@@ -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());
}
}