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);
}