From 24f5ccf7ff517649f7e52da8ca39511f2e4f10ce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 08:42:01 +0800 Subject: [PATCH] feat(appointment): share pickup and receiving followups by container --- deploy/sql/海外区域统一增量-MySQL8.sql | 4 +- .../AppointmentContainer.java | 25 ++++ .../AppointmentContainerController.java | 49 ++++++ .../AppointmentContainerMapper.java | 51 +++++++ .../AppointmentContainerService.java | 66 +++++++++ .../AppointmentFollowupService.java | 43 +++++- .../AppointmentPlanningController.java | 2 +- .../src/main/resources/db/lastleg.sql | 4 +- .../openapi/appointment-planning-v1.yaml | 140 +++++++++++++++++- .../AppointmentContainerMapperTest.java | 39 +++++ .../AppointmentContainerServiceTest.java | 35 +++++ .../AppointmentFollowupPersistenceTest.java | 2 +- .../AppointmentFollowupServiceTest.java | 54 +++++-- .../AppointmentPlanningAuthorizationTest.java | 20 +++ 14 files changed, 513 insertions(+), 21 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainer.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 744b33f45..fd57de49f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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 '同一货源及跟进对象的记录版本', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainer.java new file mode 100644 index 000000000..03c30fbb3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainer.java @@ -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 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 blockReasons) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerController.java new file mode 100644 index 000000000..fd0d40621 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerController.java @@ -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> 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 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 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 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())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapper.java new file mode 100644 index 000000000..4507db2ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapper.java @@ -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 #{id} + AND o.source_container_booking_id=#{containerId} + 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 + #{id} + AND (search_o.container_no LIKE CONCAT('%',#{keyword},'%') OR search_o.inbound_code LIKE CONCAT('%',#{keyword},'%'))) + """; + String GROUP = " GROUP BY o.source_container_booking_id,g.container_no,g.standard_node,g.aggregate_version "; + @Select("") + long count(@Param("origins") List origins, @Param("keyword") String keyword, @Param("containerId") String containerId); + + @Select("") + List page(@Param("origins") List origins, @Param("keyword") String keyword, + @Param("containerId") String containerId, @Param("size") int size, @Param("offset") long offset); + + @Select("") + List warehouses(@Param("origins") List origins, @Param("containers") List containers); + record WarehouseCount(String containerBookingId, String warehouseId, long orderCount, long customerCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerService.java new file mode 100644 index 000000000..6ae14a4aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerService.java @@ -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 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.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.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 rows,Map 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 catalog() { + try { + var response=warehouses.catalog(token); + if(response==null || !response.successful() || response.data()==null) throw new IllegalStateException(); + var result=new LinkedHashMap(); + 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java index 9999d11d4..8364549f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java @@ -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 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(); 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 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)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java index 4e71ef1e1..c87933150 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -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); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c21744461..f64023ff3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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 '同一货源及跟进对象的记录版本', diff --git a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml index 6a3b3ca95..80cc6800a 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml @@ -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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapperTest.java new file mode 100644 index 000000000..8bab79eca --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapperTest.java @@ -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()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerServiceTest.java new file mode 100644 index 000000000..18cead155 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerServiceTest.java @@ -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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java index 922d9dfdb..67f11ca6a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java @@ -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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java index dcc6a2825..f0df729a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java @@ -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(); + 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(); 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 99777fa8f..a36ac305f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -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); }