feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
+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