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,
|
||||
|
||||
Reference in New Issue
Block a user