feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkSourceMapperTest {
|
||||
@Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)");
|
||||
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
|
||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
|
||||
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
|
||||
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2)");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var rows=session.getMapper(AppointmentWorkSourceMapper.class).sources();
|
||||
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
|
||||
assertNull(rows.get(0).destinationWarehouseName());assertEquals(1,rows.get(1).deliveryVersion());
|
||||
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PickupActualCalendarMapperTest {
|
||||
@Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper=session.getMapper(CarrierScheduleQueryMapper.class);
|
||||
var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00");
|
||||
assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to));
|
||||
try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) {
|
||||
for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch();
|
||||
}
|
||||
session.clearCache();
|
||||
assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
|
||||
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkSourceServiceTest {
|
||||
private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class);
|
||||
private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class);
|
||||
private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules());
|
||||
private AppointmentWorkSourceMapper.Source source(long id,int version) {
|
||||
return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L);
|
||||
}
|
||||
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
|
||||
when(mapper.cargo(anyLong())).thenReturn(List.of()); when(facts.establishedFlow(anyLong())).thenReturn("UNKNOWN");
|
||||
var first=service.discover(null,1);
|
||||
assertFalse(first.complete()); assertNotNull(first.nextCursor());
|
||||
assertNull(first.containers().get(0).departedAt());
|
||||
assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt());
|
||||
var next=service.discover(first.nextCursor(),1);
|
||||
assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId());
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1)));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value());
|
||||
}
|
||||
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,1)));
|
||||
when(mapper.cargo(1)).thenReturn(List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
|
||||
when(facts.establishedFlow(1)).thenReturn("DIRECT");
|
||||
var source=service.container(1);var requirement=source.requirements().get(0);
|
||||
assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind());
|
||||
assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null));
|
||||
}
|
||||
@Test void internalControllerFailsClosedBeforeReadingSources() {
|
||||
var guarded=new InternalAppointmentWorkSourceController(service,"secret");
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value());
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+14
@@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
@Mock private CarrierScheduleQueryRepository repository;
|
||||
@Mock private PortScheduleRepository portSchedules;
|
||||
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||
@Mock private EmptyReturnPrerequisite emptyReturnPrerequisite;
|
||||
@InjectMocks private CarrierScheduleQueryService service;
|
||||
private CarrierScheduleAdapterRegistry adapters;
|
||||
|
||||
@@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).containerActualFacts(List.of(20L, 21L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() {
|
||||
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货"))
|
||||
.when(emptyReturnPrerequisite).requireUnloaded(20L,NOW);
|
||||
assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload",
|
||||
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||
"EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜")))
|
||||
.isInstanceOf(BizException.class).hasMessage("未卸货");
|
||||
verifyNoNodePublication();
|
||||
}
|
||||
|
||||
private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); }
|
||||
|
||||
@Test
|
||||
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
|
||||
assertThatThrownBy(() -> service.containerActualFacts(null))
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
@Test
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
int expectedTables = 226;
|
||||
int expectedTables = 227;
|
||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||
.matcher(sql).results().count();
|
||||
assertThat(actualTables).isEqualTo(expectedTables);
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryMapperTest {
|
||||
@Test void physicalSourceAggregationAndImmutableEvidenceSurviveActualPersistence() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR,unloading_completed_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)");
|
||||
s.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,order_no VARCHAR,destination_warehouse_name VARCHAR,package_count INT,deleted INT)");
|
||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))");
|
||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')");
|
||||
s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id());
|
||||
assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount());
|
||||
var at=LocalDateTime.parse("2026-01-01T10:30:00");
|
||||
mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin");
|
||||
assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key"));
|
||||
assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt());
|
||||
assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin"));
|
||||
session.rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliverySchemaContractTest {
|
||||
@Test void immutableDirectEvidenceAndIndependentWritePermissionsReachDeploymentBaseline() throws Exception {
|
||||
String source=Files.readString(Path.of("src/main/resources/db/phase3-direct-delivery-schema.sql"));
|
||||
String release=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertEquals(definition(source),definition(release));
|
||||
assertTrue(source.contains("PRIMARY KEY (container_booking_id,inbound_id)"));
|
||||
assertTrue(source.contains("UNIQUE KEY uk_direct_unloading_command"));
|
||||
String permissions=Files.readString(Path.of("../iam/src/main/resources/db/appointment-work-permissions.sql"));
|
||||
for(String code:java.util.List.of("platform:appointment:manage-work","platform:firstleg:terminal-drayage:direct-unload:confirm")) {
|
||||
assertTrue(release.contains("'"+code+"'"));assertTrue(permissions.contains("'"+code+"'"));
|
||||
}
|
||||
assertTrue(release.contains("VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3))"));
|
||||
assertTrue(release.contains("VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3))"));
|
||||
assertTrue(permissions.indexOf("CALL check_appointment_work_permission_identity")<permissions.indexOf("INSERT INTO iam_permission"));
|
||||
assertTrue(permissions.contains("platform:firstleg:terminal-drayage:direct-unload:confirm"));
|
||||
assertTrue(permissions.contains("platform:appointment:manage-work"));
|
||||
}
|
||||
private String definition(String sql) {
|
||||
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? fl_direct_delivery_unloading \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
|
||||
assertTrue(match.find());String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
|
||||
assertFalse(match.find());return result;
|
||||
}
|
||||
}
|
||||
+9
@@ -29,6 +29,15 @@ class TerminalDrayageCalendarMapperTest {
|
||||
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
|
||||
assertEquals(List.of(1L,2L),rows.stream().map(r->r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt());
|
||||
assertEquals("D0001", rows.get(0).cabinetNo());
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("UPDATE fl_terminal_drayage SET pickup_appointment_at='2026-12-01' WHERE container_booking_id=3");
|
||||
s.execute("DELETE FROM fl_terminal_drayage WHERE container_booking_id=2");
|
||||
}
|
||||
session.clearCache();
|
||||
rows=session.getMapper(TerminalDrayageMapper.class).calendarByContainers(List.of(1L,2L,3L,4L,5L));
|
||||
assertEquals(List.of(1L,2L,3L),rows.stream().map(r->r.containerBookingId()).toList());
|
||||
assertNull(rows.get(1).pickupAppointmentAt());
|
||||
assertEquals(LocalDateTime.parse("2026-12-01T00:00:00"),rows.get(2).pickupAppointmentAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.*;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkTerminalFactsTest {
|
||||
private final TerminalDrayageMapper terminal=mock(TerminalDrayageMapper.class);
|
||||
private final DirectDeliveryMapper direct=mock(DirectDeliveryMapper.class);
|
||||
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
|
||||
private final UnloadingEvidenceGuard guard=mock(UnloadingEvidenceGuard.class);
|
||||
private final AppointmentWorkTerminalFacts adapter=new AppointmentWorkTerminalFacts(terminal,direct,nodes,guard,new ObjectMapper().findAndRegisterModules());
|
||||
private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00");
|
||||
private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());}
|
||||
private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());}
|
||||
private History history(String action){return new History(action,"A","B",1,2,null,7L,"admin",at);}
|
||||
@Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() {
|
||||
var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP")));
|
||||
var fact=adapter.read(request("PICKUP"),source());
|
||||
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
|
||||
assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE"));
|
||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"),history("SCHEDULE_PICKUP")));
|
||||
assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken());
|
||||
}
|
||||
@Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() {
|
||||
var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at)));
|
||||
var fact=adapter.read(request("EMPTY_RETURN"),source());
|
||||
assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt());
|
||||
assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo());
|
||||
}
|
||||
@Test void directDeliveryReferencesOnlyItsOwnPhysicalSourceAndCustomer() {
|
||||
var own=List.of(new Reference("CUSTOMER","7",null,"客户A"),new Reference("INBOUND","2","IN2","入仓单"));
|
||||
var other=List.of(new Reference("CUSTOMER","8",null,"客户B"),new Reference("INBOUND","3","IN3","入仓单"));
|
||||
var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of(
|
||||
new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()),
|
||||
new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of())));
|
||||
when(direct.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h")));
|
||||
var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of());
|
||||
var fact=adapter.read(request,source);
|
||||
assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -132,7 +132,7 @@ class ContainerWorkbenchServiceTest {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
@@ -176,7 +176,7 @@ class ContainerWorkbenchServiceTest {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryRouteGuardTest {
|
||||
@Test void onlyConfirmedDirectRouteCanExecuteAndRemoteFailureIsNotUnknownSuccess() {
|
||||
var client=mock(AppointmentWorkRouteClient.class);var guard=new DirectDeliveryRouteGuard(client,"token");
|
||||
for(String flow:new String[]{"UNKNOWN","TRANSFER"}) {
|
||||
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route(flow)));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
|
||||
}
|
||||
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route("DIRECT")));
|
||||
assertDoesNotThrow(()->guard.requireDirect(1));
|
||||
when(client.route("token",1)).thenThrow(new IllegalStateException("offline"));
|
||||
assertEquals(503,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryServiceTest {
|
||||
private final DirectDeliveryMapper mapper=mock(DirectDeliveryMapper.class);
|
||||
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
|
||||
private final DirectDeliveryService service=new DirectDeliveryService(mapper,nodes,new ObjectMapper().findAndRegisterModules(),mock(DirectDeliveryRouteGuard.class));
|
||||
private final LocalDateTime at=LocalDateTime.parse("2026-01-03T12:00:00");
|
||||
private DirectDeliveryMapper.Delivery pending(){return new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,0,null,null,null,null,null,null,null);}
|
||||
private DirectDeliveryService.Request request(){return new DirectDeliveryService.Request(0,at,"America/Los_Angeles","收货人","POD-2","直送签收");}
|
||||
@Test void recordsOnlyActualLoadedSourceAfterPickupAndReplaysWithoutDuplicateWrite() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
// Unknown pickup zone: a numerically later source-local clock is not evidence
|
||||
// that the explicitly Los Angeles destination actual occurred first.
|
||||
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"READY","PICKED_UP",at.plusHours(1),null,null,null,null,at.plusHours(1),null)));
|
||||
doAnswer(inv->{
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,1,at,"America/Los_Angeles","收货人","POD-2","直送签收","k",inv.getArgument(8))));
|
||||
return 1;
|
||||
}).when(mapper).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
|
||||
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
|
||||
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
|
||||
verify(mapper,times(1)).insert(eq(1L),eq(2L),eq(at),eq("America/Los_Angeles"),eq("收货人"),eq("POD-2"),any(),eq("k"),anyString(),eq(7L),eq("admin"));
|
||||
}
|
||||
@Test void refusesForeignSourceAndWarehouseReceiptWithoutWriting() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",at.minusDays(1)));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
assertThrows(BizException.class,()->service.record(1,99,7,"a","k",request()));
|
||||
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",request()));
|
||||
verifyNoInteractions(nodes);
|
||||
verify(mapper,never()).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
|
||||
}
|
||||
@Test void refusesAmbiguousLocalTimeInsteadOfGuessingAnOffset() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
var ambiguous=new DirectDeliveryService.Request(0,LocalDateTime.parse("2025-11-02T01:30:00"),"America/Los_Angeles","收货人","POD",null);
|
||||
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",ambiguous));verifyNoInteractions(nodes);
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -46,8 +46,35 @@ class TerminalDrayageCalendarServiceTest {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses);
|
||||
}
|
||||
@Test void reportsTruncationInsteadOfClaimingCompleteResults() {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W")));
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(java.util.stream.LongStream.rangeClosed(1,1001).mapToObj(id->row(id,"US-W")).toList());
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size());
|
||||
}
|
||||
@Test void mergesActualMonthWithoutMovingExecutedWorkWhenPlansChangeOrDuplicatingTheContainer() {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W"),row(2,"US-W")));
|
||||
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(2L,3L));
|
||||
var movedPlan=new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W","Fleet",to.plusDays(20).atStartOfDay(),null);
|
||||
when(mapper.calendarByContainers(List.of(2L,3L))).thenReturn(List.of(row(2,"US-W"),movedPlan));
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
when(shipping.containerActualFacts(List.of(1L,2L,3L))).thenReturn(List.of(
|
||||
fact(1L,to.atStartOfDay()),fact(2L,from.atStartOfDay()),fact(3L,from.plusDays(1).atStartOfDay())));
|
||||
var result=service.calendar(from,to);
|
||||
assertEquals(List.of(2L,3L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList());
|
||||
assertEquals(to.plusDays(20).atStartOfDay(),result.records().get(1).pickupAppointmentAt());
|
||||
assertEquals(from.plusDays(1).atStartOfDay(),result.records().get(1).actualPickupAt());
|
||||
assertFalse(result.truncated());
|
||||
}
|
||||
private com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts fact(long id,java.time.LocalDateTime at) {
|
||||
return new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(id,null,null,null,null,null,null,null,null,null,at,null);
|
||||
}
|
||||
@Test void includesAnActualPickupWithoutInventingAnAppointment() {
|
||||
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(3L));
|
||||
when(mapper.calendarByContainers(List.of(3L))).thenReturn(List.of(new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W",null,null,null)));
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
when(shipping.containerActualFacts(List.of(3L))).thenReturn(List.of(fact(3L,from.atStartOfDay())));
|
||||
var result=service.calendar(from,to);
|
||||
assertEquals(1,result.records().size());
|
||||
assertNull(result.records().get(0).pickupAppointmentAt());
|
||||
assertEquals(from.atStartOfDay(),result.records().get(0).actualPickupAt());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -47,6 +47,7 @@ class TerminalDrayageServiceTest {
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
private TerminalDrayageMailService mails;
|
||||
private UnloadingEvidenceGuard unloadingEvidence;
|
||||
private Deque<ContainerActualFacts> facts;
|
||||
|
||||
@BeforeEach
|
||||
@@ -60,7 +61,8 @@ class TerminalDrayageServiceTest {
|
||||
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
|
||||
});
|
||||
mails = mock(TerminalDrayageMailService.class);
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
|
||||
unloadingEvidence = mock(UnloadingEvidenceGuard.class);
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails, unloadingEvidence);
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ class TerminalDrayageServiceTest {
|
||||
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("海外卸柜收货完成后才能确认还柜");
|
||||
.hasMessage("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@@ -138,6 +140,19 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directReturnWarningUsesDestinationInstantInsteadOfServerWallClock() {
|
||||
when(mapper.context(11L)).thenReturn(context("PICKED_UP", null));
|
||||
when(mapper.find(11L)).thenReturn(row(3));
|
||||
when(unloadingEvidence.directCompletion(11L)).thenReturn(new UnloadingEvidenceGuard.DirectCompletion(
|
||||
LocalDateTime.parse("2026-09-01T22:00:00"), "America/Los_Angeles"));
|
||||
var result=service.detail(11L);
|
||||
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.parse("2026-09-04T22:00:00"));
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("PENDING");
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T06:00:00Z"), ZoneOffset.ofHours(9)));
|
||||
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class UnloadingEvidenceGuardTest {
|
||||
@Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() {
|
||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
|
||||
when(mapper.container(1)).thenReturn(container);when(mapper.lockContainer(1)).thenReturn(container);
|
||||
var at=LocalDateTime.parse("2026-10-10T12:00:00");
|
||||
var first=new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,at,"America/Los_Angeles","张三","POD2",null,"k","h");
|
||||
var pending=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,0,null,null,null,null,null,null,null);
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(first,pending));
|
||||
assertNull(guard.completedUnloading(1));assertThrows(BizException.class,()->guard.requireUnloaded(1,at.plusDays(1)));
|
||||
var second=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,at.plusHours(1),"America/Los_Angeles","李四","POD3",null,"k3","h3");
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(first,second));
|
||||
assertEquals(at.plusHours(1),guard.completedUnloading(1));
|
||||
assertDoesNotThrow(()->guard.requireUnloaded(1,at)); // Return has no source zone.
|
||||
assertDoesNotThrow(()->guard.requireUnloaded(1,at.plusDays(1)));
|
||||
var warehouse=new DirectDeliveryMapper.Container(1,"ACTIVE",at.plusHours(1));
|
||||
when(mapper.container(1)).thenReturn(warehouse);when(mapper.lockContainer(1)).thenReturn(warehouse);
|
||||
assertThrows(BizException.class,()->guard.requireUnloaded(1,at));
|
||||
}
|
||||
@Test void comparesDestinationEvidenceByInstantAndMeasuresSeventyTwoHoursAcrossDst() {
|
||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||
var ny=LocalDateTime.parse("2026-10-10T12:00:00");
|
||||
var la=LocalDateTime.parse("2026-10-10T10:00:00");
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(
|
||||
new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,ny,"America/New_York","a","P2",null,"k2","h2"),
|
||||
new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,la,"America/Los_Angeles","a","P3",null,"k3","h3")));
|
||||
assertEquals(la,guard.directCompletion(1).localAt());
|
||||
var beforeFallback=new UnloadingEvidenceGuard.DirectCompletion(LocalDateTime.parse("2026-10-31T12:00:00"),"America/Los_Angeles");
|
||||
assertEquals(LocalDateTime.parse("2026-11-03T11:00:00"),beforeFallback.deadline(72));
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,56 @@
|
||||
],
|
||||
"sliceSpecificRules": {
|
||||
"customer-statistics": {
|
||||
"allowedOutgoing": ["shared-technical"],
|
||||
"allowedOutgoing": [
|
||||
"shared-technical"
|
||||
],
|
||||
"allowedIncoming": []
|
||||
},
|
||||
"pricebook-query": {
|
||||
"allowedOutgoing": [],
|
||||
"allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"]
|
||||
"allowedIncoming": [
|
||||
"booking-customs-trucking",
|
||||
"measurement-pricing",
|
||||
"split-loading"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewedClassDependencies": [
|
||||
{
|
||||
"source": "com.baiyue.firstleg.controller.InternalOverseasContainerEventController",
|
||||
"target": "com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer",
|
||||
"reason": "海外事件HTTP适配器按事件类型派发到暂扣消费者;入仓数据仍由消费者维护。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.loading.service.LoadingDeliveryService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "装柜实物完成时按真实分配刷新费用,调用既有计价应用服务,不读取计价持久化。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.LoadingListService",
|
||||
"target": "com.baiyue.firstleg.domain.PriceBookChannelCatalog",
|
||||
"reason": "分柜按当前有效渠道目录校验,复用纯领域目录,不访问价格簿实现。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.LoadingListService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "装柜分配变动触发既有计价服务按客户重算,费用冻结门槛保留在原服务。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.SmartLoadingService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "智能排柜分配后调用原计价服务守恒重算,不跨域修改费用表。"
|
||||
}
|
||||
],
|
||||
"reviewedPackageCycles": [
|
||||
{
|
||||
"packages": [
|
||||
"com.baiyue.firstleg.loading.bridge.service",
|
||||
"com.baiyue.firstleg.loading.service",
|
||||
"com.baiyue.firstleg.oceanbooking.service",
|
||||
"com.baiyue.firstleg.service"
|
||||
],
|
||||
"reason": "既有全局service包混合了入仓暂扣、装柜费用与订舱文档的应用编排。本轮不拆分这些无关既有服务;仅冻结当前四包SCC,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user