feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -13,7 +13,7 @@ public interface AppointmentBatchPreviewMapper {
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixedDestinations,
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS activeAppointment
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 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>")
@@ -12,14 +12,17 @@ import java.util.stream.Collectors;
public class AppointmentBatchPreviewService {
private final AppointmentSourceService sources;
private final AppointmentBatchPreviewMapper mapper;
public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper) {
this.sources=sources;this.mapper=mapper;
private final AppointmentCargoVerificationService verification;
public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper,AppointmentCargoVerificationService verification) {
this.sources=sources;this.mapper=mapper;this.verification=verification;
}
public record Preview(AppointmentPlanningPolicy.Group group,String originWarehouseName,
String destinationWarehouseCode,String destinationTimezone,List<AppointmentSourceService.Source> sources,
List<String> missingFields) { }
List<String> missingFields,List<AppointmentCargoVerificationService.Context> cargoVerification) { }
public Preview preview(List<AppointmentCoverageService.SourceRef> refs) {
var resolved=sources.resolveBatch(refs);
var verified=resolved.stream().map(verification::inspect).toList();
var bySource=verified.stream().collect(Collectors.toMap(v->v.source().sourceKey(),Function.identity()));
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()));
@@ -30,17 +33,19 @@ public class AppointmentBatchPreviewService {
else if(state!=null) {
if(state.detained()) reason="货物暂扣,请先处理后再合批";
else if(state.mixedDestinations()) reason="货物含多个目的地,请先核对明细";
else if(state.activeAppointment()) reason="货物已有有效预约,请编辑原预约";
}
var context=bySource.get(source.sourceKey());
if(context.sourceChanged() || context.unknownAllocationCount()>0 || context.effectivePackages()!=null && context.allocatedPackages()>context.effectivePackages()) reason="来源或已有分配待核实,请先处理原安排";
else if(context.availablePackages()!=null && context.availablePackages()<=0) 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);
source.destinationKey(),"US","US",context.availablePackages()==null?null:Math.toIntExact(context.availablePackages()),null,null,reason==null,reason);
}).toList();
try {
var group=AppointmentPlanningPolicy.group(cargo);
return new Preview(group,resolved.get(0).originWarehouseName(),resolved.get(0).destinationWarehouseCode(),
sources.destinationTimezone(resolved.get(0).destinationWarehouseCode()),resolved,
group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED"));
group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED"),verified);
} catch(IllegalArgumentException error) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,error.getMessage(),error);
}
@@ -35,7 +35,7 @@ public interface AppointmentCargoMapper {
g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained,
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan,
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code&lt;&gt;o.fba_warehouse_code) AS mixed_destinations
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Append-only appointment evidence; never updates an order or its declared cargo. */
@Data
@TableName("ll_appointment_cargo_verification")
public class AppointmentCargoVerification {
@TableId private Long id;
private String customerId;
private String sourceKey;
private String destinationKey;
private String sourceVersion;
private Integer sourcePackages;
private Integer version;
private Integer packageCount;
private String shipmentReferencesJson;
private String remark;
private String operatorId;
private String operatorName;
private String commandKey;
private String requestHash;
private LocalDateTime createdAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
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/sources/{sourceKey}/cargo-verification")
public class AppointmentCargoVerificationController {
private final AppointmentCargoVerificationService service;
public AppointmentCargoVerificationController(AppointmentCargoVerificationService service) {this.service=service;}
@GetMapping
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<AppointmentCargoVerificationService.Context> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceKey,@RequestParam String customerId,@RequestParam(required=false) String destinationKey) {
AppointmentPlanningController.requirePlatform(user);
return ApiResponse.success(service.get(sourceKey,customerId,destinationKey));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')")
public ApiResponse<AppointmentCargoVerificationService.Verification> save(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceKey,@RequestParam String customerId,@Valid @RequestBody AppointmentCargoVerificationService.Save request) {
AppointmentPlanningController.requirePlatform(user);
return ApiResponse.success(service.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null?user.username():user.displayName()));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> {
/** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */
@Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
Long lockSourceOrder(@Param("orderId") Long orderId);
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} ORDER BY version DESC LIMIT 1")
AppointmentCargoVerification latest(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
@Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}")
AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key);
record Allocation(long packages, long unknownItems) { }
@Select("""
SELECT COALESCE(SUM(package_count),0) AS packages,
COUNT(*)-COUNT(package_count) AS unknownItems
FROM ll_inbound_fba_appointment_item
WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND active=1
""")
Allocation allocated(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey);
}
@@ -0,0 +1,141 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
@Service
public class AppointmentCargoVerificationService {
private final AppointmentSourceService sources;
private final AppointmentCargoVerificationMapper mapper;
private final ObjectMapper json;
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
ObjectMapper json) {
this.sources=sources; this.mapper=mapper; this.json=json;
}
public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion,
@Min(0) int expectedVersion, @Positive Integer packageCount,
@NotNull @Size(max=100) List<@NotBlank @Size(max=128) String> shipmentReferences,
@Size(max=1000) String remark, @NotBlank @Size(max=64) String idempotencyKey) { }
public record Verification(int version, String sourceVersion, String destinationKey, Integer sourcePackages,
Integer packageCount, List<String> shipmentReferences, String remark,
String operatorName, Instant verifiedAt) { }
public record Context(AppointmentSourceService.Source source, Verification verification,
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages,
Long availablePackages, boolean sourceChanged, List<String> allowedActions,
List<String> blockReasons) { }
@Transactional(readOnly=true)
public Context get(String sourceKey,String customerId,String destinationKey) {
var source=sources.resolve(sourceKey,customerId,destinationKey);
return inspect(source);
}
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */
public Context inspect(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey());
var allocation=mapper.allocated(source.customerId(),source.sourceKey());
boolean changed=row!=null && changed(row,source);
Integer total=row==null?source.packages():row.getPackageCount();
var reasons=new ArrayList<>(source.blockReasons());
if(changed) reasons.add("原来源资料已变化,请重新核实预约货物资料");
if(total==null || total<=0) reasons.add("箱数尚未核实");
if(allocation.unknownItems()>0) reasons.add("已有预约的分配箱数待核实");
if(total!=null && allocation.packages()>total) reasons.add("已有分配超过当前货量,请先复核原安排");
Long available=changed || total==null || total<=0 || allocation.unknownItems()>0 || allocation.packages()>total
?null:total.longValue()-allocation.packages();
// Actions describe this endpoint's business capability; controller permissions remain mandatory.
var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of();
return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total,
available,changed,actions,List.copyOf(reasons));
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) {
validate(request,operatorId);
String hash=hash(customerId,sourceKey,operatorId,request);
var replay=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(replay!=null) return replay(replay,hash);
var refs=List.of(new AppointmentCoverageService.SourceRef(customerId,sourceKey,request.destinationKey()));
sources.lockForWrite(refs);
var before=sources.resolveBatch(refs).get(0);
if(before.orderId()!=null && mapper.lockSourceOrder(before.orderId())==null) throw conflict("原货源已变化,请重新查询");
var source=sources.resolveBatch(refs).get(0);
if(!Objects.equals(before.orderId(),source.orderId())) throw conflict("货源关联已变化,请重新查询");
// The order/booking lock serializes verification with allocation and invalidates pre-lock facts.
replay=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(replay!=null) return replay(replay,hash);
if(!source.canFollowup()) throw conflict("当前货源已完成、取消或尚未具备安排资格");
if(!Objects.equals(source.sourceVersion(),request.expectedSourceVersion())) throw conflict("原来源版本已变化,请重新核实");
var latest=mapper.latest(customerId,sourceKey);
if((latest==null?0:latest.getVersion())!=request.expectedVersion()) throw conflict("货物核实资料已更新,请刷新后提交");
Integer count=request.packageCount();
if(count!=null && source.packages()!=null && count>source.packages()) throw conflict("核实箱数不能超过原来源箱数,请先核对来源");
var allocation=mapper.allocated(customerId,sourceKey);
if(allocation.packages()>0 && (count==null || count<allocation.packages())) throw conflict("核实箱数不能低于已有预约分配总量");
var row=new AppointmentCargoVerification(); row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setSourceKey(sourceKey);
row.setDestinationKey(source.destinationKey());row.setSourceVersion(source.sourceVersion());row.setSourcePackages(source.packages());
row.setVersion(request.expectedVersion()+1);row.setPackageCount(count);
row.setShipmentReferencesJson(encode(request.shipmentReferences().stream().map(String::trim).distinct().toList()));
row.setRemark(clean(request.remark()));row.setOperatorId(operatorId);row.setOperatorName(clean(operatorName));
row.setCommandKey(request.idempotencyKey());row.setRequestHash(hash);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS));
try { mapper.insert(row); }
catch(DuplicateKeyException error) {
var raced=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(raced!=null) return replay(raced,hash);
throw conflict("核实资料版本已更新,请刷新后提交");
}
return view(row);
}
/** Caller already holds the original source lock when using this evidence to allocate or confirm. */
public Verification current(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey());
if(row!=null && changed(row,source)) throw conflict("原来源已变化,请重新核实预约货物资料");
return row==null?null:view(row);
}
private static boolean changed(AppointmentCargoVerification row,AppointmentSourceService.Source source) {
return !Objects.equals(row.getSourceVersion(),source.sourceVersion()) || !Objects.equals(row.getDestinationKey(),source.destinationKey())
|| !Objects.equals(row.getSourcePackages(),source.packages());
}
private Verification replay(AppointmentCargoVerification row,String hash) {
if(!Objects.equals(row.getRequestHash(),hash)) throw conflict("同一保存请求的内容或操作人不一致,请核对核实记录");
return view(row);
}
private Verification view(AppointmentCargoVerification row) {
try {
return new Verification(row.getVersion(),row.getSourceVersion(),row.getDestinationKey(),row.getSourcePackages(),
row.getPackageCount(),json.readValue(row.getShipmentReferencesJson(),new TypeReference<List<String>>(){}),row.getRemark(),
row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC));
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料不可读",error);}
}
private String hash(String customer,String source,String operator,Save request) {
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customer+"\n"+source+"\n"+operator+"\n"+encode(request)).getBytes(StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException error) {throw new IllegalStateException(error);}
}
private String encode(Object value) {
try {return json.writeValueAsString(value);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料无法保存",error);}
}
private static void validate(Save request,String operator) {
if(request==null || clean(operator)==null || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64
|| clean(request.destinationKey())==null || request.destinationKey().length()>160 || request.expectedVersion()<0
|| request.packageCount()!=null && request.packageCount()<=0 || request.shipmentReferences()==null || request.shipmentReferences().size()>100
|| request.shipmentReferences().stream().anyMatch(ref->clean(ref)==null || ref.length()>128)
|| request.remark()!=null && request.remark().length()>1000 || request.expectedSourceVersion()!=null && request.expectedSourceVersion().length()>160)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请核对货物箱数、货件号及保存版本");
}
private static String clean(String value) {return value==null || value.isBlank()?null:value.trim();}
private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -19,7 +19,7 @@ public interface AppointmentCoverageMapper {
record Fact(Long orderId, String customerId, String appointmentNo, String status,
LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt,
Long itemId, Integer packages, String warehouseCode,String sourceKey,String sourceVersion,
String confirmationNo,String timezone) { }
String confirmationNo,String timezone,Integer cargoVerificationVersion) { }
@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, ",
@@ -31,11 +31,18 @@ public interface AppointmentCoverageMapper {
@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.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 ",
"i.source_key AS sourceKey,i.source_version AS sourceVersion,a.confirmation_no AS confirmationNo,a.timezone,i.cargo_verification_version AS cargoVerificationVersion ",
"FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id ",
"WHERE i.active=1 AND a.appointment_status NOT IN ('FAILED','CANCELLED') AND ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(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<AppointmentCoverageService.SourceRef> sources);
@Select({"<script>SELECT v.* FROM ll_appointment_cargo_verification v WHERE ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(v.source_key=#{s.sourceKey} AND v.customer_id=#{s.customerId})</foreach>",
" AND v.version=(SELECT MAX(latest.version) FROM ll_appointment_cargo_verification latest",
" WHERE latest.source_key=v.source_key AND latest.customer_id=v.customer_id)</script>"})
List<AppointmentCargoVerification> verifications(@Param("sources") List<AppointmentCoverageService.SourceRef> sources);
}
@@ -49,6 +49,7 @@ public class AppointmentCoverageService {
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=mapper.appointments(requested);
var verified=mapper.verifications(requested).stream().collect(Collectors.toMap(v->key(v.getCustomerId(),v.getSourceKey()),v->v));
var bySource=facts.stream().collect(Collectors.groupingBy(f -> key(f.customerId(),f.sourceKey())));
var output=new ArrayList<Coverage>();
for (var source:requested) {
@@ -75,25 +76,30 @@ public class AppointmentCoverageService {
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue;
}
var distinct=new LinkedHashMap<String,Appointment>();
var evidence=verified.get(key(source.customerId(),source.sourceKey()));
String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version());
Integer packages=evidence==null?order.packages():evidence.getPackageCount();
var seen=new HashSet<Long>(); long confirmed=0, covered=0; boolean invalid=false, changed=false;
if(evidence!=null) changed=!Objects.equals(currentVersion,evidence.getSourceVersion())
|| !Objects.equals(source.destinationKey(),evidence.getDestinationKey()) || !Objects.equals(order.packages(),evidence.getSourcePackages());
for (var fact:relevant) {
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();
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());
if(evidence!=null) changed|=!Objects.equals(evidence.getVersion(),fact.cargoVerificationVersion());
}
var missing=new ArrayList<String>();
String status;
if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); }
if (invalid || packages==null || packages<=0 || covered>packages) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); }
else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); }
else if (covered<order.packages()) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
else if (confirmed<order.packages()) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else if (covered<packages) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else { status="CONFIRMED"; }
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 (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES");
if (changed) missing.add("SOURCE_CHANGED_REVIEW");
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
@@ -106,4 +112,13 @@ public class AppointmentCoverageService {
private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; }
private static String version(Number version) { return version==null ? null : version.toString(); }
private static boolean blank(String value) { return value==null || value.isBlank(); }
private static boolean hasReferences(String json) {
if(blank(json)) return false;
try {
var refs=new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
if(!refs.isArray() || refs.isEmpty()) return false;
for(var ref:refs) if(!ref.isTextual() || ref.asText().isBlank()) return false;
return true;
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约核实资料不可读",error);}
}
}
@@ -29,6 +29,20 @@ public class AppointmentPlanningController {
}
public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { }
@GetMapping("/sources/resolve-order")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<Resolved> resolveOrder(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam Long orderId, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) {
requirePlatform(user);
if(orderId==null || orderId<=0 || customerId==null || customerId.isBlank() || customerId.length()>128)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实订单及所属客户");
var ref=sources.canonicalOrders(java.util.List.of(new AppointmentExternalSourceGateway.OrderRef(customerId,orderId))).get(0);
var source=sources.resolve(ref.sourceKey(),customerId,destinationKey);
if(!orderId.equals(source.orderId())) throw new ResponseStatusException(HttpStatus.CONFLICT,"订单与原来源关联已变化,请刷新后核对");
var result=coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())));
return ApiResponse.success(new Resolved(source,result.get(0)));
}
@GetMapping("/sources/resolve")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<Resolved> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Original business commands invalidate the manifest atomically with their business write. */
@Component
public class AppointmentWorkChanges {
private final AppointmentWorkMapper mapper;
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
@Transactional(propagation=Propagation.MANDATORY)
public void requirementsChanged() {
if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化");
}
}
@@ -0,0 +1,161 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkCommandService {
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
private final AppointmentWorkSyncService sync;
private final AppointmentWorkQueryService queries;
public AppointmentWorkCommandService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway,AppointmentWorkSyncService sync,AppointmentWorkQueryService queries) {
this.store=store; this.gateway=gateway; this.sync=sync; this.queries=queries;
}
@Transactional
public CommandResult execute(String key,CommandRequest request,Actor actor) {
if(request==null || request.command()==null || request.expectedVersion()<0 || request.idempotencyKey()==null || request.idempotencyKey().isBlank() || request.idempotencyKey().length()>64 || request.payload()==null || !request.payload().isObject()) throw bad("工作命令参数无效");
String permission=switch(request.command()) { case SAVE_FOLLOWUP,RESOLVE_REVIEW->FOLLOWUP; default->MANAGE; };
if(!actor.has(LIST) || !actor.has(permission)) throw forbidden("缺少工作操作权限");
store.lock();
var state=store.get(key);
var hash=digest(actor.id()+"\n"+request.command()+"\n"+request.expectedVersion()+"\n"+canonical(request.payload()));
var replay=store.replay(key,request.idempotencyKey());
if(replay!=null) {
if(!Objects.equals(actor.id(),replay.getOperatorId()) || !Objects.equals(hash,replay.getRequestHash())) throw conflict("同一请求编号的操作者或内容不一致");
if(state.getReadPermissions().isEmpty() || !state.getReadPermissions().stream().allMatch(actor::has)) throw forbidden("关联来源读取权限已失效");
return store.read(replay.getResponseJson(),CommandResult.class);
}
var current=gateway.facts(state.getType()==Type.ROUTE?List.of():List.of(state)); var fact=current.get(key);
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联业务来源");
state.setReadPermissions(state.getType()==Type.ROUTE?List.of(ORDER_READ):List.copyOf(fact.readPermissions()));
if(state.getVersion()!=request.expectedVersion()) throw conflict("事项已更新,请刷新后保留输入重新提交");
if(state.isRetired() || completed(fact)) throw conflict("该事项已完成或已替代,只能查看历史");
var payload=request.payload(); var affected=new LinkedHashSet<String>(); String summary;
switch(request.command()) {
case CONFIRM_ROUTE -> {
fields(payload,Set.of("flow","reason"),Set.of("flow"));
if(state.getType()!=Type.ROUTE) throw bad("只能在路线需求确认运输方式");
Flow flow; try { flow=Flow.valueOf(text(payload,"flow",true,16)); } catch(IllegalArgumentException e) { throw bad("运输路线无效"); }
if(flow==Flow.UNKNOWN) throw bad("请选择中转或直送");
var source=gateway.container(state.getContainerBookingId()); AppointmentWorkSyncService.validateSource(source);
if(!Objects.equals(state.getSourceVersion(),source.sourceVersion())) throw conflict("柜次来源版本变化,请先同步核对");
if(source.establishedFlow()!=Flow.UNKNOWN && source.establishedFlow()!=flow) throw conflict("选择路线与原业务既定路线不一致");
var existing=store.container(state.getContainerBookingId());
if(existing.stream().anyMatch(s->s.getType()!=Type.ROUTE)) throw conflict("该柜已生成路线工作,不能切换抹去已有安排");
affected.addAll(sync.generate(source,flow,existing,actor)); state.setFlow(flow); state.setRetired(true); state.setRetirementReason("ROUTE_CONFIRMED"); state.setReplacementKeys(List.copyOf(affected));
summary="确认"+(flow==Flow.TRANSFER?"海外仓中转":"整柜直送")+";"+Objects.toString(text(payload,"reason",false,1000),"");
}
case SAVE_FOLLOWUP -> {
fields(payload,Set.of("ownerId","followupDate","note"),Set.of("ownerId","followupDate","note"));
state.setOwner(gateway.owner(text(payload,"ownerId",true,64),actor));
state.setFollowupDate(date(payload.get("followupDate")));
summary=text(payload,"note",true,1000);
// This command never clears pending acceptance, review, or original work requirements.
}
case RESOLVE_REVIEW -> {
fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note"));
int version=integer(payload,"reviewVersion");
if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对");
if(changed(state,fact)) throw conflict("原业务已有更新,须先同步最新影响");
if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
}
case SAVE_REQUIREMENT -> {
fields(payload,Set.of("target","bookingRequired","receivingRequirements","reason"),Set.of("target","bookingRequired","receivingRequirements","reason"));
if(!requirementOwned(state,fact)) throw conflict("该计划由原业务维护,请从原安排入口处理");
if(!payload.get("bookingRequired").isBoolean()) throw bad("预约要求须为布尔值");
boolean bookingRequired=payload.get("bookingRequired").asBoolean();
String requirements=text(payload,"receivingRequirements",false,1000),reason=text(payload,"reason",true,1000);
if(!bookingRequired && (state.getType()!=Type.DELIVERY || !"CUSTOMER".equals(state.getDestinationKind()) || requirements==null)) throw bad("仅客户仓可凭明确收货要求登记无需预约");
Slot target=slot(payload.get("target")); validateSlot(target);
String authorityZone=fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone();
if(target!=null && !Objects.equals(authorityZone,target.timezone())) throw conflict("作业地点时区尚未核实或已变化");
boolean hadPlan=arranged(state,fact);
boolean change=!Objects.equals(target,state.getTarget()) || state.isBookingRequired()!=bookingRequired || !Objects.equals(requirements,state.getReceivingRequirements());
state.setTarget(target); state.setBookingRequired(bookingRequired); state.setReceivingRequirements(requirements); state.setRequirementReason(reason);
if(hadPlan && change) affected.addAll(markDownstream(state,reason,actor));
summary=reason;
}
case HANDOFF_RETURN -> {
fields(payload,Set.of("recipient","reason"),Set.of("recipient","reason")); requireReturn(state);
var recipient=responsibility(payload.get("recipient"),actor);
String reason=text(payload,"reason",true,1000);
var previous=state.getResponsibility();
if(previous==null && fact!=null) previous=fact.responsibility();
if(previous==null) previous=new Responsibility(ResponsibilityKind.EXTERNAL,null,"原责任方待核实");
int version=state.getReturnHandoff()==null?1:state.getReturnHandoff().version()+1;
state.setReturnHandoff(new ReturnHandoff(version,previous,recipient,HandoffStatus.PENDING_ACCEPTANCE,reason));
if(state.getOwner()==null) state.setOwner(new Owner(actor.id(),actor.name()));
var localZone=queries.current(state,fact,actor).timezone();
state.setFollowupDate(LocalDate.now(localZone==null?REMINDER_ZONE:zone(localZone)));
summary="还空责任转交:"+previous.name()+" → "+recipient.name()+";待接手;"+reason;
}
case ACCEPT_RETURN -> {
fields(payload,Set.of("handoffVersion","note"),Set.of("handoffVersion","note")); requireReturn(state);
var handoff=state.getReturnHandoff();
if(handoff==null || handoff.status()!=HandoffStatus.PENDING_ACCEPTANCE || handoff.version()!=integer(payload,"handoffVersion")) throw conflict("还空转交版本变化或已经接手");
if(handoff.to().kind()==ResponsibilityKind.USER && !actor.id().equals(handoff.to().id())) throw forbidden("仅当前指定接手人可确认接手");
String note=text(payload,"note",handoff.to().kind()!=ResponsibilityKind.USER,1000);
state.setResponsibility(handoff.to()); state.setReturnHandoff(new ReturnHandoff(handoff.version(),handoff.from(),handoff.to(),HandoffStatus.ACCEPTED,handoff.reason()));
if(handoff.to().kind()==ResponsibilityKind.USER) state.setOwner(new Owner(actor.id(),actor.name()));
summary="已确认接手:"+handoff.to().name()+";"+Objects.toString(note,"本人确认");
}
default -> throw bad("不支持的工作命令");
}
store.save(state); affected.add(key);
var result=new CommandResult(queries.current(state,fact,actor),List.copyOf(affected));
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
return result;
}
private List<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
var states=store.all(); var keys=new LinkedHashSet<String>(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey());
var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList<String>();
for(var state:candidates) { var fact=facts.get(state.getWorkItemKey()); if(!completed(fact) && arranged(state,fact)) {
state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason(reason); store.save(state);
store.history(state,actor,"RELATED_CHANGE",reason,null,null,null); affected.add(state.getWorkItemKey());
} }
return affected;
}
private Responsibility responsibility(JsonNode node,Actor actor) {
fields(node,Set.of("kind","id","name"),Set.of("kind","id","name"));
ResponsibilityKind kind; try { kind=ResponsibilityKind.valueOf(text(node,"kind",true,16)); } catch(IllegalArgumentException e) { throw bad("责任方类型无效"); }
var id=text(node,"id",false,64); var name=text(node,"name",true,200);
if(kind==ResponsibilityKind.USER) { var owner=gateway.owner(id,actor); return new Responsibility(kind,owner.id(),owner.name()); }
if(kind==ResponsibilityKind.SUPPLIER) { if(!actor.has("platform:supplier:list")) throw forbidden("缺少供应商读取权限"); return gateway.supplier(id); }
if(id!=null) throw bad("外部责任方不接受未经核验的内部标识");
return new Responsibility(kind,null,name);
}
private static void requireReturn(AppointmentWorkState state) { if(state.getType()!=Type.EMPTY_RETURN) throw bad("仅还空事项可交接责任"); }
static void fields(JsonNode node,Set<String> allowed,Set<String> required) {
if(node==null || !node.isObject()) throw bad("命令payload须为对象");
var names=new HashSet<String>(); node.fieldNames().forEachRemaining(names::add);
if(!allowed.containsAll(names) || !names.containsAll(required)) throw bad("命令payload包含不支持的字段或缺少必需字段");
}
static String text(JsonNode node,String field,boolean required,int max) {
var value=node.get(field);
if(value==null || value.isNull()) { if(required) throw bad("请填写"+field); return null; }
if(!value.isTextual() || value.textValue().length()>max) throw bad(field+"格式无效");
var result=value.textValue().trim(); if(required && result.isEmpty()) throw bad("请填写"+field); return result.isEmpty()?null:result;
}
private int integer(JsonNode node,String name) { var value=node.get(name); if(value==null || !value.isIntegralNumber() || !value.canConvertToInt() || value.intValue()<0) throw bad(name+"必须为有效版本"); return value.intValue(); }
private LocalDate date(JsonNode value) { if(value==null || value.isNull()) return null; try { if(!value.isTextual()) throw bad("日期格式无效"); return LocalDate.parse(value.textValue()); } catch(java.time.DateTimeException e) { throw bad("日期格式无效"); } }
private Slot slot(JsonNode node) {
if(node==null || node.isNull()) return null;
fields(node,Set.of("date","startTime","endDate","endTime","timezone"),Set.of("date","startTime","endDate","endTime","timezone"));
try { return new Slot(date(node.get("date")),time(node.get("startTime")),date(node.get("endDate")),time(node.get("endTime")),text(node,"timezone",true,64)); }
catch(java.time.DateTimeException e) { throw bad("目标日期或时段格式无效"); }
}
private LocalTime time(JsonNode value) { if(value.isNull()) return null; if(!value.isTextual() || !value.textValue().matches("\\d{2}:\\d{2}")) throw bad("时段须为HH:mm"); return LocalTime.parse(value.textValue()); }
static String canonical(JsonNode node) {
if(node.isObject()) { var parts=new TreeMap<String,String>(); node.fields().forEachRemaining(e->parts.put(e.getKey(),canonical(e.getValue()))); return parts.toString(); }
if(node.isArray()) { var parts=new ArrayList<String>(); node.forEach(v->parts.add(canonical(v))); return parts.toString(); }
return node.toString();
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Audit baseline for semantic impacts. Written only by original business transactions, never by reads. */
@Component
public class AppointmentWorkCommitmentService {
public record Commitment(Slot target,Slot confirmed,String assignment,String cargoToken,boolean cancelled) { }
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkCommitmentService(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
@Transactional(propagation=Propagation.MANDATORY)
public void record(String key,Commitment current) {
var row=mapper.lockCommitment(key);
try {
if(row==null) mapper.insertCommitment(key,json.writeValueAsString(current));
else {
var previous=json.readValue(row.getStateJson(),Commitment.class);
mapper.updateCommitment(key,json.writeValueAsString(current),row.getImpactVersion()+(impacts(previous,current)?1:0));
}
} catch(com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException("无法保存原业务承诺影响依据",error); }
}
public String impactToken(String key) {
var row=mapper.commitment(key);
return key+":"+(row==null?0:row.getImpactVersion());
}
public static boolean impacts(Commitment old,Commitment now) {
if(old.cancelled()!=now.cancelled()) return true;
boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date());
if(old.target()!=null && !Objects.equals(old.target(),now.target()) && !(firstSameDayConfirmation && now.target()==null)) return true;
if(old.confirmed()!=null && !Objects.equals(old.confirmed(),now.confirmed())) return true;
if(old.confirmed()==null && now.confirmed()!=null && old.target()!=null && !old.target().date().equals(now.confirmed().date())) return true;
if(old.assignment()!=null && !Objects.equals(old.assignment(),now.assignment())) return true;
return old.cargoToken()!=null && !Objects.equals(old.cargoToken(),now.cargoToken());
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Set;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-work")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public class AppointmentWorkController {
private final AppointmentWorkQueryService queries;
private final AppointmentWorkCommandService commands;
public AppointmentWorkController(AppointmentWorkQueryService queries,AppointmentWorkCommandService commands) { this.queries=queries; this.commands=commands; }
@GetMapping("/calendar")
public ApiResponse<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set<Type> types) {
return ApiResponse.success(queries.calendar(from,to,flow,types,actor(user)));
}
@GetMapping("/summary")
public ApiResponse<Summary> summary(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(queries.summary(actor(user))); }
@GetMapping("/items")
public ApiResponse<PageResult<WorkItem>> items(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue="ALL") View view,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size,
@RequestParam(required=false) LocalDate date,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set<Type> types,
@RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) {
return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user)));
}
@GetMapping("/items/{workItemKey}")
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
}
@PostMapping("/items/{workItemKey}/commands")
public ApiResponse<CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey,@Valid @RequestBody CommandRequest request) {
return ApiResponse.success(commands.execute(workItemKey,request,actor(user)));
}
static Actor actor(AuthenticatedUser user) {
if(user==null || user.userId()==null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()) || !user.permissions().contains(LIST)) throw forbidden("仅平台有权访问工作事项");
return new Actor(user.userId().toString(),user.displayName()==null?user.username():user.displayName(),user.permissions());
}
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** Direct transport uses terminal carrier arrangements and real destination unloading, never warehouse stock. */
@Component
public class AppointmentWorkDirectTransportProvider implements BusinessProvider {
private final AppointmentWorkFirstlegClient firstleg;
private final String token;
public AppointmentWorkDirectTransportProvider(AppointmentWorkFirstlegClient firstleg,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.token=token; }
public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.DIRECT; }
public List<Fact> read(List<FactRequest> requests) {
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var input=new ArrayList<FactRequest>();
for(var r:requests) { input.add(request(r,Type.PICKUP,"/carrier")); input.add(request(r,Type.DELIVERY,"/delivery")); }
var facts=new HashMap<String,Fact>();
for(int start=0;start<input.size();start+=200) {
var batch=input.subList(start,Math.min(start+200,input.size())); var response=firstleg.facts(token,batch);
if(response==null || !response.successful()) throw unavailable("直送承运及卸货事实不可用");
AppointmentWorkFactAdapter.append(facts,batch,response.data());
}
return requests.stream().map(r->{
var carrier=facts.get(r.workItemKey()+"/carrier"); var delivery=facts.get(r.workItemKey()+"/delivery");
var original=carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.action():carrier.action();
var action=original==null?null:new Action(original.code(),original.label(),r.workItemKey(),original.target());
var read=new LinkedHashSet<>(carrier.readPermissions());read.addAll(delivery.readPermissions());
var missing=new LinkedHashSet<>(carrier.missingFields());missing.addAll(delivery.missingFields());
var refs=new LinkedHashSet<>(carrier.references());refs.addAll(delivery.references());
boolean done=delivery.executionStatus()==ExecutionStatus.COMPLETED;
return new Fact(r.workItemKey(),digest(carrier.fingerprint()+":"+delivery.fingerprint()),carrier.impactToken(),carrier.planExists(),carrier.planStatus(),
done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING,
carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null,
List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action,
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility());
}).toList();
}
private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.appointmentwork;
import jakarta.validation.constraints.*;
import java.util.List;
/** Real dispatch allocation boundary, implemented by the original appointment domain. */
public interface AppointmentWorkDispatchPort {
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -0,0 +1,112 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
private final AppointmentWorkFirstlegClient firstleg;
private final List<BusinessProvider> providers;
private final ObjectProvider<OwnerDirectory> directory;
private final ObjectProvider<RequirementProvider> requirements;
private final ObjectProvider<SupplierDirectory> suppliers;
private final String token;
public AppointmentWorkFactAdapter(AppointmentWorkFirstlegClient firstleg, ObjectProvider<BusinessProvider> providers,
ObjectProvider<OwnerDirectory> directory, ObjectProvider<RequirementProvider> requirements, ObjectProvider<SupplierDirectory> suppliers,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.firstleg=firstleg; this.providers=providers.orderedStream().toList(); this.directory=directory; this.requirements=requirements; this.suppliers=suppliers; this.token=token;
}
public SourcePage discover(String after, int size) {
requireToken();
try { var response=firstleg.discover(token,after,size);
if(response==null || !response.successful() || response.data()==null) throw unavailable("已开船来源读取失败");
var page=response.data();
if(page.scopeVersion()==null || page.scopeVersion().isBlank() || page.containers()==null || page.containers().size()>size || !page.complete() && (page.nextCursor()==null || page.containers().isEmpty() || Objects.equals(after,page.nextCursor())))
throw unavailable("已开船来源分页不完整");
return page;
} catch(RuntimeException error) { throw unavailable("已开船来源不可用,不能计算完整工作范围",error); }
}
public ContainerSource container(String id) {
requireToken();
try { var response=firstleg.container(token,id);
if(response==null || !response.successful() || response.data()==null || !id.equals(response.data().containerBookingId())) throw unavailable("柜次事实缺失");
return response.data();
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
}
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
var result=new LinkedHashMap<String,Fact>();
var remote=new ArrayList<FactRequest>();
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
for(var state:states) {
if(state.getType()==Type.ROUTE) continue;
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),state.getContainerBookingId(),state.getRequirementKey(),state.getBatchKey(),state.getTripKey(),state.getBatchKeys(),state.getContainers(),state.getSources());
if(state.getType()==Type.PICKUP || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && state.getFlow()==Flow.DIRECT) remote.add(request);
else {
var matches=providers.stream().filter(p->p.supports(state.getType(),state.getFlow())).toList();
if(matches.size()!=1) throw unavailable("原业务事实适配尚未就绪:"+state.getType());
groups.computeIfAbsent(matches.get(0),ignored->new ArrayList<>()).add(request);
}
}
for(int start=0; start<remote.size(); start+=200) {
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
try { var response=firstleg.facts(token,requests);
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
append(result,requests,response.data());
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
}
groups.forEach((provider,requests)->{
for(int start=0; start<requests.size(); start+=200) {
var batch=requests.subList(start,Math.min(start+200,requests.size()));
try { append(result,batch,provider.read(batch)); }
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
}
});
return result;
}
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
var result=provider.requirements(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
}
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
var expected=new HashSet<>(requests.stream().map(FactRequest::workItemKey).toList());
for(var fact:facts) {
if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null
|| fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null)
throw unavailable("工作事实缺少身份、版本或资格");
if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间");
if(fact.actual()!=null) validateActual(fact.actual());
if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed());
if(fact.timezone()!=null) zone(fact.timezone());
if(fact.actual()!=null && fact.actual().timezone()!=null) zone(fact.actual().timezone());
if(fact.action()!=null && (fact.action().target()==null || fact.action().target() instanceof WorkTarget || fact.actionPermissions().isEmpty())) throw unavailable("原业务动作缺少独立权限或越过工作命令边界");
if(target.putIfAbsent(fact.workItemKey(),fact)!=null) throw unavailable("工作事实重复");
}
}
public Owner owner(String id,Actor actor) {
if(id==null || id.isBlank()) throw bad("请选择负责人");
var resolver=directory.getIfAvailable();
if(resolver==null) throw unavailable("负责人目录尚未接入,不能确认其他接手人");
var owner=resolver.activePlatformOwner(id);
if(owner==null || !id.equals(owner.id())) throw bad("接手人不是有效平台操作人员");
return owner;
}
public Responsibility supplier(String id) {
if(id==null || id.isBlank()) throw bad("请选择供应商责任方");
var provider=suppliers.getIfAvailable();
if(provider==null) throw unavailable("供应商权威目录适配尚未接入");
var result=provider.activeSupplier(id);
if(result==null || result.kind()!=ResponsibilityKind.SUPPLIER || !id.equals(result.id()) || result.name()==null || result.name().isBlank()) throw bad("供应商责任方无效");
return result;
}
private void requireToken() { if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.appointmentwork;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Authority ports. A missing entity must be explicitly represented; missing responses are failures. */
public interface AppointmentWorkFactGateway {
record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind,
String originName, String destinationName, String timezone, List<BusinessReference> references,
List<String> replacesRequirementKeys, List<SourceReference> sources) { }
record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, java.time.LocalDateTime departedLocalAt, java.time.LocalDateTime sailedEvidenceLocalAt,
String sourceVersion, Flow establishedFlow, String timezone, List<Requirement> requirements) { }
record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
record FactRequest(String workItemKey, Type type, Flow flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,
List<SourceReference> sources) { }
/** impactToken changes only for real changes to an existing commitment, never first arrangement/assignment. */
record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, PlanStatus planStatus,
ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone,
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
Responsibility responsibility) {
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; }
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
Map<String, Fact> facts(List<AppointmentWorkState> states);
Owner owner(String id, Actor actor);
Responsibility supplier(String id);
/** Implemented in original Lastleg business domains; they may depend on this port, never vice versa. */
interface BusinessProvider {
boolean supports(Type type, Flow flow);
List<Fact> read(List<FactRequest> requests);
}
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
interface OwnerDirectory { Owner activePlatformOwner(String id); }
interface SupplierDirectory { Responsibility activeSupplier(String id); }
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
/** Provider contract pending original-domain integration; HTTP errors are never treated as empty facts. */
@FeignClient(name="firstleg-service", contextId="appointmentWorkFirstlegClient",
url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
public interface AppointmentWorkFirstlegClient {
@GetMapping("/internal/firstleg/appointment-work/containers")
ApiResponse<SourcePage> discover(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam(value="after", required=false) String after, @RequestParam("size") int size);
@GetMapping("/internal/firstleg/appointment-work/containers/{id}")
ApiResponse<ContainerSource> container(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("id") String id);
@PostMapping("/internal/firstleg/appointment-work/facts")
ApiResponse<List<Fact>> facts(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody List<FactRequest> requests);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Internal orchestration only, not exposed through the public gateway. */
@RestController
@RequestMapping("/internal/lastleg/appointment-work")
public class AppointmentWorkInternalController {
private final InternalServiceTokenGuard guard;
private final AppointmentWorkSyncService sync;
private final AppointmentWorkRouteService routes;
public AppointmentWorkInternalController(InternalServiceTokenGuard guard,AppointmentWorkSyncService sync,AppointmentWorkRouteService routes) { this.guard=guard; this.sync=sync; this.routes=routes; }
@GetMapping("/containers/{id}/route")
public ApiResponse<AppointmentWorkRouteService.Route> route(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable String id) {
guard.verify(token); return ApiResponse.success(routes.route(id));
}
@PostMapping("/synchronize")
public ApiResponse<SyncResult> synchronize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody SyncRequest request) {
guard.verify(token); return ApiResponse.success(sync.synchronize(request));
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.appointmentwork;
import lombok.Data;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkMapper {
@Data class Row {
private String workItemKey;
private String containerBookingId;
private int version;
private boolean retired;
private String stateJson;
}
@Data class Guard {
private int id;
private String scanCursor;
private boolean initialized;
private String scopeVersion;
private String scanScopeVersion;
private LocalDateTime completedAt;
private long requirementVersion;
private long synchronizedRequirementVersion;
private long scanRequirementVersion;
}
@Data class History {
private String id;
private String workItemKey;
private String operatorId;
private String operatorName;
private String action;
private String summary;
private String commandKey;
private String requestHash;
private String responseJson;
private LocalDateTime occurredAt;
}
@Data class CommitmentRow {
private String entityKey;
private String stateJson;
private long impactVersion;
}
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key);
@Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key);
@Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)")
int insertCommitment(@Param("key") String key,@Param("json") String json);
@Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}")
int updateCommitment(@Param("key") String key,@Param("json") String json,@Param("version") long version);
@Select("SELECT * FROM ll_appointment_work_guard WHERE id=1 FOR UPDATE") Guard lockGuard();
@Select("SELECT * FROM ll_appointment_work_guard WHERE id=1") Guard guard();
@Update("UPDATE ll_appointment_work_guard SET scan_cursor=#{scanCursor},initialized=#{initialized},completed_at=#{completedAt},scope_version=#{scopeVersion},scan_scope_version=#{scanScopeVersion},synchronized_requirement_version=#{synchronizedRequirementVersion},scan_requirement_version=#{scanRequirementVersion} WHERE id=1") int saveGuard(Guard row);
@Update("UPDATE ll_appointment_work_guard SET requirement_version=requirement_version+1 WHERE id=1") int invalidateRequirements();
@Select("SELECT * FROM ll_appointment_work WHERE work_item_key=#{key}") Row get(String key);
@Select("SELECT * FROM ll_appointment_work ORDER BY work_item_key LIMIT #{limit}") List<Row> all(int limit);
@Select("SELECT * FROM ll_appointment_work WHERE container_booking_id=#{id} ORDER BY work_item_key") List<Row> container(String id);
@Insert("INSERT INTO ll_appointment_work(work_item_key,container_booking_id,version,retired,state_json) VALUES(#{workItemKey},#{containerBookingId},#{version},#{retired},#{stateJson})") int insert(Row row);
@Update("UPDATE ll_appointment_work SET version=#{version},retired=#{retired},state_json=#{stateJson} WHERE work_item_key=#{workItemKey} AND version=#{version}-1") int update(Row row);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND command_key=#{commandKey}")
History command(@Param("key") String key,@Param("commandKey") String commandKey);
@Insert("""
INSERT INTO ll_appointment_work_history(id,work_item_key,operator_id,operator_name,action,summary,command_key,request_hash,response_json,occurred_at)
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
""") int insertHistory(History row);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List<History> selectHistory(@Param("key") String key);
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.*;
import java.time.*;
import java.util.*;
/** Public v1 contracts. No editable cargo quantities or execution commands live here. */
public final class AppointmentWorkModels {
private AppointmentWorkModels() { }
public enum Type { ROUTE, PICKUP, WAREHOUSE_RECEIPT, DELIVERY, TRANSPORT, EMPTY_RETURN }
public enum Flow { TRANSFER, DIRECT, UNKNOWN }
public enum PlanStatus { UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED }
public enum ExecutionStatus { PENDING, IN_PROGRESS, COMPLETED }
public enum DateBasis { ACTUAL, CONFIRMED, TARGET, NONE }
public enum View { ALL, FOLLOW_TODAY, OVERDUE, UNDATED, REVIEW }
public enum TargetKind { WORK_COMMAND, CARGO_VERIFICATION, FBA_APPOINTMENT, FIRSTLEG_CONTAINER, FLEET_TRIP, WAREHOUSE_RECEIPT, READ_ONLY }
public enum Command { CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN }
public enum Bucket { PENDING, CONFIRMED, NO_BOOKING_REQUIRED, REVIEW, COMPLETED }
public enum ActionCode { VIEW_DETAIL, SAVE_PLAN, REQUEST_BOOKING, RECORD_CONFIRMATION, CHANGE_BOOKING, CANCEL_BOOKING, VERIFY_CARGO, EDIT_BATCH, ARRANGE_TRANSPORT, RECORD_EXECUTION, CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN, VIEW_HISTORY }
public record Slot(LocalDate date, @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime startTime, LocalDate endDate,
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime endTime, String timezone) { }
public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { }
public record Owner(String id, String name) { }
public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { }
public record SourceReference(String customerId, String sourceKey, String destinationKey) { }
@JsonTypeInfo(use=JsonTypeInfo.Id.NAME, property="kind")
@JsonSubTypes({@JsonSubTypes.Type(value=WorkTarget.class,name="WORK_COMMAND"), @JsonSubTypes.Type(value=CargoTarget.class,name="CARGO_VERIFICATION"),
@JsonSubTypes.Type(value=AppointmentTarget.class,name="FBA_APPOINTMENT"), @JsonSubTypes.Type(value=ContainerTarget.class,name="FIRSTLEG_CONTAINER"),
@JsonSubTypes.Type(value=TripTarget.class,name="FLEET_TRIP"), @JsonSubTypes.Type(value=ReceiptTarget.class,name="WAREHOUSE_RECEIPT"), @JsonSubTypes.Type(value=ReadTarget.class,name="READ_ONLY")})
public sealed interface ActionTarget permits WorkTarget,CargoTarget,AppointmentTarget,ContainerTarget,TripTarget,ReceiptTarget,ReadTarget { }
public record WorkTarget(Command command) implements ActionTarget { }
public record CargoTarget(SourceReference source) implements ActionTarget { }
public record AppointmentTarget(String appointmentId,String batchKey,List<SourceReference> sources) implements ActionTarget { }
public record ContainerTarget(String containerBookingId) implements ActionTarget { }
public record TripTarget(String tripId,List<String> batchKeys) implements ActionTarget { }
public record ReceiptTarget(String inboundPlanId,String inboundPlanNo,String customerId,String containerBookingId) implements ActionTarget { }
public record ReadTarget(List<BusinessReference> references) implements ActionTarget { }
public record Action(ActionCode code, String label, String targetWorkItemKey, ActionTarget target) { }
public record WorkItem(String workItemKey, int version, Type type, Flow flow, String title,
String businessNo, List<ContainerReference> containers, String requirementKey,
String batchKey, String batchNo, String originName, String destinationName,
PlanStatus planStatus, ExecutionStatus executionStatus, boolean reviewRequired,
Slot target, Slot confirmed, Actual actual, LocalDate calendarDate, DateBasis dateBasis,
String timezone, Owner owner, LocalDate followupDate, List<String> overdueReasons,
List<Action> allowedActions, Action nextAction, List<String> blockReasons, List<String> missingFields) { }
public record BusinessReference(String kind, String key, String businessNo, String label) { }
public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { }
public record WorkDetail(WorkItem item, List<WorkItem> relatedItems,
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
public record Review(int version,List<String> reasons) { }
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
public record Responsibility(ResponsibilityKind kind,String id,String name) { }
public enum HandoffStatus { PENDING_ACCEPTANCE, ACCEPTED }
public record ReturnHandoff(int version,Responsibility from,Responsibility to,HandoffStatus status,String reason) { }
public record Summary(long all, long followToday, long overdue, long undated, long review) { }
public record BucketCounts(long pending, long confirmed, long noBookingRequired, long review, long completed) { }
public record DayAggregate(LocalDate date, long total, Map<Type, Long> typeCounts, BucketCounts counts) { }
public record CalendarResult(LocalDate from, LocalDate to, long total, BucketCounts counts, List<DayAggregate> days) { }
public record CommandRequest(@NotNull Command command, @Min(0) int expectedVersion,
@NotBlank @Size(max=64) String idempotencyKey, @NotNull JsonNode payload) { }
public record CommandResult(WorkItem item, List<String> affectedWorkItemKeys) { }
public record SyncRequest(@Size(max=128) String afterContainerBookingId, @Min(1) @Max(100) int size) { }
public record SyncResult(int sources, int created, String nextCursor, boolean complete) { }
public record Actor(String id, String name, Set<String> permissions) {
public boolean has(String permission) { return permissions.contains(permission); }
}
public static final String LIST = "platform:appointment:list";
public static final String FOLLOWUP = "platform:appointment:followup";
public static final String MANAGE = "platform:appointment:manage-work";
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="iam-service",contextId="appointmentWorkOwnerClient",url="${baiyue.clients.iam.url:}")
public interface AppointmentWorkOwnerClient {
@GetMapping("/internal/iam/platform-owners/{authUserId}")
ApiResponse<AppointmentWorkModels.Owner> active(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("authUserId") String id);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkOwnerDirectory implements AppointmentWorkFactGateway.OwnerDirectory {
private final AppointmentWorkOwnerClient client;
private final String token;
public AppointmentWorkOwnerDirectory(AppointmentWorkOwnerClient client,@Value("${baiyue.security.internal-service-token:}") String token) { this.client=client; this.token=token; }
public AppointmentWorkModels.Owner activePlatformOwner(String id) {
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=client.active(token,id);
if(response==null || !response.successful() || response.data()==null) throw unavailable("平台人员权威目录读取失败");
var owner=response.data();
if(!id.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw unavailable("平台人员目录返回身份或姓名不完整");
return owner;
}
}
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
public final class AppointmentWorkPolicy {
/** Explicit business fallback for reminder dates only; never used to fabricate actual/appointment time. */
public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles");
public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
private AppointmentWorkPolicy() { }
public static String key(String containerId,Type type,String requirementKey) {
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
}
public static String digest(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch(Exception e) { throw new IllegalStateException(e); }
}
public static void validateSlot(Slot slot) {
if(slot==null) return;
if(slot.date()==null || slot.timezone()==null || slot.timezone().isBlank()) throw bad("目标日期与地点时区必填");
validateSourceSlot(slot);
var zone=zone(slot.timezone());
for(int index=0; index<2; index++) { var time=index==0?slot.startTime():slot.endTime(); if(time==null) continue;
if(time.getSecond()!=0 || time.getNano()!=0) throw bad("时间精度须为分钟");
if(zone.getRules().getValidOffsets(LocalDateTime.of(index==0?slot.date():slot.endDate(),time)).size()!=1) throw bad("当地时段存在夏令时跳变或歧义,请重新选择");
}
}
/** Original local plans remain visible even when their source did not record a timezone. */
public static void validateSourceSlot(Slot slot) {
if(slot==null) return;
if(slot.date()==null) throw unavailable("原计划缺少当地日期");
if(slot.timezone()!=null) zone(slot.timezone());
if((slot.endDate()==null)!=(slot.endTime()==null) || slot.startTime()==null && slot.endTime()!=null) throw bad("结束日期与时间须同时填写,并先明确开始时间");
if(slot.endTime()!=null && !LocalDateTime.of(slot.endDate(),slot.endTime()).isAfter(LocalDateTime.of(slot.date(),slot.startTime()))) throw bad("完整结束时间必须晚于开始时间,不自动推断次日");
}
public static void validateActual(Actual actual) {
if(actual.occurredAt()==null && actual.localOccurredAt()==null) throw unavailable("实际事实缺少发生时间");
if(actual.timezone()!=null) {
var zone=zone(actual.timezone());
if(actual.occurredAt()!=null && actual.localOccurredAt()!=null && !actual.occurredAt().atZone(zone).toLocalDateTime().equals(actual.localOccurredAt()))
throw unavailable("实际发生时刻与当地时间不一致");
}
}
public static ZoneId zone(String value) {
if(value==null || !ZoneId.getAvailableZoneIds().contains(value)) throw bad("需要有效IANA地点时区");
return ZoneId.of(value);
}
public static boolean pendingHandoff(AppointmentWorkState state) {
return state.getReturnHandoff()!=null && state.getReturnHandoff().status()==HandoffStatus.PENDING_ACCEPTANCE;
}
public static boolean canRead(AppointmentWorkState state,Fact fact,Actor actor) {
return actor.has(LIST) && (state.getType()==Type.ROUTE ? actor.has(ORDER_READ) : fact!=null && fact.readPermissions().stream().allMatch(actor::has));
}
public static boolean arranged(AppointmentWorkState state,Fact fact) {
return state.getTarget()!=null || !state.isBookingRequired() || fact!=null && fact.arranged();
}
public static boolean changed(AppointmentWorkState state,Fact fact) {
return fact!=null && state.getObservedFingerprint()!=null && !Objects.equals(state.getObservedFingerprint(),fact.fingerprint());
}
public static boolean completed(Fact fact) { return fact!=null && fact.executionStatus()==ExecutionStatus.COMPLETED; }
public static WorkItem project(AppointmentWorkState state,Fact fact,Actor actor,Clock clock) {
boolean route=state.getType()==Type.ROUTE;
if(!route && fact==null) throw unavailable("缺少事项权威事实");
var target=fact!=null && fact.planExists()?fact.target():state.getTarget();
var confirmed=fact==null?null:fact.confirmed();
var actual=fact==null?null:fact.actual();
var execution=route?state.isRetired()?ExecutionStatus.COMPLETED:ExecutionStatus.PENDING:fact.executionStatus();
var plan=!state.isBookingRequired()?PlanStatus.NOT_REQUIRED:fact!=null && fact.planExists()?fact.planStatus():target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED;
var basis=actual!=null?DateBasis.ACTUAL:confirmed!=null?DateBasis.CONFIRMED:target!=null?DateBasis.TARGET:DateBasis.NONE;
String timezone=actual!=null?actual.timezone():confirmed!=null?confirmed.timezone():target!=null?target.timezone():fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone();
LocalDate date=null;
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
if(fact!=null) { blocks.addAll(fact.blockReasons()); missing.addAll(fact.missingFields()); }
if(actual!=null) {
validateActual(actual);
if(timezone!=null && actual.occurredAt()!=null) date=actual.occurredAt().atZone(zone(timezone)).toLocalDate();
else if(actual.localOccurredAt()!=null) date=actual.localOccurredAt().toLocalDate();
if(timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE");
}
else if(confirmed!=null) { validateSourceSlot(confirmed); date=confirmed.date(); }
else if(target!=null) { validateSourceSlot(target); date=target.date(); }
if((confirmed!=null || target!=null) && timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE");
var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone)));
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配");
var overdue=new ArrayList<String>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
if(date!=null && date.isBefore(today)) overdue.add("作业日期已过,尚未完成");
}
if(pendingHandoff(state) && execution!=ExecutionStatus.COMPLETED) blocks.add("还空接手尚未确认,继续跟进");
if(changed(state,fact)) blocks.add("原业务事实已变化,须同步影响后再复核");
var actions=new ArrayList<Action>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(actor.has(FOLLOWUP)) actions.add(work(state,Command.SAVE_FOLLOWUP,"记录回复与跟进"));
if(route && actor.has(MANAGE)) actions.add(work(state,Command.CONFIRM_ROUTE,"确认路线"));
if(state.isReviewRequired() && actor.has(FOLLOWUP) && !changed(state,fact)) actions.add(work(state,Command.RESOLVE_REVIEW,"复核安排"));
if(actor.has(MANAGE) && requirementOwned(state,fact)) actions.add(work(state,Command.SAVE_REQUIREMENT,"登记作业要求"));
if(state.getType()==Type.EMPTY_RETURN && actor.has(MANAGE)) {
actions.add(work(state,Command.HANDOFF_RETURN,"转交还空责任"));
if(pendingHandoff(state) && (state.getReturnHandoff().to().kind()!=ResponsibilityKind.USER || actor.id().equals(state.getReturnHandoff().to().id()))) actions.add(work(state,Command.ACCEPT_RETURN,"确认接手反馈"));
}
if(fact!=null && fact.action()!=null) {
boolean executionBlocked=fact.action().code()==ActionCode.RECORD_EXECUTION && (!fact.blockReasons().isEmpty() || state.getType()!=Type.EMPTY_RETURN && (state.isReviewRequired() || changed(state,fact)));
if(fact.actionPermissions().stream().allMatch(actor::has) && !executionBlocked) actions.add(fact.action());
else if(!fact.actionPermissions().stream().allMatch(actor::has)) blocks.add("缺少原业务操作权限,可查看安排并联系有权限人员处理");
}
}
var read=new Action(execution==ExecutionStatus.COMPLETED?ActionCode.VIEW_HISTORY:ActionCode.VIEW_DETAIL,"查看详情",state.getWorkItemKey(),new ReadTarget(state.getReferences()));
actions.add(read);
Action next=actions.stream().filter(a->a.code()==ActionCode.ACCEPT_RETURN).findFirst().orElse(null);
if(next==null && pendingHandoff(state)) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read);
if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.CONFIRM_ROUTE || a.code()==ActionCode.RESOLVE_REVIEW).findFirst().orElse(null);
if(next==null && fact!=null && fact.action()!=null && actions.contains(fact.action())) next=fact.action();
if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_REQUIREMENT || a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read);
var names=Map.of(Type.ROUTE,"确认路线",Type.PICKUP,"提柜",Type.WAREHOUSE_RECEIPT,"接仓卸柜",Type.DELIVERY,"送仓",Type.TRANSPORT,"用车",Type.EMPTY_RETURN,"还空柜");
var businessNo=fact==null?null:fact.businessNo();
var label=businessNo!=null?businessNo:state.getBatchNo()!=null?state.getBatchNo():state.getContainerNo()!=null?state.getContainerNo():"—";
return new WorkItem(state.getWorkItemKey(),state.getVersion(),state.getType(),state.getFlow(),label+" · "+names.get(state.getType()),businessNo,
state.getContainers().isEmpty()?List.of(new ContainerReference(state.getContainerBookingId(),null,state.getContainerNo())):List.copyOf(state.getContainers()),state.getRequirementKey(),state.getBatchKey(),state.getBatchNo(),
state.getOriginName(),state.getDestinationName(),plan,execution,state.isReviewRequired() && execution!=ExecutionStatus.COMPLETED,
target,confirmed,actual,date,basis,timezone,state.getOwner(),state.getFollowupDate(),List.copyOf(overdue),List.copyOf(actions),next,List.copyOf(blocks),List.copyOf(missing));
}
public static boolean requirementOwned(AppointmentWorkState state,Fact fact) {
return (fact==null || !fact.planExists()) && (state.getType()==Type.WAREHOUSE_RECEIPT || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && "CUSTOMER".equals(state.getDestinationKind()));
}
public static Action work(AppointmentWorkState state,Command command,String label) {
return new Action(ActionCode.valueOf(command.name()),label,state.getWorkItemKey(),new WorkTarget(command));
}
public static boolean followToday(WorkItem item,Clock clock) {
var today=LocalDate.now(clock.withZone(item.timezone()==null?REMINDER_ZONE:zone(item.timezone())));
return item.followupDate()!=null && !item.followupDate().isAfter(today) || item.blockReasons().contains("还空接手尚未确认,继续跟进");
}
public static Bucket bucket(WorkItem item) {
return item.executionStatus()==ExecutionStatus.COMPLETED?Bucket.COMPLETED:item.reviewRequired()?Bucket.REVIEW:
item.planStatus()==PlanStatus.CONFIRMED?Bucket.CONFIRMED:item.planStatus()==PlanStatus.NOT_REQUIRED?Bucket.NO_BOOKING_REQUIRED:Bucket.PENDING;
}
public static BucketCounts counts(List<WorkItem> items) {
var counts=new EnumMap<Bucket,Long>(Bucket.class); items.forEach(i->counts.merge(bucket(i),1L,Long::sum));
return new BucketCounts(counts.getOrDefault(Bucket.PENDING,0L),counts.getOrDefault(Bucket.CONFIRMED,0L),counts.getOrDefault(Bucket.NO_BOOKING_REQUIRED,0L),counts.getOrDefault(Bucket.REVIEW,0L),counts.getOrDefault(Bucket.COMPLETED,0L));
}
public static List<String> effectiveKeys(Collection<String> keys,Map<String,AppointmentWorkState> states) {
var pending=new ArrayDeque<>(keys); var seen=new HashSet<String>(); var effective=new LinkedHashSet<String>();
while(!pending.isEmpty()) {
var key=pending.remove(); if(!seen.add(key)) continue;
var state=states.get(key);
if(state!=null && state.isRetired() && !state.getReplacementKeys().isEmpty()) pending.addAll(state.getReplacementKeys());
else effective.add(key);
}
return List.copyOf(effective);
}
public static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,text); }
public static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT,text); }
public static ResponseStatusException missing(String text) { return new ResponseStatusException(HttpStatus.NOT_FOUND,text); }
public static ResponseStatusException forbidden(String text) { return new ResponseStatusException(HttpStatus.FORBIDDEN,text); }
public static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text); }
public static ResponseStatusException unavailable(String text,Throwable cause) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text,cause); }
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.PageResult;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
@Transactional(readOnly=true)
public class AppointmentWorkQueryService {
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway facts;
private final Clock clock;
@org.springframework.beans.factory.annotation.Autowired
public AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts) { this(store,facts,Clock.systemUTC()); }
AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; }
record Snapshot(List<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> items) { }
Snapshot snapshot(Actor actor) {
String scopeVersion=store.requireInitialized();
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
var all=store.all();
var states=all.stream().filter(s->!s.isRetired()).toList();
var current=facts.facts(states);
var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList();
var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
return new Snapshot(all,current,items.stream().map(i->withNext(i,next(i,byKey,stateByKey,new HashSet<>()))).toList());
}
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction();
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
var prior=items.get(key);
if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) {
var action=next(prior,items,states,visited);
if(action!=null) return new Action(action.code(),"先"+action.label().replaceFirst("^先",""),action.targetWorkItemKey(),action.target());
}
}
return item.nextAction();
}
public Summary summary(Actor actor) {
var items=snapshot(actor).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count());
}
public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set<Type> types,Actor actor) {
if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天");
if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看");
var items=snapshot(actor).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
.filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList();
var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList()));
var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList();
return new CalendarResult(from,to,items.size(),counts(items),days);
}
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> types,String containerId,Bucket bucket,String keyword,Actor actor) {
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT);
var items=snapshot(actor).items().stream()
.filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED)
.filter(i->date==null || date.equals(i.calendarDate()))
.filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type()))
.filter(i->containerId==null || i.containers().stream().anyMatch(c->containerId.equals(c.containerBookingId())))
.filter(i->bucket==null || bucket==bucket(i))
.filter(i->word.isEmpty() || searchable(i).contains(word))
.filter(i->switch(view) { case ALL->true; case FOLLOW_TODAY->i.executionStatus()!=ExecutionStatus.COMPLETED && followToday(i,clock); case OVERDUE->!i.overdueReasons().isEmpty(); case UNDATED->i.calendarDate()==null; case REVIEW->i.reviewRequired(); })
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
}
public WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var fact=snapshot.facts().get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key);
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
var statesByKey=snapshot.states().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key),
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff());
}
WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); }
private String searchable(WorkItem i) {
return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT);
}
private WorkItem withNext(WorkItem i,Action next) {
return new WorkItem(i.workItemKey(),i.version(),i.type(),i.flow(),i.title(),i.businessNo(),i.containers(),i.requirementKey(),i.batchKey(),i.batchNo(),i.originName(),i.destinationName(),i.planStatus(),i.executionStatus(),i.reviewRequired(),i.target(),i.confirmed(),i.actual(),i.calendarDate(),i.dateBasis(),i.timezone(),i.owner(),i.followupDate(),i.overdueReasons(),i.allowedActions(),next,i.blockReasons(),i.missingFields());
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
@Service
public class AppointmentWorkRouteService {
public record Route(Flow flow) { }
private final AppointmentWorkStore store;
public AppointmentWorkRouteService(AppointmentWorkStore store) { this.store=store; }
/** Local committed state only. No guard lock and no upstream HTTP calls. */
@Transactional(readOnly=true)
public Route route(String container) {
var flows=store.container(container).stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getFlow).filter(f->f!=null && f!=Flow.UNKNOWN).distinct().toList();
if(flows.size()>1) throw AppointmentWorkPolicy.conflict("柜次持久路线冲突,不能执行原业务写入");
return new Route(flows.isEmpty()?Flow.UNKNOWN:flows.get(0));
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.appointmentwork;
import lombok.Data;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Work-owned state only. observedFingerprint is an audit baseline, never a second business plan. */
@Data
public class AppointmentWorkState {
private String workItemKey;
private int version;
private Type type;
private Flow flow;
private String containerBookingId;
private String containerNo;
private String requirementKey;
private String batchKey;
private String batchNo;
private String tripKey;
private List<String> batchKeys = new ArrayList<>();
private List<ContainerReference> containers = new ArrayList<>();
private String destinationKind;
private String originName;
private String destinationName;
private String timezone;
private String sourceVersion;
private Slot target;
private boolean bookingRequired = true;
private String receivingRequirements;
private String requirementReason;
private Owner owner;
private LocalDate followupDate;
private boolean reviewRequired;
private int reviewVersion;
private String reviewReason;
private String observedFingerprint;
private String observedImpactToken;
private boolean observedArranged;
private Responsibility responsibility;
private ReturnHandoff returnHandoff;
private boolean retired;
private String retirementReason;
private List<String> replacementKeys = new ArrayList<>();
private List<String> prerequisites = new ArrayList<>();
private List<BusinessReference> references = new ArrayList<>();
private List<SourceReference> sources = new ArrayList<>();
private List<String> readPermissions = new ArrayList<>();
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkStore {
public static final int MAX_ITEMS=10000;
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
public AppointmentWorkMapper.Guard lock() {
var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard;
}
public String requireInitialized() {
var guard=mapper.guard();
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
return guard.getScopeVersion();
}
public void checkpoint(AppointmentWorkMapper.Guard guard) { mapper.saveGuard(guard); }
public List<AppointmentWorkState> all() {
var rows=mapper.all(MAX_ITEMS+1);
if(rows.size()>MAX_ITEMS) throw conflict("工作事项超过当前全量核对上限,不能返回截断统计");
return rows.stream().map(this::decode).toList();
}
public List<AppointmentWorkState> container(String id) { return all().stream().filter(s->id.equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->id.equals(c.containerBookingId()))).toList(); }
public AppointmentWorkState get(String key) {
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
}
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
public void save(AppointmentWorkState state) {
state.setVersion(state.getVersion()+1);
if(mapper.update(row(state))!=1) throw conflict("事项版本已变化,请刷新后处理");
}
public AppointmentWorkMapper.History replay(String key,String commandKey) { return mapper.command(key,commandKey); }
public void history(AppointmentWorkState state,Actor actor,String action,String summary,String commandKey,String hash,Object result) {
var row=new AppointmentWorkMapper.History(); row.setId(UUID.randomUUID().toString()); row.setWorkItemKey(state.getWorkItemKey());
row.setOperatorId(actor.id()); row.setOperatorName(actor.name()); row.setAction(action); row.setSummary(summary);
row.setCommandKey(commandKey); row.setRequestHash(hash); row.setResponseJson(result==null?null:write(result));
row.setOccurredAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); mapper.insertHistory(row);
}
public List<HistoryEntry> history(String key) {
var rows=mapper.selectHistory(key);
if(rows.size()>1000) throw conflict("事项历史超过详情上限,需接入历史分页后查看");
return rows.stream().map(r->new HistoryEntry(r.getOccurredAt().toInstant(ZoneOffset.UTC),r.getOperatorName(),r.getAction(),r.getSummary())).toList();
}
public String write(Object value) { try { return json.writeValueAsString(value); } catch(Exception e) { throw new IllegalStateException("工作事项序列化失败",e); } }
public <T> T read(String value,Class<T> type) { try { return json.readValue(value,type); } catch(Exception e) { throw new IllegalStateException("工作事项记录读取失败",e); } }
private AppointmentWorkState decode(AppointmentWorkMapper.Row row) { return read(row.getStateJson(),AppointmentWorkState.class); }
private AppointmentWorkMapper.Row row(AppointmentWorkState state) {
var row=new AppointmentWorkMapper.Row(); row.setWorkItemKey(state.getWorkItemKey()); row.setContainerBookingId(state.getContainerBookingId());
row.setVersion(state.getVersion()); row.setRetired(state.isRetired()); row.setStateJson(write(state)); return row;
}
}
@@ -0,0 +1,217 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkSyncService {
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
/** Called by trusted server orchestration, never by GET or by opening a particular container. */
@Transactional
public SyncResult synchronize(SyncRequest request) {
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
var guard=store.lock();
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
var page=gateway.discover(request.afterContainerBookingId(),request.size());
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
int created=0; var ids=new HashSet<String>();
for(var source:page.containers()) {
validateSource(source);
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
var existing=store.container(source.containerBookingId());
if(existing.isEmpty()) {
if(source.establishedFlow()==Flow.UNKNOWN) {
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
} else {
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size();
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
if(source.establishedFlow()!=Flow.UNKNOWN) {
var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size();
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
}
}
}
}
// An existing row is never deleted because an order left the active source page.
// Empty return and other unfinished work continue to read their original authority.
var all=new ArrayList<>(store.all());
var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList());
reconcileTransport(all,facts);
all=new ArrayList<>(store.all());
facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList());
observe(all,facts,SYSTEM);
guard.setScanCursor(page.complete()?null:page.nextCursor());
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
store.checkpoint(guard);
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
}
static void validateSource(ContainerSource source) {
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
throw unavailable("已开船来源缺少身份或真实开船依据");
if(source.timezone()!=null) zone(source.timezone());
var seen=new HashSet<String>(); var batches=new HashSet<String>();
for(var requirement:source.requirements()) {
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
throw unavailable("送货需求缺少稳定身份或重复");
if(requirement.batchKey()!=null && !batches.add(requirement.batchKey())) throw unavailable("同柜批次需求重复,必须由原域归并来源");
if(requirement.timezone()!=null) zone(requirement.timezone());
}
}
/** Caller holds work guard and validates the route against current original facts. */
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
validateSource(source);
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
var requirements=gateway.requirements(source,flow);
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
validateSource(source);
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a));
var created=new ArrayList<AppointmentWorkState>();
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
for(var requirement:source.requirements()) {
add(source,Type.DELIVERY,requirement,flow,known,created);
add(source,Type.TRANSPORT,requirement,flow,known,created);
}
for(var state:created) {
state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name()));
state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone())));
String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null);
if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup));
if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.TRANSPORT) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.DELIVERY,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.EMPTY_RETURN) {
String containerId=source.containerBookingId();
state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList());
}
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
}
String generationContainerId=source.containerBookingId();
var activeKeys=new HashSet<String>(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); });
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
String containerId=source.containerBookingId();
// A mutable original dispatch can remove this container's cargo while keeping other containers.
// This scan is a complete authority manifest for this container, including its remaining obligations.
var retained=old.getContainers().stream().filter(c->!containerId.equals(c.containerBookingId())).toList();
if(old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:") && !retained.isEmpty()) {
old.setContainers(retained);
if(containerId.equals(old.getContainerBookingId())) { old.setContainerBookingId(retained.get(0).containerBookingId());old.setContainerNo(retained.get(0).containerNo()); }
var removedDependency=key(containerId,flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null);
old.setPrerequisites(old.getPrerequisites().stream().filter(k->!removedDependency.equals(k)).toList());
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
}
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
.map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
// Removed dispatch items no longer exist in the original allocation table. Its full current
// container manifest restores the unallocated demand or names the newly assigned dispatch.
if(replacements.isEmpty() && old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:"))
replacements=requirements.stream().filter(r->"CUSTOMER".equals(r.destinationKind()) && r.sources().stream().anyMatch(old.getSources()::contains)).map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
if(replacements.isEmpty()) throw conflict("原需求离开有效清单但没有明确替代关系,不能隐藏剩余工作");
old.setRetired(true); old.setRetirementReason("BATCH_REPLACED"); old.setReplacementKeys(replacements); store.save(old); store.history(old,actor,"REPLACED","原预约域已提供完整替代需求",null,null,null);
}
return created.stream().map(AppointmentWorkState::getWorkItemKey).toList();
}
private static String requirementKey(String container,Type type,String requirement,String batch) { return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,null); }
private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map<String,AppointmentWorkState> known,List<AppointmentWorkState> created) {
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); }
else if(requirement!=null) {
if("BATCH_REPLACED".equals(old.getRetirementReason())) {
// The full authority manifest explicitly reopens a cancelled obligation/restored original appointment.
// Repartitioned batches never appear here: the provider excludes replacement-bearing originals.
old.setRetired(false); old.setRetirementReason(null); old.setReplacementKeys(List.of()); store.save(old);
}
if(old.getFlow()!=flow) throw conflict("同一批次不能混合中转与直送路线");
var containers=new ArrayList<>(old.getContainers());
boolean update=!Objects.equals(old.getBatchNo(),requirement.batchNo()) || !Objects.equals(old.getOriginName(),requirement.originName()) || !Objects.equals(old.getDestinationName(),requirement.destinationName())
|| !Objects.equals(old.getDestinationKind(),requirement.destinationKind()) || !Objects.equals(old.getReferences(),requirement.references()) || !Objects.equals(old.getSources(),requirement.sources()) || type==Type.DELIVERY && !Objects.equals(old.getTimezone(),requirement.timezone());
if(containers.stream().noneMatch(c->c.containerBookingId().equals(source.containerBookingId()))) {
containers.addAll(state.getContainers()); old.setContainers(List.copyOf(containers));
var dependencies=new LinkedHashSet<>(old.getPrerequisites()); dependencies.add(key(source.containerBookingId(),flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null));
old.setPrerequisites(List.copyOf(dependencies)); update=true;
}
old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources());
if(type==Type.DELIVERY) old.setTimezone(requirement.timezone());
if(update) store.save(old);
}
}
private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) {
var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey()));
state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion());
state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo())));
if(requirement!=null) { state.setRequirementKey(requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources());
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
return state;
}
void observe(List<AppointmentWorkState> states,Map<String,Fact> facts,Actor actor) {
var affected=new LinkedHashSet<String>();
for(var state:states) {
if(state.isRetired()) continue;
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
affected.add(state.getWorkItemKey());
expandDependents(state.getWorkItemKey(),states,affected);
}
}
for(var state:states) {
if(state.isRetired()) continue;
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact);
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
if(changed || baseline || review) {
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
}
}
}
static void expandDependents(String key,List<AppointmentWorkState> states,Set<String> affected) {
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue;
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
}
}
private void reconcileTransport(List<AppointmentWorkState> all,Map<String,Fact> facts) {
var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null)
.collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey()));
var liveCanonical=new HashSet<String>();
for(var entry:members.entrySet()) {
var group=entry.getValue(); group.sort(Comparator.comparing(AppointmentWorkState::getWorkItemKey));
String canonicalKey=key("TRIP:"+entry.getKey(),Type.TRANSPORT,null); liveCanonical.add(canonicalKey);
var canonical=all.stream().filter(s->s.getWorkItemKey().equals(canonicalKey)).findFirst().orElse(null);
boolean fresh=canonical==null;
if(fresh) { canonical=store.read(store.write(group.get(0)),AppointmentWorkState.class); canonical.setWorkItemKey(canonicalKey); canonical.setVersion(0); canonical.setTripKey(entry.getKey()); canonical.setRequirementKey(null); canonical.setBatchKey(null); canonical.setBatchNo(null); canonical.setReplacementKeys(List.of()); }
var containers=group.stream().flatMap(s->s.getContainers().stream()).distinct().sorted(Comparator.comparing(ContainerReference::containerBookingId)).toList();
var batches=group.stream().map(AppointmentWorkState::getBatchKey).filter(Objects::nonNull).distinct().sorted().toList();
var prerequisites=group.stream().flatMap(s->s.getPrerequisites().stream()).distinct().sorted().toList();
boolean changed=canonical.isRetired() || !containers.equals(canonical.getContainers()) || !batches.equals(canonical.getBatchKeys()) || !prerequisites.equals(canonical.getPrerequisites());
canonical.setContainers(containers); canonical.setBatchKeys(batches); canonical.setPrerequisites(prerequisites); canonical.setRetired(false); canonical.setRetirementReason(null);
canonical.setReferences(group.stream().flatMap(s->s.getReferences().stream()).distinct().toList());
canonical.setSources(group.stream().flatMap(s->s.getSources().stream()).distinct().toList());
if(fresh) store.insert(canonical); else if(changed) store.save(canonical);
for(var member:group) if(!member.isRetired() || !member.getReplacementKeys().equals(List.of(canonicalKey))) { member.setRetired(true); member.setRetirementReason("TRIP_LINKED"); member.setReplacementKeys(List.of(canonicalKey)); store.save(member); }
}
for(var state:all) {
if(state.getTripKey()!=null && !liveCanonical.contains(state.getWorkItemKey()) && !state.isRetired()) { state.setRetired(true); state.setRetirementReason("TRIP_UNLINKED"); store.save(state); }
if(state.getType()==Type.TRANSPORT && state.getTripKey()==null && "TRIP_LINKED".equals(state.getRetirementReason()) && facts.containsKey(state.getWorkItemKey()) && facts.get(state.getWorkItemKey()).tripKey()==null) { state.setRetired(false); state.setRetirementReason(null); state.setReplacementKeys(List.of()); store.save(state); }
}
// Keep requirement edges stable; query traversal follows retired transport replacement keys.
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.appointmentwork;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** Periodic full scan also compensates remote changes and process restarts; no GET performs writes. */
@Component
@EnableScheduling
public class AppointmentWorkSynchronizationJob {
private static final Logger log=LoggerFactory.getLogger(AppointmentWorkSynchronizationJob.class);
private final AppointmentWorkSyncService synchronization;
private String cursor;
public AppointmentWorkSynchronizationJob(AppointmentWorkSyncService synchronization) { this.synchronization=synchronization; }
@Scheduled(initialDelayString="${baiyue.appointment-work.sync-initial-delay-ms:30000}",fixedDelayString="${baiyue.appointment-work.sync-delay-ms:10000}")
public void run() {
try {
var result=synchronization.synchronize(new AppointmentWorkModels.SyncRequest(cursor,100));
cursor=result.complete()?null:result.nextCursor();
} catch(RuntimeException error) {
cursor=null;
log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",error.getClass().getSimpleName());
}
}
}
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
public final class DispatchRequests {
private DispatchRequests() {
@@ -37,10 +38,15 @@ public final class DispatchRequests {
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo,
@Size(max = 500) List<@NotNull Long> returnReceiptIds) {
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public PreArrange {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public PreArrange(List<Long> planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List<Long> returnReceiptIds) {
this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, String draftToken, String relabelNo) {
@@ -74,11 +80,14 @@ public final class DispatchRequests {
public record DraftGroup(@NotNull Long draftContainerId,
@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 500) List<@NotNull Long> returnReceiptIds) {
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public DraftGroup {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public DraftGroup(Long id,List<Long> pallets,List<Long> receipts) { this(id,pallets,receipts,List.of()); }
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
@@ -11,12 +11,14 @@ import java.util.List;
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 " +
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END 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 NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item other_batch " +
"WHERE other_batch.order_id=fi.order_id AND other_batch.customer_id=fi.customer_id AND other_batch.active=1 AND other_batch.appointment_id!=fi.appointment_id) " +
"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 =
@@ -52,6 +52,8 @@ import java.util.stream.Collectors;
@Service
public class DispatchService {
@Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
@Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges;
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
@@ -350,7 +352,7 @@ public class DispatchService {
var results = new java.util.ArrayList<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>();
for (var group : prepared) {
var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null,
token, null, group.request().returnReceiptIds());
token, null, group.request().returnReceiptIds(),group.request().batchAllocations());
var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true);
results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response));
}
@@ -375,6 +377,8 @@ public class DispatchService {
private DispatchResponse preArrangeContainer(String customerId, String operatorId,
DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) {
if(StringUtils.hasText(request.fbaAppointmentNo()) && request.batchAllocations().isEmpty())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"预约关联必须提供不可变批次键及逐票实物分配,不能仅填写预约号");
if (StringUtils.hasText(request.relabelNo())) {
if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用");
return preArrangeRelabel(customerId, operatorId, request);
@@ -444,7 +448,7 @@ public class DispatchService {
var existing = items.findByDispatchOrder(customerId, dispatch.getId());
if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet())
.equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet())))
return response(dispatch, existing);
{ appointmentAllocations.requireReplay(customerId,dispatch.getId(),request.batchAllocations()); return response(dispatch, existing); }
}
if (refreshing) {
requirePalletEditingAllowed(dispatch);
@@ -581,7 +585,9 @@ public class DispatchService {
DispatchOrder responseOrder = preserveExactReplay
? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing")
: dispatch;
return response(responseOrder, items.findByDispatchOrder(customerId, dispatch.getId()));
var assignedItems=items.findByDispatchOrder(customerId, dispatch.getId());
appointmentAllocations.bind(customerId,dispatch.getId(),assignedItems.stream().map(i->new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(),request.batchAllocations());
return response(responseOrder, assignedItems);
}
@Transactional
@@ -736,6 +742,7 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
appointmentAllocations.removeItem(dispatch.getId(),item.getId());
List<DispatchItem> remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
dispatch.setItemCount(remaining.size());
@@ -775,6 +782,7 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
workChanges.requirementsChanged();
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
@@ -23,6 +23,7 @@ import java.util.*;
@Service
public class FleetTripExecutionService {
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
@@ -228,6 +229,7 @@ public class FleetTripExecutionService {
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
// Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result.
for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo());
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
@@ -7,7 +7,14 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
@Select("SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,expected_driver_end_at,departed_at,completed_at,stops_json FROM ll_fleet_trip WHERE planned_departure_at>=#{from} AND planned_departure_at<#{to} ORDER BY planned_departure_at,trip_no LIMIT 2001")
@Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json
FROM ll_fleet_trip
WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
""")
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@@ -27,6 +27,8 @@ import java.util.*;
@Service
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
@@ -205,6 +207,7 @@ public class FleetTripService {
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
@@ -339,6 +342,7 @@ public class FleetTripService {
record(trip, action, reason, operator);
}
private void record(FleetTripEntity trip, String action, String reason, String operator) {
workChanges.changed(trip);
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.SupplierDirectory;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import com.baiyue.lastleg.fleet.quote.FleetQuoteSupplierClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkSupplierDirectory implements SupplierDirectory {
private final FleetQuoteSupplierClient suppliers;
private final String token;
public AppointmentWorkSupplierDirectory(FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token:}") String token) { this.suppliers=suppliers; this.token=token; }
public Responsibility activeSupplier(String id) {
final long number;
try { number=Long.parseLong(id); if(number<=0 || !Long.toString(number).equals(id)) throw new NumberFormatException(); }
catch(RuntimeException error) { throw bad("供应商关联标识无效"); }
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=suppliers.get(token,number,true,"TRUCKING");
if(response==null || !response.successful() || response.data()==null) throw unavailable("供应商权威目录读取失败");
var supplier=response.data();
if(supplier.supplierId()!=number || !"ACTIVE".equals(supplier.status()) || !"TRUCKING".equals(supplier.category()) || !"OVERSEAS".equals(supplier.truckingScope()) || supplier.name()==null || supplier.name().isBlank()) throw bad("还空责任方须为有效海外车队");
return new Responsibility(ResponsibilityKind.SUPPLIER,id,supplier.name());
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkTransportProvider implements AppointmentWorkFactGateway.BusinessProvider {
private final AppointmentWorkTripMapper mapper;
private final FleetWarehouseClient warehouses;
private final AppointmentWorkCommitmentService commitments;
private final String token;
public AppointmentWorkTransportProvider(AppointmentWorkTripMapper mapper,FleetWarehouseClient warehouses,AppointmentWorkCommitmentService commitments,@Value("${baiyue.security.internal-service-token:}") String token) { this.mapper=mapper; this.warehouses=warehouses; this.commitments=commitments; this.token=token; }
public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.TRANSFER; }
public List<Fact> read(List<FactRequest> requests) { return requests.stream().map(this::read).toList(); }
private Fact read(FactRequest request) {
FleetTripEntity trip=null;
if(request.tripKey()!=null) {
try { trip=mapper.trip(Long.parseLong(request.tripKey())); }
catch(NumberFormatException error) { throw unavailable("车次关联键无效",error); }
if(trip==null) throw unavailable("已关联原车次缺失");
} else if(request.batchKey()!=null) {
var trips=mapper.batchTrips(request.batchKey());
if(trips.size()>1) throw conflict("同一预约批次关联多个车次,请在原业务核对分批与实物关系");
if(!trips.isEmpty()) trip=trips.get(0);
}
if(trip==null) {
var action=new Action(ActionCode.ARRANGE_TRANSPORT,"按批次选择实物并安排车次",request.workItemKey(),new TripTarget(null,request.batchKeys()));
return new Fact(request.workItemKey(),"UNASSIGNED","UNASSIGNED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,null,null,
List.of(ORDER_READ),List.of("platform:fleet:trip:create","platform:fleet:trip:edit"),action,
request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null);
}
String timezone=timezone(trip.getWarehouseId());
var missing=timezone==null?List.of("SOURCE_TIMEZONE"):List.<String>of();
var target=localSlot(trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),timezone);
boolean complete="COMPLETED".equals(trip.getStatus()) && trip.getCompletedAt()!=null;
var occurred=complete?trip.getCompletedAt():null;
var actual=occurred==null?null:new Actual(occurred.toInstant(ZoneOffset.UTC),null,timezone);
var execution=complete?ExecutionStatus.COMPLETED:trip.getDepartedAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var action=complete?null:new Action(ActionCode.ARRANGE_TRANSPORT,"维护原车次安排",request.workItemKey(),new TripTarget(trip.getId().toString(),mapper.batches(trip.getId())));
return new Fact(request.workItemKey(),"TRIP:"+trip.getId()+":"+trip.getRecordVersion(),commitments.impactToken("TRIP:"+trip.getId()),true,
target==null?PlanStatus.UNPLANNED:trip.getVehicleId()!=null && trip.getDriverId()!=null?PlanStatus.CONFIRMED:PlanStatus.TARGET_SET,
execution,target,trip.getVehicleId()!=null && trip.getDriverId()!=null?target:null,actual,timezone,trip.getTripNo(),trip.getId().toString(),
List.of(ORDER_READ,"platform:fleet:trip:list"),action==null?List.of():List.of("platform:fleet:trip:edit"),action,
"CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null);
}
private String timezone(Long warehouse) {
if(warehouse==null) return null;
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=warehouses.platformMetadata(token,new FleetWarehouseClient.MetadataRequest(List.of(warehouse)));
if(response==null || !response.successful() || response.data()==null || response.data().records()==null || response.data().missingIds()==null) throw unavailable("车次地点目录读取不完整");
var matches=response.data().records().stream().filter(w->warehouse.equals(w.id())).toList();
if(matches.size()>1 || matches.isEmpty() && !response.data().missingIds().contains(warehouse)) throw unavailable("车次地点目录遗漏请求结果");
if(matches.isEmpty() || matches.get(0).timezone()==null) return null;
zone(matches.get(0).timezone()); return matches.get(0).timezone();
}
private Slot localSlot(LocalDateTime start,LocalDateTime end,String timezone) {
// An absolute UTC plan cannot be relabelled as unknown local time.
if(start==null || timezone==null) return null;
var local=start.toInstant(ZoneOffset.UTC).atZone(zone(timezone)); var finish=end==null?null:end.toInstant(ZoneOffset.UTC).atZone(zone(timezone));
return new Slot(local.toLocalDate(),local.toLocalTime(),finish==null?null:finish.toLocalDate(),finish==null?null:finish.toLocalTime(),timezone);
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class AppointmentWorkTripChanges {
private final AppointmentWorkChanges changes;
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkTripMapper mapper;
public AppointmentWorkTripChanges(AppointmentWorkChanges changes,AppointmentWorkCommitmentService commitments,AppointmentWorkTripMapper mapper) { this.changes=changes; this.commitments=commitments; this.mapper=mapper; }
public void changed(FleetTripEntity trip) {
changes.requirementsChanged();
String assignment=trip.getVehicleId()==null && trip.getDriverId()==null?null:trip.getVehicleId()+":"+trip.getDriverId();
String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId())+":"+Objects.toString(trip.getStopsJson(),""));
// Fleet plan storage is explicitly UTC. This is an audit baseline, not a location-time projection.
var start=trip.getPlannedDepartureAt(); var end=trip.getExpectedDriverEndAt();
var slot=start==null?null:new AppointmentWorkModels.Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),"UTC");
commitments.record("TRIP:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,cargo,"CANCELLED".equals(trip.getStatus())));
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentWorkTripMapper {
@Select("SELECT DISTINCT t.* FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id JOIN ll_fleet_trip t ON t.id=b.trip_id AND t.status<>'CANCELLED' WHERE a.batch_key=#{batch}")
List<FleetTripEntity> batchTrips(String batch);
@Select("SELECT * FROM ll_fleet_trip WHERE id=#{id}") FleetTripEntity trip(long id);
@Select("SELECT DISTINCT a.batch_key FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE b.trip_id=#{id} ORDER BY a.batch_key")
List<String> batches(long id);
}
@@ -24,6 +24,12 @@ public class AppointmentSourceCreateController {
requirePlatform(user);
return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request));
}
@PostMapping("/repartition")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')")
public ApiResponse<RepartitionFbaAppointmentsResponse> repartition(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody RepartitionFbaAppointmentsRequest request) {
requirePlatform(user);
return ApiResponse.success(service.repartition(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) {
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentBatchContext;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches")
public class FbaAppointmentBatchController {
private final InboundFbaAppointmentService service;
public FbaAppointmentBatchController(InboundFbaAppointmentService service) {this.service=service;}
@GetMapping("/{batchKey}")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<FbaAppointmentBatchContext> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) {
if(user==null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return ApiResponse.success(service.batchContext(batchKey));
}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.util.List;
public record FbaAppointmentBatchContext(FbaAppointmentResponse appointment,List<SourceAllocation> sources,
List<String> allowedActions,List<String> blockReasons) {
public record SourceAllocation(String customerId,String sourceKey,String destinationKey,Integer packageCount,
Integer originalPackages,String sourceVersion,Integer cargoVerificationVersion,
List<String> shipmentReferences) { }
}
@@ -24,9 +24,14 @@ public record FbaAppointmentResponse(
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<Item> items,
Integer version
Integer version,
List<String> replacementAppointmentNos,
String repartitionReason,
String batchKey,
List<String> replacementBatchKeys
) {
public record Item(Long id, String sourceKey, String sourceVersion, Long orderId, String inboundCode,
String fbaWarehouseCode, String customerId, Integer packageCount) {
String fbaWarehouseCode, String customerId, Integer packageCount,
Integer cargoVerificationVersion, List<String> shipmentReferences, String destinationKey) {
}
}
@@ -21,7 +21,7 @@ public final class InboundPlanRequests {
String destinationWarehouseCode,
String warehouse,
String fbaWarehouseCode,
@Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED")
@Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED|MULTIPLE")
String appointmentStatus,
@Pattern(regexp = "UNSCHEDULED|SCHEDULED")
String dispatchStatus,
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
import java.util.List;
public record RepartitionFbaAppointmentsRequest(
@NotEmpty @Size(max=20) List<@NotNull @Valid Original> originals,
@NotEmpty @Size(max=20) List<@NotNull @Valid Batch> batches,
@NotBlank @Size(max=1000) String reason,
@NotBlank @Size(max=64) String idempotencyKey
) {
public record Original(@NotBlank @Size(max=64) String appointmentNo,@NotNull @Min(0) Integer version) { }
public record Batch(LocalDate plannedDate,@Size(max=1000) String remark,
@NotEmpty @Size(max=100) List<@NotNull @Valid Allocation> allocations) { }
public record Allocation(@NotBlank @Size(max=128) String customerId,@NotBlank @Size(max=160) String sourceKey,
@NotNull @Positive Integer packageCount,@Size(max=160) String expectedSourceVersion,
@Min(1) Integer expectedVerificationVersion) { }
}
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.util.List;
public record RepartitionFbaAppointmentsResponse(List<String> replacedBatchKeys,List<String> replacedAppointmentNos,
List<FbaAppointmentResponse> appointments) { }
@@ -38,6 +38,9 @@ public class InboundFbaAppointment {
private String appointmentRemark;
private String fbaWarehouseCode;
private Integer selectedCount;
private String replacementNosJson;
private String replacementKeysJson;
private String repartitionReason;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -19,6 +19,10 @@ public class InboundFbaAppointmentItem {
private String sourceVersion;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private Integer packageCount;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private Integer cargoVerificationVersion;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String shipmentReferencesJson;
private Boolean active;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private Long orderId;
@@ -14,11 +14,19 @@ public interface InboundFbaAppointmentQueryMapper {
@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);
record Allocation(long packages,long count,long unknownCount) { }
@Select("<script>SELECT COALESCE(SUM(package_count),0) AS packages,COUNT(*) AS count,COUNT(*)-COUNT(package_count) AS unknownCount " +
"FROM ll_inbound_fba_appointment_item WHERE active=1 AND customer_id=#{customerId} " +
"<if test='excludedId != null'>AND appointment_id&lt;&gt;#{excludedId} </if>" +
"AND (source_key=#{sourceKey}<if test='orderId != null'> OR order_id=#{orderId}</if>)</script>")
Allocation allocation(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,
@Param("orderId") Long orderId,@Param("excludedId") Long excludedId);
@Select({"<script>SELECT DISTINCT appointment_id FROM ll_inbound_fba_appointment_item WHERE ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(customer_id=#{s.customerId} AND source_key=#{s.sourceKey} AND CONCAT('FBA:',fba_warehouse_code)=#{s.destinationKey})</foreach>",
" ORDER BY appointment_id DESC LIMIT 1001</script>"})
List<Long> relatedBatchIds(@Param("sources") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> sources);
@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 ("+
@@ -34,16 +42,10 @@ public interface InboundFbaAppointmentQueryMapper {
"</script>")
List<FbaAppointmentLineSnapshot> selectedLines(@Param("lineIds") List<Long> lineIds);
@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("""
SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i
JOIN ll_dispatch_item di ON di.order_id=i.order_id AND di.customer_id=i.customer_id
JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id
WHERE i.appointment_id=#{appointmentId} AND d.status<>'CANCELLED'
SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a
JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED'
""")
long countActiveDispatches(@Param("appointmentId") Long appointmentId);
}
@@ -13,6 +13,7 @@ import java.util.List;
@Mapper
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
String APPOINTMENT_STATUS="CASE WHEN (SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=p.source_order_id AND current_item.customer_id=p.customer_id AND current_item.active=1)&gt;1 THEN 'MULTIPLE' ELSE 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.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND a.replacement_nos_json IS NULL ORDER BY ai.active DESC,a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') END";
String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
"LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " +
"WHERE p.customer_id=#{customerId}" +
@@ -23,8 +24,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<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.order_id=p.source_order_id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
"AND ("+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.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>" +
@@ -34,9 +34,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
"l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," +
"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.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" +
APPOINTMENT_STATUS+" 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,
@Param("q") InboundPlanRequests.Query query,
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper
public interface InboundPlanQueryMapper {
String ACTIVE_APPOINTMENT_COUNT="(SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=l.order_id AND current_item.customer_id=p.customer_id AND current_item.fba_warehouse_code=l.fba_warehouse_code AND current_item.active=1)";
String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+"&gt;1 THEN 'MULTIPLE' ELSE a.appointment_status END";
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
" AND dispatch.customer_id=di.customer_id" +
@@ -24,7 +26,7 @@ public interface InboundPlanQueryMapper {
" 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.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)" +
" AND a2.replacement_nos_json IS NULL ORDER BY ai2.active DESC,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" +
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
@@ -36,7 +38,7 @@ public interface InboundPlanQueryMapper {
"<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 OR ai.active=0)</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND ("+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>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
@@ -55,8 +57,8 @@ public interface InboundPlanQueryMapper {
"(SELECT i.net_weight_kg FROM ll_inbound_order i WHERE i.id=l.order_id AND i.customer_id=p.customer_id),0) netWeightKg," +
"l.volume_cbm volumeCbm," +
"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," +
"p.urgent_flag urgent,p.status status,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+"&gt;1 THEN NULL ELSE a.appointment_no END appointmentNo," +
APPOINTMENT_STATUS+" appointmentStatus,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+"&gt;1 THEN NULL ELSE a.appointment_at END appointmentAt," +
"NULL expectedLoadingAt," +
"CASE WHEN " + SCHEDULED_DISPATCH +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
@@ -102,6 +102,7 @@ public class ReceivingContainerService {
@Resource private ContainerLoadingEvidenceMapper domesticEvidence;
@Resource private InboundPdaWorkflowService workflow;
@Resource private ObjectMapper sourceObjectMapper;
@Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard workRouteGuard;
private ObjectMapper objectMapper;
@@ -638,6 +639,7 @@ public class ReceivingContainerService {
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
: sessions.findActiveByOperator(key);
if (session == null) throw notFound("Current receiving container session was not found");
if (lock) workRouteGuard.requireWarehouseFlow(session.getSourceContainerBookingId());
requireStableSessionFacts(session, lock);
return session;
}
@@ -646,6 +648,7 @@ public class ReceivingContainerService {
long overseasWarehouseId, boolean lock) {
String bookingId = trim(sourceContainerBookingId);
if (bookingId == null) throw conflict("sourceContainerBookingId is required");
if (lock) workRouteGuard.requireWarehouseFlow(bookingId);
LambdaQueryWrapper<LastlegOrder> query = new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getContainerNo, containerNo)
.eq(LastlegOrder::getSourceContainerBookingId, bookingId)
@@ -29,12 +29,15 @@ public class InboundFbaAppointmentService {
@Resource private FbaAppointmentCommandMapper commands;
@Resource private AppointmentSourceService sources;
@Resource private AppointmentBatchPreviewMapper flags;
@Resource private AppointmentCargoVerificationService verification;
@Resource private ObjectMapper json;
@Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges;
@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>()
.isNull(InboundFbaAppointment::getReplacementNosJson)
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"))
.apply("COALESCE(appointment_at,planned_at,planned_date)<{1} AND (COALESCE(appointment_at,planned_at,planned_date)>={0} OR CASE WHEN appointment_at IS NOT NULL THEN appointment_end_at WHEN planned_at IS NOT NULL THEN planned_end_at ELSE NULL END>{0})",from.atStartOfDay(),to.atStartOfDay())
.orderByAsc(InboundFbaAppointment::getId);
@@ -44,6 +47,7 @@ public class InboundFbaAppointmentService {
public PageResult<FbaAppointmentResponse> unscheduled(int page,int size) {
if(page<1 || size<1 || size>100) throw bad("分页参数无效");
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
.isNull(InboundFbaAppointment::getReplacementNosJson)
.isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt)
.isNull(InboundFbaAppointment::getPlannedDate)
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"));
@@ -72,7 +76,7 @@ public class InboundFbaAppointmentService {
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 resolved=resolveForWrite(refs);
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("接仓点或目的仓已变化,请重新核对预约货物");
@@ -83,11 +87,12 @@ public class InboundFbaAppointmentService {
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);
var evidence=verification(resolved);
apply(appointment,details,resolved,evidence);
if(Set.of("FAILED","CANCELLED").contains(appointment.getAppointmentStatus())) throw bad("新安排请先保存待确认或登记已取得的正式确认");
try {appointmentMapper.insert(appointment);}
catch(DuplicateKeyException duplicate){throw conflict("该目的仓的外部约号已有记录,请继续原预约");}
writeItems(appointment,resolved,List.of());
writeItems(appointment,resolved,List.of(),evidence,Map.of());
return response(appointment,items(appointment.getId()));
}
@@ -96,11 +101,56 @@ public class InboundFbaAppointmentService {
var appointment=find(appointmentNo,false);if(appointment==null) throw missing();
return response(appointment,items(appointment.getId()));
}
/** Internal read boundary. Caller authorizes source access; includes cancelled/replaced history so demand is not silently erased. */
@Transactional(readOnly=true)
public List<FbaAppointmentResponse> relatedBatchSnapshots(List<AppointmentCoverageService.SourceRef> refs) {
if(refs==null || refs.isEmpty()) return List.of();
if(refs.size()>100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) throw bad("每次最多读取100个完整原来源的关联批次");
var ids=queryMapper.relatedBatchIds(refs.stream().distinct().toList());
if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对");
return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids));
}
@Transactional(readOnly=true)
public FbaAppointmentBatchContext batchContext(String key) {
final long id;
try {id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id<=0 || !key.equals(batchKey(id))) throw new IllegalArgumentException();}
catch(RuntimeException error) {throw bad("批次关联键无效");}
var appointment=appointmentMapper.selectById(id);if(appointment==null) throw missing();
var rows=items(id);var snapshot=response(appointment,rows);
var reasons=new ArrayList<String>();var actions=new ArrayList<String>();
if(appointment.getReplacementNosJson()!=null) reasons.add("原批次已被分批或合并,请进入替代批次");
if(!"PENDING".equals(appointment.getAppointmentStatus())) reasons.add("仅未取得正式确认的活动批次可分批或合并");
if(queryMapper.countActiveDispatches(id)>0) reasons.add("已关联排车,请先处理运输安排");
if(!reasons.isEmpty()) return new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.copyOf(reasons));
var refs=rows.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList();
var resolved=sources.resolveBatch(refs);var evidence=verification(resolved);
if(resolved.stream().anyMatch(s->!Objects.equals(s.originWarehouseId(),appointment.getOriginWarehouseId()) || !Objects.equals(s.destinationWarehouseCode(),appointment.getFbaWarehouseCode()))) reasons.add("原来源起讫点已变化,请先复核原批次");
var originalItems=rows.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item));
var allocations=new ArrayList<FbaAppointmentBatchContext.SourceAllocation>();
for(var source:resolved) {
var item=originalItems.get(source.sourceKey());var verified=evidence.get(source.sourceKey());
var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),id);
Integer total=quantity(source,evidence),count=item.getPackageCount();
if(count==null && total!=null && other.unknownCount()==0 && other.packages()<total) count=Math.toIntExact(total-other.packages());
if(!source.canFollowup()) reasons.add(source.blockReasons().isEmpty()?"原来源当前不可安排":String.join(";",source.blockReasons()));
if(!Objects.equals(source.sourceVersion(),item.getSourceVersion())) reasons.add("原批次来源版本已变化,请先保存核对后的预约安排");
if(total==null || count==null || count<=0 || other.unknownCount()>0 || (long)count+other.packages()>total) reasons.add("原来源箱数或分配总量待核实");
allocations.add(new FbaAppointmentBatchContext.SourceAllocation(source.customerId(),source.sourceKey(),source.destinationKey(),count,
source.packages(),source.sourceVersion(),verified==null?null:verified.version(),verified==null?List.of():verified.shipmentReferences()));
}
if(resolved.stream().allMatch(AppointmentSourceService.Source::canFollowup)) actions.add("VERIFY_CARGO");
if(reasons.isEmpty()) {
if(allocations.stream().mapToLong(a->a.packageCount()).sum()>1) actions.add("SPLIT");
actions.add("MERGE");
}
return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList());
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) {
if ("CANCELLED".equals(status(request.details().appointmentStatus()))) throw bad("取消预约请使用取消操作并填写实际取消原因");
return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
requireCurrent(appointment);
if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约");
var current=items(appointment.getId());
@@ -112,11 +162,12 @@ public class InboundFbaAppointmentService {
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());
var resolved=resolveForWrite(refs);
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);
var evidence=verification(resolved);
apply(appointment,request.details(),resolved,evidence);
writeItems(appointment,resolved,current,evidence,Map.of());
}
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("预约版本已变化");
@@ -130,6 +181,7 @@ public class InboundFbaAppointmentService {
var cancellation=new CancelFbaAppointmentRequest(version,request.cancelReason().trim());
command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,cancellation,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
requireCurrent(appointment);
if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车");
appointment.setAppointmentStatus("CANCELLED");
@@ -139,7 +191,106 @@ public class InboundFbaAppointmentService {
});
}
private List<AppointmentSourceService.Source> resolveForWrite(List<AppointmentCoverageService.SourceRef> refs,Long excludedId) {
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) {
validateRepartition(request);
return executeCommand("REPARTITION",operator,request.idempotencyKey(),request,RepartitionFbaAppointmentsResponse.class,
result->result.appointments().get(0).appointmentNo(),()->{
var originals=new ArrayList<InboundFbaAppointment>();
var originalItems=new LinkedHashMap<Long,List<InboundFbaAppointmentItem>>();
var refs=new LinkedHashMap<String,AppointmentCoverageService.SourceRef>();
for(var intent:request.originals().stream().sorted(Comparator.comparing(RepartitionFbaAppointmentsRequest.Original::appointmentNo)).toList()) {
var row=find(intent.appointmentNo(),true);if(row==null) throw missing();
requireCurrent(row);
if(!Objects.equals(row.getVersion(),intent.version())) throw conflict("原预约已更新,请重新核对分批资料");
if(!"PENDING".equals(row.getAppointmentStatus()) || row.getConfirmationNo()!=null || row.getAppointmentAt()!=null)
throw conflict("仅未取得正式确认的批次可以分批或合并,请先处理已确认预约");
if(queryMapper.countActiveDispatches(row.getId())>0) throw conflict("原批次已有关联排车,请先处理运输安排");
if(!originals.isEmpty() && (!Objects.equals(originals.get(0).getOriginWarehouseId(),row.getOriginWarehouseId())
|| !Objects.equals(originals.get(0).getFbaWarehouseCode(),row.getFbaWarehouseCode()))) throw bad("仅同一起运仓、同一目的仓可以合并");
var items=items(row.getId());
if(items.isEmpty() || items.stream().anyMatch(item->!Boolean.TRUE.equals(item.getActive()))) throw conflict("原批次货物已变化,请重新核对");
for(var item:items) {
var ref=new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+row.getFbaWarehouseCode());
var previous=refs.putIfAbsent(item.getSourceKey(),ref);
if(previous!=null && !previous.equals(ref)) throw conflict("同一原来源的客户或目的地不一致");
}
originals.add(row);originalItems.put(row.getId(),items);
}
if(refs.size()>100) throw bad("一次最多重新分配100票原来源");
var resolved=resolveForWrite(List.copyOf(refs.values()));
var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s));
var grouped=group(resolved);
if(!Objects.equals(grouped.originWarehouseId(),originals.get(0).getOriginWarehouseId())
|| !Objects.equals(grouped.destinationKey(),"FBA:"+originals.get(0).getFbaWarehouseCode())) throw conflict("原来源起讫点已变化,请先复核原批次");
var evidence=verification(resolved);
var totals=new LinkedHashMap<String,Long>();
for(var rows:originalItems.values()) for(var item:rows) {
var source=bySource.get(item.getSourceKey());
if(!Objects.equals(item.getSourceVersion(),source.sourceVersion())) throw conflict("原来源版本已变化,请先复核原批次");
Integer count=item.getPackageCount();
if(count==null) {
var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),item.getAppointmentId());
var total=quantity(source,evidence);
if(total==null || other.unknownCount()>0 || other.packages()>=total) throw conflict("原批次箱数尚未核实,不能分批");
count=Math.toIntExact(total-other.packages());
}
if(count<=0) throw conflict("原批次箱数无效,请核实");
totals.merge(source.sourceKey(),count.longValue(),Long::sum);
}
var allocated=new LinkedHashMap<String,Long>();
for(var batch:request.batches()) for(var allocation:batch.allocations()) {
var source=bySource.get(allocation.sourceKey());
if(source==null || !Objects.equals(source.customerId(),allocation.customerId())) throw bad("新批次只能使用原批次的真实来源及客户归属");
var verified=evidence.get(source.sourceKey());
if(!Objects.equals(source.sourceVersion(),allocation.expectedSourceVersion())
|| !Objects.equals(verified==null?null:verified.version(),allocation.expectedVerificationVersion())) throw conflict("所选来源或货物核实版本已变化,请刷新后重新分配");
allocated.merge(source.sourceKey(),allocation.packageCount().longValue(),Long::sum);
}
if(!totals.equals(allocated)) throw bad("分批或合并前后每票原来源的分配箱数必须完全相等");
// Release only these old batches while holding every original source lock. All inserts and replacements roll back together.
originalItems.values().forEach(rows->rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);}));
var replacements=new ArrayList<FbaAppointmentResponse>();
for(var batch:request.batches()) {
var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList();
var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount));
var row=new InboundFbaAppointment();row.setId(IdWorker.getId());
row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt());
apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence);
appointmentMapper.insert(row);writeItems(row,rows,List.of(),evidence,quantities);
replacements.add(response(row,items(row.getId())));
}
String replacementsJson,replacementKeysJson;
try {replacementsJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::appointmentNo).toList());
replacementKeysJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::batchKey).toList());}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("分批关系无法保存",error);}
for(var row:originals) {
row.setReplacementNosJson(replacementsJson);row.setReplacementKeysJson(replacementKeysJson);row.setRepartitionReason(request.reason().trim());row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());
if(appointmentMapper.updateById(row)!=1) throw conflict("原批次替代关系未保存");
}
return new RepartitionFbaAppointmentsResponse(originals.stream().map(row->batchKey(row.getId())).toList(),originals.stream().map(InboundFbaAppointment::getAppointmentNo).toList(),List.copyOf(replacements));
});
}
private static void validateRepartition(RepartitionFbaAppointmentsRequest request) {
if(request==null || request.originals()==null || request.batches()==null || request.originals().isEmpty() || request.batches().isEmpty()
|| request.originals().size()>20 || request.batches().size()>20 || clean(request.reason())==null || request.reason().length()>1000
|| clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64) throw bad("请填写有效原批次、新批次、调整原因和请求标识");
if(!(request.originals().size()==1 && request.batches().size()>1 || request.originals().size()>1 && request.batches().size()==1))
throw bad("分批须为一批分为多批,合并须为多批合为一批");
var originals=new HashSet<String>();
for(var row:request.originals()) if(row==null || clean(row.appointmentNo())==null || row.version()==null || row.version()<0 || !originals.add(row.appointmentNo())) throw bad("请选择不重复的原预约及有效版本");
for(var batch:request.batches()) {
if(batch==null || batch.allocations()==null || batch.allocations().isEmpty() || batch.allocations().size()>100 || batch.remark()!=null && batch.remark().length()>1000) throw bad("每批请选择1至100票来源");
var keys=new HashSet<String>();
for(var row:batch.allocations()) if(row==null || clean(row.customerId())==null || clean(row.sourceKey())==null
|| row.packageCount()==null || row.packageCount()<=0 || row.expectedVerificationVersion()!=null && row.expectedVerificationVersion()<1
|| !keys.add(row.sourceKey())) throw bad("同批来源不可重复,每票箱数必须为正整数");
}
}
private List<AppointmentSourceService.Source> resolveForWrite(List<AppointmentCoverageService.SourceRef> refs) {
sources.lockForWrite(refs);
var resolved=sources.resolveBatch(refs);
var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().sorted().toList();
@@ -153,14 +304,26 @@ public class InboundFbaAppointmentService {
if(states.size()!=ids.size() || states.stream().anyMatch(s->s.detained() || s.mixedDestinations())) throw conflict("货物暂扣或含多个目的地,请先核对");
}
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;
}
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 void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows) {
private Map<String,AppointmentCargoVerificationService.Verification> verification(List<AppointmentSourceService.Source> rows) {
var result=new HashMap<String,AppointmentCargoVerificationService.Verification>();
for(var source:rows) {
var current=verification.current(source);
if(current!=null) result.put(source.sourceKey(),current);
}
return result;
}
private static Integer quantity(AppointmentSourceService.Source source,Map<String,AppointmentCargoVerificationService.Verification> evidence) {
var verified=evidence.get(source.sourceKey());
return verified==null?source.packages():verified.packageCount();
}
private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows,
Map<String,AppointmentCargoVerificationService.Verification> evidence) {
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("计划结束时间必须晚于开始时间");
@@ -171,7 +334,11 @@ public class InboundFbaAppointmentService {
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货件号或追踪号");
var unverifiedIds=rows.stream().filter(s->!evidence.containsKey(s.sourceKey())).map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
if(ids.size()!=rows.size() || rows.stream().anyMatch(s->quantity(s,evidence)==null || quantity(s,evidence)<=0)
|| evidence.values().stream().anyMatch(v->v.shipmentReferences().isEmpty())
|| !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>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());}
@@ -182,32 +349,54 @@ public class InboundFbaAppointmentService {
appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
}
}
private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous) {
private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous,
Map<String,AppointmentCargoVerificationService.Verification> evidence,Map<String,Integer> requested) {
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;
var allocated=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),appointment.getId());
Integer total=quantity(source,evidence);
if(allocated.unknownCount()>0) throw conflict("原来源已有箱数待核实的安排,请先核对原预约");
Integer count=requested.get(source.sourceKey());
if(count==null && !fresh) count=item.getPackageCount();
if(count==null && total!=null) {
long remaining=total.longValue()-allocated.packages();
if(remaining<=0) throw conflict("原来源箱数已分配完,请继续已有预约");
count=Math.toIntExact(remaining);
}
if(total==null && (count!=null || allocated.count()>0)) throw conflict("原来源总箱数待核实,不能继续分配");
if(count!=null && (count<=0 || (long)count+allocated.packages()>total)) throw conflict("本批及其他活动预约的分配总量超过原来源可分配箱数");
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);
item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(count);item.setActive(true);
var verified=evidence.get(source.sourceKey());
item.setCargoVerificationVersion(verified==null?null:verified.version());
try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);}
try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);}
catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");}
}
}
private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier<FbaAppointmentResponse> action) {
return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action);
}
private <T> T executeCommand(String operation,String operator,String key,Object request,Class<T> type,
java.util.function.Function<T,String> appointmentNumber,Supplier<T> 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);
if(existing!=null)return replay(existing,hash,type);
try {commands.begin(key,hash,operation,operator,now());}
catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);}
catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash,type);}
var response=action.get();
try {if(commands.finish(key,response.appointmentNo(),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");}
workChanges.changed(response);
try {if(commands.finish(key,appointmentNumber.apply(response),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);}
return response;
}
private String commandSnapshot(FbaAppointmentResponse response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException {
private String commandSnapshot(Object response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException {
var node=json.valueToTree(response);
if(request instanceof CancelFbaAppointmentRequest cancellation) ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("cancelReason",cancellation.cancelReason());
return json.writeValueAsString(node);
@@ -241,9 +430,9 @@ public class InboundFbaAppointmentService {
}).toList();
return new PageResult<>(records,page,size,total,(total+size-1)/size);
}
private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) {
private <T> T replay(FbaAppointmentCommandMapper.Command value,String hash,Class<T> type) {
if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对");
try {return readSnapshot(value.responseJson());}
try {var node=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(value.responseJson());node.remove("cancelReason");return json.treeToValue(node,type);}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);}
}
private InboundFbaAppointment find(String number,boolean lock) {
@@ -259,8 +448,20 @@ public class InboundFbaAppointmentService {
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());
items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount(),item.getCargoVerificationVersion(),shipmentReferences(item),"FBA:"+item.getFbaWarehouseCode())).toList(),row.getVersion(),stringList(row.getReplacementNosJson()),row.getRepartitionReason(),batchKey(row.getId()),stringList(row.getReplacementKeysJson()));
}
private List<String> shipmentReferences(InboundFbaAppointmentItem item) {
return stringList(item.getShipmentReferencesJson());
}
private List<String> stringList(String value) {
if(value==null) return List.of();
try {return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<String>>(){});}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约货件快照不可读",error);}
}
private static void requireCurrent(InboundFbaAppointment appointment) {
if(appointment.getReplacementNosJson()!=null) throw conflict("该批次已被分批或合并,请进入替代批次继续操作");
}
public static String batchKey(Long id) {return "FBA_APPOINTMENT:"+id;}
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);}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.RepartitionFbaAppointmentsResponse;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
@Component
public class AppointmentWorkAppointmentChanges {
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkChanges changes;
public AppointmentWorkAppointmentChanges(AppointmentWorkCommitmentService commitments,AppointmentWorkChanges changes) { this.commitments=commitments; this.changes=changes; }
public void changed(Object response) {
changes.requirementsChanged();
if(response instanceof FbaAppointmentResponse appointment) record(appointment);
else if(response instanceof RepartitionFbaAppointmentsResponse partition) partition.appointments().forEach(this::record);
else throw new IllegalArgumentException("预约命令缺少原业务响应");
}
private void record(FbaAppointmentResponse a) {
boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus());
var cargo=a.items().stream().map(i->List.of(i.customerId(),i.sourceKey(),Objects.toString(i.destinationKey(),""),Objects.toString(i.packageCount(),""),Objects.toString(i.shipmentReferences(),""))).sorted(Comparator.comparing(Object::toString)).toList();
commitments.record(a.batchKey(),new AppointmentWorkCommitmentService.Commitment(cancelled?null:target(a),cancelled?null:confirmed(a),null,AppointmentWorkPolicy.digest(cargo.toString()),cancelled));
}
public static Slot target(FbaAppointmentResponse a) {
return a.plannedAt()!=null?slot(a.plannedAt(),a.plannedEndAt(),a.timezone()):a.plannedDate()==null?null:new Slot(a.plannedDate(),null,null,null,a.timezone());
}
public static Slot confirmed(FbaAppointmentResponse a) { return "SUCCESS".equals(a.appointmentStatus())?slot(a.appointmentAt(),a.appointmentEndAt(),a.timezone()):null; }
public static Slot slot(LocalDateTime start,LocalDateTime end,String timezone) { return start==null?null:new Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),timezone); }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.work;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkDeliveryMapper {
record CustomerDelivery(long orderId,String customerId,long pickedPackages,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt,LocalDateTime plannedDeliveryAt) { }
String CUSTOMER_SELECT="SELECT o.id order_id,o.customer_id,SUM(a.packages) picked_packages,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at,x.planned_delivery_at FROM ll_order o JOIN ll_dispatch_pda_pick_order a ON a.order_id=o.id AND a.customer_id=o.customer_id JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status<>'CANCELLED' JOIN ll_delivery_order x ON x.dispatch_order_id=i.dispatch_order_id AND x.customer_id=i.customer_id AND x.destination_type='PRIVATE' AND x.delivery_address=i.delivery_address AND (x.postal_code=i.postal_code OR (x.postal_code IS NULL AND i.postal_code IS NULL)) ";
String CUSTOMER_GROUP=" GROUP BY o.id,o.customer_id,x.id,x.delivery_no,x.status,x.package_count,x.signed_packages,x.signed_at,x.planned_delivery_at ORDER BY o.id,x.id LIMIT 1001";
@Select(CUSTOMER_SELECT+" WHERE o.source_container_booking_id=#{container} AND o.source_inbound_id=#{inbound}"+CUSTOMER_GROUP)
List<CustomerDelivery> customer(@Param("container") String container,@Param("inbound") String inbound);
@Select(CUSTOMER_SELECT+" WHERE CONCAT('DISPATCH:',i.dispatch_order_id)=#{batch}"+CUSTOMER_GROUP)
List<CustomerDelivery> privateBatch(String batch);
record Delivery(String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { }
@Select("SELECT b.customer_id,b.source_key,b.destination_key,b.package_count allocated_packages,d.status dispatch_status,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id AND d.status<>'CANCELLED' LEFT JOIN ll_delivery_order x ON x.dispatch_order_id=b.dispatch_id AND x.customer_id=b.customer_id AND CONCAT('FBA:',x.destination_warehouse_code)=b.destination_key WHERE b.batch_key=#{batch} ORDER BY b.dispatch_item_id,b.source_key,x.id LIMIT 1001")
List<Delivery> batch(String batch);
}
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
import static com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.*;
@Component
public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGateway.BusinessProvider {
private final InboundFbaAppointmentService appointments;
private final AppointmentCargoVerificationService cargo;
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkDeliveryMapper deliveries;
private final AppointmentWorkSourceMapper sourceMapping;
public AppointmentWorkDeliveryProvider(InboundFbaAppointmentService appointments,AppointmentCargoVerificationService cargo,AppointmentWorkCommitmentService commitments,AppointmentWorkDeliveryMapper deliveries,AppointmentWorkSourceMapper sourceMapping) {
this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;
}
public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; }
public List<Fact> read(List<FactRequest> requests) { return requests.stream().map(this::read).toList(); }
private Fact read(FactRequest request) {
if(request.batchKey()==null) return unbatched(request);
if(request.batchKey().startsWith("DISPATCH:")) return customerBatch(request);
var context=appointments.batchContext(request.batchKey()); var a=context.appointment();
boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus());
var target=cancelled?null:target(a); var confirmed=cancelled?null:confirmed(a);
var rows=deliveries.batch(a.batchKey());
if(rows.size()>1000) throw unavailable("批次派送事实超过完整读取上限");
boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages()));
// Every original source allocation must have real dispatch coverage, not just one signed fragment.
for(var item:a.items()) {
long allocated=rows.stream().filter(d->item.customerId().equals(d.customerId()) && item.sourceKey().equals(d.sourceKey()) && item.destinationKey().equals(d.destinationKey())).mapToLong(d->d.allocatedPackages()).sum();
if(item.packageCount()==null || allocated!=item.packageCount()) complete=false;
}
var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.Delivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null;
var execution=complete?ExecutionStatus.COMPLETED:rows.stream().anyMatch(d->Set.of("DEPARTED","PARTIAL_DEPARTED").contains(d.dispatchStatus()))?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var blocks=new ArrayList<String>();
if(cancelled) blocks.add("原预约已取消或失败,需求须重新安排");
if(!a.replacementBatchKeys().isEmpty()) blocks.add("原批次已替代,请进入新批次");
var sourceRefs=a.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).toList();
var action=complete?null:new Action(confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs));
var references=new ArrayList<BusinessReference>(); references.add(new BusinessReference("FBA_APPOINTMENT",a.batchKey(),a.appointmentNo(),"送仓预约"));
rows.stream().filter(d->d.deliveryNo()!=null).map(d->new BusinessReference("DELIVERY",d.deliveryNo(),d.deliveryNo(),"实际派送")).distinct().forEach(references::add);
return new Fact(request.workItemKey(),digest(a.batchKey()+":"+a.version()+":"+rows),commitments.impactToken(a.batchKey()),true,
confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,a.timezone(),a.appointmentNo(),null,
List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks,
actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null);
}
private Fact unbatched(FactRequest request) {
if(request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) {
var mapped=sourceMapping.containerSources(request.containerBookingId());
if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限");
var related=mapped.stream().filter(s->request.requirementKey().equals("INBOUND:"+s.sourceInboundId())).toList();
if(!related.isEmpty() && related.stream().allMatch(s->"PRIVATE".equals(s.destinationType()))) return customer(request,related);
}
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>(); Action action=null;
var fingerprints=new ArrayList<String>();
if(request.sources().isEmpty()) { blocks.add("尾程货源尚未建立,请先核对整柜来源与入仓计划"); missing.add("LASTLEG_SOURCE"); }
else {
for(var source:request.sources()) {
var context=cargo.get(source.sourceKey(),source.customerId(),source.destinationKey());
fingerprints.add(context.toString()); blocks.addAll(context.blockReasons());
}
var first=request.sources().get(0);
action=new Action(ActionCode.VERIFY_CARGO,"核实货物并建立预约",request.workItemKey(),new CargoTarget(first));
}
return new Fact(request.workItemKey(),digest(request.requirementKey()+":"+fingerprints),"UNBATCHED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,
null,null,null,null,null,null,List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,
blocks.stream().distinct().toList(),missing,List.of(),null);
}
private Fact customer(FactRequest request,List<AppointmentWorkSourceMapper.Source> sources) {
var rows=deliveries.customer(request.containerBookingId(),request.requirementKey().substring(8));
var expected=sources.stream().map(s->new Expected(s.id(),s.customerId(),s.packageCount()==null?null:s.packageCount().longValue())).toList();
return customerFact(request,rows,expected,null);
}
private record Expected(long orderId,String customerId,Long packages) { }
private Fact customerBatch(FactRequest request) {
var sources=sourceMapping.privateBatch(request.batchKey());
if(sources.size()>1000) throw unavailable("客户仓实物批次超过完整读取上限");
// A complete scan may not have reached this changed dispatch's container yet. Represent its
// removal explicitly so compensation can advance to that page and restore the original demand.
if(sources.isEmpty() || sources.stream().anyMatch(s->"CANCELLED".equals(s.status())))
return new Fact(request.workItemKey(),digest("REMOVED:"+sources),"REMOVED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,
sources.isEmpty()?null:sources.get(0).dispatchNo(),null,List.of(ORDER_READ),List.of(),null,List.of("原客户仓排车已取消或移除实物,待完整需求同步"),List.of(),List.of(),null);
return customerFact(request,deliveries.privateBatch(request.batchKey()),sources.stream().map(s->new Expected(s.orderId(),s.customerId(),s.packages())).toList(),sources.get(0).dispatchNo());
}
private Fact customerFact(FactRequest request,List<AppointmentWorkDeliveryMapper.CustomerDelivery> rows,List<Expected> sources,String dispatchNo) {
if(rows.size()>1000) throw unavailable("客户仓派送事实超过完整读取上限");
boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages()));
for(var source:sources) {
long delivered=rows.stream().filter(r->r.orderId()==source.orderId() && source.customerId().equals(r.customerId())).mapToLong(AppointmentWorkDeliveryMapper.CustomerDelivery::pickedPackages).sum();
if(source.packages()==null || source.packages()<=0 || delivered!=source.packages()) complete=false;
}
if(rows.stream().anyMatch(r->sources.stream().noneMatch(s->s.orderId()==r.orderId() && s.customerId().equals(r.customerId())))) complete=false;
var dates=rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::plannedDeliveryAt).filter(Objects::nonNull).distinct().sorted().toList();
var target=dates.isEmpty()?null:slot(dates.get(0),null,null);
var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null;
var refs=new ArrayList<BusinessReference>();
if(dispatchNo!=null) refs.add(new BusinessReference("DISPATCH",request.batchKey(),dispatchNo,"客户仓排车"));
rows.stream().map(r->new BusinessReference("DELIVERY",r.deliveryNo(),r.deliveryNo(),"客户仓实际派送")).distinct().forEach(refs::add);
sources.stream().map(s->new BusinessReference("CUSTOMER",s.customerId(),null,"所属客户")).distinct().forEach(refs::add);
var action=rows.isEmpty() || complete?null:new Action(ActionCode.VIEW_DETAIL,"查看原派送进度",request.workItemKey(),new ReadTarget(List.copyOf(refs)));
return new Fact(request.workItemKey(),digest(sources+":"+rows),digest(sources+":"+dates),!rows.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET,
complete?ExecutionStatus.COMPLETED:rows.isEmpty()?ExecutionStatus.PENDING:ExecutionStatus.IN_PROGRESS,target,null,actual,null,dispatchNo!=null?dispatchNo:rows.size()==1?rows.get(0).deliveryNo():null,null,
List.of(ORDER_READ),action==null?List.of():List.of("platform:overseas-warehouse:delivery:list"),action,
dates.size()>1?List.of("同一收货需求存在多个派送日期,请在原派送单核对"):List.of(),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(refs),null);
}
}
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPort {
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
private final AppointmentWorkChanges changes;
public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes) {
this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;
}
public void bind(String customer,long dispatch,List<PhysicalItem> items,List<Allocation> allocations) {
if(allocations==null) throw bad("批次实物分配清单必填");
if(allocations.isEmpty()) {
if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配");
for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId());
var privateRows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item));
if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); }
return;
}
if(allocations.size()>1000) throw bad("批次实物分配最多1000条");
var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i));
var seen=new HashSet<String>();
for(var row:allocations) {
if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘");
if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复");
}
var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList());
for(var batch:batches) lock(batch);
var persisted=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var item:byPallet.values()) {
var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList();
if(selected.isEmpty()) {
var privateRows=privateAllocation(customer,dispatch,item);
if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配");
persisted.addAll(privateRows);continue;
}
validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false);
for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount()));
}
for(var batch:batches) {
var context=appointments.batchContext(batch); var appointment=context.appointment();
if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定取消或被替代的预约批次");
var grouped=allocations.stream().filter(a->batch.equals(a.batchKey())).collect(Collectors.groupingBy(a->a.sourceKey()+"/"+a.destinationKey()));
for(var group:grouped.values()) {
var ref=group.get(0);
var original=appointment.items().stream().filter(i->customer.equals(i.customerId()) && ref.sourceKey().equals(i.sourceKey()) && ref.destinationKey().equals(i.destinationKey())).findFirst().orElseThrow(()->conflict("实物来源不属于所选预约批次"));
long count=group.stream().mapToLong(Allocation::packageCount).sum();
if(original.packageCount()==null || mapper.allocated(batch,customer,ref.sourceKey(),ref.destinationKey(),dispatch)+count>original.packageCount()) throw conflict("所选实物超过该批次逐票分配量");
}
}
mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged();
}
public void requireReplay(String customer,long dispatch,List<Allocation> allocations) {
if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致");
var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:"))
.map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList();
if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配");
}
private void validatePhysical(String customer,PhysicalItem item,List<Allocation> selected,boolean received,boolean picked) {
var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer);
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次");
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var remaining=new HashMap<String,Long>();
for(int i=0;i<physical.size();i++) {
var p=physical.get(i); Integer count=received?p.countedPackages():p.plannedPackages();
if(count==null || count<=0) throw conflict("托盘逐票实物箱数未核实");
remaining.merge(AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey()+"/"+p.destinationKey(),count.longValue(),Long::sum);
}
for(var a:selected) remaining.merge(a.sourceKey()+"/"+a.destinationKey(),-(long)a.packageCount(),Long::sum);
if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致");
if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致");
}
public void requireDeparture(String dispatchNo) {
var rows=mapper.bindings(dispatchNo);
var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet());
for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) {
requireNoBatch(item.customerId(),item.planPalletId());
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
}
for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) {
if(batch.startsWith("DISPATCH:")) continue;
lock(batch); var a=appointments.batchContext(batch).appointment();
if(!"SUCCESS".equals(a.appointmentStatus()) || a.appointmentAt()==null || !a.replacementBatchKeys().isEmpty()) throw conflict("关联预约尚未有效确认或已被替代,请重新核对批次后发车");
}
for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) {
var first=group.get(0);
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null),
group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true);
}
}
private void requireNoBatch(String customer,long pallet) {
var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList();
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->!Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()) && a.replacementBatchKeys().isEmpty()))
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
}
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); }
public void requireTripBinding(String dispatchNo,long tripId) {
for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) {
if(batch.startsWith("DISPATCH:")) continue;
lock(batch);
if(mapper.trips(batch).stream().anyMatch(id->id!=tripId)) throw conflict("同一预约批次已关联其他车次,请先在原预约分批后分别安排车辆");
}
}
private List<AppointmentWorkDispatchMapper.Bound> privateAllocation(String customer,long dispatch,PhysicalItem item) {
var physical=mapper.physical(item.planPalletId(),customer);
if(physical.isEmpty() || physical.stream().noneMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) return List.of();
if(physical.stream().anyMatch(p->p.destinationKey()==null || !p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓实物不能混入FBA批次,请先核对托盘目的地");
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var part:physical) {
var count="RECEIVED".equals(item.sourceStage())?part.countedPackages():part.plannedPackages();
if(count==null || count<=0) throw conflict("客户仓逐票实物数量尚未核实");
rows.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),"DISPATCH:"+dispatch,customer,AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey(),count));
}
if(item.packages()==null || rows.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()!=item.packages()) throw conflict("客户仓逐票实物分配与所选托盘箱数不一致");
return rows;
}
private void lock(String batch) {
if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效");
try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); }
catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); }
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.work;
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 static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches")
public class AppointmentWorkDispatchCandidateController {
private final AppointmentWorkDispatchCandidates service;
public AppointmentWorkDispatchCandidateController(AppointmentWorkDispatchCandidates service) { this.service=service; }
@GetMapping("/{batchKey}/dispatch-candidates")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:overseas-warehouse:inbound-order:list')")
public ApiResponse<AppointmentWorkDispatchCandidates.Result> candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) {
if(user==null || user.userId()==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw forbidden("仅平台操作人员可读取批次实物");
return ApiResponse.success(service.read(batchKey));
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkDispatchCandidates {
public record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,Integer packageCount,List<Allocation> allocations,List<String> blockReasons) { }
public record Result(String batchKey,Integer version,List<Candidate> candidates,List<String> blockReasons) { }
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; }
@Transactional(readOnly=true)
public Result read(String batch) {
var appointment=appointments.batchContext(batch).appointment();
if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原预约取消或替代,不能选择实物"));
var candidates=mapper.candidates(appointment.id());
if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批");
var output=new ArrayList<Candidate>();
for(var candidate:candidates) {
var reasons=new ArrayList<String>(); var allocations=new ArrayList<Allocation>();
if(candidate.locked()) reasons.add("托盘已被其他排车或草稿占用");
var physical=mapper.physical(candidate.planPalletId(),candidate.customerId());
var refs=AppointmentWorkPhysicalSources.canonical(physical,sources);
Integer packages=0;
for(int i=0;i<physical.size();i++) {
var part=physical.get(i); var ref=AppointmentWorkPhysicalSources.ref(refs,part); var count="RECEIVED".equals(candidate.sourceStage())?part.countedPackages():part.plannedPackages();
if(count==null || count<=0) { packages=null; reasons.add("逐票实物箱数尚未核实"); continue; }
if(packages!=null) packages=Math.addExact(packages,count);
var item=appointment.items().stream().filter(a->a.customerId().equals(part.customerId()) && a.sourceKey().equals(ref.sourceKey()) && a.destinationKey().equals(part.destinationKey())).findFirst().orElse(null);
if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; }
long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1);
if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count));
}
if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); }
output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList()));
}
return new Result(batch,appointment.version(),List.copyOf(output),List.of());
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentWorkDispatchMapper {
record Physical(Long orderId,String customerId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { }
record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { }
record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { }
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.plan_pallet_id=p.id AND k.status='ACTIVE') locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,SUM(a.planned_package_count) planned_packages,SUM(a.counted_package_count) counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
@Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code")
List<Physical> pickedPhysical(@Param("item") long item,@Param("customer") String customer);
@Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE") InboundFbaAppointment lockAppointment(long id);
@Select("SELECT COALESCE(SUM(b.package_count),0) FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE b.batch_key=#{batch} AND b.customer_id=#{customer} AND b.source_key=#{source} AND b.destination_key=#{destination} AND b.dispatch_id<>#{dispatch} AND d.status<>'CANCELLED'")
long allocated(@Param("batch") String batch,@Param("customer") String customer,@Param("source") String source,@Param("destination") String destination,@Param("dispatch") long dispatch);
@Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch}") int remove(long dispatch);
@Insert("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(#{dispatchId},#{itemId},#{palletId},#{batchKey},#{customerId},#{sourceKey},#{destinationKey},#{packages})")
int insert(Bound row);
@Select("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") int count(long id);
@Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch} AND dispatch_item_id=#{item}") int removeItem(@Param("dispatch") long dispatch,@Param("item") long item);
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
List<Bound> bindings(String number);
@Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List<Bound> byDispatch(long id);
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
final class AppointmentWorkPhysicalSources {
private AppointmentWorkPhysicalSources() { }
static Map<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef> canonical(List<AppointmentWorkDispatchMapper.Physical> rows,AppointmentSourceService sources) {
var identities=rows.stream().map(p->new AppointmentExternalSourceGateway.OrderRef(p.customerId(),p.orderId())).distinct().toList();
if(identities.isEmpty()) return Map.of();
var refs=sources.canonicalOrders(identities);
if(refs.size()!=identities.size()) throw unavailable("逐票来源规范化响应不完整");
var result=new LinkedHashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
for(int i=0;i<identities.size();i++) {
if(!identities.get(i).customerId().equals(refs.get(i).customerId())) throw unavailable("逐票来源客户归属不一致");
result.put(identities.get(i),refs.get(i));
}
return result;
}
static AppointmentCoverageService.SourceRef ref(Map<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef> refs,AppointmentWorkDispatchMapper.Physical row) {
return refs.get(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId()));
}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFirstlegClient;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Flow;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkReceivingGuard {
private final AppointmentWorkFirstlegClient firstleg;
private final String token;
private final com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes;
public AppointmentWorkReceivingGuard(AppointmentWorkFirstlegClient firstleg,com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.routes=routes; this.token=token; }
public void requireWarehouseFlow(String container) {
if(routes.route(container).flow()==Flow.DIRECT) throw conflict("该柜已确认直送路线,不能进入海外仓收货");
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置,无法核对收货路线");
final com.baiyue.common.core.api.ApiResponse<com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.ContainerSource> response;
try { response=firstleg.container(token,container); }
catch(RuntimeException error) { throw unavailable("无法核对柜次原业务路线",error); }
if(response==null || !response.successful() || response.data()==null || !container.equals(response.data().containerBookingId())) throw unavailable("柜次原业务路线缺失");
if(response.data().establishedFlow()==Flow.DIRECT) throw conflict("该柜已有直送卸货事实,不能进入海外仓收货");
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentWorkReceivingMapper {
@Select("SELECT DISTINCT p.* FROM ll_inbound_plan p JOIN ll_inbound_plan_line l ON l.plan_id=p.id JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id WHERE o.source_container_booking_id=#{container} ORDER BY p.id LIMIT 1001")
List<InboundPlan> plans(String container);
@Select("SELECT * FROM ll_receiving_container_session WHERE source_container_booking_id=#{container} ORDER BY created_at DESC,id DESC LIMIT 1")
ReceivingContainerSession session(String container);
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkReceivingProvider implements AppointmentWorkFactGateway.BusinessProvider {
private final AppointmentWorkReceivingMapper mapper;
public AppointmentWorkReceivingProvider(AppointmentWorkReceivingMapper mapper) { this.mapper=mapper; }
public boolean supports(Type type,Flow flow) { return type==Type.WAREHOUSE_RECEIPT && flow==Flow.TRANSFER; }
public List<Fact> read(List<FactRequest> requests) { return requests.stream().map(this::read).toList(); }
private Fact read(FactRequest request) {
var plans=mapper.plans(request.containerBookingId());
if(plans.size()>1000) throw unavailable("柜次接仓计划超过完整读取上限");
var session=mapper.session(request.containerBookingId());
var dates=plans.stream().map(p->p.getPlannedArrivalDate()).filter(Objects::nonNull).distinct().sorted().toList();
var target=dates.isEmpty()?null:new Slot(dates.get(0),null,null,null,null);
boolean complete=session!=null && session.getCompletedAt()!=null && Set.of("COMPLETED","COMPLETED_WITH_DIFFERENCE").contains(session.getStatus());
var local=complete?session.getCompletedAt():null;
var actual=local==null?null:new Actual(null,local,null);
var execution=complete?ExecutionStatus.COMPLETED:session!=null && session.getReceivingAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var blocks=new ArrayList<String>();
if(dates.size()>1) blocks.add("同柜入仓计划日期不一致,请在原入仓计划核对");
var refs=plans.stream().map(p->new BusinessReference("INBOUND_PLAN",p.getId().toString(),p.getPlanNo(),"入仓计划")).toList();
var permission=plans.isEmpty()?OverseasPermissions.INBOUND_ORDER_CREATE:OverseasPermissions.INBOUND_ORDER_EDIT;
var single=plans.size()==1?plans.get(0):null;
var action=complete?null:new Action(ActionCode.SAVE_PLAN,plans.isEmpty()?"建立接仓计划":"查看并维护接仓计划",request.workItemKey(),new ReceiptTarget(single==null?null:single.getId().toString(),single==null?null:single.getPlanNo(),single==null?null:single.getCustomerId(),request.containerBookingId()));
String fingerprint=digest(plans.stream().map(p->p.getId()+":"+p.getVersion()+":"+p.getStatus()).toList()+":"+(session==null?"":session.getId()+":"+session.getVersion()));
// Arrival-date mutations are authoritative planned changes; first date is baselined by the work observer.
String impact=digest(dates.toString());
return new Fact(request.workItemKey(),fingerprint,impact,!plans.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET,
execution,target,null,actual,null,plans.size()==1?plans.get(0).getPlanNo():null,null,List.of(OverseasPermissions.INBOUND_ORDER_LIST),
action==null?List.of():List.of(permission),action,List.copyOf(blocks),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),refs,null);
}
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** Full manifest from source quantities and original appointment allocations; owns no quantity ledger. */
@Component
public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGateway.RequirementProvider {
private final AppointmentSourceService sources;
private final AppointmentCargoVerificationService cargo;
private final InboundFbaAppointmentService appointments;
private final AppointmentWorkSourceMapper sourceMapping;
public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) {
this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping;
}
public List<Requirement> requirements(ContainerSource container,Flow effectiveFlow) {
var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList();
if(effectiveFlow==Flow.DIRECT) return original;
if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线");
var mapped=sourceMapping.containerSources(container.containerBookingId());
if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限");
var result=new LinkedHashMap<String,Requirement>();
var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.<AppointmentWorkSourceMapper.DispatchSource>of();
if(privateDispatches.size()>1000) throw unavailable("客户仓排车来源超过完整读取上限");
for(var requirement:original) {
var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null;
var sourceOrders=mapped.stream().filter(s->Objects.equals(s.sourceInboundId(),inbound)).toList();
if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; }
if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; }
if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; }
var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList());
var resolved=sources.resolveBatch(refs);
var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList();
var history=appointments.relatedBatchSnapshots(complete);
var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList();
// Unknown remaining quantity is still work, never a zero or an absent requirement.
boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0);
var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList();
if(remainder) result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(),
requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),replaced,
complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList()));
for(var batch:active) {
var replacementKeys=new LinkedHashSet<>(replaced); replacementKeys.add(requirement.requirementKey());
history.stream().filter(a->a.replacementBatchKeys()!=null && a.replacementBatchKeys().contains(batch.batchKey())).map(FbaAppointmentResponse::batchKey).forEach(replacementKeys::add);
var previous=result.get(batch.batchKey());
if(previous!=null) replacementKeys.addAll(previous.replacesRequirementKeys());
var references=new ArrayList<>(requirement.references());
if(previous!=null) references.addAll(previous.references());
references.add(new BusinessReference("FBA_APPOINTMENT",batch.batchKey(),batch.appointmentNo(),"送仓预约"));
var batchSources=batch.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).distinct().toList();
result.put(batch.batchKey(),new Requirement(batch.batchKey(),batch.batchKey(),batch.appointmentNo(),"FBA",requirement.originName(),
batch.fbaWarehouseCode(),batch.timezone(),references.stream().distinct().toList(),List.copyOf(replacementKeys),batchSources));
}
}
return List.copyOf(result.values());
}
private void customer(Requirement requirement,List<AppointmentWorkSourceMapper.Source> orders,List<AppointmentWorkSourceMapper.DispatchSource> dispatches,Map<String,Requirement> result) {
if(orders.stream().anyMatch(s->!"PRIVATE".equals(s.destinationType()))) throw conflict("客户仓需求与原订单目的地不一致");
var related=dispatches.stream().filter(d->orders.stream().anyMatch(o->o.id()==d.orderId() && o.customerId().equals(d.customerId()))).toList();
var active=related.stream().filter(d->!"CANCELLED".equals(d.status())).toList();
var cancelled=related.stream().filter(d->"CANCELLED".equals(d.status())).map(d->"DISPATCH:"+d.dispatchId()).distinct().toList();
boolean remaining=false;
for(var order:orders) {
long allocated=active.stream().filter(d->d.orderId()==order.id() && d.customerId().equals(order.customerId())).mapToLong(AppointmentWorkSourceMapper.DispatchSource::packages).sum();
if(order.packageCount()!=null && allocated>order.packageCount()) throw conflict("客户仓排车逐票分配超过原货物数量");
if(order.packageCount()==null || allocated<order.packageCount()) remaining=true;
}
if(remaining) result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),cancelled,orders.stream().map(o->new SourceReference(o.customerId(),"ORDER:"+o.id(),"CUSTOMER:"+o.id())).toList()));
for(var batch:active.stream().map(d->"DISPATCH:"+d.dispatchId()).distinct().toList()) {
var all=sourceMapping.privateBatch(batch);
if(all.isEmpty() || all.size()>1000 || all.stream().anyMatch(d->"CANCELLED".equals(d.status()))) throw unavailable("客户仓排车批次来源不完整");
var previous=result.get(batch); var replacements=new LinkedHashSet<>(cancelled);replacements.add(requirement.requirementKey());
var refs=new ArrayList<>(requirement.references());
if(previous!=null) { replacements.addAll(previous.replacesRequirementKeys());refs.addAll(previous.references()); }
refs.add(new BusinessReference("DISPATCH",batch,all.get(0).dispatchNo(),"客户仓排车"));
result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList()));
}
}
private List<AppointmentCoverageService.SourceRef> canonical(List<SourceReference> input) {
if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系");
var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{
try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); }
catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); }
}).toList();
var result=new ArrayList<AppointmentCoverageService.SourceRef>();
if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders));
input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add);
if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING");
return result.stream().distinct().toList();
}
private static boolean active(FbaAppointmentResponse batch) {
return !Set.of("CANCELLED","FAILED").contains(batch.appointmentStatus()) && (batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty());
}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inboundplan.work;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentWorkSourceMapper {
record Source(Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { }
@Select("SELECT id,customer_id,source_order_id,source_inbound_id,destination_type,fba_warehouse_code,package_count FROM ll_order WHERE source_container_booking_id=#{container} ORDER BY id LIMIT 1001")
List<Source> containerSources(String container);
record DispatchSource(long dispatchId,String dispatchNo,String status,long orderId,String customerId,String sourceKey,String destinationKey,long packages) { }
@Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=CONCAT('DISPATCH:',d.id) AND o.source_container_booking_id=#{container} GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY b.dispatch_id,o.id LIMIT 1001")
List<DispatchSource> privateDispatches(String container);
@Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=#{batch} AND b.batch_key=CONCAT('DISPATCH:',d.id) GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY o.id LIMIT 1001")
List<DispatchSource> privateBatch(String batch);
}
@@ -0,0 +1,59 @@
-- DDL proposal only. The coordinator must integrate the approved baseline; do not execute from this task.
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
dispatch_id BIGINT NOT NULL,
dispatch_item_id BIGINT NOT NULL,
plan_pallet_id BIGINT NOT NULL,
batch_key VARCHAR(96) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
source_key VARCHAR(160) NOT NULL,
destination_key VARCHAR(160) NOT NULL,
package_count INT NOT NULL,
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
entity_key VARCHAR(160) NOT NULL,
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
impact_version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
id INT NOT NULL COMMENT '工作事项协调锁,固定1',
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本',
scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移',
completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间',
requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本',
synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本',
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫';
INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1);
CREATE TABLE IF NOT EXISTS ll_appointment_work (
work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成',
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作',
state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实',
PRIMARY KEY (work_item_key),
KEY idx_appointment_work_container (container_booking_id,retired)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
id VARCHAR(36) NOT NULL COMMENT '历史记录标识',
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
operator_name VARCHAR(128) NULL COMMENT '操作人显示名称',
action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作',
summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据',
command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空',
request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要',
response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变',
occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_work_command (work_item_key,command_key),
KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
@@ -1667,6 +1667,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码',
selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数',
replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读',
replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联',
repartition_reason VARCHAR(1000) NULL 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时间',
@@ -1686,19 +1689,42 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
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 '所选整票预报箱数,未知保持空',
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
shipment_references_json LONGTEXT 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_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_active_source (customer_id,source_key,active),
KEY idx_ll_fba_active_order (customer_id,order_id,active),
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_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键',
destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地',
source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本',
source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空',
version INT NOT NULL COMMENT '同一来源的核实版本,从一递增',
package_count INT NULL COMMENT '独立核实箱数,不回写原订单',
shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单',
remark VARCHAR(1000) NULL COMMENT '核实说明',
operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人',
operator_name VARCHAR(200) NULL COMMENT '操作人名称快照',
command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键',
request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要',
created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version),
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) 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 '操作、操作者和请求摘要',
@@ -4082,3 +4108,65 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation (
dispatch_id BIGINT NOT NULL,
dispatch_item_id BIGINT NOT NULL,
plan_pallet_id BIGINT NOT NULL,
batch_key VARCHAR(96) NOT NULL,
customer_id VARCHAR(64) NOT NULL,
source_key VARCHAR(160) NOT NULL,
destination_key VARCHAR(160) NOT NULL,
package_count INT NOT NULL,
PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key),
KEY idx_dispatch_appointment_batch(batch_key,customer_id),
KEY idx_dispatch_appointment_dispatch(dispatch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实';
CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment (
entity_key VARCHAR(160) NOT NULL,
state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划',
impact_version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (entity_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线';
CREATE TABLE IF NOT EXISTS ll_appointment_work_guard (
id INT NOT NULL COMMENT '工作事项协调锁,固定1',
scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标',
initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描',
scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本',
scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移',
completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间',
requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本',
synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本',
scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫';
INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1);
CREATE TABLE IF NOT EXISTS ll_appointment_work (
work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成',
container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号',
version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本',
retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作',
state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实',
PRIMARY KEY (work_item_key),
KEY idx_appointment_work_container (container_booking_id,retired)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系';
CREATE TABLE IF NOT EXISTS ll_appointment_work_history (
id VARCHAR(36) NOT NULL COMMENT '历史记录标识',
work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识',
operator_name VARCHAR(128) NULL COMMENT '操作人显示名称',
action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作',
summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据',
command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空',
request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要',
response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变',
occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_work_command (work_item_key,command_key),
KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
@@ -0,0 +1,136 @@
openapi: 3.0.3
info:
title: 预约货物分批与合并
version: 1.0.0
description: 原子重新分配已有未确认批次。保留原来源、客户、柜及历史;不创建车次、库存或外部确认。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/batches/{batchKey}:
get:
operationId: getFbaAppointmentBatchContext
summary: 按不可变批次键读取分批资料和操作条件
description: >-
仅平台,需预约列表及尾程订单列表权限。batchKey为FBA_APPOINTMENT加内部不可变主键,仅供关联,不作为展示编号。
返回appointment原快照、sources当前逐来源可用于重新分配的本批数量、allowedActions和blockReasons。
原批次箱数未知且已独立核实总量时,本批数量由核实总量减其他活动批次分配量计算;不修改旧快照。
已替代、已确认或关联排车时不开放分批合并。原来源或核实事实读取失败明确报错,不以空列表冒充无货。
allowedActions表示业务资格,前端与FBA预约写权限取交集;实际写入仍在事务内重验。
parameters:
- {name: batchKey, in: path, required: true, schema: {type: string, pattern: '^FBA_APPOINTMENT:[1-9][0-9]*$'}}
responses:
'200':
description: code=200时data为批次上下文
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
appointment: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}
sources:
type: array
items:
type: object
properties:
customerId: {type: string}
sourceKey: {type: string}
destinationKey: {type: string}
packageCount: {type: integer, nullable: true, description: 本批分配量或可从独立核实资料确认的本批量}
originalPackages: {type: integer, nullable: true, description: 原订单/登记箱数,独立核实不回写}
sourceVersion: {type: string, nullable: true}
cargoVerificationVersion: {type: integer, nullable: true}
shipmentReferences: {type: array, items: {type: string}}
allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO, SPLIT, MERGE]}}
blockReasons: {type: array, items: {type: string}}
'400': {description: 批次键格式无效}
'403': {description: 非平台或缺少读取权限}
'404': {description: 批次或原来源不存在}
'409': {description: 原来源变化,需重新核实资料}
'503': {description: 原来源权威目录不可用}
/api/platform/v1/last-leg/appointment-planning/appointments/repartition:
post:
operationId: repartitionFbaAppointments
summary: 一批分多批或多批合一批
description: >-
仅平台,需platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。
originals为一条且batches多条表示分批;originals多条且batches一条表示合并,其他组合拒绝。
原批次必须同起运仓、同目的仓,尚未正式确认、未被替代且没有有效关联排车。
先按业务号有序锁原预约,再锁原BOOKING/订单;版本、来源资格、客户归属及数量均在锁内重新核对。
按每个稳定来源分别比较调整前后总量,不能只比较合计箱数或跨客户挪量;逐来源预期版本及核实版本必须与最新资料一致。
原批次箱数未知时可采用刚保存的独立核实总量,无需先登记预约确认;仍未知则拒绝。
全部旧明细释放、新批次和来源明细建立、替代关系及幂等结果在同一事务内;任一失败全部回滚。
原批次仍可详情读取,replacementAppointmentNos和repartitionReason标识替代关系,不能再次编辑或取消;日历和未定时列表只返回当前批次。
新批次都是PENDING,仅保存显式计划日期,确认号和确认时段为空;旧柜级提柜、接仓、还空不复制。
同键同操作人同请求返回原结果,后续编辑不会改变原重试快照;同键异内容、并发版本或已安排冲突返回409。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/Repartition'}
responses:
'200':
description: code=200时data为原批次业务号及新批次快照,按请求batches顺序返回
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
replacedBatchKeys: {type: array, items: {type: string}, description: 被替代的稳定批次键,用于协调原工作需求}
replacedAppointmentNos: {type: array, items: {type: string}}
appointments: {type: array, items: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}}
'400': {description: 分组不合法、客户/来源替换、数量不守恒或跨起讫点}
'403': {description: 非平台或权限不足}
'404': {description: 原批次或货源不存在}
'409': {description: 原版本变化、来源待核实、已有确认或有效排车、幂等内容冲突}
'503': {description: 权威仓库目录或时区暂不可用}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Repartition:
type: object
required: [originals, batches, reason, idempotencyKey]
properties:
originals:
type: array
minItems: 1
maxItems: 20
items:
type: object
required: [appointmentNo, version]
properties:
appointmentNo: {type: string, minLength: 1, maxLength: 64}
version: {type: integer, minimum: 0}
batches:
type: array
minItems: 1
maxItems: 20
items:
type: object
required: [allocations]
properties:
plannedDate: {type: string, format: date, nullable: true}
remark: {type: string, maxLength: 1000, nullable: true}
allocations:
type: array
minItems: 1
maxItems: 100
items:
type: object
required: [customerId, sourceKey, packageCount]
properties:
customerId: {type: string, minLength: 1, maxLength: 128}
sourceKey: {type: string, minLength: 1, maxLength: 160}
packageCount: {type: integer, minimum: 1, description: 本批来自该原来源的箱数;不使用零数量占位行}
expectedSourceVersion: {type: string, maxLength: 160, nullable: true, description: 批次上下文sources的sourceVersion;空值也须一致}
expectedVerificationVersion: {type: integer, minimum: 1, nullable: true, description: 批次上下文sources的cargoVerificationVersion,无独立核实时为空}
reason: {type: string, minLength: 1, maxLength: 1000}
idempotencyKey: {type: string, minLength: 1, maxLength: 64}
@@ -0,0 +1,114 @@
openapi: 3.0.3
info:
title: 预约货物核实资料
version: 1.0.0
description: >-
按原ORDER或BOOKING货源保存独立核实资料,不修改订单、登记、库存或预约确认状态。
未取得预约确认也可保存箱数和各票货件号。核实记录逐版本追加,取消预约不删除资料。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/cargo-verification:
parameters:
- {name: sourceKey, in: path, required: true, schema: {type: string, maxLength: 160}, description: 原稳定货源键;外接货来货前后均使用BOOKING键}
- {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}, description: 服务端核对的原客户归属}
get:
operationId: getAppointmentCargoVerification
summary: 读取本票独立核实资料和分配总量
description: >-
仅平台,需platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
source为权威原来源,verification为空表示尚无独立核实记录。
原版本、目的地或原箱数变化后sourceChanged为true,可分配量返回null,重新核实前不得采用旧资料。
allocatedPackages合计当前活动预约明细;未知分配单独计数,不当作零。
availablePackages为当前有效总量减已分配量,未知、超量或待复核时为空,不虚构可用量。
allowedActions表示业务资格;前端仍须与当前用户写权限取交集。
parameters:
- {name: destinationKey, in: query, schema: {type: string, maxLength: 160}, description: 可传选择时的目的地;与原来源不符则拒绝}
responses:
'200':
description: 业务响应,data为核实上下文
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {$ref: '#/components/schemas/CargoContext'}
'400': {description: 来源格式或目的地不符}
'403': {description: 非平台身份或权限不足}
'404': {description: 原来源不存在或客户不匹配}
'503': {description: 权威仓库目录不可用}
post:
operationId: saveAppointmentCargoVerification
summary: 独立保存箱数和本票货件或追踪号
description: >-
仅平台,需两个读取权限及platform:overseas-warehouse:inbound-order:fba-appointment。
操作人取登录身份。原货源锁与预约写命令共用,锁后重读来源和版本;expectedVersion首次为0。
原来源已知箱数时,核实量不得超过原来源;任何情况下不得低于活动预约已分配量。
原来源箱数未知时允许独立核实总量,原来源仍为空。
可以暂缺箱数或货件,保存不会自动确认预约;正式确认仍需完整材料。
同来源客户内的同幂等键、同操作人、同请求返回原核实版本,即使此后又有新版本。
同键异内容、并发版本冲突或原来源变化返回409;记录与业务写入处于同一事务。
后续预约写入采用核实版本及货件快照;单独再次核实不会改写已保存预约快照。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SaveCargoVerification'}
responses:
'200':
description: 保存或重试原命令的核实资料快照
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {$ref: '#/components/schemas/CargoVerification'}
'400': {description: 请求字段无效}
'403': {description: 非平台身份或缺少独立写权限}
'404': {description: 原来源不存在或客户不匹配}
'409': {description: 原来源或核实版本变化、幂等冲突、箱数越界或来源不可安排}
'503': {description: 权威仓库目录不可用}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
SaveCargoVerification:
type: object
required: [destinationKey, expectedVersion, shipmentReferences, idempotencyKey]
properties:
destinationKey: {type: string, minLength: 1, maxLength: 160}
expectedSourceVersion: {type: string, maxLength: 160, nullable: true, description: 读取上下文中的原来源版本,空值也须一致}
expectedVersion: {type: integer, minimum: 0}
packageCount: {type: integer, minimum: 1, nullable: true}
shipmentReferences: {type: array, maxItems: 100, items: {type: string, minLength: 1, maxLength: 128}, description: 本票货件号或追踪号,允许暂时空列表;保存时去首尾空格并去重}
remark: {type: string, maxLength: 1000, nullable: true}
idempotencyKey: {type: string, minLength: 1, maxLength: 64}
CargoVerification:
type: object
required: [version, destinationKey, shipmentReferences, verifiedAt]
properties:
version: {type: integer, minimum: 1}
sourceVersion: {type: string, nullable: true}
destinationKey: {type: string}
sourcePackages: {type: integer, nullable: true}
packageCount: {type: integer, nullable: true}
shipmentReferences: {type: array, items: {type: string}}
remark: {type: string, nullable: true}
operatorName: {type: string, nullable: true}
verifiedAt: {type: string, format: date-time, description: UTC时间}
CargoContext:
type: object
properties:
source: {$ref: './appointment-planning-v1.yaml#/components/schemas/PlanningSource'}
verification:
allOf: [{$ref: '#/components/schemas/CargoVerification'}]
nullable: true
allocatedPackages: {type: integer, format: int64}
unknownAllocationCount: {type: integer, format: int64}
effectivePackages: {type: integer, nullable: true, description: 独立核实箱数;没有核实记录时采用原来源箱数}
availablePackages: {type: integer, format: int64, nullable: true}
sourceChanged: {type: boolean}
allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO]}}
blockReasons: {type: array, items: {type: string}}
@@ -6,6 +6,37 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
get:
operationId: resolveOrderAppointmentSource
summary: 从实际订单读取规范原货源及预约覆盖
description: >-
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
入仓列表只知道订单时使用此只读入口;服务端保留外接登记BOOKING键,其他来源返回ORDER键,核对客户、订单关联及目的地。
前端后续核实和预约命令必须使用返回的source.sourceKey,不将外接来货改写为ORDER别名。
parameters:
- {name: orderId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: customerId, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
- {name: destinationKey, in: query, schema: {type: string}}
responses:
'200':
description: 真实原来源和当前活动预约覆盖
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
source: {$ref: '#/components/schemas/PlanningSource'}
coverage: {$ref: '#/components/schemas/Coverage'}
'400': {description: 订单、客户或目的地参数无效}
'403': {description: 非平台或缺少任一读取权限}
'404': {description: 当前客户下来源不存在}
'409': {description: 订单与原来源关联变化}
'503': {description: 权威目录不可用}
/api/platform/v1/last-leg/appointment-planning/external-candidates:
get:
operationId: listExternalAppointmentCandidates
@@ -52,7 +83,7 @@ paths:
仅平台;同时需要platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。
与入仓明细入口共用同一FBA预约聚合;系统生成AP单号,不创建入仓计划、库存或车次。
PENDING允许日期、时段、外部约号及箱数暂缺。SUCCESS必须有权威目的仓时区、真实货件材料、确定箱数及外部约号和确认时段。
实际客户、起运仓、目的仓及原BOOKING由服务端核对;活动来源与已形成订单均唯一占用,取消后释放并保留历史。
实际客户、起运仓、目的仓及原BOOKING由服务端核对;锁原来源后按剩余未分配箱数建立新安排,分批总量不能超过独立核实或原来源总量。取消只释放本批数量并保留历史。
命令与主单、明细同事务;同键同操作人同内容重试返回原快照,不受后来编辑影响。异内容或来源占用冲突拒绝。
requestBody:
required: true
@@ -117,7 +148,7 @@ paths:
description: >-
只读核对,不保存预约安排、不占货、不确认预约。仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
最多100票,批量查询货源、实际开船节点和美国仓目录,支持ORDER与已确认承运BOOKING混合;同一订单重复别名拒绝。
必须同美国起运仓及同美国FBA目的仓;暂扣、多目的地、已有有效预约或已取消完成货源拒绝;未知箱数保留待核实,不填0。
必须同美国起运仓及同美国FBA目的仓;暂扣、多目的地、已分配完或已取消完成货源拒绝;部分分配后仍可选择剩余量。未知箱数保留待核实,不填0。
未建入仓计划及尚未来货的已确认BOOKING不伪造计划、库存或入仓码;正式确认前必须核实箱数。
未接入权威备妥窗口时返回readinessIncomplete及READY_WINDOW_UNVERIFIED,不拿到港/到仓预测代替。
requestBody:
@@ -156,6 +187,7 @@ paths:
destinationWarehouseCode: {type: string}
destinationTimezone: {type: string, nullable: true, description: 当前目的仓主档IANA时区;主档为空时为null,读取失败返回错误,不使用历史预约快照}
sources: {type: array, items: {$ref: '#/components/schemas/PlanningSource'}}
cargoVerification: {type: array, items: {$ref: './appointment-cargo-verification-v1.yaml#/components/schemas/CargoContext'}, description: 各原来源当前独立核实及剩余分配量,sources本身仍保留原事实}
missingFields: {type: array, items: {type: string}}
group:
type: object
File diff suppressed because it is too large Load Diff
@@ -229,6 +229,10 @@ components:
properties:
id: {type: string, description: 内部标识,不作为展示编号}
appointmentNo: {type: string, description: 系统生成AP业务单号}
batchKey: {type: string, description: FBA_APPOINTMENT加不可变主键,用于工作事项关联,不展示内部键}
replacementBatchKeys: {type: array, items: {type: string}, description: 分批/合并后的稳定批次键;旧批次历史保留}
replacementAppointmentNos: {type: array, items: {type: string}, description: 替代批次业务号;非空时原批次只读}
repartitionReason: {type: string, nullable: true}
confirmationNo: {type: string, nullable: true, description: 对方正式外部确认号}
plannedDate: {type: string, format: date, nullable: true}
plannedAt: {type: string, nullable: true}
@@ -255,8 +259,11 @@ components:
id: {type: string}
customerId: {type: string, description: 本票货物客户归属,不在预约主单指定客户}
sourceKey: {type: string}
destinationKey: {type: string, description: 本票目的地FBA加仓码,与原来源归属一同保留}
sourceVersion: {type: string, nullable: true}
orderId: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
packageCount: {type: integer, nullable: true}
cargoVerificationVersion: {type: integer, nullable: true, description: 本次采用的独立核实版本,后续单独核实不改写此快照}
shipmentReferences: {type: array, items: {type: string}, description: 本次采用的独立核实货件或追踪号快照,无核实资料时为空列表}
fbaWarehouseCode: {type: string}
@@ -29,12 +29,12 @@ security:
paths:
/api/platform/v1/last-leg/fleet/trips/calendar:
get:
summary: 读取美国车次计划日历来源
summary: 读取美国车次用车日历来源
description: >-
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,按计划发车时间读取半开区间,最多44天;
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
一车一条,保留取消及完成状态;记录只表示用车计划,不表示收货预约已确认。
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。
parameters:
@@ -512,7 +512,7 @@ paths:
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
- { name: warehouse, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED, MULTIPLE] } }
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
@@ -1372,7 +1372,7 @@ paths:
- { name: status, in: query, schema: { type: string } }
- { name: overseasWarehouseId, in: query, schema: { type: string } }
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED, MULTIPLE] } }
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
@@ -2698,6 +2698,7 @@ components:
draftContainerId: { type: integer, format: int64 }
planPalletIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } }
returnReceiptIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } }
batchAllocations: { type: array, maxItems: 1000, items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' } }
SmartDispatchDraftGroupResult:
type: object
required: [draftContainerId, dispatch]
@@ -2708,6 +2709,11 @@ components:
type: object
description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。
properties:
batchAllocations:
type: array
maxItems: 1000
description: 已有预约批次的FBA实物必须逐票绑定,整托总量或可变预约号不能替代;发车再次校验实物与原预约。
items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' }
planPalletIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string } }
returnReceiptIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string }, description: 原订单实际退回入账来源ID }
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
@@ -17,11 +17,11 @@ class AppointmentBatchPreviewMapperTest {
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("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT,active BOOLEAN)");
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)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10,TRUE),(1,'OTHER',11,TRUE),(3,'C',11,TRUE)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentBatchPreviewMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
@@ -7,6 +7,21 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentBatchPreviewServiceTest {
private static AppointmentCargoVerificationService verification() {
var service=mock(AppointmentCargoVerificationService.class);
when(service.inspect(any())).thenAnswer(call->{AppointmentSourceService.Source source=call.getArgument(0);return new AppointmentCargoVerificationService.Context(source,null,0,0,source.packages(),source.packages()==null?null:source.packages().longValue(),false,List.of("VERIFY_CARGO"),List.of());});
return service;
}
@Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);var verification=verification();
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));var source=source("ORDER:1","A",1L,"100","FBA:ONT8");
when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)));
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,6,0,10,4L,false,List.of("VERIFY_CARGO"),List.of()));
var service=new AppointmentBatchPreviewService(sources,mapper,verification);
assertEquals(4,service.preview(refs).group().packages());
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,10,0,10,0L,false,List.of("VERIFY_CARGO"),List.of()));
assertThrows(ResponseStatusException.class,()->service.preview(refs));
}
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());
}
@@ -16,7 +31,7 @@ class AppointmentBatchPreviewServiceTest {
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)));
when(sources.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");
var preview=new AppointmentBatchPreviewService(sources,mapper).preview(refs);
var preview=new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs);
assertEquals("America/Los_Angeles",preview.destinationTimezone());
assertEquals(2,preview.group().customerCount());assertEquals(20,preview.group().packages());
assertTrue(preview.group().readinessIncomplete());assertNull(preview.group().readyFrom());
@@ -28,9 +43,9 @@ class AppointmentBatchPreviewServiceTest {
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);
var service=new AppointmentBatchPreviewService(sources,mapper,verification());
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)))) {
List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)))) {
when(mapper.flags(List.of(1L))).thenReturn(flags);
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
}
@@ -39,14 +54,14 @@ class AppointmentBatchPreviewServiceTest {
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));
assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs));
verifyNoInteractions(mapper);
}
@Test void absentTimezoneStaysUnknownAndLookupFailureIsNotMasked() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8"));
when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8")));
var service=new AppointmentBatchPreviewService(sources,mapper);
var service=new AppointmentBatchPreviewService(sources,mapper,verification());
assertNull(service.preview(refs).destinationTimezone());
when(sources.destinationTimezone("ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"lookup failed"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
@@ -17,7 +17,7 @@ class AppointmentCargoMapperTest {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)");
s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)");
s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT,active BOOLEAN)");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)");
s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)");
@@ -33,7 +33,7 @@ class AppointmentCargoMapperTest {
try (var s=session.getConnection().createStatement()) {
s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20,TRUE)");
}
session.clearCache();
var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
@@ -24,6 +24,11 @@ class AppointmentCoverageMapperTest {
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)");
s.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD cargo_verification_version INT");
String baseline;try(var input=getClass().getResourceAsStream("/db/lastleg.sql")){baseline=new String(java.util.Objects.requireNonNull(input).readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);}
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_cargo_verification (");
s.execute(baseline.substring(start,baseline.indexOf(';',start)+1));
s.execute("INSERT INTO ll_appointment_cargo_verification(id,customer_id,source_key,destination_key,version,package_count,shipment_references_json,operator_id,command_key,request_hash,created_at) VALUES(1,'A','ORDER:1','FBA:ONT8',1,19,'[]','operator','one',REPEAT('a',64),CURRENT_TIMESTAMP),(2,'A','ORDER:1','FBA:ONT8',2,20,'[\"FBA-A\"]','operator','two',REPEAT('b',64),CURRENT_TIMESTAMP),(3,'B','ORDER:1','FBA:ONT8',1,8,'[]','operator','one',REPEAT('c',64),CURRENT_TIMESTAMP)");
}
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)) {
@@ -35,6 +40,8 @@ class AppointmentCoverageMapperTest {
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());
var evidence=mapper.verifications(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));
assertEquals(1,evidence.size());assertEquals(2,evidence.get(0).getVersion());assertEquals(20,evidence.get(0).getPackageCount());
}
}
}
}
@@ -13,7 +13,7 @@ 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","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles");
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",null);
}
@Test void completeQuantitySharesOneAppointmentWithItsActualConfirmationAndTimezone() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
@@ -42,7 +42,7 @@ class AppointmentCoverageServiceTest {
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()); 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(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")));
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",null)));
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"))));
}
@@ -60,6 +60,22 @@ class AppointmentCoverageServiceTest {
var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES")));
assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt());
verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper);
verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verify(mapper,times(1)).verifications(anyList()); verifyNoMoreInteractions(mapper);
}
@Test void independentVerificationSuppliesQuantityAndReferencesWithoutRewritingOriginalOrderFacts() {
var mapper=mock(AppointmentCoverageMapper.class);
when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",null,2,BEFORE,true)));
var evidence=new AppointmentCargoVerification();evidence.setCustomerId("A");evidence.setSourceKey("ORDER:1");evidence.setDestinationKey("FBA:ONT8");
evidence.setSourceVersion("ORDER:2");evidence.setPackageCount(20);evidence.setVersion(1);evidence.setShipmentReferencesJson("[\"FBA-A\"]");
when(mapper.verifications(anyList())).thenReturn(List.of(evidence));
when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",1)));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
var result=service.query(refs).get(0);
assertEquals("CONFIRMED",result.status());assertFalse(result.changeReviewRequired());assertFalse(result.missingFields().contains("FBA_SHIPMENT_REFERENCES"));
evidence.setVersion(2);evidence.setShipmentReferencesJson("[\"FBA-CHANGED\"]");
var changed=service.query(refs).get(0);
assertTrue(changed.changeReviewRequired());assertTrue(changed.missingFields().contains("SOURCE_CHANGED_REVIEW"));
assertEquals(result.appointments(),changed.appointments(),"核实资料变更不能重写原确认时间");
}
}
@@ -17,6 +17,53 @@ 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 orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var sources=context.getBean(AppointmentSourceService.class);
var coverage=context.getBean(AppointmentCoverageService.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8"));
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8"));
login(LIST,CARGO);
assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(1L,"CLIENT"),1L,"A","FBA:ONT8"));
assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),0L,"A","FBA:ONT8"));
verifyNoInteractions(sources);
var ref=new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8");
var source=new AppointmentSourceService.Source("BOOKING:B-1","A",1L,"IN-A","B-1",null,"100","美国仓","FBA:ONT8","ONT8",10,"BOOKING:2/ORDER:1",true,List.of());
when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L)))).thenReturn(List.of(ref));
when(sources.resolve("BOOKING:B-1","A","FBA:ONT8")).thenReturn(source);
var snapshot=new AppointmentCoverageService.Coverage("A","BOOKING:B-1","FBA:ONT8","PENDING",List.of(),List.of(),source.sourceVersion(),false);
when(coverage.query(List.of(ref))).thenReturn(List.of(snapshot));
assertEquals(source,controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8").data().source());
verify(sources,never()).resolve("ORDER:1","A","FBA:ONT8");
when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)))).thenReturn(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1",null)));
when(sources.resolve("ORDER:1","OTHER","FBA:ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND));
assertEquals(404,assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"OTHER","FBA:ONT8")).getStatusCode().value());
verify(coverage,times(1)).query(anyList());
} finally {SecurityContextHolder.clearContext();}
}
@Test void batchContextRequiresBothListPermissionsAndRejectsCustomerIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1"));
login(LIST,CARGO);assertThrows(ResponseStatusException.class,()->controller.detail(user(1L,"CLIENT"),"FBA_APPOINTMENT:1"));
assertDoesNotThrow(()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1"));
} finally {SecurityContextHolder.clearContext();}
}
@Test void cargoVerificationRequiresIndependentWritePermissionAndPlatformIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentCargoVerificationController.class);
var request=new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,10,List.of("FBA-A"),null,"verify");
login(LIST);assertThrows(AccessDeniedException.class,()->controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null));
login(LIST,CARGO);controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null);
assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request));
login(LIST,CARGO,WRITE);assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request));
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
assertThrows(ResponseStatusException.class,()->controller.save(user(1L,"CLIENT"),"ORDER:1","A",request));
controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request);
verify(context.getBean(AppointmentCargoVerificationService.class)).save("ORDER:1","A",request,"7","operator");
} finally {SecurityContextHolder.clearContext();}
}
@Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentSourceCreateController.class);
@@ -24,9 +71,12 @@ class AppointmentPlanningAuthorizationTest {
"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));
assertThrows(AccessDeniedException.class,()->controller.repartition(user(0L,"PLATFORM"),null));
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
assertThrows(ResponseStatusException.class,()->controller.create(user(1L,"CLIENT"),request));
assertThrows(ResponseStatusException.class,()->controller.repartition(user(1L,"CLIENT"),null));
assertDoesNotThrow(()->controller.create(user(0L,"PLATFORM"),request));
assertDoesNotThrow(()->controller.repartition(user(0L,"PLATFORM"),null));
login(LIST);assertDoesNotThrow(()->controller.unscheduled(user(0L,"PLATFORM"),1,20));
} finally {SecurityContextHolder.clearContext();}
}
@@ -91,6 +141,9 @@ 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 com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController batchController() {return new com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class));}
@Bean AppointmentCargoVerificationService verificationService() {return mock(AppointmentCargoVerificationService.class);}
@Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);}
@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); }
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
class AppointmentWorkCommandServiceTest {
@Test void sameCommandReplaysItsOriginalSnapshotAfterLaterChangesAndRejectsChangedContent() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var first=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"已联系\"}");
var saved=f.commands.execute(state.getWorkItemKey(),first,ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"second","{\"ownerId\":\"1\",\"followupDate\":\"2026-10-16\",\"note\":\"等待回复\"}"),ACTOR);
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
assertEquals(2,f.history.size()); assertNull(saved.item().followupDate());
var changed=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"不同内容\"}");
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),changed,ACTOR)).getStatusCode().value());
var other=new Actor("2","运营乙",ACTOR.permissions());
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,other)).getStatusCode().value());
when(f.gateway.facts(anyList())).thenThrow(new IllegalStateException("source temporarily offline"));
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
var revoked=new Actor("1","运营甲",Set.of(LIST,FOLLOWUP,MANAGE));
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,revoked)).getStatusCode().value());
}
@Test void directRouteGeneratesFourTypesOnceAndDoesNotCreateWarehouseReceipt() throws Exception {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(route);
var requirement=new Requirement("requirement-1",null,null,"CUSTOMER","洛杉矶港","客户仓","America/Los_Angeles",List.of(),List.of(),List.of());
when(f.gateway.container("1")).thenReturn(new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,"America/Los_Angeles",List.of(requirement)));
var request=f.command(Command.CONFIRM_ROUTE,0,"route","{\"flow\":\"DIRECT\",\"reason\":\"按客户直送要求\"}");
var result=f.commands.execute(route.getWorkItemKey(),request,ACTOR);
assertEquals(5,f.rows.size()); assertEquals(5,result.affectedWorkItemKeys().size());
assertTrue(f.store.get(route.getWorkItemKey()).isRetired());
assertFalse(f.store.all().stream().anyMatch(s->s.getType()==Type.WAREHOUSE_RECEIPT));
assertEquals(result,f.commands.execute(route.getWorkItemKey(),request,ACTOR)); assertEquals(5,f.rows.size());
assertThrows(ResponseStatusException.class,()->f.commands.execute(route.getWorkItemKey(),f.command(Command.CONFIRM_ROUTE,1,"switch","{\"flow\":\"TRANSFER\"}"),ACTOR));
}
@Test void externalHandoffRequiresExplicitAcceptanceAndOrdinaryReplyCannotClearIt() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(state);
f.commands.execute(state.getWorkItemKey(),f.command(Command.HANDOFF_RETURN,0,"handoff","{\"recipient\":{\"kind\":\"EXTERNAL\",\"id\":null,\"name\":\"新车队\"},\"reason\":\"原承运另有任务\"}"),ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"reply","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"等待新车队回复\"}"),ACTOR);
assertTrue(pendingHandoff(f.store.get(state.getWorkItemKey())));
assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"empty","{\"handoffVersion\":1,\"note\":\"\"}"),ACTOR));
var accepted=f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"accept","{\"handoffVersion\":1,\"note\":\"已电话确认接手,联系人张先生\"}"),ACTOR);
assertEquals(HandoffStatus.ACCEPTED,f.store.get(state.getWorkItemKey()).getReturnHandoff().status());
assertEquals(ExecutionStatus.PENDING,accepted.item().executionStatus()); assertNull(accepted.item().actual());
assertEquals("1",f.history.get(f.history.size()-1).getOperatorId());
}
@Test void firstRequirementDoesNotReviewDownstreamButRealRescheduleDoes() throws Exception {
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); delivery.setTarget(slot("2026-10-17")); f.add(delivery);
var payload="{\"target\":{\"date\":\"2026-10-15\",\"startTime\":null,\"endDate\":null,\"endTime\":null,\"timezone\":\"America/Los_Angeles\"},\"bookingRequired\":true,\"receivingRequirements\":\"先接仓\",\"reason\":\"首次目标\"}";
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,0,"first",payload),ACTOR);
assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,1,"second",payload.replace("2026-10-15","2026-10-18").replace("首次目标","改期")),ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertEquals(1,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"old","{\"reviewVersion\":0,\"note\":\"旧影响\"}"),ACTOR));
var resolved=f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"review","{\"reviewVersion\":1,\"note\":\"已核对安排\"}"),ACTOR);
assertFalse(resolved.item().reviewRequired()); assertFalse(f.store.get(receipt.getWorkItemKey()).isReviewRequired());
}
@Test void unsupportedPayloadAndReadOnlyActorCannotWrite() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var readonly=new Actor("1","只读",Set.of(LIST,ORDER_READ));
var request=f.command(Command.SAVE_FOLLOWUP,0,"one","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\"}");
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),request,readonly)).getStatusCode().value());
var invalid=f.command(Command.SAVE_FOLLOWUP,0,"two","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\",\"executionStatus\":\"COMPLETED\"}");
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),invalid,ACTOR)).getStatusCode().value());
assertEquals(0,f.store.get(state.getWorkItemKey()).getVersion()); assertTrue(f.history.isEmpty());
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.slot;
class AppointmentWorkCommitmentServiceTest {
@Test void firstTargetSameDayConfirmationAndFirstAssignmentAreNotImpacts() {
var empty=new Commitment(null,null,null,"cargo",false);
var planned=new Commitment(slot("2026-10-16"),null,null,"cargo",false);
var confirmed=new Commitment(planned.target(),slot("2026-10-16"),"vehicle-1","cargo",false);
assertFalse(impacts(empty,planned)); assertFalse(impacts(planned,confirmed));
assertFalse(impacts(planned,new Commitment(null,confirmed.confirmed(),null,"cargo",false)));
assertTrue(impacts(planned,new Commitment(planned.target(),slot("2026-10-17"),null,"cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),"vehicle-2","cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),confirmed.assignment(),"changed",false)));
assertTrue(impacts(confirmed,new Commitment(null,null,null,"cargo",true)));
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkContractTest {
@Test void actionTargetsSerializeWithDiscriminatorAndSlotsNeverInventSeconds() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
var action=new Action(ActionCode.VERIFY_CARGO,"核实资料","work-1",new CargoTarget(new SourceReference("12","ORDER:1","FBA:2")));
var tree=json.readTree(json.writeValueAsString(action));
assertEquals("CARGO_VERIFICATION",tree.path("target").path("kind").asText());
assertEquals("12",tree.path("target").path("source").path("customerId").asText());
assertEquals(action,json.treeToValue(tree,Action.class));
var slot=new Slot(LocalDate.of(2026,10,15),LocalTime.of(10,30),null,null,"America/Los_Angeles");
assertEquals("10:30",json.readTree(json.writeValueAsString(slot)).path("startTime").asText());
var precise=new Slot(slot.date(),LocalTime.of(10,30,45,123000000),null,null,null);
assertEquals("10:30:45.123",json.readTree(json.writeValueAsString(precise)).path("startTime").asText());
assertEquals(precise,json.readValue(json.writeValueAsString(precise),Slot.class));
assertDoesNotThrow(()->AppointmentWorkPolicy.validateSourceSlot(precise));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->AppointmentWorkPolicy.validateSlot(precise));
}
@Test void openApiDefinesFrozenPathsAndDiscriminatedCommands() throws Exception {
var options=new org.yaml.snakeyaml.LoaderOptions();options.setAllowDuplicateKeys(false);
Map<String,Object> root; try(var input=getClass().getResourceAsStream("/openapi/appointment-work-v1.yaml")) { assertNotNull(input); root=new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(input); }
var paths=(Map<?,?>)root.get("paths"); var prefix="/api/platform/v1/last-leg/appointment-work";
for(var path:List.of("/calendar","/summary","/items","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
var schemas=(Map<?,?>)((Map<?,?>)root.get("components")).get("schemas");
var command=(Map<?,?>)schemas.get("CommandRequest"); assertEquals(6,((List<?>)command.get("oneOf")).size());
var item=(Map<?,?>)((Map<?,?>)schemas.get("WorkItem")).get("properties"); assertTrue(item.containsKey("containers")); assertFalse(item.containsKey("containerBookingId"));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDirectTransportProviderTest {
@Test void pickupStartsDirectTransportButOnlyOriginalDestinationUnloadingCompletesIt() {
var client=mock(AppointmentWorkFirstlegClient.class);var provider=new AppointmentWorkDirectTransportProvider(client,"internal-test");
var request=new FactRequest("W",Type.TRANSPORT,Flow.DIRECT,"1","INBOUND:1",null,null,List.of(),List.of(),List.of());
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var pickup=fact("W/carrier",ExecutionStatus.COMPLETED,new Actual(null,time,null));
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.PENDING,null))));
var moving=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.IN_PROGRESS,moving.executionStatus());assertNull(moving.actual());assertNull(moving.tripKey());
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.COMPLETED,new Actual(null,time.plusHours(2),null)))));
var done=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,done.executionStatus());assertEquals(time.plusHours(2),done.actual().localOccurredAt());
assertFalse(provider.supports(Type.TRANSPORT,Flow.TRANSFER));
}
private Fact fact(String key,ExecutionStatus execution,Actual actual) {
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null);
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkFactAdapterTest {
@Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of()));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),null));
var target=new HashMap<String,Fact>(); AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null)));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null))));
}
@Test void completedFactRequiresActualEvidence() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of(fact(state,null,null,ExecutionStatus.COMPLETED,null))));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.appointmentwork;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkPersistenceTest {
@Test void ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception {
var ds=database();
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentWorkMapper.class); var store=new AppointmentWorkStore(mapper,new ObjectMapper().findAndRegisterModules());
assertNotNull(store.lock()); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
var guard=store.lock(); guard.setInitialized(true); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
mapper.invalidateRequirements(); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
guard=store.lock(); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); store.insert(state);
assertThrows(RuntimeException.class,()->store.insert(state));
var stale=store.get(state.getWorkItemKey()); state.setOwner(new Owner("1","运营甲")); store.save(state);
assertEquals(1,store.get(state.getWorkItemKey()).getVersion());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->store.save(stale));
store.history(state,ACTOR,"SAVE_FOLLOWUP","等待回复","same-key","hash",Map.of("version",1));
assertEquals("运营甲",store.replay(state.getWorkItemKey(),"same-key").getOperatorName());
assertThrows(RuntimeException.class,()->store.history(state,new Actor("2","运营乙",ACTOR.permissions()),"SAVE_FOLLOWUP","其他人重复键","same-key","other",Map.of("version",2)));
assertEquals(1,store.history(state.getWorkItemKey()).size());
}
}
@Test void transactionRollbackCannotLeaveHalfGeneratedRoute() throws Exception {
var ds=database(); var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
var factory=new MybatisSqlSessionFactoryBuilder().build(config);
try(var session=factory.openSession(false)) {
var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),new ObjectMapper().findAndRegisterModules());
var state=new WorkTestFixture().state("1",Type.PICKUP,Flow.TRANSFER,null); store.insert(state);
store.history(state,ACTOR,"GENERATED","路线生成",null,null,null); session.rollback();
}
try(var session=factory.openSession(true)) { assertTrue(session.getMapper(AppointmentWorkMapper.class).all(10).isEmpty()); }
}
private JdbcDataSource database() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:work-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
String ddl; try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")) { assertNotNull(stream); ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8); }
try(Connection connection=ds.getConnection(); var statement=connection.createStatement()) { for(var sql:ddl.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); }
return ds;
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
class AppointmentWorkPolicyTest {
@Test void originalDeliveryScheduleDoesNotUndoTheRecordedNoBookingRequirement() {
var fixture=new WorkTestFixture();var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"DISPATCH:1");state.setDestinationKind("CUSTOMER");state.setBookingRequired(false);
var item=project(state,fact(state,null,slot("2026-10-16"),ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(PlanStatus.NOT_REQUIRED,item.planStatus());assertEquals(LocalDate.of(2026,10,16),item.calendarDate());assertEquals(ExecutionStatus.PENDING,item.executionStatus());
}
@Test void localActualWithoutTimezoneRemainsCompletedOnItsRecordedDate() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var actual=new Actual(null,LocalDateTime.parse("2026-10-12T23:45:00"),null);
var item=project(state,fact(state,null,null,ExecutionStatus.COMPLETED,actual),ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,12),item.calendarDate()); assertEquals(ExecutionStatus.COMPLETED,item.executionStatus());
assertNull(item.timezone()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateActual(new Actual(null,null,null)));
}
@Test void sourcePlanWithoutTimezoneIsVisibleButNewRequirementRejectsIt() {
var source=new Slot(LocalDate.of(2026,10,16),LocalTime.of(10,30),null,null,null);
assertDoesNotThrow(()->validateSourceSlot(source)); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateSlot(source));
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.PICKUP,Flow.TRANSFER,null);
var item=project(state,fact(state,null,source,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(source.date(),item.calendarDate()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
}
@Test void followupDateNeverBecomesAnAppointmentAndUnknownTimezoneUsesExplicitBusinessClock() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.ROUTE,Flow.UNKNOWN,null); state.setTimezone(null); state.setFollowupDate(LocalDate.of(2026,10,14));
var item=project(state,null,ACTOR,CLOCK);
assertNull(item.calendarDate()); assertEquals(DateBasis.NONE,item.dateBasis()); assertTrue(followToday(item,CLOCK));
assertTrue(item.missingFields().stream().anyMatch(s->s.contains("America/Los_Angeles")));
assertEquals(ActionCode.CONFIRM_ROUTE,item.nextAction().code());
}
@Test void actualDateWinsInItsOwnZoneAndBucketsAreExclusive() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); state.setReviewRequired(true);
var actual=new Actual(Instant.parse("2026-10-16T01:00:00Z"),null,"America/Los_Angeles");
var fact=fact(state,slot("2026-10-12"),slot("2026-10-13"),ExecutionStatus.COMPLETED,actual);
var item=project(state,fact,ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,15),item.calendarDate()); assertEquals(DateBasis.ACTUAL,item.dateBasis());
assertEquals(Bucket.COMPLETED,bucket(item)); assertEquals(new BucketCounts(0,0,0,0,1),counts(List.of(item))); assertTrue(item.overdueReasons().isEmpty());
assertEquals(ActionCode.VIEW_HISTORY,item.nextAction().code());
}
@Test void nonexistentAndAmbiguousLocalTimesAreRejectedButDateOnlyStaysDateOnly() {
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,8),LocalTime.of(2,30),null,null,"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,11,1),LocalTime.of(1,30),null,null,"America/Los_Angeles")));
assertDoesNotThrow(()->validateSlot(slot("2026-11-01")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,10,15),null,null,null,"-08:00")));
}
@Test void overnightSlotRequiresExplicitEndDateAndValidEndLocalTime() {
var day=LocalDate.of(2026,10,15);
assertDoesNotThrow(()->validateSlot(new Slot(day,LocalTime.of(23,0),day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),null,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),day,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,null,day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,7),LocalTime.of(23,0),LocalDate.of(2026,3,8),LocalTime.of(2,30),"America/Los_Angeles")));
}
@Test void pendingAcceptanceSurvivesAnEmptyReminderAndDoesNotGrantExecution() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
state.setReturnHandoff(new ReturnHandoff(2,new Responsibility(ResponsibilityKind.EXTERNAL,null,"原车队"),new Responsibility(ResponsibilityKind.EXTERNAL,null,"新车队"),HandoffStatus.PENDING_ACCEPTANCE,"原车次结束"));
var item=project(state,fact(state,null,null,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertTrue(followToday(item,CLOCK)); assertEquals(ActionCode.ACCEPT_RETURN,item.nextAction().code());
assertEquals(ExecutionStatus.PENDING,item.executionStatus()); assertNull(item.actual());
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkQueryServiceTest {
@Test void globalSummaryRetainsUnknownRoutesAndReturnAfterDeliveryAcrossCalendarFilters() {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); route.setFollowupDate(LocalDate.of(2026,10,14)); f.add(route);
var returns=f.state("2",Type.EMPTY_RETURN,Flow.DIRECT,null); returns.setTarget(slot("2026-09-01")); f.add(returns);
var delivered=f.state("2",Type.DELIVERY,Flow.DIRECT,"r1"); f.add(delivered);
f.facts.put(delivered.getWorkItemKey(),fact(delivered,null,null,ExecutionStatus.COMPLETED,new Actual(Instant.parse("2026-10-14T20:00:00Z"),null,"America/Los_Angeles")));
assertEquals(new Summary(2,1,1,1,0),f.queries.summary(ACTOR));
assertEquals(0,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),Flow.TRANSFER,Set.of(),ACTOR).total());
assertEquals(2,f.queries.summary(ACTOR).all());
var day=f.queries.items(View.ALL,1,20,LocalDate.of(2026,10,14),null,null,null,Bucket.COMPLETED,null,ACTOR);
assertEquals(1,day.total()); assertEquals(delivered.getWorkItemKey(),day.records().get(0).workItemKey());
assertEquals(2,f.queries.items(View.ALL,1,20,null,null,null,null,null,null,ACTOR).total());
}
@Test void canonicalTripIsOneWorkItemAndMatchesEveryAssociatedContainer() {
var f=new WorkTestFixture(); var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,null); trip.setTripKey("trip-1");
trip.setContainers(List.of(new ContainerReference("1","CB-1","A"),new ContainerReference("2","CB-2","B"))); f.add(trip);
assertEquals(1,f.queries.summary(ACTOR).all());
assertEquals(1,f.queries.items(View.ALL,1,20,null,null,null,"2",null,"B",ACTOR).total());
assertEquals(2,f.queries.detail(trip.getWorkItemKey(),ACTOR).item().containers().size());
}
@Test void incompleteBootstrapScopeDriftAndAuthorityFailureCannotProduceZero() {
var f=new WorkTestFixture(); f.guard.setInitialized(false);
assertEquals(503,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
f.guard.setInitialized(true); when(f.gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"new-scope"));
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
when(f.gateway.discover(isNull(),anyInt())).thenThrow(new IllegalStateException("offline"));
assertThrows(IllegalStateException.class,()->f.queries.summary(ACTOR));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRouteServiceTest {
@Test void onlyCurrentCommittedItemsDefineRouteAndNoGuardOrRemoteReadIsRequired() {
var fixture=new WorkTestFixture();var old=fixture.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);old.setRetired(true);fixture.add(old);
var current=fixture.state("1",Type.DELIVERY,Flow.DIRECT,"INBOUND:1");fixture.add(current);
assertEquals(Flow.DIRECT,new AppointmentWorkRouteService(fixture.store).route("1").flow());
org.mockito.Mockito.verify(fixture.mapper,org.mockito.Mockito.never()).lockGuard();
org.mockito.Mockito.verifyNoInteractions(fixture.gateway);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkSyncServiceTest {
@Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() {
var f=new WorkTestFixture();
var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:1","INBOUND:2"),List.of(source1,source2));
var first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
var second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var root=new Requirement("INBOUND:1",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source1));
first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root));
var remainingBatch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:2"),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(remainingBatch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var active=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();assertEquals(2,active.size());
var kept=active.stream().filter(s->"DISPATCH:1".equals(s.getBatchKey())).findFirst().orElseThrow();
assertEquals(List.of("2"),kept.getContainers().stream().map(ContainerReference::containerBookingId).toList());assertEquals("2",kept.getContainerBookingId());
assertFalse(kept.getPrerequisites().contains(AppointmentWorkPolicy.key("1",Type.WAREHOUSE_RECEIPT,null)));
assertFalse(f.store.container("1").stream().anyMatch(s->"DISPATCH:1".equals(s.getBatchKey())));
assertEquals(List.of(source2),kept.getSources());
var root2=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root2));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
assertEquals(2,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired() && s.getBatchKey()==null).count());
}
@Test void removedPrivateBatchCannotBeReplacedByAnotherCustomersUnrelatedDemand() {
var f=new WorkTestFixture();var oldSource=new SourceReference("C1","ORDER:1","CUSTOMER:1");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,null,null,List.of(),List.of(),List.of(oldSource));
var container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var unrelated=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,null,null,List.of(),List.of(),List.of(new SourceReference("C2","ORDER:2","CUSTOMER:2")));
container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(unrelated));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10)));
}
@Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() {
var f=new WorkTestFixture();
var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of());
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of());
for(var requirement:List.of(root,batch,root,batch)) {
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10));
var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();
assertEquals(1,deliveries.size()); assertEquals(requirement.requirementKey(),deliveries.get(0).getRequirementKey());
}
}
@Test void localRequirementVersionChangingBetweenPagesCannotBePublishedComplete() {
var f=new WorkTestFixture();f.guard.setScanCursor("10");f.guard.setScanScopeVersion("scope");f.guard.setScanRequirementVersion(1);f.guard.setRequirementVersion(2);
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertEquals(0,f.guard.getSynchronizedRequirementVersion());
}
@Test void serverDiscoveryCreatesUnknownRouteWithoutWarehouseBindingAndRetainsOldUnreturnedWork() {
var f=new WorkTestFixture(); var returns=f.state("old",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(returns);
var source=new ContainerSource("new","柜new",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of());
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
assertEquals(1,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(0,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(2,f.rows.size()); assertNotNull(f.store.get(returns.getWorkItemKey()));
assertTrue(f.store.all().stream().anyMatch(s->s.getFlow()==Flow.UNKNOWN && s.getType()==Type.ROUTE));
}
@Test void threeTransferRequirementsGenerateOnlyOnePickupReceiptAndReturn() {
var f=new WorkTestFixture();
var requirements=List.of("one","two","three").stream().map(id->new Requirement(id,null,null,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())).toList();
var source=new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",requirements);
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
assertEquals(9,f.rows.size());
for(var type:List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN)) assertEquals(1,f.store.all().stream().filter(s->s.getType()==type).count());
}
@Test void firstConfirmationUsesBaselineWhileSubsequentImpactMarksOnlyArrangedRelatedWork() {
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); receipt.setTarget(slot("2026-10-15")); receipt.setObservedFingerprint("old"); receipt.setObservedImpactToken("same-impact"); receipt.setObservedArranged(true); f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setTarget(slot("2026-10-16")); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(delivery);
var unscheduled=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r2"); unscheduled.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(unscheduled);
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
var facts=new HashMap<String,Fact>(); facts.put(receipt.getWorkItemKey(),first); facts.put(delivery.getWorkItemKey(),fact(delivery,null,null,ExecutionStatus.PENDING,null)); facts.put(unscheduled.getWorkItemKey(),fact(unscheduled,null,null,ExecutionStatus.PENDING,null));
f.sync.observe(f.store.all(),facts,ACTOR); assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
f.sync.observe(f.store.all(),facts,ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertFalse(f.store.get(unscheduled.getWorkItemKey()).isReviewRequired());
int version=f.store.get(delivery.getWorkItemKey()).getReviewVersion(); f.sync.observe(f.store.all(),facts,ACTOR); assertEquals(version,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
}
@Test void changingDiscoveryScopeDuringPaginationIsNotMarkedComplete() {
var f=new WorkTestFixture(); f.guard.setInitialized(false); f.guard.setScanCursor("10"); f.guard.setScanScopeVersion("before");
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"after"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertFalse(f.guard.isInitialized());
}
@Test void sharedTripAcrossTwoContainersIsPersistedOnceAndKeepsBothContainers() {
var f=new WorkTestFixture();
var sources=List.of("1","2").stream().map(id->new ContainerSource(id,"柜"+id,Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",
List.of(new Requirement("R"+id,"B"+id,"AP"+id,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())))).toList();
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(sources,null,true,"scope"));
when(f.gateway.facts(anyList())).thenAnswer(call->{
var values=new HashMap<String,Fact>();
for(var state:call.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) {
if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null));
}
return values;
});
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
var transports=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList();
assertEquals(1,transports.size()); assertEquals(2,transports.get(0).getContainers().size()); assertEquals(List.of("B1","B2"),transports.get(0).getBatchKeys());
assertEquals(9,f.store.all().stream().filter(s->!s.isRetired()).count());
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
final class WorkTestFixture {
static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-15T04:00:00Z"),ZoneOffset.UTC);
static final Actor ACTOR=new Actor("1","运营甲",Set.of(LIST,ORDER_READ,FOLLOWUP,MANAGE));
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
final AppointmentWorkMapper mapper=mock(AppointmentWorkMapper.class);
final AppointmentWorkFactGateway gateway=mock(AppointmentWorkFactGateway.class);
final Map<String,AppointmentWorkMapper.Row> rows=new TreeMap<>();
final Map<String,Fact> facts=new HashMap<>();
final List<AppointmentWorkMapper.History> history=new ArrayList<>();
final AppointmentWorkMapper.Guard guard=new AppointmentWorkMapper.Guard();
final AppointmentWorkStore store=new AppointmentWorkStore(mapper,json);
final AppointmentWorkSyncService sync=new AppointmentWorkSyncService(store,gateway);
final AppointmentWorkQueryService queries=new AppointmentWorkQueryService(store,gateway,CLOCK);
final AppointmentWorkCommandService commands=new AppointmentWorkCommandService(store,gateway,sync,queries);
WorkTestFixture() {
guard.setId(1); guard.setInitialized(true); guard.setScopeVersion("scope");
when(mapper.guard()).thenReturn(guard); when(mapper.lockGuard()).thenReturn(guard);
when(mapper.get(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(mapper.all(anyInt())).thenAnswer(c->rows.values().stream().limit(c.getArgument(0,Integer.class)).toList());
when(mapper.container(anyString())).thenAnswer(c->rows.values().stream().filter(r->r.getContainerBookingId().equals(c.getArgument(0))).toList());
when(mapper.insert(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); if(rows.putIfAbsent(row.getWorkItemKey(),row)!=null) throw new org.springframework.dao.DuplicateKeyException("duplicate"); return 1; });
when(mapper.update(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); var old=rows.get(row.getWorkItemKey()); if(old==null || old.getVersion()!=row.getVersion()-1) return 0; rows.put(row.getWorkItemKey(),row); return 1; });
when(mapper.insertHistory(any(AppointmentWorkMapper.History.class))).thenAnswer(c->{history.add(c.getArgument(0)); return 1;});
when(mapper.selectHistory(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0))).toList());
when(mapper.command(anyString(),anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && Objects.equals(h.getCommandKey(),c.getArgument(1))).findFirst().orElse(null));
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; });
when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope"));
when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲"));
when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements());
}
AppointmentWorkState state(String container,Type type,Flow flow,String requirement) {
var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container);
state.setContainers(List.of(new ContainerReference(container,"CB-"+container,"柜"+container))); state.setType(type); state.setFlow(flow); state.setRequirementKey(requirement); state.setSourceVersion("v1"); state.setTimezone("America/Los_Angeles");
return state;
}
void add(AppointmentWorkState state) { store.insert(state); }
CommandRequest command(Command command,int version,String id,String payload) throws Exception { return new CommandRequest(command,version,id,json.readTree(payload)); }
static Slot slot(String date) { return new Slot(LocalDate.parse(date),null,null,null,"America/Los_Angeles"); }
static Fact fact(AppointmentWorkState state,Slot target,Slot confirmed,ExecutionStatus execution,Actual actual) {
return new Fact(state.getWorkItemKey(),"fact-v1","impact-v1",target!=null || confirmed!=null,confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.contract;
import org.junit.jupiter.api.Test;
import java.nio.file.*;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSchemaContractTest {
@Test void completeWorkAndPhysicalAllocationSchemaMatchesBothInitializationEntries() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/appointment-work.sql"));
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
String target=Files.readString(Path.of(path));
for(String table:List.of("ll_dispatch_appointment_allocation","ll_appointment_work_commitment","ll_appointment_work_guard","ll_appointment_work","ll_appointment_work_history"))
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
}
assertTrue(source.contains("scan_cursor VARCHAR(128)"));
assertTrue(source.contains("PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key)"));
}
@Test void overseasInitializationReportsSuccessOnlyAfterEveryTableAndSeed() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
long count=Pattern.compile("(?m)^CREATE TABLE\\s+").matcher(sql).results().count();
String pass="SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, "+count+" AS initialized_tables;";
assertTrue(sql.stripTrailing().endsWith(pass));
assertEquals(1,Pattern.compile("SELECT 'PASS' AS initialization_status").matcher(sql).results().count());
String reset=Files.readString(Path.of("../deploy/sql/reset-test-fba-appointments.sql"));
assertTrue(reset.indexOf("CALL assert_test_appointment_reset_preconditions()")<reset.indexOf("DROP TABLE ll_inbound_fba_appointment_item"));
assertTrue(reset.contains("'ll_fba_appointment_command','ll_appointment_cargo_verification'"));
}
private String definition(String sql,String table) {
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+Pattern.quote(table)+" \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(match.find(),"Missing "+table);
String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(match.find(),"Duplicate "+table);return result;
}
}
@@ -50,6 +50,15 @@ class DispatchCandidateMapperContractTest {
}
assertEquals(8, count);
}
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES (99,'2026-10-15 10:00:00',NULL,'SUCCESS','ACY2')");
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES (99,1,'9',1,'ACY2')");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"一票多批时不得将最晚确认冒充整个托盘的预约时间");
}
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=99");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"部分批次确认也不能代表整票");
}
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))) {
@@ -71,6 +71,8 @@ class DispatchServiceTest {
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements);
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
@@ -28,6 +28,18 @@ class FleetTripCalendarMapperTest {
assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST");
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
// The actual month is authoritative even when the original plan is outside this window.
jdbc.update(insert,"TR-ACTUAL-IN","IN_TRANSIT",from.minusDays(3),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from.plusHours(2),"TR-ACTUAL-IN");
jdbc.update(insert,"TR-ACTUAL-OUT","IN_TRANSIT",from.plusHours(3),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
jdbc.batchUpdate(insert,many);
assertThat(mapper.calendar(from,to)).hasSize(2001);
@@ -146,7 +146,10 @@ class FleetTripPersistenceTest {
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class));
ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
ReflectionTestUtils.setField(execution,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null));
@@ -4,11 +4,34 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanSchemaContractTest {
@Test
void splitBatchAndVerificationDefinitionsMatchEveryDeploymentEntry() throws IOException {
String source=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(var file:List.of("../deploy/sql/海外区域统一增量-MySQL8.sql","../deploy/sql/reset-test-fba-appointments.sql")) {
String deployment=Files.readString(Path.of(file));
for(var name:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_appointment_cargo_verification")) {
assertEquals(definition(source,name),definition(deployment,name),file+" must preserve allocation and history for "+name);
}
}
}
private static String definition(String sql,String name) {
var matcher=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+Pattern.quote(name)+" \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(matcher.find(),name+" must exist");
String definition=matcher.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(matcher.find(),name+" must have one definition");
return definition;
}
@Test
void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
@@ -33,8 +56,11 @@ class InboundPlanSchemaContractTest {
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)"));
assertFalse(schema.contains("unique key uk_ll_fba_active_source (active_source_key)"));
assertFalse(schema.contains("unique key uk_ll_fba_active_order (active_order_id)"));
assertTrue(schema.contains("key idx_ll_fba_active_source (customer_id,source_key,active)"));
assertTrue(schema.contains("key idx_ll_fba_active_order (customer_id,order_id,active)"));
assertTrue(schema.contains("replacement_keys_json longtext null"));
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"));

Some files were not shown because too many files have changed in this diff Show More