feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
+1
-1
@@ -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>")
|
||||
|
||||
+11
-6
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<>o.fba_warehouse_code) AS mixed_destinations
|
||||
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+141
@@ -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);}
|
||||
}
|
||||
+9
-2
@@ -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);
|
||||
}
|
||||
|
||||
+20
-5
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+16
@@ -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("工作事项同步守卫未初始化");
|
||||
}
|
||||
}
|
||||
+161
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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()); }
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+112
@@ -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("内部服务身份未配置"); }
|
||||
}
|
||||
+43
@@ -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); }
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
+75
@@ -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";
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+164
@@ -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); }
|
||||
}
|
||||
+98
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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));
|
||||
}
|
||||
}
|
||||
+49
@@ -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<>();
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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.
|
||||
}
|
||||
}
|
||||
+27
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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 =
|
||||
|
||||
+11
-3
@@ -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())) {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+26
@@ -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());
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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())));
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+6
@@ -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) {
|
||||
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+7
-2
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+20
@@ -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) { }
|
||||
}
|
||||
+6
@@ -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) { }
|
||||
+3
@@ -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;
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+16
-14
@@ -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<>#{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<>#{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<>#{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);
|
||||
}
|
||||
|
||||
+3
-5
@@ -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)>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 >=#{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,
|
||||
|
||||
+6
-4
@@ -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+">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 >=#{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+">1 THEN NULL ELSE a.appointment_no END appointmentNo," +
|
||||
APPOINTMENT_STATUS+" appointmentStatus,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">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" +
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+220
-19
@@ -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);}
|
||||
|
||||
+32
@@ -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); }
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+136
@@ -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("预约批次键无效"); }
|
||||
}
|
||||
}
|
||||
+21
@@ -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));
|
||||
}
|
||||
}
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+24
@@ -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()));
|
||||
}
|
||||
}
|
||||
+24
@@ -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("该柜已有直送卸货事实,不能进入海外仓收货");
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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());
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user