优化
This commit is contained in:
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.client;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
@@ -29,6 +30,12 @@ public interface LoadingDocumentFileClient {
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);
|
||||
|
||||
@DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
|
||||
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long tenantId, @PathVariable String businessType,
|
||||
@PathVariable String businessId, @PathVariable long fileId);
|
||||
|
||||
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {}
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {
|
||||
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} "
|
||||
+ "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL")
|
||||
int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId);
|
||||
}
|
||||
|
||||
+2
-5
@@ -81,11 +81,8 @@ public class CustomsRepository {
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public CustomsCaseEntity clearedContainerCase(long containerBookingId) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED")
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
public boolean loadingDeclarationCompleted(long containerBookingId) {
|
||||
return cases.completedLoadingDeclaration(containerBookingId) == 1;
|
||||
}
|
||||
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
|
||||
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
|
||||
+3
-5
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
@@ -18,12 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
|
||||
if (context.tenantId() <= 0 || context.customerId() <= 0) {
|
||||
return gate(false, "INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId());
|
||||
if (value == null) return gate(false, "MISSING");
|
||||
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
|
||||
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId());
|
||||
return gate(completed, completed ? "COMPLETED" : "MISSING");
|
||||
}
|
||||
|
||||
private static GateItem gate(boolean passed, String actual) {
|
||||
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED");
|
||||
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,13 @@ public class LoadingListRepository {
|
||||
String fromStatus, String toStatus, String beforeSnapshot,
|
||||
String afterSnapshot, long operatorId, String operatorName,
|
||||
String operationReason) {
|
||||
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
|
||||
int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
|
||||
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
|
||||
operationReason));
|
||||
if (changed != 1) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
|
||||
|
||||
@@ -31,12 +31,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
@@ -49,6 +52,7 @@ import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LoadingListService {
|
||||
private static final Logger log = LoggerFactory.getLogger(LoadingListService.class);
|
||||
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
|
||||
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
|
||||
public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION";
|
||||
@@ -317,8 +321,16 @@ public class LoadingListService {
|
||||
requireReplaceableDocument(current, expectedVersion);
|
||||
LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile(
|
||||
businessType, businessId, operatorId, file, fileHash);
|
||||
return persistDocument(plan, documentType, current, metadata, operatorId, operatorName,
|
||||
operationId, "UPLOAD_" + documentType, requestHash, operationReason);
|
||||
try {
|
||||
return persistDocument(plan, documentType, current, metadata, operatorId, operatorName,
|
||||
operationId, "UPLOAD_" + documentType, requestHash, operationReason);
|
||||
} catch (RuntimeException exception) {
|
||||
compensateUploadedFile(businessType, businessId, metadata.fileId(), operatorId);
|
||||
if (exception instanceof DataIntegrityViolationException) {
|
||||
throw conflict("资料状态或数据库约束已变化,请刷新后重试");
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessDocument persistDocument(AggregateRow plan, String documentType,
|
||||
@@ -337,12 +349,18 @@ public class LoadingListService {
|
||||
if (changed != 1) throw conflict("资料版本已变化,请刷新后重试");
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("资料已被其他操作上传,请刷新后重试");
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("资料状态或数据库约束已变化,请刷新后重试");
|
||||
}
|
||||
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType);
|
||||
repository.appendHistory(plan.getPlanId(), operationId, actionType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(operationReason));
|
||||
try {
|
||||
repository.appendHistory(plan.getPlanId(), operationId, actionType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(operationReason));
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("资料历史约束已变化,请刷新后重试");
|
||||
}
|
||||
return document(saved);
|
||||
}
|
||||
|
||||
@@ -414,6 +432,19 @@ public class LoadingListService {
|
||||
}
|
||||
}
|
||||
|
||||
private void compensateUploadedFile(String businessType, String businessId, long fileId, long operatorId) {
|
||||
try {
|
||||
var response = documentFiles.delete(internalToken, operatorId, 0, businessType, businessId, fileId);
|
||||
if (response == null || !response.successful()) {
|
||||
log.error("Business file compensation failed: type={}, businessId={}, fileId={}",
|
||||
businessType, businessId, fileId);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Business file compensation request failed: type={}, businessId={}, fileId={}",
|
||||
businessType, businessId, fileId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) {
|
||||
if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试");
|
||||
if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) {
|
||||
|
||||
@@ -70,7 +70,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -84,7 +84,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -1122,7 +1122,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -1138,7 +1138,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -37,7 +37,7 @@ paths:
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
|
||||
get:
|
||||
summary: Evaluate every inbound binding in the fail-closed container outbound gate
|
||||
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed.
|
||||
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed.
|
||||
x-permission: platform:firstleg:outbound-dispatch:check
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
|
||||
+30
-12
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -22,19 +22,37 @@ class CustomsDispatchGateContributorTest {
|
||||
.isEqualTo("INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
|
||||
@Test void passesOnlyCanonicalClearanceCompletedCase() {
|
||||
CustomsCaseEntity value = new CustomsCaseEntity();
|
||||
assertThat(contributor.assess(context()).passed()).isFalse();
|
||||
value.setStatus("CLEARANCE_COMPLETED");
|
||||
when(repository.clearedContainerCase(81L)).thenReturn(value);
|
||||
assertThat(contributor.assess(context()))
|
||||
.satisfies(item -> {
|
||||
assertThat(item.passed()).isTrue();
|
||||
assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED");
|
||||
});
|
||||
verify(repository, times(2)).clearedContainerCase(81L);
|
||||
@Test void uploadedButIncompleteDeclarationRemainsBlocked() {
|
||||
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false);
|
||||
|
||||
assertThat(contributor.assess(context())).satisfies(item -> {
|
||||
assertThat(item.passed()).isFalse();
|
||||
assertThat(item.actual()).isEqualTo("MISSING");
|
||||
assertThat(item.requirement()).isEqualTo("COMPLETED");
|
||||
});
|
||||
}
|
||||
|
||||
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
|
||||
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true);
|
||||
|
||||
assertThat(contributor.assess(context())).satisfies(item -> {
|
||||
assertThat(item.passed()).isTrue();
|
||||
assertThat(item.actual()).isEqualTo("COMPLETED");
|
||||
assertThat(item.requirement()).isEqualTo("COMPLETED");
|
||||
});
|
||||
verify(repository).loadingDeclarationCompleted(81L);
|
||||
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception {
|
||||
String sql = String.join(" ", CustomsCaseMapper.class
|
||||
.getMethod("completedLoadingDeclaration", long.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}")
|
||||
.contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL")
|
||||
.doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED");
|
||||
}
|
||||
|
||||
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
|
||||
}
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ class OutboundDispatchSqlContractTest {
|
||||
.contains("absence never blocks manual outbound confirmation")
|
||||
.contains("PENDING or any non-terminal state never affects gate.passed")
|
||||
.contains("Loading evidence is evaluated once for the physical container/loading plan")
|
||||
.contains("Customs clearance is also resolved once at container scope")
|
||||
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null")
|
||||
.contains("fl_customs_case is not an alternative gate fact")
|
||||
.contains("missing standalone sailing schedule is reference-only")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key")
|
||||
|
||||
+15
@@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("operator_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception {
|
||||
String documentInsert = sql("insertDocument", Insert.class);
|
||||
String historyInsert = sql("insertHistory", Insert.class);
|
||||
assertThat(documentInsert)
|
||||
.contains("INSERT INTO fl_container_document")
|
||||
.contains("#{documentType}", "#{fileId}", "#{fileHash}")
|
||||
.contains("CURRENT_TIMESTAMP(3)", "version")
|
||||
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
|
||||
assertThat(historyInsert)
|
||||
.contains("INSERT INTO fl_loading_plan_history")
|
||||
.contains("#{planId}", "#{operationId}", "#{actionType}")
|
||||
.contains("#{beforeSnapshot}", "#{afterSnapshot}");
|
||||
}
|
||||
|
||||
private static String sql(String methodName, Class<?> annotationType) throws Exception {
|
||||
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
|
||||
+4
-4
@@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("CREATE TRIGGER trg_container_booking_before_update")
|
||||
.contains("current_plan_status IN ('LOADING','COMPLETED')")
|
||||
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')");
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')");
|
||||
}
|
||||
|
||||
private static void assertSimpleCustomsDocumentContract(String sql) {
|
||||
@@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读");
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -597,6 +599,129 @@ class LoadingListServiceTest {
|
||||
verify(repository, never()).aggregateByBooking(30L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
|
||||
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false);
|
||||
saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
|
||||
9L, "operator", "do-first", 0, null, file);
|
||||
|
||||
assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version())
|
||||
.containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0);
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
|
||||
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
|
||||
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
|
||||
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.replaceDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
|
||||
9L, "operator", "pu-update", 2, null, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(102L);
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).replaceDocument(any());
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER))
|
||||
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false));
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
|
||||
|
||||
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
|
||||
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content));
|
||||
HistoryRow replay = new HistoryRow();
|
||||
replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}");
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(replay);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER))
|
||||
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false));
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
|
||||
9L, "operator", "do-replay", 0, null, file);
|
||||
|
||||
assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER);
|
||||
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L,
|
||||
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(any())).thenThrow(
|
||||
new org.springframework.dao.DataIntegrityViolationException("trigger rejected"));
|
||||
when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(103L))).thenReturn(ApiResponse.success(null));
|
||||
|
||||
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
|
||||
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(103L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
|
||||
Reference in New Issue
Block a user