feat(appointments): discover external booking candidates through fleet boundary
This commit is contained in:
+22
@@ -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<PageResult<AppointmentExternalCandidateService.Row>> 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));
|
||||
}
|
||||
}
|
||||
+53
@@ -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<Row> 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<AppointmentWarehouseClient.Warehouse> 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);}
|
||||
}
|
||||
+4
@@ -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<String> originIds, List<String> destinationCodes,
|
||||
String warehouseId, String destination, String keyword) { }
|
||||
com.baiyue.common.core.api.PageResult<Candidate> 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,
|
||||
|
||||
+28
@@ -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 <foreach collection='q.originIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
AND b.destination_key IN <foreach collection='q.destinationCodes' item='code' open='(' separator=',' close=')'>CONCAT('FBA:',#{code})</foreach>
|
||||
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
||||
<if test='q.destination != null'>AND b.destination_key=CONCAT('FBA:',#{q.destination})</if>
|
||||
<if test='q.keyword != null'>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},'%'))</if>
|
||||
""";
|
||||
@Select("<script>SELECT COUNT(*) " + FILTER + "</script>")
|
||||
long count(@Param("q") CandidateQuery query);
|
||||
@Select("<script>SELECT b.customer_id,b.customer_name,b.booking_no,b.trip_no,b.destination_key " + FILTER
|
||||
+ " ORDER BY b.created_at DESC,b.id DESC LIMIT #{q.size} OFFSET #{offset}</script>")
|
||||
List<Candidate> page(@Param("q") CandidateQuery query,@Param("offset") long offset);
|
||||
}
|
||||
+11
@@ -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<Candidate> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
@@ -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);}
|
||||
}
|
||||
}
|
||||
+33
@@ -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("目录暂不可用");
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user