perf(lastleg): reuse warehouse directory within sync generation
This commit is contained in:
+23
-5
@@ -58,18 +58,36 @@ public class AppointmentSourceService {
|
||||
return response.data().warehouse().timezone();
|
||||
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
|
||||
}
|
||||
/** One catalog request and batched source reads, including mixed first-leg and external bookings. */
|
||||
/** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */
|
||||
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||
return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList();
|
||||
return openReadScope().resolveBatch(requested);
|
||||
}
|
||||
/** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */
|
||||
public ReadScope openReadScope() { return new ReadScope(); }
|
||||
public final class ReadScope {
|
||||
private List<AppointmentWarehouseClient.Warehouse> directory;
|
||||
/** 只能由所属来源服务创建,不允许传入未经校验的目录。 */
|
||||
private ReadScope() { }
|
||||
/** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */
|
||||
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||
return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList();
|
||||
}
|
||||
/** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */
|
||||
private List<AppointmentWarehouseClient.Warehouse> catalog() {
|
||||
if(directory==null) directory=AppointmentSourceService.this.catalog();
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
/** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */
|
||||
public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) {
|
||||
public Source requireSource() { if(error!=null) throw error; return source; }
|
||||
}
|
||||
/** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */
|
||||
public List<Resolution> inspectBatch(List<AppointmentCoverageService.SourceRef> requested) {
|
||||
return resolveBatch(requested,false);
|
||||
return resolveBatch(requested,false,openReadScope());
|
||||
}
|
||||
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict) {
|
||||
/** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */
|
||||
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict,ReadScope scope) {
|
||||
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
|
||||
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
|
||||
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
|
||||
@@ -96,7 +114,7 @@ public class AppointmentSourceService {
|
||||
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
|
||||
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
|
||||
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
|
||||
var catalog=catalog();
|
||||
var catalog=scope.catalog();
|
||||
var result=requested.stream().map(ref->{
|
||||
try {
|
||||
if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))))
|
||||
|
||||
+16
-6
@@ -68,12 +68,22 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
|
||||
var provider=requirements.getIfAvailable();
|
||||
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
|
||||
var result=provider.requirements(source,effectiveFlow);
|
||||
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
|
||||
return List.copyOf(result);
|
||||
/** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */
|
||||
public RequirementReader openRequirements() {
|
||||
return new RequirementReader() {
|
||||
private RequirementReader reader;
|
||||
/** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */
|
||||
public List<Requirement> read(ContainerSource source,Flow effectiveFlow) {
|
||||
if(reader==null) {
|
||||
var provider=requirements.getIfAvailable();
|
||||
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
|
||||
reader=provider.openRequirements();
|
||||
}
|
||||
var result=reader.read(source,effectiveFlow);
|
||||
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
|
||||
return List.copyOf(result);
|
||||
}
|
||||
};
|
||||
}
|
||||
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
|
||||
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
|
||||
|
||||
+5
-3
@@ -27,8 +27,8 @@ public interface AppointmentWorkFactGateway {
|
||||
}
|
||||
SourcePage discover(String after, int size);
|
||||
ContainerSource container(String containerBookingId);
|
||||
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
|
||||
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
|
||||
/** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */
|
||||
RequirementReader openRequirements();
|
||||
Map<String, Fact> facts(List<AppointmentWorkState> states);
|
||||
Owner owner(String id, Actor actor);
|
||||
Responsibility supplier(String id);
|
||||
@@ -41,5 +41,7 @@ public interface AppointmentWorkFactGateway {
|
||||
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
|
||||
interface OwnerDirectory { Owner activePlatformOwner(String id); }
|
||||
interface SupplierDirectory { Responsibility activeSupplier(String id); }
|
||||
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
|
||||
/** 单次生成显式复用目录,不跨请求保存来源事实。 */
|
||||
interface RequirementReader { List<Requirement> read(ContainerSource source,Flow effectiveFlow); }
|
||||
interface RequirementProvider { RequirementReader openRequirements(); }
|
||||
}
|
||||
|
||||
+7
-6
@@ -36,6 +36,7 @@ public class AppointmentWorkSyncService {
|
||||
timing.next(SyncPhase.GENERATE);
|
||||
// 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。
|
||||
var known=workingSet();
|
||||
var requirementReader=gateway.openRequirements();
|
||||
int created=0; var ids=new HashSet<String>();
|
||||
for(var source:page.containers()) {
|
||||
validateSource(source);
|
||||
@@ -45,13 +46,13 @@ public class AppointmentWorkSyncService {
|
||||
if(existing.isEmpty()) {
|
||||
if(source.establishedFlow()==Flow.UNKNOWN) {
|
||||
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++;
|
||||
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known).size();
|
||||
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size();
|
||||
} else {
|
||||
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
|
||||
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known).size();
|
||||
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size();
|
||||
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
|
||||
if(source.establishedFlow()!=Flow.UNKNOWN) {
|
||||
var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known); created+=generated.size();
|
||||
var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size();
|
||||
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
|
||||
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
|
||||
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
|
||||
@@ -125,7 +126,7 @@ public class AppointmentWorkSyncService {
|
||||
}
|
||||
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
|
||||
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
|
||||
return generate(source,flow,existing,actor,workingSet());
|
||||
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements());
|
||||
}
|
||||
/** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */
|
||||
private Map<String,AppointmentWorkState> workingSet() {
|
||||
@@ -135,10 +136,10 @@ public class AppointmentWorkSyncService {
|
||||
}
|
||||
/** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */
|
||||
private List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor,
|
||||
Map<String,AppointmentWorkState> known) {
|
||||
Map<String,AppointmentWorkState> known,RequirementReader requirementReader) {
|
||||
validateSource(source);
|
||||
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
|
||||
var requirements=gateway.requirements(source,flow);
|
||||
var requirements=requirementReader.read(source,flow);
|
||||
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
|
||||
validateSource(source);
|
||||
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
|
||||
|
||||
+16
-2
@@ -20,8 +20,22 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
|
||||
public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) {
|
||||
this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping;
|
||||
}
|
||||
/** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */
|
||||
public RequirementReader openRequirements() {
|
||||
return new RequirementReader() {
|
||||
private AppointmentSourceService.ReadScope scope;
|
||||
/** 直送和未建立尾程来源的需求不创建目录读取依赖。 */
|
||||
public List<Requirement> read(ContainerSource container,Flow flow) {
|
||||
return requirements(container,flow,refs->{
|
||||
if(scope==null) scope=sources.openReadScope();
|
||||
return scope.resolveBatch(refs);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
/** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */
|
||||
public List<Requirement> requirements(ContainerSource container,Flow effectiveFlow) {
|
||||
private List<Requirement> requirements(ContainerSource container,Flow effectiveFlow,
|
||||
java.util.function.Function<List<AppointmentCoverageService.SourceRef>,List<AppointmentSourceService.Source>> resolver) {
|
||||
var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList();
|
||||
if(effectiveFlow==Flow.DIRECT) return original;
|
||||
if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线");
|
||||
@@ -38,7 +52,7 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
|
||||
if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; }
|
||||
var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList());
|
||||
List<AppointmentSourceService.Source> resolved;
|
||||
try { resolved=sources.resolveBatch(refs); }
|
||||
try { resolved=resolver.apply(refs); }
|
||||
catch(org.springframework.web.server.ResponseStatusException error) {
|
||||
if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error;
|
||||
// Keep the complete physical obligation and its canonical source identities. The fact
|
||||
|
||||
+32
@@ -12,6 +12,38 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentSourceServiceTest {
|
||||
/** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */
|
||||
@Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() {
|
||||
var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);
|
||||
var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token");
|
||||
var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOrderType("FIRST_LEG");
|
||||
order.setSourceContainerBookingId("C1");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10);
|
||||
when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order));
|
||||
var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40);
|
||||
when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate));
|
||||
var catalog=ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")));
|
||||
var outage=new IllegalStateException("offline");
|
||||
when(warehouse.catalog("token")).thenThrow(outage).thenReturn(catalog);
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
|
||||
var scope=service.openReadScope();verifyNoInteractions(warehouse);
|
||||
var error=assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));assertEquals(503,error.getStatusCode().value());assertSame(outage,error.getCause());
|
||||
assertEquals(10,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token");
|
||||
order.setPackageCount(12);assertEquals(12,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token");
|
||||
assertEquals(404,assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8")))).getStatusCode().value());
|
||||
assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))));
|
||||
assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(refs.get(0),refs.get(0))));
|
||||
gate.setNodeSequence(30);assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));gate.setNodeSequence(40);
|
||||
order.setOverseasWarehouseId("missing");assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));order.setOverseasWarehouseId("100");
|
||||
when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,12,1L)));
|
||||
assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));when(external.resolveOrders(anyList())).thenReturn(List.of());
|
||||
assertEquals(12,service.openReadScope().resolveBatch(refs).get(0).packages());verify(warehouse,times(3)).catalog("token");
|
||||
service.resolveBatch(refs);service.resolveBatch(refs);service.inspectBatch(refs);service.inspectBatch(refs);verify(warehouse,times(7)).catalog("token");
|
||||
when(warehouse.catalog("token")).thenReturn(null).thenReturn(catalog);
|
||||
var next=service.openReadScope();assertEquals(503,assertThrows(ResponseStatusException.class,()->next.resolveBatch(refs)).getStatusCode().value());
|
||||
assertEquals(12,next.resolveBatch(refs).get(0).packages());verify(warehouse,times(9)).catalog("token");
|
||||
}
|
||||
|
||||
@Test void batchResolvesTwoCustomersWithOneCatalogAndOneOrderAndShippingQuery() {
|
||||
var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);
|
||||
var gateway=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
|
||||
+27
@@ -9,6 +9,33 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
|
||||
|
||||
class AppointmentWorkFactAdapterTest {
|
||||
/** 未读取需求的未知路线不依赖提供端,首次读取才开作用域并逐份校验结果。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test void requirementReaderIsLazyAndKeepsProviderCompletenessChecks() {
|
||||
var providers=(org.springframework.beans.factory.ObjectProvider<RequirementProvider>)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class);
|
||||
var business=(org.springframework.beans.factory.ObjectProvider<BusinessProvider>)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class);
|
||||
org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty());
|
||||
var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token");
|
||||
var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers);
|
||||
var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of());
|
||||
var f=new WorkTestFixture();
|
||||
org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements());
|
||||
org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
|
||||
f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType());
|
||||
org.mockito.Mockito.verifyNoInteractions(providers);
|
||||
assertThrows(ResponseStatusException.class,()->unused.read(source,Flow.TRANSFER));
|
||||
var provider=org.mockito.Mockito.mock(RequirementProvider.class);
|
||||
var delegate=org.mockito.Mockito.mock(RequirementReader.class);
|
||||
org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider);
|
||||
org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of());
|
||||
var reader=adapter.openRequirements();
|
||||
assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));
|
||||
assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));
|
||||
assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER));
|
||||
org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements();
|
||||
}
|
||||
@Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() {
|
||||
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
|
||||
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest {
|
||||
var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8")));
|
||||
var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement));
|
||||
var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class);
|
||||
org.mockito.Mockito.when(facts.requirements(source,Flow.TRANSFER)).thenReturn(List.of(requirement));
|
||||
org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement));
|
||||
tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR));
|
||||
assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate());
|
||||
var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B");
|
||||
|
||||
+48
-1
@@ -12,6 +12,50 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
|
||||
class AppointmentWorkSyncServiceTest {
|
||||
/** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */
|
||||
@Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() {
|
||||
var f=new WorkTestFixture();
|
||||
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
var shipping=mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class);
|
||||
var external=mock(com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.class);
|
||||
var warehouse=mock(com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
|
||||
when(orders.selectBatchIds(anyCollection())).thenAnswer(c->c.<Collection<Long>>getArgument(0).stream().map(id->{
|
||||
var order=new com.baiyue.lastleg.entity.LastlegOrder();order.setId(id);order.setCustomerId("C"+id);
|
||||
order.setOrderType("FIRST_LEG");order.setSourceContainerBookingId(id.toString());order.setOverseasWarehouseId("100");
|
||||
order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(1);return order;
|
||||
}).toList());
|
||||
when(shipping.selectBatchIds(anyCollection())).thenAnswer(c->c.<Collection<String>>getArgument(0).stream().map(id->{
|
||||
var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate;
|
||||
}).toList());
|
||||
var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token");
|
||||
var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class);
|
||||
var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class);
|
||||
when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation);
|
||||
var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class);
|
||||
when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(
|
||||
new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));});
|
||||
var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,
|
||||
mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping);
|
||||
when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements());
|
||||
var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i,
|
||||
Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null,
|
||||
List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList();
|
||||
when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope"));
|
||||
f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token");
|
||||
verify(orders,times(100)).selectBatchIds(anyCollection());
|
||||
var before=f.store.all().stream().map(f.store::write).toList();
|
||||
f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token");
|
||||
assertEquals(before,f.store.all().stream().map(f.store::write).toList());
|
||||
f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR);
|
||||
verify(warehouse,times(3)).catalog("token");
|
||||
f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR);
|
||||
verify(warehouse,times(4)).catalog("token");
|
||||
verify(orders,times(202)).selectBatchIds(anyCollection());
|
||||
}
|
||||
|
||||
/** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */
|
||||
@Test void hundredContainersReuseOneGenerationWorkingSetAndKeepUnknownRoutesAndHistory() {
|
||||
var f=new WorkTestFixture();
|
||||
@@ -57,6 +101,9 @@ class AppointmentWorkSyncServiceTest {
|
||||
var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class);
|
||||
var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class);
|
||||
var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class);
|
||||
when(sources.openReadScope()).thenReturn(scope);
|
||||
when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0)));
|
||||
var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList());
|
||||
@@ -70,7 +117,7 @@ class AppointmentWorkSyncServiceTest {
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot));
|
||||
var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs);
|
||||
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root));
|
||||
doAnswer(c->provider.requirements(c.getArgument(0),c.getArgument(1))).when(f.gateway).requirements(any(),eq(Flow.TRANSFER));
|
||||
when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements());
|
||||
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
|
||||
f.guard.setRequirementVersion(1);
|
||||
f.sync.synchronize(new SyncRequest(null,10));
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ final class WorkTestFixture {
|
||||
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; });
|
||||
when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope"));
|
||||
when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲"));
|
||||
when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements());
|
||||
when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements());
|
||||
}
|
||||
AppointmentWorkState state(String container,Type type,Flow flow,String requirement) {
|
||||
var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container);
|
||||
|
||||
+36
-17
@@ -15,7 +15,7 @@ class AppointmentWorkRequirementProviderTest {
|
||||
@Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() {
|
||||
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
|
||||
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60)));
|
||||
@@ -33,29 +33,29 @@ class AppointmentWorkRequirementProviderTest {
|
||||
when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2");
|
||||
when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of());
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current));
|
||||
var actual=provider.requirements(container,Flow.TRANSFER);
|
||||
var actual=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources());
|
||||
assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
when(allocation.availablePackages()).thenReturn(20L);
|
||||
actual=provider.requirements(container,Flow.TRANSFER);
|
||||
actual=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys());
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled));
|
||||
when(allocation.availablePackages()).thenReturn(60L);
|
||||
actual=provider.requirements(container,Flow.TRANSFER);
|
||||
actual=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources());
|
||||
assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
// 非取消的空批次仍属缺失事实,不能静默跳过。
|
||||
when(cancelled.appointmentStatus()).thenReturn("PENDING");
|
||||
assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.requirements(container,Flow.TRANSFER)).getStatusCode().value());
|
||||
assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() {
|
||||
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
|
||||
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60)));
|
||||
@@ -72,55 +72,74 @@ class AppointmentWorkRequirementProviderTest {
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
|
||||
for(var status:List.of("CANCELLED","FAILED")) {
|
||||
when(batch.appointmentStatus()).thenReturn(status);
|
||||
var result=provider.requirements(container,Flow.TRANSFER);
|
||||
var result=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources());
|
||||
assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1"));
|
||||
}
|
||||
when(allocation.availablePackages()).thenReturn(20L);
|
||||
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
|
||||
when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2"));
|
||||
assertEquals(List.of("INBOUND:1"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
|
||||
}
|
||||
@Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() {
|
||||
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
|
||||
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4)));
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(refs);
|
||||
when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约"));
|
||||
var actual=provider.requirements(container,Flow.TRANSFER);
|
||||
var actual=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey());
|
||||
assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources());
|
||||
verifyNoInteractions(cargo,appointments);
|
||||
doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.requirements(container,Flow.TRANSFER));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER));
|
||||
}
|
||||
@Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() {
|
||||
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of());
|
||||
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
|
||||
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10)));
|
||||
var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4);
|
||||
when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch));
|
||||
var manifest=provider.requirements(container,Flow.TRANSFER);
|
||||
var manifest=provider.openRequirements().read(container,Flow.TRANSFER);
|
||||
assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList());
|
||||
assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey());
|
||||
when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4)));
|
||||
var restored=provider.requirements(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys());
|
||||
var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys());
|
||||
verifyNoInteractions(sources,cargo,appointments);
|
||||
}
|
||||
@Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() {
|
||||
var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement));
|
||||
assertEquals(List.of(requirement),provider.requirements(source,Flow.DIRECT));
|
||||
assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT));
|
||||
verifyNoInteractions(sources,cargo,appointments,mapping);
|
||||
}
|
||||
/** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */
|
||||
@Test void missingLastlegSourcesDoNotOpenDirectoryScope() {
|
||||
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
|
||||
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
|
||||
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
|
||||
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
|
||||
var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
|
||||
var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping);
|
||||
assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments);
|
||||
}
|
||||
|
||||
/** 保留原来源模拟,显式接入本次读取作用域。 */
|
||||
private static void scoped(AppointmentSourceService sources) {
|
||||
when(sources.openReadScope()).thenAnswer(call->{
|
||||
var scope=mock(AppointmentSourceService.ReadScope.class);
|
||||
when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0)));
|
||||
return scope;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user