feat(appointment): share pickup and receiving followups by container
This commit is contained in:
@@ -1482,8 +1482,8 @@ CREATE TABLE ll_order (
|
||||
|
||||
CREATE TABLE ll_appointment_followup (
|
||||
id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER、BOOKING或CONTAINER及接仓点来源键',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '货源客户;整柜共享记录由服务端固定为平台作用域0',
|
||||
source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空',
|
||||
stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING',
|
||||
version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本',
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -1627,8 +1627,8 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_appointment_followup (
|
||||
id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户',
|
||||
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER、BOOKING或CONTAINER及接仓点来源键',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '货源客户;整柜共享记录由服务端固定为平台作用域0',
|
||||
source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空',
|
||||
stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING',
|
||||
version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本',
|
||||
|
||||
@@ -6,6 +6,108 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/appointment-planning/containers:
|
||||
get:
|
||||
operationId: appointmentPlanningContainers
|
||||
summary: 分页读取美国整柜提柜与接仓范围
|
||||
description: >-
|
||||
仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
已实际开船且仍有效的FIRST_LEG货物按柜汇总,不限FBA目的地,不要求入仓计划。
|
||||
仅统计美国接仓点货物;存在未知或非美国接仓点的同柜有效货物时incompleteScope=true,禁止新增整柜跟进。
|
||||
关键词命中某票时仍返回整柜美国范围数量;任一数量缺失时packages为空。预计到港保留来源快照,不代表实时船司ETA。
|
||||
parameters:
|
||||
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
responses:
|
||||
'200':
|
||||
description: 核对code后读取整柜分页
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/Container'}}
|
||||
page: {type: integer}
|
||||
size: {type: integer}
|
||||
total: {type: integer}
|
||||
pages: {type: integer}
|
||||
'400': {description: 分页或搜索条件无效}
|
||||
'403': {description: 缺少读取权限或非平台身份}
|
||||
'503': {description: 美国仓库目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/containers/{containerId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerId'}
|
||||
- {$ref: '#/components/parameters/ReceivingWarehouse'}
|
||||
get:
|
||||
operationId: resolveAppointmentContainer
|
||||
summary: 核对整柜或接仓点的跟进资格
|
||||
description: 仅平台,同时要求两项列表权限;接仓点必须属于当前柜次。内部柜次ID仅供关联,不能作为展示柜号。
|
||||
responses:
|
||||
'200':
|
||||
description: code=200时data为已核实上下文
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data: {$ref: '#/components/schemas/ContainerContext'}
|
||||
'400': {description: 标识无效或接仓点不属于该柜}
|
||||
'403': {description: 缺少读取权限或非平台身份}
|
||||
'404': {description: 未找到有效美国整柜来源}
|
||||
'503': {description: 仓库目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/containers/{containerId}/follow-ups:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerId'}
|
||||
- {$ref: '#/components/parameters/ReceivingWarehouse'}
|
||||
get:
|
||||
operationId: appointmentContainerFollowupHistory
|
||||
summary: 读取整柜或接仓点共享跟进历史
|
||||
description: 仅平台,同时要求两项列表权限;已有历史在来源离开活动列表后仍可读取,不按客户拆分。
|
||||
parameters:
|
||||
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
|
||||
responses:
|
||||
'200': {$ref: '#/paths/~1api~1platform~1v1~1last-leg~1appointment-planning~1sources~1{sourceKey}~1follow-ups/get/responses/200'}
|
||||
'400': {description: 标识或分页无效}
|
||||
'403': {description: 缺少读取权限或非平台身份}
|
||||
'404': {description: 无历史且来源不存在}
|
||||
post:
|
||||
operationId: appendAppointmentContainerFollowup
|
||||
summary: 追加整柜提柜联系或指定仓库接仓联系
|
||||
description: >-
|
||||
仅平台,两项列表权限及platform:appointment:followup均必需。
|
||||
未传warehouseId只接受DRAYAGE_CONTACT或TERMINAL_APPOINTMENT;传入时只接受WAREHOUSE_RECEIVING。
|
||||
服务端产生共享来源键,不接收customerId或操作者字段;同柜跨客户共用一份历史。
|
||||
版本、持久化幂等和跟进日期规则与货源跟进相同,不产生正式预约或执行结果。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [stage, expectedVersion, idempotencyKey, result]
|
||||
properties:
|
||||
stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING]}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
idempotencyKey: {type: string, minLength: 1, maxLength: 64}
|
||||
contactedParty: {type: string, maxLength: 200, nullable: true}
|
||||
result: {type: string, minLength: 1, maxLength: 1000}
|
||||
nextAction: {type: string, maxLength: 500, nullable: true}
|
||||
followupDate: {type: string, format: date, nullable: true}
|
||||
responses:
|
||||
'200': {$ref: '#/paths/~1api~1platform~1v1~1last-leg~1appointment-planning~1sources~1{sourceKey}~1follow-ups/post/responses/200'}
|
||||
'400': {description: 事项与范围不符或参数无效}
|
||||
'403': {description: 缺少权限或非平台身份}
|
||||
'404': {description: 来源不存在}
|
||||
'409': {description: 范围待核对、版本或幂等内容冲突}
|
||||
'503': {description: 仓库目录不可用}
|
||||
/api/platform/v1/last-leg/appointment-planning/cargo:
|
||||
get:
|
||||
operationId: appointmentPlanningCargo
|
||||
@@ -123,7 +225,7 @@ paths:
|
||||
type: object
|
||||
required: [stage, expectedVersion, idempotencyKey, result]
|
||||
properties:
|
||||
stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]}
|
||||
stage: {type: string, enum: [FBA_RECEIVING], description: 提柜及接仓统一使用整柜接口,不按客户货源重复登记}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 初次为0,后续取history.versions中对应事项版本}
|
||||
idempotencyKey: {type: string, maxLength: 64}
|
||||
contactedParty: {type: string, maxLength: 200, nullable: true}
|
||||
@@ -146,9 +248,45 @@ paths:
|
||||
'404': {description: 来源不存在或不可筹划}
|
||||
'409': {description: 版本冲突或同键异内容}
|
||||
components:
|
||||
parameters:
|
||||
ContainerId: {name: containerId, in: path, required: true, schema: {type: string, pattern: '^[A-Za-z0-9_-]{1,64}$'}}
|
||||
ReceivingWarehouse: {name: warehouseId, in: query, schema: {type: string, pattern: '^[0-9]{1,20}$'}, description: 不传表示整柜,传入表示该柜实际接仓点}
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
schemas:
|
||||
Container:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
orderCount: {type: integer}
|
||||
customerCount: {type: integer}
|
||||
packages: {type: integer, nullable: true}
|
||||
unknownPackageOrders: {type: integer}
|
||||
earliestArrivalAt: {type: string, nullable: true}
|
||||
latestArrivalAt: {type: string, nullable: true}
|
||||
standardNode: {type: string}
|
||||
sourceVersion: {type: string, nullable: true}
|
||||
incompleteScope: {type: boolean}
|
||||
warehouses:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
warehouseId: {type: string}
|
||||
warehouseName: {type: string}
|
||||
orderCount: {type: integer}
|
||||
customerCount: {type: integer}
|
||||
ContainerContext:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
warehouseId: {type: string, nullable: true}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
canFollowup: {type: boolean}
|
||||
blockReasons: {type: array, items: {type: string}}
|
||||
FollowupRecord:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentContainerMapperTest {
|
||||
@Test void groupsWholeContainersAcrossCustomersWithoutFbaOnlyFilterOrSearchTruncation() throws Exception {
|
||||
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:containers-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT,source_container_booking_id VARCHAR(64),container_no VARCHAR(64),order_type VARCHAR(32),current_status VARCHAR(32),overseas_warehouse_id VARCHAR(64),customer_id VARCHAR(64),inbound_code VARCHAR(64),package_count INT,estimated_arrival_at TIMESTAMP,created_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR(64),container_no VARCHAR(64),node_sequence INT,standard_node VARCHAR(64),aggregate_version BIGINT)");
|
||||
s.execute("INSERT INTO ll_container_shipping_gate VALUES('C1','BOX-1',40,'DEPARTED',1),('C2','BOX-2',30,'READY',1),('C3','BOX-3',40,'DEPARTED',1)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','100','A','IN-A',10,NULL,CURRENT_TIMESTAMP),(2,'C1','BOX-1','FIRST_LEG','INBOUND_PLANNED','100','B','IN-B',20,NULL,CURRENT_TIMESTAMP),(3,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','200','A','IN-C',NULL,NULL,CURRENT_TIMESTAMP),(4,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','999','C','OUT-US',4,NULL,CURRENT_TIMESTAMP),(5,'C2','BOX-2','FIRST_LEG','PENDING_ASSIGNMENT','100','A','UNSAILED',1,NULL,CURRENT_TIMESTAMP),(6,'C3','BOX-3','FIRST_LEG','PENDING_ASSIGNMENT','999','C','FOREIGN',1,NULL,CURRENT_TIMESTAMP)");
|
||||
}
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentContainerMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var mapper=session.getMapper(AppointmentContainerMapper.class);var origins=List.of("100","200");
|
||||
assertEquals(1,mapper.count(origins,null,null));
|
||||
var row=mapper.page(origins,"IN-A",null,20,0).get(0);
|
||||
assertEquals("C1",row.getContainerBookingId());assertEquals("BOX-1",row.getContainerNo());
|
||||
assertEquals(3,row.getOrderCount());assertEquals(2,row.getCustomerCount());assertNull(row.getPackages());
|
||||
assertEquals(1,row.getUnknownPackageOrders());assertTrue(row.isIncompleteScope());
|
||||
var groups=mapper.warehouses(origins,List.of("C1"));assertEquals(2,groups.size());
|
||||
assertEquals(2,groups.get(0).orderCount());assertEquals(2,groups.get(0).customerCount());
|
||||
assertTrue(mapper.page(origins,null,"C3",20,0).isEmpty());
|
||||
try(var s=session.getConnection().createStatement()) {s.execute("UPDATE ll_order SET current_status='CANCELLED' WHERE id=4");s.execute("UPDATE ll_order SET package_count=5 WHERE id=3");}
|
||||
session.clearCache();var updated=mapper.page(origins,null,"C1",20,0).get(0);
|
||||
assertFalse(updated.isIncompleteScope());assertEquals(35,updated.getPackages());
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentContainerServiceTest {
|
||||
@Test void usesOneBatchedWarehouseReadAndRejectsAnUnrelatedReceivingPoint() {
|
||||
var mapper=mock(AppointmentContainerMapper.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US","US receiving","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"OTHER","Foreign","OVERSEAS","GB"))));
|
||||
var row=new AppointmentContainer();row.setContainerBookingId("C1");row.setContainerNo("BOX-1");row.setOrderCount(2);row.setCustomerCount(2);
|
||||
when(mapper.count(anyList(),isNull(),isNull())).thenReturn(1L);
|
||||
when(mapper.page(anyList(),isNull(),any(),anyInt(),anyLong())).thenReturn(List.of(row));
|
||||
when(mapper.warehouses(anyList(),anyList())).thenReturn(List.of(new AppointmentContainerMapper.WarehouseCount("C1","100",2,2)));
|
||||
var service=new AppointmentContainerService(mapper,warehouse,"token");
|
||||
var page=service.page(1,20,null);assertEquals("US receiving",page.records().get(0).getWarehouses().get(0).warehouseName());
|
||||
verify(mapper).warehouses(List.of("100"),List.of("C1"));
|
||||
assertEquals("US receiving",service.context("C1","100").warehouseName());
|
||||
assertThrows(ResponseStatusException.class,()->service.context("C1","200"));
|
||||
row.setIncompleteScope(true);assertFalse(service.context("C1",null).canFollowup());
|
||||
assertEquals("CONTAINER:C1",AppointmentContainerService.subjectKey("C1",null));
|
||||
assertEquals("CONTAINER:C1:WAREHOUSE:100",AppointmentContainerService.subjectKey("C1","100"));
|
||||
assertThrows(ResponseStatusException.class,()->AppointmentContainerService.subjectKey("C1:WAREHOUSE:100",null));
|
||||
}
|
||||
@Test void unavailableDirectoryDoesNotBecomeAnEmptyAuthoritativeResult() {
|
||||
var mapper=mock(AppointmentContainerMapper.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog(anyString())).thenThrow(new IllegalStateException());
|
||||
var error=assertThrows(ResponseStatusException.class,()->new AppointmentContainerService(mapper,warehouse,"token").page(1,20,null));
|
||||
assertEquals(503,error.getStatusCode().value());verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ class AppointmentFollowupPersistenceTest {
|
||||
var sources=org.mockito.Mockito.mock(AppointmentSourceService.class);
|
||||
org.mockito.Mockito.when(sources.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source(
|
||||
"ORDER:1","A",1L,"IN-1",null,"CONTAINER","100","US warehouse","FBA:ONT8","ONT8",20,"ORDER:1",true,java.util.List.of()));
|
||||
var service=new AppointmentFollowupService(sources,mapper,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
var service=new AppointmentFollowupService(sources,mapper,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),null);
|
||||
var command=new AppointmentFollowupService.Save("FBA_RECEIVING",1,"timestamp-replay",null,"Test contact",null,null);
|
||||
var saved=service.save("ORDER:1","A",command,"operator","Operator");
|
||||
var replay=service.save("ORDER:1","A",command,"operator","Operator");
|
||||
|
||||
+45
-9
@@ -11,36 +11,72 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentFollowupServiceTest {
|
||||
@Test void containerContactsShareOneSubjectAndReceivingContactsStayWarehouseScoped() {
|
||||
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
|
||||
var containers=mock(AppointmentContainerService.class);
|
||||
when(containers.context("C1",null)).thenReturn(new AppointmentContainer.Context("C1","CONTAINER-A",null,null,true,List.of()));
|
||||
when(containers.context("C1","100")).thenReturn(new AppointmentContainer.Context("C1","CONTAINER-A","100","美国仓",true,List.of()));
|
||||
var saved=new HashMap<String,AppointmentFollowup>();
|
||||
when(mapper.command(anyString(),eq("0"),anyString(),anyString())).thenAnswer(call -> saved.get(call.getArgument(0)+"/"+call.getArgument(3)));
|
||||
when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> {
|
||||
AppointmentFollowup row=call.getArgument(0); saved.put(row.getSourceKey()+"/"+row.getCommandKey(),row); return 1;
|
||||
});
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules(),containers);
|
||||
var pickup=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"pickup",null,"整柜统一联系",null,null);
|
||||
var first=service.saveContainer("C1",null,pickup,"operator","运营");
|
||||
assertEquals(first,service.saveContainer("C1",null,pickup,"operator","运营"));
|
||||
service.saveContainer("C1","100",new AppointmentFollowupService.Save("WAREHOUSE_RECEIVING",0,"receiving",null,"联系接仓",null,null),"operator","运营");
|
||||
assertEquals(2,saved.size());
|
||||
assertNull(saved.get("CONTAINER:C1/pickup").getSourceOrderId());
|
||||
assertEquals("0",saved.get("CONTAINER:C1:WAREHOUSE:100/receiving").getCustomerId());
|
||||
verify(containers,times(1)).context("C1",null);
|
||||
verifyNoInteractions(cargo);
|
||||
assertThrows(ResponseStatusException.class,()->service.saveContainer("C1","100",pickup,"operator","运营"));
|
||||
assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",pickup,"operator","运营"));
|
||||
assertThrows(ResponseStatusException.class,()->service.history("CONTAINER:C1","0",1,20));
|
||||
verify(mapper,times(2)).insert(any(AppointmentFollowup.class));
|
||||
}
|
||||
|
||||
@Test void uncertainContainerScopeBlocksNewRecords() {
|
||||
var mapper=mock(AppointmentFollowupMapper.class); var containers=mock(AppointmentContainerService.class);
|
||||
when(containers.context("C1",null)).thenReturn(new AppointmentContainer.Context("C1",null,null,null,false,List.of("范围待核对")));
|
||||
var service=new AppointmentFollowupService(mock(AppointmentSourceService.class),mapper,new ObjectMapper(),containers);
|
||||
var failure=assertThrows(ResponseStatusException.class,()->service.saveContainer("C1",null,
|
||||
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"key",null,"跟进",null,null),"operator","运营"));
|
||||
assertEquals(409,failure.getStatusCode().value());
|
||||
verify(mapper,never()).insert(any(AppointmentFollowup.class));
|
||||
}
|
||||
|
||||
@Test void contactsBeforePlanningAreAppendOnlyAndOldCommandsReplayTheirOriginalResult() {
|
||||
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
|
||||
var saved=new HashMap<String,AppointmentFollowup>(); var version=new int[]{0};
|
||||
when(cargo.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,"C1","100","美国仓","FBA:ONT8","ONT8",10,"ORDER:0",true,List.of()));
|
||||
when(mapper.command(eq("ORDER:1"),eq("A"),eq("DRAYAGE_CONTACT"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3)));
|
||||
when(mapper.version("ORDER:1","A","DRAYAGE_CONTACT")).thenAnswer(call -> version[0]);
|
||||
when(mapper.command(eq("ORDER:1"),eq("A"),eq("FBA_RECEIVING"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3)));
|
||||
when(mapper.version("ORDER:1","A","FBA_RECEIVING")).thenAnswer(call -> version[0]);
|
||||
when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> {
|
||||
AppointmentFollowup row=call.getArgument(0); saved.put(row.getCommandKey(),row); version[0]=row.getVersion(); return 1;
|
||||
});
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules());
|
||||
var first=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15));
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules(),null);
|
||||
var first=new AppointmentFollowupService.Save("FBA_RECEIVING",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15));
|
||||
var result=service.save("ORDER:1","A",first,"operator","运营甲");
|
||||
service.save("ORDER:1","A",new AppointmentFollowupService.Save("DRAYAGE_CONTACT",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲");
|
||||
service.save("ORDER:1","A",new AppointmentFollowupService.Save("FBA_RECEIVING",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲");
|
||||
var replay=service.save("ORDER:1","A",first,"operator","运营甲");
|
||||
assertEquals(result,replay); assertEquals(1,replay.version()); assertEquals(2,saved.size());
|
||||
assertEquals("运营甲",result.operatorName()); assertEquals("已联系,等待回复",result.result());
|
||||
verify(mapper,times(2)).insert(any(AppointmentFollowup.class));
|
||||
verify(cargo,times(2)).resolve("ORDER:1","A",null);
|
||||
var conflict=assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
|
||||
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first",null,"不同结果",null,null),"operator","运营甲"));
|
||||
new AppointmentFollowupService.Save("FBA_RECEIVING",0,"first",null,"不同结果",null,null),"operator","运营甲"));
|
||||
assertEquals(409,conflict.getStatusCode().value());
|
||||
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",first,"another-operator","运营乙"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
|
||||
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"stale",null,"并发旧版本",null,null),"operator","运营甲"));
|
||||
new AppointmentFollowupService.Save("FBA_RECEIVING",0,"stale",null,"并发旧版本",null,null),"operator","运营甲"));
|
||||
assertEquals(2,saved.size());
|
||||
}
|
||||
|
||||
@Test void followupDateRequiresAnActionAndInvalidStagesDoNotWrite() {
|
||||
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper(),null);
|
||||
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
|
||||
new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"等待材料",null,LocalDate.of(2026,10,15)),"operator","运营"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
|
||||
@@ -51,7 +87,7 @@ class AppointmentFollowupServiceTest {
|
||||
@Test void existingHistoryRemainsReadableWhenTheCargoHasLeftTheActivePlanningList() {
|
||||
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
|
||||
when(mapper.count("ORDER:1","A")).thenReturn(21L); when(mapper.page("ORDER:1","A",20,20)).thenReturn(List.of());
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
|
||||
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper(),null);
|
||||
var history=service.history("ORDER:1","A",2,20);
|
||||
assertEquals(21,history.history().total()); assertEquals(2,history.history().pages());
|
||||
verifyNoInteractions(cargo);
|
||||
|
||||
+20
@@ -16,6 +16,24 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentPlanningAuthorizationTest {
|
||||
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
|
||||
@Test void containerReadsAndWritesRequirePlatformIdentityAndSeparatePermissions() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentContainerController.class);
|
||||
var user=user(0L,"PLATFORM");
|
||||
var request=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"key",null,"已联系",null,null);
|
||||
login(LIST);
|
||||
assertThrows(AccessDeniedException.class,()->controller.page(user,1,20,null));
|
||||
assertThrows(AccessDeniedException.class,()->controller.context(user,"C1",null));
|
||||
assertThrows(AccessDeniedException.class,()->controller.history(user,"C1",null,1,20));
|
||||
login(LIST,CARGO);
|
||||
controller.page(user,1,20,null);
|
||||
assertThrows(AccessDeniedException.class,()->controller.save(user,"C1",null,request));
|
||||
login(LIST,CARGO,WRITE);
|
||||
assertThrows(ResponseStatusException.class,()->controller.save(user(1L,"CLIENT"),"C1",null,request));
|
||||
controller.save(user,"C1",null,request);
|
||||
verify(context.getBean(AppointmentFollowupService.class)).saveContainer("C1",null,request,"7","operator");
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void readRequiresBothSourcePermissionsAndCannotWrite() {
|
||||
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentPlanningController.class);
|
||||
@@ -47,6 +65,8 @@ class AppointmentPlanningAuthorizationTest {
|
||||
private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); }
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
@Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); }
|
||||
@Bean AppointmentContainerController containerController(AppointmentContainerService containers,AppointmentFollowupService followups) { return new AppointmentContainerController(containers,followups); }
|
||||
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
|
||||
@Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); }
|
||||
@Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); }
|
||||
|
||||
Reference in New Issue
Block a user