feat(appointment): share pickup and receiving followups by container
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class AppointmentContainer {
|
||||
private String containerBookingId;
|
||||
private String containerNo;
|
||||
private long orderCount;
|
||||
private long customerCount;
|
||||
private Long packages;
|
||||
private long unknownPackageOrders;
|
||||
private LocalDateTime earliestArrivalAt;
|
||||
private LocalDateTime latestArrivalAt;
|
||||
private String standardNode;
|
||||
private Long sourceVersion;
|
||||
private boolean incompleteScope;
|
||||
private List<Warehouse> warehouses = List.of();
|
||||
public record Warehouse(String containerBookingId, String warehouseId, String warehouseName,
|
||||
long orderCount, long customerCount) { }
|
||||
public record Context(String containerBookingId, String containerNo, String warehouseId,
|
||||
String warehouseName, boolean canFollowup, List<String> blockReasons) { }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/containers")
|
||||
public class AppointmentContainerController {
|
||||
private final AppointmentContainerService containers;
|
||||
private final AppointmentFollowupService followups;
|
||||
public AppointmentContainerController(AppointmentContainerService containers,AppointmentFollowupService followups) {
|
||||
this.containers=containers; this.followups=followups;
|
||||
}
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<PageResult<AppointmentContainer>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String keyword) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(containers.page(page,size,keyword));
|
||||
}
|
||||
@GetMapping("/{containerId}")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<AppointmentContainer.Context> context(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String containerId,@RequestParam(required=false) String warehouseId) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(containers.context(containerId,warehouseId));
|
||||
}
|
||||
@GetMapping("/{containerId}/follow-ups")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
|
||||
public ApiResponse<AppointmentFollowupService.History> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String containerId,@RequestParam(required=false) String warehouseId,
|
||||
@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(followups.containerHistory(containerId,warehouseId,page,size));
|
||||
}
|
||||
@PostMapping("/{containerId}/follow-ups")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')")
|
||||
public ApiResponse<AppointmentFollowupService.Record> save(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String containerId,@RequestParam(required=false) String warehouseId,
|
||||
@Valid @RequestBody AppointmentFollowupService.Save request) {
|
||||
AppointmentPlanningController.requirePlatform(user);
|
||||
return ApiResponse.success(followups.saveContainer(containerId,warehouseId,request,user.userId().toString(),user.displayName()==null?user.username():user.displayName()));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface AppointmentContainerMapper {
|
||||
String FILTER = """
|
||||
FROM ll_order o JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id
|
||||
WHERE o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED') AND g.node_sequence >=40
|
||||
AND o.overseas_warehouse_id IN <foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
<if test='containerId != null'>AND o.source_container_booking_id=#{containerId}</if>
|
||||
<if test='keyword != null'>AND EXISTS(SELECT 1 FROM ll_order search_o
|
||||
WHERE search_o.source_container_booking_id=o.source_container_booking_id AND search_o.order_type='FIRST_LEG'
|
||||
AND search_o.current_status NOT IN ('CANCELLED','COMPLETED') AND search_o.overseas_warehouse_id IN
|
||||
<foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
AND (search_o.container_no LIKE CONCAT('%',#{keyword},'%') OR search_o.inbound_code LIKE CONCAT('%',#{keyword},'%')))</if>
|
||||
""";
|
||||
String GROUP = " GROUP BY o.source_container_booking_id,g.container_no,g.standard_node,g.aggregate_version ";
|
||||
@Select("<script>SELECT COUNT(*) FROM (SELECT o.source_container_booking_id " + FILTER + GROUP + ") counted</script>")
|
||||
long count(@Param("origins") List<String> origins, @Param("keyword") String keyword, @Param("containerId") String containerId);
|
||||
|
||||
@Select("<script>" + """
|
||||
SELECT o.source_container_booking_id AS container_booking_id,g.container_no,
|
||||
COUNT(*) AS order_count,COUNT(DISTINCT o.customer_id) AS customer_count,
|
||||
SUM(CASE WHEN o.package_count IS NULL OR o.package_count<=0 THEN 1 ELSE 0 END) AS unknown_package_orders,
|
||||
CASE WHEN SUM(CASE WHEN o.package_count IS NULL OR o.package_count<=0 THEN 1 ELSE 0 END)>0
|
||||
THEN NULL ELSE SUM(o.package_count) END AS packages,
|
||||
MIN(o.estimated_arrival_at) AS earliest_arrival_at,MAX(o.estimated_arrival_at) AS latest_arrival_at,
|
||||
g.standard_node,g.aggregate_version AS source_version,
|
||||
EXISTS(SELECT 1 FROM ll_order other_o WHERE other_o.source_container_booking_id=o.source_container_booking_id
|
||||
AND other_o.order_type='FIRST_LEG' AND other_o.current_status NOT IN ('CANCELLED','COMPLETED')
|
||||
AND (other_o.overseas_warehouse_id IS NULL OR other_o.overseas_warehouse_id NOT IN
|
||||
<foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>)) AS incomplete_scope
|
||||
""" + FILTER + GROUP + " ORDER BY MAX(o.created_at) DESC,o.source_container_booking_id LIMIT #{size} OFFSET #{offset}</script>")
|
||||
List<AppointmentContainer> page(@Param("origins") List<String> origins, @Param("keyword") String keyword,
|
||||
@Param("containerId") String containerId, @Param("size") int size, @Param("offset") long offset);
|
||||
|
||||
@Select("<script>" + """
|
||||
SELECT o.source_container_booking_id AS containerBookingId,o.overseas_warehouse_id AS warehouseId,
|
||||
COUNT(*) AS orderCount,COUNT(DISTINCT o.customer_id) AS customerCount
|
||||
FROM ll_order o WHERE o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
|
||||
AND o.source_container_booking_id IN <foreach collection='containers' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
AND o.overseas_warehouse_id IN <foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
GROUP BY o.source_container_booking_id,o.overseas_warehouse_id ORDER BY o.source_container_booking_id,o.overseas_warehouse_id
|
||||
""" + "</script>")
|
||||
List<WarehouseCount> warehouses(@Param("origins") List<String> origins, @Param("containers") List<String> containers);
|
||||
record WarehouseCount(String containerBookingId, String warehouseId, long orderCount, long customerCount) { }
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AppointmentContainerService {
|
||||
private final AppointmentContainerMapper mapper;
|
||||
private final AppointmentWarehouseClient warehouses;
|
||||
private final String token;
|
||||
public AppointmentContainerService(AppointmentContainerMapper mapper, AppointmentWarehouseClient warehouses,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.mapper=mapper; this.warehouses=warehouses; this.token=token;
|
||||
}
|
||||
public PageResult<AppointmentContainer> page(int page,int size,String keyword) {
|
||||
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
|
||||
var catalog=catalog();
|
||||
if(catalog.isEmpty()) return new PageResult<>(List.of(),page,size,0,0);
|
||||
var origins=List.copyOf(catalog.keySet());
|
||||
keyword=clean(keyword);
|
||||
long total=mapper.count(origins,keyword,null);
|
||||
var rows=total==0 ? List.<AppointmentContainer>of() : mapper.page(origins,keyword,null,size,(long)(page-1)*size);
|
||||
attachWarehouses(rows,catalog);
|
||||
return new PageResult<>(rows,page,size,total,(total+size-1)/size);
|
||||
}
|
||||
public AppointmentContainer.Context context(String containerId,String warehouseId) {
|
||||
subjectKey(containerId,warehouseId);
|
||||
var catalog=catalog();
|
||||
var rows=catalog.isEmpty() ? List.<AppointmentContainer>of() : mapper.page(List.copyOf(catalog.keySet()),null,containerId,1,0);
|
||||
if(rows.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到美国范围内已开船的有效柜次");
|
||||
attachWarehouses(rows,catalog);
|
||||
var row=rows.get(0);
|
||||
String warehouseName=null;
|
||||
if(warehouseId!=null) warehouseName=row.getWarehouses().stream().filter(w->w.warehouseId().equals(warehouseId))
|
||||
.map(AppointmentContainer.Warehouse::warehouseName).findFirst().orElseThrow(()->bad("该接仓点不属于当前柜次"));
|
||||
return new AppointmentContainer.Context(containerId,row.getContainerNo(),warehouseId,warehouseName,
|
||||
!row.isIncompleteScope(),row.isIncompleteScope()?List.of("同柜仍有未明确或非美国接仓点,请先核对整柜范围"):List.of());
|
||||
}
|
||||
public static String subjectKey(String containerId,String warehouseId) {
|
||||
if(containerId==null || !containerId.matches("[A-Za-z0-9_-]{1,64}") || warehouseId!=null && !warehouseId.matches("[0-9]{1,20}")) throw bad("柜次或接仓点标识无效");
|
||||
return "CONTAINER:"+containerId+(warehouseId==null?"":":WAREHOUSE:"+warehouseId);
|
||||
}
|
||||
private void attachWarehouses(List<AppointmentContainer> rows,Map<String,AppointmentWarehouseClient.Warehouse> catalog) {
|
||||
if(rows.isEmpty()) return;
|
||||
var groups=mapper.warehouses(List.copyOf(catalog.keySet()),rows.stream().map(AppointmentContainer::getContainerBookingId).toList()).stream()
|
||||
.collect(Collectors.groupingBy(AppointmentContainerMapper.WarehouseCount::containerBookingId));
|
||||
for(var row:rows) row.setWarehouses(groups.getOrDefault(row.getContainerBookingId(),List.of()).stream()
|
||||
.map(w->new AppointmentContainer.Warehouse(w.containerBookingId(),w.warehouseId(),catalog.get(w.warehouseId()).warehouseName(),w.orderCount(),w.customerCount())).toList());
|
||||
}
|
||||
private Map<String,AppointmentWarehouseClient.Warehouse> catalog() {
|
||||
try {
|
||||
var response=warehouses.catalog(token);
|
||||
if(response==null || !response.successful() || response.data()==null) throw new IllegalStateException();
|
||||
var result=new LinkedHashMap<String,AppointmentWarehouseClient.Warehouse>();
|
||||
for(var w:response.data()) if(w!=null && w.id()!=null && w.id()>0 && "US".equals(w.countryCode()) && Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(Objects.toString(w.warehouseType(),""))) result.put(w.id().toString(),w);
|
||||
return result;
|
||||
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,无法核对美国整柜范围",error); }
|
||||
}
|
||||
private static String clean(String value) { return value==null || value.isBlank()?null:value.trim(); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
|
||||
}
|
||||
+38
-5
@@ -24,8 +24,10 @@ public class AppointmentFollowupService {
|
||||
private final AppointmentSourceService sources;
|
||||
private final AppointmentFollowupMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) {
|
||||
this.sources = sources; this.mapper = mapper; this.json = json;
|
||||
private final AppointmentContainerService containers;
|
||||
public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json,
|
||||
AppointmentContainerService containers) {
|
||||
this.sources = sources; this.mapper = mapper; this.json = json; this.containers = containers;
|
||||
}
|
||||
public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion,
|
||||
@NotBlank @Size(max=64) String idempotencyKey,
|
||||
@@ -37,9 +39,16 @@ public class AppointmentFollowupService {
|
||||
private static final Set<String> STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING");
|
||||
|
||||
public History history(String sourceKey, String customerId, int page, int size) {
|
||||
requireCargoKey(sourceKey);
|
||||
return history(sourceKey,customerId,page,size,()->sources.resolve(sourceKey,customerId,null));
|
||||
}
|
||||
public History containerHistory(String containerId,String warehouseId,int page,int size) {
|
||||
return history(AppointmentContainerService.subjectKey(containerId,warehouseId),"0",page,size,()->containers.context(containerId,warehouseId));
|
||||
}
|
||||
private History history(String sourceKey,String customerId,int page,int size,Runnable verifySource) {
|
||||
if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效");
|
||||
long total = mapper.count(sourceKey,customerId);
|
||||
if (total == 0) sources.resolve(sourceKey,customerId,null);
|
||||
if (total == 0) verifySource.run();
|
||||
var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList();
|
||||
var versions = new java.util.LinkedHashMap<String,Integer>();
|
||||
STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage)));
|
||||
@@ -48,6 +57,26 @@ public class AppointmentFollowupService {
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) {
|
||||
requireCargoKey(sourceKey);
|
||||
if(!"FBA_RECEIVING".equals(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"提柜及接仓事项请进入整柜跟进,避免按货物重复联系");
|
||||
return append(sourceKey,customerId,request,operatorId,operatorName,()->{
|
||||
var source=sources.resolve(sourceKey,customerId,null);
|
||||
return new Subject(source.orderId(),source.canFollowup());
|
||||
});
|
||||
}
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public Record saveContainer(String containerId,String warehouseId,Save request,String operatorId,String operatorName) {
|
||||
String key=AppointmentContainerService.subjectKey(containerId,warehouseId);
|
||||
boolean allowed=warehouseId==null ? Set.of("DRAYAGE_CONTACT","TERMINAL_APPOINTMENT").contains(request.stage()) : "WAREHOUSE_RECEIVING".equals(request.stage());
|
||||
if(!allowed) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择对应柜次或接仓点的跟进事项");
|
||||
return append(key,"0",request,operatorId,operatorName,()->{
|
||||
var context=containers.context(containerId,warehouseId);
|
||||
return new Subject(null,context.canFollowup());
|
||||
});
|
||||
}
|
||||
private record Subject(Long orderId,boolean canFollowup) { }
|
||||
private Record append(String sourceKey,String customerId,Save request,String operatorId,String operatorName,
|
||||
java.util.function.Supplier<Subject> resolve) {
|
||||
if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项");
|
||||
if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排");
|
||||
@@ -60,10 +89,10 @@ public class AppointmentFollowupService {
|
||||
if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录");
|
||||
return view(existing);
|
||||
}
|
||||
var source = sources.resolve(sourceKey,customerId,null);
|
||||
var source = resolve.get();
|
||||
if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态");
|
||||
if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交");
|
||||
var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(source.sourceKey()); value.setCustomerId(source.customerId());
|
||||
var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(sourceKey); value.setCustomerId(customerId);
|
||||
value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty()));
|
||||
value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate());
|
||||
value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey());
|
||||
@@ -79,6 +108,10 @@ public class AppointmentFollowupService {
|
||||
return view(value);
|
||||
}
|
||||
private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); }
|
||||
private static void requireCargoKey(String sourceKey) {
|
||||
if(sourceKey==null || !(sourceKey.startsWith("ORDER:") || sourceKey.startsWith("BOOKING:")))
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效货物来源");
|
||||
}
|
||||
private static Record view(AppointmentFollowup row) {
|
||||
return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ public class AppointmentPlanningController {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(followups.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null ? user.username() : user.displayName()));
|
||||
}
|
||||
private static void requirePlatform(AuthenticatedUser user) {
|
||||
static void requirePlatform(AuthenticatedUser user) {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user