fix: complete October 22 workflow feedback and request failure recovery
This commit is contained in:
+3
-2
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
|
||||
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
|
||||
void documentsContainerContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -106,7 +106,8 @@ class ContainerControllerContractTest {
|
||||
.contains("未满配 requirementId")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
|
||||
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
|
||||
.contains("确认预装和创建PDA任务前仍校验ISO格式")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
|
||||
+17
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.customs.container.controller.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
@@ -12,6 +13,22 @@ import java.lang.reflect.Method;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerCustomsControllerContractTest {
|
||||
/** 下载契约必须跟随实际 Controller,避免前端和文档共同固化不存在的地址。 */
|
||||
@Test void platformPreEntryDownloadMatchesActualControllerAndOpenApi() throws Exception {
|
||||
Method download = java.util.Arrays.stream(PlatformContainerCustomsController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("downloadCompletion")).findFirst().orElseThrow();
|
||||
String root = PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
String path = root + download.getAnnotation(GetMapping.class).value()[0];
|
||||
String source = new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
java.util.Map<?, ?> specification = new Yaml().load(source);
|
||||
java.util.Map<?, ?> paths = (java.util.Map<?, ?>) specification.get("paths");
|
||||
|
||||
assertThat(path).isEqualTo("/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download");
|
||||
assertThat(paths.containsKey(path)).isTrue();
|
||||
assertThat(paths.containsKey(root + "/documents/{documentId}/receipt/download")).isFalse();
|
||||
assertThat(download.getAnnotation(PreAuthorize.class).value()).contains("platform:firstleg:customs:list");
|
||||
}
|
||||
|
||||
@Test void platformUsesListForReadsAndManageForWrites() {
|
||||
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/customs/loading-plans");
|
||||
|
||||
+38
-2
@@ -442,6 +442,7 @@ class ContainerCustomsServiceTest {
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 上传被拒绝时使用预录单名称,并保留报关行执行后的替换门禁。 */
|
||||
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
|
||||
@@ -450,7 +451,10 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
|
||||
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
});
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@@ -512,11 +516,43 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
/** 下载不可见或缺失的预录单时使用正式名称,不改变客户范围校验。 */
|
||||
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
||||
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("报关行回执不存在");
|
||||
.hasMessageContaining("预录单不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 平台下载的当前预录单不存在时仍返回原业务错误码和正式名称。 */
|
||||
@Test void platformMissingPreEntryDocumentUsesCurrentName() {
|
||||
when(mapper.document(1L)).thenReturn(document(1L));
|
||||
when(mapper.completion(1L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadCompletion(0L, 1L, true))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
assertThat(error).hasMessage("预录单不存在");
|
||||
});
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 发送前置校验仅调整用户可见名称,不改变全买单或客户确认门禁。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"0,0,全买单柜无需发送预录单确认结果给报关行", "1,1,仍有预录单未完成客户确认"})
|
||||
void brokerSubmissionErrorsUsePreEntryDocumentName(int taxRefundCount, int unconfirmedCount, String message) {
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_TO_BROKER_9"), eq("pre-entry-name"), anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||
when(mapper.taxRefundCount(9L)).thenReturn(taxRefundCount);
|
||||
when(mapper.unconfirmedReceiptCount(2L)).thenReturn(unconfirmedCount);
|
||||
|
||||
assertThatThrownBy(() -> service.submitToBroker(9L, 5L, "admin", "pre-entry-name", new ActionRequest(3, true, null)))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage(message);
|
||||
});
|
||||
verify(mapper, never()).submitToBroker(anyLong(), anyInt(), anyLong(), anyString());
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -14,6 +14,37 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformOceanBookingControllerContractTest {
|
||||
/** 预计到港命令仅接收版本与时间,沿用编辑权限及独立审计。 */
|
||||
@Test void estimatedArrivalCommandHasNarrowDtoAndEditPermission() throws Exception {
|
||||
var endpoint = method("updateEstimatedArrival");
|
||||
assertThat(endpoint.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()).containsExactly("/{oceanBookingId}/estimated-arrival");
|
||||
assertThat(endpoint.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:firstleg:ocean-booking:edit')");
|
||||
assertThat(endpoint.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
assertThat(Arrays.stream(com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest.class.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName)).containsExactly("expectedVersion", "estimatedArrivalAt");
|
||||
assertThat(Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")))
|
||||
.contains("/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:", "#/components/schemas/EstimatedArrivalRequest");
|
||||
}
|
||||
|
||||
/** 真实方法权限拦截器拒绝只有读取权限的账号,命令不会进入服务层。 */
|
||||
@Test void estimatedArrivalRejectsReadOnlyAccountBeforeServiceCall() {
|
||||
var service = org.mockito.Mockito.mock(com.baiyue.firstleg.oceanbooking.service.OceanBookingService.class);
|
||||
var target = new PlatformOceanBookingController();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target, "service", service);
|
||||
var factory = new org.springframework.aop.framework.ProxyFactory(target);
|
||||
factory.setProxyTargetClass(true);
|
||||
factory.addAdvisor(org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
|
||||
var secured = (PlatformOceanBookingController) factory.getProxy();
|
||||
var context = org.springframework.security.core.context.SecurityContextHolder.getContext();
|
||||
context.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("reader", "",
|
||||
java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:firstleg:ocean-booking:list"))));
|
||||
try {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> secured.updateEstimatedArrival(null, 1L, "eta-denied",
|
||||
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest(3, java.time.LocalDateTime.now())))
|
||||
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(service);
|
||||
} finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test
|
||||
void usesDedicatedResourceAndPermissions() {
|
||||
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
|
||||
|
||||
+7
@@ -14,6 +14,13 @@ import java.util.Arrays;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingMapperContractTest {
|
||||
/** 放舱后预计到港只更新该字段及版本审计,不允许带出其他冻结资料。 */
|
||||
@Test void estimatedArrivalUpdateIsNarrowAndVersioned() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateEstimatedArrival", long.class,
|
||||
java.time.LocalDateTime.class, int.class, long.class, String.class).getAnnotation(Update.class).value());
|
||||
assertThat(sql).contains("estimated_arrival_at=#{estimatedArrivalAt}", "status='RELEASED'", "version=#{version}", "version=version+1")
|
||||
.doesNotContain("vessel_name=", "voyage_no=", "estimated_sailing_at=", "actual_", "declared_", "warehouse_id=");
|
||||
}
|
||||
@Test
|
||||
void listUsesPlatformAggregateFilters() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
|
||||
|
||||
+8
@@ -10,6 +10,14 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingSqlContractTest {
|
||||
/** 两份触发器基线只放开预计到港,继续冻结船名、开船、预报和其他运输资料。 */
|
||||
@Test void releasedFreezeAllowsEstimatedArrivalOnly() throws Exception {
|
||||
for (String source : new String[] {sql(), Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))}) {
|
||||
assertThat(source).doesNotContain("NEW.estimated_arrival_at <=> OLD.estimated_arrival_at")
|
||||
.contains("NEW.vessel_name <=> OLD.vessel_name", "NEW.estimated_sailing_at <=> OLD.estimated_sailing_at",
|
||||
"NEW.declared_package_count <=> OLD.declared_package_count", "NEW.version = OLD.version + 1");
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
+51
@@ -399,6 +399,57 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 预计到港独立更新、回显并保留修改前后审计,不能触及其他冻结字段。 */
|
||||
@Test void maintainsReleasedEstimatedArrivalWithVersionAndAudit() {
|
||||
var before = completeBooking("RELEASED", 3);
|
||||
var after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
||||
var arrival = before.getEstimatedSailingAt().plusDays(20);
|
||||
after.setEstimatedArrivalAt(arrival); after.setVersion(4);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.updateEstimatedArrival(1L, arrival, 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
var result = service.updateEstimatedArrival(1L, 9L, "admin", "eta-save", new EstimatedArrivalRequest(3, arrival));
|
||||
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isEqualTo(arrival);
|
||||
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo(before.getVesselName());
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ESTIMATED_ARRIVAL");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("estimatedArrivalAt");
|
||||
assertThat(history.getValue().getAfterSnapshot()).contains("estimatedArrivalAt");
|
||||
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).replaceRequirements(anyLong(), any());
|
||||
}
|
||||
|
||||
/** 草稿、旧版本、空值及逆序时间均不能走放舱后的预计到港命令。 */
|
||||
@Test void rejectsInvalidEstimatedArrivalCommands() {
|
||||
var value = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(value);
|
||||
var arrival = value.getEstimatedSailingAt().plusDays(20);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-draft", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
value.setStatus("RELEASED");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-stale", new EstimatedArrivalRequest(2, arrival))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-empty", new EstimatedArrivalRequest(3, null))).hasMessageContaining("不能为空");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-early", new EstimatedArrivalRequest(3, value.getEstimatedSailingAt().minusSeconds(1)))).hasMessageContaining("不能早于");
|
||||
when(repository.updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-conflict", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 同一预计到港命令重放复用原结果,不重复保存或追加审计。 */
|
||||
@Test void estimatedArrivalReplayDoesNotWriteAgain() throws Exception {
|
||||
var request = new EstimatedArrivalRequest(3, LocalDateTime.of(2026, 10, 25, 10, 0));
|
||||
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
var operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hash("UPDATE_ESTIMATED_ARRIVAL", 1L, request));
|
||||
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
|
||||
when(repository.claimOperation(1L, 9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay")).thenReturn(operation);
|
||||
assertThat(service.updateEstimatedArrival(1L, 9L, "admin", "eta-replay", request).oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository, never()).updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
|
||||
OceanBookingEntity before = completeBooking("RELEASED", 3);
|
||||
|
||||
+47
-3
@@ -8,12 +8,23 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("货柜数据库映射契约")
|
||||
class ContainerBookingMapperContractTest {
|
||||
@@ -106,7 +117,7 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
|
||||
@DisplayName("初始化与统一增量保留柜号权威及订舱内唯一,不限制关联柜号ISO格式")
|
||||
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
@@ -118,8 +129,41 @@ class ContainerBookingMapperContractTest {
|
||||
assertThat(source.toLowerCase())
|
||||
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
|
||||
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
|
||||
.contains("constraint chk_container_booking_iso6346")
|
||||
.contains("^[a-z]{3}[ujz][0-9]{7}$");
|
||||
.doesNotContain("chk_container_booking_iso6346", "^[a-z]{3}[ujz][0-9]{7}$");
|
||||
}
|
||||
}
|
||||
|
||||
/** 从当前建表基线创建隔离数据库,真实执行关联 Mapper 并验证唯一键和版本。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void linksNonIsoContainerNumbersThroughActualSchemaAndMapper(String containerNo) throws Exception {
|
||||
var dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:container-link-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
String source = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
int start = source.indexOf("CREATE TABLE IF NOT EXISTS fl_container_booking (");
|
||||
jdbc.execute(source.substring(start, source.indexOf(';', source.indexOf(") ENGINE=InnoDB", start)) + 1));
|
||||
for (long id = 1; id <= 3; id++) {
|
||||
jdbc.update("INSERT INTO fl_container_booking(id,cabinet_no,warehouse_id,warehouse_name,container_type,created_by,updated_by) VALUES(?,?,8,'测试仓','40HQ',9,9)",
|
||||
id, "D" + id);
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(ContainerBookingMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var repository = new ContainerRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", session.getMapper(ContainerBookingMapper.class));
|
||||
var booking = new OceanBookingEntity();
|
||||
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT container_no FROM fl_container_booking WHERE id=1", String.class)).isEqualTo(containerNo);
|
||||
assertThat(jdbc.queryForObject("SELECT version FROM fl_container_booking WHERE id=1", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isZero();
|
||||
assertThatThrownBy(() -> repository.linkReleasedBooking(2L, 21L, 31L, containerNo, booking, 0, 9L, "操作员"))
|
||||
.isInstanceOf(DuplicateKeyException.class);
|
||||
assertThat(repository.linkReleasedBooking(3L, 22L, 32L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,17 +329,21 @@ class ContainerServiceTest {
|
||||
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝错误ISO 6346校验位")
|
||||
void rejectsInvalidContainerNumberCheckDigit() {
|
||||
/** 关联入口允许非 ISO 柜号,继续按原版本和关联范围保存。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
@DisplayName("首次关联不因柜号长度结构或校验位阻断")
|
||||
void linksWithoutIsoContainerNumberValidation(String containerNo) {
|
||||
stubLinkContext();
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
|
||||
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, containerNo, 2));
|
||||
|
||||
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
verify(repository).containerNoExists(21L, containerNo, 11L);
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -26,6 +26,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -147,10 +150,42 @@ class LoadingListServiceTest {
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
/** 只更新航次时,关联已接受的非 ISO 柜号不应被重新校验或改写。 */
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"LOCAL-1", " local-1 "})
|
||||
void preservesLinkedNonIsoNumberWhenOnlyVoyageChanges(String requested) {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
before.setVoyageNo("001E");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(repository.updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator")).thenReturn(1);
|
||||
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "preserve-linked-number",
|
||||
new TransportUpdateRequest(requested, "023E", 3, null));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
|
||||
@Test
|
||||
void stillRejectsNewNonIsoNumberInTransportCorrection() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction",
|
||||
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
|
||||
@Test
|
||||
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("MSCU6639870");
|
||||
before.setContainerNo("LOCAL-1");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
|
||||
@@ -227,7 +227,7 @@ class LoadingPickServiceTest {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
booking.setId(40L); booking.setContainerNo("MSCU6639870");
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
@@ -962,6 +962,57 @@ class LoadingPickServiceTest {
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, containerNo, "EDITING", 3, 2, null);
|
||||
var booking = new ContainerBookingEntity();
|
||||
booking.setId(40L);
|
||||
booking.setContainerNo(containerNo);
|
||||
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
|
||||
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
verify(completionCommands, never()).insert(any());
|
||||
|
||||
booking.setContainerNo("MSCU6639870");
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
ocean.setId(90L);
|
||||
ocean.setStatus("RELEASED");
|
||||
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
|
||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
|
||||
}
|
||||
|
||||
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
|
||||
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
|
||||
}
|
||||
|
||||
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
|
||||
@Test
|
||||
void completionStillRequiresIsoNumberForCrossServiceFacts() {
|
||||
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
|
||||
Reference in New Issue
Block a user