feat(appointment): share pickup and receiving followups by container

This commit is contained in:
2026-10-10 08:42:01 +08:00
parent 885cac13a4
commit 24f5ccf7ff
14 changed files with 513 additions and 21 deletions
@@ -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) { }
}
@@ -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()));
}
}
@@ -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 &gt;=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&lt;=0 THEN 1 ELSE 0 END) AS unknown_package_orders,
CASE WHEN SUM(CASE WHEN o.package_count IS NULL OR o.package_count&lt;=0 THEN 1 ELSE 0 END)&gt;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) { }
}
@@ -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); }
}
@@ -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));
}
@@ -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);
}