This commit is contained in:
2026-08-26 13:58:33 +08:00
parent fc0a6ea7ca
commit 4398e8b347
78 changed files with 920 additions and 1549 deletions
@@ -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);
}
@@ -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>()
@@ -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'}
@@ -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); }
}
@@ -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")
@@ -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))
@@ -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);