diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateController.java new file mode 100644 index 000000000..24816af25 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateController.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +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/external-candidates") +public class AppointmentExternalCandidateController { + private final AppointmentExternalCandidateService service; + public AppointmentExternalCandidateController(AppointmentExternalCandidateService service){this.service=service;} + @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 warehouseId,@RequestParam(required=false) String destination,@RequestParam(required=false) String keyword) { + AppointmentPlanningController.requirePlatform(user); + return ApiResponse.success(service.page(page,size,warehouseId,destination,keyword)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateService.java new file mode 100644 index 000000000..19009cac7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateService.java @@ -0,0 +1,53 @@ +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.function.Function; +import java.util.stream.Collectors; + +@Service +public class AppointmentExternalCandidateService { + public record Row(String customerName,String tripNo,AppointmentSourceService.Source source, + AppointmentCoverageService.Coverage coverage) { } + private final AppointmentExternalSourceGateway external; + private final AppointmentWarehouseClient warehouses; + private final AppointmentSourceService sources; + private final AppointmentCoverageService coverage; + private final String token; + public AppointmentExternalCandidateService(AppointmentExternalSourceGateway external,AppointmentWarehouseClient warehouses, + AppointmentSourceService sources,AppointmentCoverageService coverage, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.external=external;this.warehouses=warehouses;this.sources=sources;this.coverage=coverage;this.token=token; + } + public PageResult page(int page,int size,String warehouseId,String destination,String keyword) { + if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("候选分页或关键词无效"); + warehouseId=clean(warehouseId);destination=clean(destination);keyword=clean(keyword); + List catalog; + try { + var response=warehouses.catalog(token); + if(response==null || !response.successful() || response.data()==null) throw new IllegalStateException(); + catalog=response.data(); + } catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,无法核实外接货范围",error);} + var us=catalog.stream().filter(w->w!=null && w.id()!=null && w.id()>0 && "US".equals(w.countryCode())).toList(); + var origins=us.stream().filter(w->Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(Objects.toString(w.warehouseType(),""))).map(w->w.id().toString()).distinct().toList(); + var destinations=us.stream().filter(w->"FBA".equals(w.warehouseType()) && clean(w.warehouseCode())!=null).map(AppointmentWarehouseClient.Warehouse::warehouseCode).distinct().toList(); + if(warehouseId!=null && !origins.contains(warehouseId) || destination!=null && !destinations.contains(destination)) throw bad("请选择已启用的美国接仓点及FBA目的仓"); + if(origins.isEmpty() || destinations.isEmpty()) return new PageResult<>(List.of(),page,size,0,0); + var found=external.candidates(new AppointmentExternalSourceGateway.CandidateQuery(page,size,origins,destinations,warehouseId,destination,keyword)); + if(found.records().isEmpty()) return new PageResult<>(List.of(),page,size,found.total(),found.pages()); + var refs=found.records().stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"BOOKING:"+r.bookingNo(),r.destinationKey())).toList(); + var resolved=sources.resolveBatch(refs).stream().collect(Collectors.toMap(r->r.customerId()+"\n"+r.sourceKey(),Function.identity())); + var covered=coverage.query(refs).stream().collect(Collectors.toMap(r->r.customerId()+"\n"+r.sourceKey(),Function.identity())); + var rows=found.records().stream().map(r->{ + String key=r.customerId()+"\nBOOKING:"+r.bookingNo(); + return new Row(r.customerName(),r.tripNo(),Objects.requireNonNull(resolved.get(key)),Objects.requireNonNull(covered.get(key))); + }).toList(); + return new PageResult<>(rows,page,size,found.total(),found.pages()); + } + 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/AppointmentExternalSourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java index eaeb3ff95..b4c9e05b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java @@ -4,6 +4,10 @@ import java.util.List; /** Implemented by the external-booking owner; planning must not query Fleet's persistence directly. */ public interface AppointmentExternalSourceGateway { + record Candidate(String customerId, String customerName, String bookingNo, String tripNo, String destinationKey) { } + record CandidateQuery(int page, int size, List originIds, List destinationCodes, + String warehouseId, String destination, String keyword) { } + com.baiyue.common.core.api.PageResult candidates(CandidateQuery query); record SourceRef(String customerId, String bookingNo) { } record OrderRef(String customerId, Long orderId) { } record Source(String customerId, String bookingNo, String tripNo, String bookingStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapper.java new file mode 100644 index 000000000..49c5c025c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapper.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.Candidate; +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.CandidateQuery; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Fleet-owned discovery; original booking remains the identity before and after receipt. */ +@Mapper +public interface FleetAppointmentCandidateMapper { + String FILTER = """ + FROM ll_fleet_external_booking b JOIN ll_fleet_trip t ON t.id=b.trip_id + WHERE b.offer_id IS NOT NULL AND b.status IN ('CONFIRMED','RECEIVED','BOUND') + AND t.status NOT IN ('CANCELLED','COMPLETED') + AND t.warehouse_id IN #{id} + AND b.destination_key IN CONCAT('FBA:',#{code}) + AND t.warehouse_id=#{q.warehouseId} + AND b.destination_key=CONCAT('FBA:',#{q.destination}) + AND (b.booking_no LIKE CONCAT('%',#{q.keyword},'%') + OR b.trip_no LIKE CONCAT('%',#{q.keyword},'%') OR b.customer_name LIKE CONCAT('%',#{q.keyword},'%') + OR b.incoming_no LIKE CONCAT('%',#{q.keyword},'%')) + """; + @Select("") + long count(@Param("q") CandidateQuery query); + @Select("") + List page(@Param("q") CandidateQuery query,@Param("offset") long offset); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java index c47e70e18..7e904e278 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java @@ -7,6 +7,17 @@ import java.util.List; /** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */ @Component public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway { + @jakarta.annotation.Resource private FleetAppointmentCandidateMapper candidates; + @Override + public com.baiyue.common.core.api.PageResult candidates(CandidateQuery query) { + if (query == null || query.page()<1 || query.size()<1 || query.size()>100) + throw new IllegalArgumentException("外接候选分页无效"); + if (query.originIds().isEmpty() || query.destinationCodes().isEmpty()) + return new com.baiyue.common.core.api.PageResult<>(List.of(),query.page(),query.size(),0,0); + long total=candidates.count(query); + return new com.baiyue.common.core.api.PageResult<>(candidates.page(query,(long)(query.page()-1)*query.size()), + query.page(),query.size(),total,(total+query.size()-1)/query.size()); + } private final FleetPoolSourceResolver sources; public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;} @Override 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 9629a271b..faf22b634 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,44 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/last-leg/appointment-planning/external-candidates: + get: + operationId: listExternalAppointmentCandidates + summary: 分页发现已确认承运的美国FBA外接货 + description: '仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。按权威启用美国接仓点/FBA目录筛选原拼车登记,排除询价、取消和已完成车次。来货前后仅返回同一BOOKING来源;已有预约仍展示覆盖状态,不能重复加入。整页批量解析及核对覆盖,不逐条HTTP请求。' + 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: warehouseId, in: query, schema: {type: string}} + - {name: destination, in: query, schema: {type: string}, description: 美国FBA仓码} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 登记号、车次号、客户名称或来货号} + responses: + '200': + description: 业务响应;data为服务器分页结果 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + type: object + properties: + customerName: {type: string, nullable: true} + tripNo: {type: string} + source: {$ref: '#/components/schemas/PlanningSource'} + coverage: {$ref: '#/components/schemas/Coverage'} + '403': {description: 非平台或缺少读取权限} + '503': {description: 仓库目录失败,不能当作无货} /api/platform/v1/last-leg/appointment-planning/appointments: post: operationId: createAppointmentFromSources diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateAuthorizationTest.java new file mode 100644 index 000000000..5a7c8523b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateAuthorizationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentExternalCandidateAuthorizationTest { + @Test void requiresBothPermissionsAndPlatformIdentity() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentExternalCandidateController.class); + var service=context.getBean(AppointmentExternalCandidateService.class); + var permissions=List.of("platform:appointment:list","platform:overseas-warehouse:last-mile-order:list"); + var platform=new AuthenticatedUser(1L,0L,0L,1L,"staff","PLATFORM","WEB",Set.copyOf(permissions)); + for(var permission:permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",List.of(new SimpleGrantedAuthority(permission)))); + assertThatThrownBy(()->controller.page(platform,1,20,null,null,null)).isInstanceOf(AccessDeniedException.class); + } + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList())); + var customer=new AuthenticatedUser(1L,9L,9L,1L,"customer","MERCHANT","WEB",Set.copyOf(permissions)); + assertThatThrownBy(()->controller.page(customer,1,20,null,null,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(service); + controller.page(platform,1,20,null,null,null);verify(service).page(1,20,null,null,null); + } finally {SecurityContextHolder.clearContext();} + } + @Configuration @EnableMethodSecurity static class Config { + @Bean AppointmentExternalCandidateService service(){return mock(AppointmentExternalCandidateService.class);} + @Bean AppointmentExternalCandidateController controller(AppointmentExternalCandidateService service){return new AppointmentExternalCandidateController(service);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateServiceTest.java new file mode 100644 index 000000000..0e1044ca9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateServiceTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentExternalCandidateServiceTest { + @Test void batchesResolutionAndCoverageWhileKeepingOriginalIdentityAndServerTotal() { + var external=mock(AppointmentExternalSourceGateway.class);var warehouses=mock(AppointmentWarehouseClient.class); + var sources=mock(AppointmentSourceService.class);var coverage=mock(AppointmentCoverageService.class); + var service=new AppointmentExternalCandidateService(external,warehouses,sources,coverage,"token"); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(100L,"US","US","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(200L,"CA","CA","OVERSEAS","CA"), + new AppointmentWarehouseClient.Warehouse(300L,"ONT8","ONT8","FBA","US")))); + var query=new AppointmentExternalSourceGateway.CandidateQuery(2,20,List.of("100"),List.of("ONT8"),null,null,"FB"); + when(external.candidates(query)).thenReturn(new PageResult<>(List.of(new AppointmentExternalSourceGateway.Candidate("C","Customer","FB-1","TR-1","FBA:ONT8")),2,20,21,2)); + var refs=List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:FB-1","FBA:ONT8")); + var source=new AppointmentSourceService.Source("BOOKING:FB-1","C",null,null,"FB-1",null,"100","US","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of()); + var covered=new AppointmentCoverageService.Coverage("C","BOOKING:FB-1","FBA:ONT8","UNSCHEDULED",List.of(),List.of(),"BOOKING:1",false); + when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(coverage.query(refs)).thenReturn(List.of(covered)); + var result=service.page(2,20,null,null," FB "); + assertThat(result.total()).isEqualTo(21);assertThat(result.records().get(0).source()).isSameAs(source); + assertThat(result.records().get(0).source().packages()).isNull(); + verify(external).candidates(query);verify(sources).resolveBatch(refs);verify(coverage).query(refs); + verifyNoMoreInteractions(external,sources,coverage); + assertThatThrownBy(()->service.page(1,20,"200",null,null)).hasMessageContaining("美国"); + when(warehouses.catalog("token")).thenThrow(new IllegalStateException()); + assertThatThrownBy(()->service.page(1,20,null,null,null)).hasMessageContaining("目录暂不可用"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapperTest.java new file mode 100644 index 000000000..70e948880 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapperTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.CandidateQuery; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +class FleetAppointmentCandidateMapperTest { + @Test void pagesOriginalBookingsAcrossCustomersAndExcludesIneligibleScope() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,warehouse_id BIGINT,status VARCHAR(32))"); + db.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(64),customer_name VARCHAR(64),booking_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),offer_id BIGINT,destination_key VARCHAR(160),incoming_no VARCHAR(64),created_at TIMESTAMP)"); + db.update("INSERT INTO ll_fleet_trip VALUES(1,100,'PLANNED'),(2,200,'PLANNED'),(3,100,'COMPLETED')"); + for(int i=1;i<=7;i++) db.update("INSERT INTO ll_fleet_external_booking VALUES(?,1,?,? ,?,'TR-1','CONFIRMED',1,'FBA:ONT8',NULL,CURRENT_TIMESTAMP)",i,"C"+i,"Customer "+i,"FB-"+i); + db.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=3"); + db.update("UPDATE ll_fleet_external_booking SET status='DRAFT' WHERE id=4"); + db.update("UPDATE ll_fleet_external_booking SET trip_id=2 WHERE id=5"); + db.update("UPDATE ll_fleet_external_booking SET trip_id=3 WHERE id=6"); + db.update("UPDATE ll_fleet_external_booking SET destination_key='FBA:YYZ1' WHERE id=7"); + db.update("UPDATE ll_fleet_external_booking SET status='BOUND',incoming_no='IN-2' WHERE id=2"); + var config=new MybatisConfiguration();config.addMapper(FleetAppointmentCandidateMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetAppointmentCandidateMapper.class); + var query=new CandidateQuery(1,1,List.of("100"),List.of("ONT8"),null,null,null); + assertThat(mapper.count(query)).isEqualTo(2); + assertThat(mapper.page(query,0)).extracting(r->r.bookingNo()).containsExactly("FB-2"); + assertThat(mapper.page(query,1)).extracting(r->r.bookingNo()).containsExactly("FB-1"); + assertThat(mapper.page(new CandidateQuery(1,20,List.of("100"),List.of("ONT8"),"100","ONT8","IN-2"),0)) + .extracting(r->r.customerId()).containsExactly("C2"); + db.update("UPDATE ll_fleet_external_booking SET offer_id=NULL WHERE id=1"); + assertThat(mapper.count(query)).isEqualTo(1); + } +}