优化
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** Validates that a referenced File object belongs to the exact loading business resource. */
|
||||
@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
|
||||
public interface LoadingDocumentFileClient {
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
|
||||
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@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) {}
|
||||
}
|
||||
+38
@@ -11,6 +11,10 @@ import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.service.ContainerService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -25,6 +29,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/api/platform/v1/containers")
|
||||
public class PlatformContainerController {
|
||||
@Resource private ContainerService service;
|
||||
@Resource private LoadingListService loadingLists;
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
@@ -60,4 +65,37 @@ public class PlatformContainerController {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:view')")
|
||||
@Operation(summary = "查询货柜目的港清关资料")
|
||||
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentAttachRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(loadingLists.attachContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "确认货柜目的港清关完成")
|
||||
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentCompleteRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(loadingLists.completeContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -10,6 +10,9 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -22,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
@@ -89,4 +93,38 @@ public class PlatformLoadingListController {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询装柜清单出口报关文档")
|
||||
@GetMapping("/loading-lists/{planId}/customs")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
|
||||
public ApiResponse<BusinessDocument> declaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "上传或替换装柜清单出口报关文档")
|
||||
@PostMapping("/loading-lists/{planId}/customs/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
public ApiResponse<BusinessDocument> attachDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentAttachRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.attachLoadingDeclaration(
|
||||
planId, warehouseId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认装柜清单出口报关完成")
|
||||
@PostMapping("/loading-lists/{planId}/customs/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
public ApiResponse<BusinessDocument> completeDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentCompleteRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.completeLoadingDeclaration(
|
||||
planId, warehouseId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -57,6 +58,26 @@ public final class LoadingListDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
public record DocumentAttachRequest(
|
||||
@NotNull @Positive Long fileId,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String operationReason
|
||||
) {
|
||||
}
|
||||
|
||||
public record DocumentCompleteRequest(
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String operationReason
|
||||
) {
|
||||
}
|
||||
|
||||
public record BusinessDocument(
|
||||
Long id, String documentType, Long fileId, String fileName, String fileHash,
|
||||
Long uploadedBy, String uploadedByName, LocalDateTime uploadedAt,
|
||||
Long completedBy, String completedByName, LocalDateTime completedAt, Integer version
|
||||
) {
|
||||
}
|
||||
|
||||
public record Customer(Long accountId, String customerName) {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ContainerDocumentEntity {
|
||||
private Long id;
|
||||
private Long containerBookingId;
|
||||
private String documentType;
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
private String fileHash;
|
||||
private Long uploadedBy;
|
||||
private String uploadedByName;
|
||||
private LocalDateTime createdAt;
|
||||
private Long completedBy;
|
||||
private String completedByName;
|
||||
private LocalDateTime completedAt;
|
||||
private Integer version;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
@@ -99,6 +100,59 @@ public interface LoadingListQueryMapper {
|
||||
""" + AGGREGATE_GROUP)
|
||||
AggregateRow selectAggregate(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
""" + AGGREGATE_COLUMNS + """
|
||||
FROM fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
||||
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
|
||||
WHERE p.container_booking_id = #{bookingId}
|
||||
GROUP BY
|
||||
""" + AGGREGATE_GROUP)
|
||||
AggregateRow selectAggregateByBooking(@Param("bookingId") long bookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT id, container_booking_id, document_type, file_id, file_name, file_hash,
|
||||
uploaded_by, uploaded_by_name, created_at, completed_by, completed_by_name,
|
||||
completed_at, version
|
||||
FROM fl_container_document
|
||||
WHERE container_booking_id = #{bookingId} AND document_type = #{documentType}
|
||||
""")
|
||||
ContainerDocumentEntity selectDocument(@Param("bookingId") long bookingId,
|
||||
@Param("documentType") String documentType);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_container_document
|
||||
(id, container_booking_id, document_type, file_id, file_name, file_hash,
|
||||
uploaded_by, uploaded_by_name, created_at, updated_at, version)
|
||||
VALUES
|
||||
(#{id}, #{containerBookingId}, #{documentType}, #{fileId}, #{fileName}, #{fileHash},
|
||||
#{uploadedBy}, #{uploadedByName}, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0)
|
||||
""")
|
||||
int insertDocument(ContainerDocumentEntity value);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_document
|
||||
SET file_id = #{fileId}, file_name = #{fileName}, file_hash = #{fileHash},
|
||||
uploaded_by = #{uploadedBy}, uploaded_by_name = #{uploadedByName},
|
||||
completed_by = NULL, completed_by_name = NULL, completed_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
""")
|
||||
int replaceDocument(ContainerDocumentEntity value);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_document
|
||||
SET completed_by = #{operatorId}, completed_by_name = #{operatorName},
|
||||
completed_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3),
|
||||
version = version + 1
|
||||
WHERE id = #{documentId} AND version = #{expectedVersion} AND completed_at IS NULL
|
||||
""")
|
||||
int completeDocument(@Param("documentId") long documentId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id,
|
||||
inbound_code, sku_code, destination_warehouse_name, product_name_zh,
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow;
|
||||
@@ -50,6 +51,14 @@ public class LoadingListRepository {
|
||||
return mapper.selectAggregate(planId);
|
||||
}
|
||||
|
||||
public AggregateRow aggregateByBooking(long bookingId) { return mapper.selectAggregateByBooking(bookingId); }
|
||||
public ContainerDocumentEntity document(long bookingId, String type) { return mapper.selectDocument(bookingId, type); }
|
||||
public int createDocument(ContainerDocumentEntity value) { return mapper.insertDocument(value); }
|
||||
public int replaceDocument(ContainerDocumentEntity value) { return mapper.replaceDocument(value); }
|
||||
public int completeDocument(long id, int version, long operatorId, String operatorName) {
|
||||
return mapper.completeDocument(id, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public PreloadDetail preloadDetail(AggregateRow row) {
|
||||
List<ItemRow> items = mapper.selectItems(row.getPlanId());
|
||||
List<Customer> customers = items.stream()
|
||||
|
||||
@@ -11,6 +11,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
@@ -18,6 +23,10 @@ import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -33,12 +42,20 @@ import java.util.Set;
|
||||
@Service
|
||||
public class LoadingListService {
|
||||
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";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT";
|
||||
@Resource
|
||||
private LoadingListRepository repository;
|
||||
@Resource
|
||||
private LoadingPickRepository pickRepository;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource
|
||||
private LoadingDocumentFileClient documentFiles;
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String internalToken;
|
||||
|
||||
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
|
||||
validateRange(query.createdFrom(), query.createdTo());
|
||||
@@ -74,6 +91,182 @@ public class LoadingListService {
|
||||
return repository.confirmedDetail(row);
|
||||
}
|
||||
|
||||
public BusinessDocument containerClearance(long bookingId) {
|
||||
requireBookingPlan(bookingId);
|
||||
return document(repository.document(bookingId, DESTINATION_CLEARANCE));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
|
||||
resourceId = "#bookingId", description = "上传目的港清关资料")
|
||||
public BusinessDocument attachContainerClearance(long bookingId, long operatorId, String operatorName,
|
||||
String key, DocumentAttachRequest request) {
|
||||
AggregateRow plan = requireBookingPlan(bookingId);
|
||||
return attachDocument(plan, DESTINATION_CLEARANCE, CLEARANCE_FILE_TYPE,
|
||||
Long.toString(bookingId), operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "CONTAINER_CLEARANCE_COMPLETE", resourceType = "CONTAINER",
|
||||
resourceId = "#bookingId", description = "确认目的港清关完成")
|
||||
public BusinessDocument completeContainerClearance(long bookingId, long operatorId, String operatorName,
|
||||
String key, DocumentCompleteRequest request) {
|
||||
return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE,
|
||||
operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
public BusinessDocument loadingDeclaration(long planId, long warehouseId) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "上传出口报关文档")
|
||||
public BusinessDocument attachLoadingDeclaration(long planId, long warehouseId, long operatorId,
|
||||
String operatorName, String key,
|
||||
DocumentAttachRequest request) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE,
|
||||
Long.toString(planId), operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_COMPLETE", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "确认出口报关完成")
|
||||
public BusinessDocument completeLoadingDeclaration(long planId, long warehouseId, long operatorId,
|
||||
String operatorName, String key,
|
||||
DocumentCompleteRequest request) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
return completeDocument(plan, CUSTOMS_DECLARATION, operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType,
|
||||
String businessId, long operatorId, String operatorName,
|
||||
String key, DocumentAttachRequest request) {
|
||||
String requestHash = sha256("ATTACH|" + documentType + '|' + plan.getPlanId() + '|'
|
||||
+ request.fileId() + '|' + request.expectedVersion() + '|' + trim(request.operationReason()));
|
||||
String operationId = operationId("ATTACH_" + documentType, plan.getPlanId(), key);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
return requireDocument(plan.getContainerBookingId(), documentType);
|
||||
}
|
||||
ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType);
|
||||
if (current == null && request.expectedVersion() != 0) throw conflict("资料版本已变化,请刷新后重试");
|
||||
if (current != null && !java.util.Objects.equals(current.getVersion(), request.expectedVersion())) {
|
||||
throw conflict("资料版本已变化,请刷新后重试");
|
||||
}
|
||||
if (current != null && current.getCompletedAt() != null) throw conflict("已完成的资料不可替换");
|
||||
LoadingDocumentFileClient.Metadata metadata = validateDocumentFile(
|
||||
businessType, businessId, request.fileId());
|
||||
Map<String, Object> beforeSnapshot = documentSnapshot(current);
|
||||
ContainerDocumentEntity value = current == null ? new ContainerDocumentEntity() : current;
|
||||
if (current == null) value.setId(IdWorker.getId());
|
||||
value.setContainerBookingId(plan.getContainerBookingId()); value.setDocumentType(documentType);
|
||||
value.setFileId(metadata.fileId()); value.setFileName(metadata.fileName()); value.setFileHash(metadata.sha256());
|
||||
value.setUploadedBy(operatorId); value.setUploadedByName(trim(operatorName));
|
||||
try {
|
||||
int changed = current == null ? repository.createDocument(value) : repository.replaceDocument(value);
|
||||
if (changed != 1) throw conflict("资料版本已变化,请刷新后重试");
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("资料已被其他操作上传,请刷新后重试");
|
||||
}
|
||||
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType);
|
||||
repository.appendHistory(plan.getPlanId(), operationId, "ATTACH_" + documentType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(request.operationReason()));
|
||||
return document(saved);
|
||||
}
|
||||
|
||||
private BusinessDocument completeDocument(AggregateRow plan, String documentType, long operatorId,
|
||||
String operatorName, String key,
|
||||
DocumentCompleteRequest request) {
|
||||
String requestHash = sha256("COMPLETE|" + documentType + '|' + plan.getPlanId() + '|'
|
||||
+ request.expectedVersion() + '|' + trim(request.operationReason()));
|
||||
String operationId = operationId("COMPLETE_" + documentType, plan.getPlanId(), key);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
return requireDocument(plan.getContainerBookingId(), documentType);
|
||||
}
|
||||
ContainerDocumentEntity current = requireDocumentEntity(plan.getContainerBookingId(), documentType);
|
||||
if (current.getCompletedAt() != null) {
|
||||
if (!java.util.Objects.equals(current.getVersion(), request.expectedVersion())) {
|
||||
throw conflict("资料版本已变化,请刷新后重试");
|
||||
}
|
||||
return document(current);
|
||||
}
|
||||
if (repository.completeDocument(current.getId(), request.expectedVersion(), operatorId,
|
||||
trim(operatorName)) != 1) throw conflict("资料版本已变化,请刷新后重试");
|
||||
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType);
|
||||
repository.appendHistory(plan.getPlanId(), operationId, "COMPLETE_" + documentType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(documentSnapshot(current)),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(request.operationReason()));
|
||||
return document(saved);
|
||||
}
|
||||
|
||||
private LoadingDocumentFileClient.Metadata validateDocumentFile(String businessType, String businessId,
|
||||
long fileId) {
|
||||
try {
|
||||
var response = documentFiles.metadata(internalToken, 0, businessType, businessId, fileId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !Long.valueOf(fileId).equals(response.data().fileId())
|
||||
|| !Long.valueOf(0).equals(response.data().tenantId())
|
||||
|| !businessType.equals(response.data().businessType())
|
||||
|| !businessId.equals(response.data().businessId())
|
||||
|| !StringUtils.hasText(response.data().fileName())
|
||||
|| !StringUtils.hasText(response.data().sha256())) {
|
||||
throw badRequest("附件不存在、归属不匹配或文件元数据不完整");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private AggregateRow requireBookingPlan(long bookingId) {
|
||||
AggregateRow row = repository.aggregateByBooking(bookingId);
|
||||
if (row == null) throw notFound("货柜或关联装柜计划不存在");
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void requireCompletedPlan(AggregateRow plan) {
|
||||
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关");
|
||||
}
|
||||
|
||||
private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) {
|
||||
ContainerDocumentEntity value = repository.document(bookingId, documentType);
|
||||
if (value == null) throw conflict("请先上传资料");
|
||||
return value;
|
||||
}
|
||||
|
||||
private BusinessDocument requireDocument(long bookingId, String documentType) {
|
||||
return document(requireDocumentEntity(bookingId, documentType));
|
||||
}
|
||||
|
||||
private static BusinessDocument document(ContainerDocumentEntity value) {
|
||||
return value == null ? null : new BusinessDocument(value.getId(), value.getDocumentType(), value.getFileId(),
|
||||
value.getFileName(), value.getFileHash(), value.getUploadedBy(), value.getUploadedByName(),
|
||||
value.getCreatedAt(), value.getCompletedBy(), value.getCompletedByName(), value.getCompletedAt(),
|
||||
value.getVersion());
|
||||
}
|
||||
|
||||
private static Map<String, Object> documentSnapshot(ContainerDocumentEntity value) {
|
||||
return value == null ? Map.of() : values("id", value.getId(), "type", value.getDocumentType(),
|
||||
"fileId", value.getFileId(), "fileName", value.getFileName(), "fileHash", value.getFileHash(),
|
||||
"completedAt", value.getCompletedAt() == null ? null : value.getCompletedAt().toString(),
|
||||
"version", value.getVersion());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "修改预装集装箱号与航次")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_simple_customs_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE add_simple_customs_column(IN p_name VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_document'
|
||||
AND column_name = p_name) THEN
|
||||
SET @ddl = CONCAT('ALTER TABLE fl_container_document ADD COLUMN ', p_name, ' ', p_definition);
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL add_simple_customs_column('file_hash', "VARCHAR(64) NULL COMMENT 'File服务SHA-256快照' AFTER file_name");
|
||||
CALL add_simple_customs_column('completed_by', "BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID' AFTER uploaded_by_name");
|
||||
CALL add_simple_customs_column('completed_by_name', "VARCHAR(64) NULL COMMENT '一键完成操作人名称快照' AFTER completed_by");
|
||||
CALL add_simple_customs_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name");
|
||||
DROP PROCEDURE add_simple_customs_column;
|
||||
|
||||
SET @old_check = (SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_document'
|
||||
AND constraint_type = 'CHECK' AND constraint_name = 'chk_container_document_type');
|
||||
SET @ddl = IF(@old_check IS NULL, 'SELECT 1',
|
||||
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
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
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
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
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
@@ -180,15 +180,20 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
-- 目的港清关和出口报关均为柜次级简单文件事实:上传资料后由平台一键完成。
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
|
||||
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '一键完成操作人名称快照',
|
||||
completed_at DATETIME(3) NULL COMMENT '一键完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
@@ -198,7 +203,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
@@ -557,7 +562,8 @@ BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status
|
||||
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
|
||||
IF current_plan_status = 'COMPLETED' THEN
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
|
||||
END IF;
|
||||
IF current_plan_status = 'LOADING' AND (
|
||||
@@ -920,7 +926,8 @@ BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
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' THEN
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -243,6 +243,27 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或状态、版本、重复号冲突}
|
||||
'403': {description: 无货柜编辑权限}
|
||||
/api/platform/v1/containers/{id}/clearance:
|
||||
get:
|
||||
summary: 查询货柜目的港清关资料
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功或货柜不存在}}
|
||||
/api/platform/v1/containers/{id}/clearance/document:
|
||||
post:
|
||||
summary: 上传或替换货柜目的港清关资料
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}}
|
||||
responses: {'200': {description: 成功、文件归属不匹配或版本冲突}}
|
||||
/api/platform/v1/containers/{id}/clearance/complete:
|
||||
post:
|
||||
summary: 点击完成目的港清关
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentCompleteRequest'}}}
|
||||
responses: {'200': {description: 成功、未上传资料或版本冲突}}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}:
|
||||
get:
|
||||
summary: 按货柜恢复智能装柜草稿
|
||||
@@ -1034,6 +1055,19 @@ components:
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
reason: {type: string, maxLength: 500}
|
||||
ContainerDocumentAttachRequest:
|
||||
type: object
|
||||
required: [fileId, expectedVersion]
|
||||
properties:
|
||||
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
ContainerDocumentCompleteRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
MerchantInboundBillingEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -68,6 +68,33 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs:
|
||||
get:
|
||||
summary: 查询装柜清单出口报关文档
|
||||
x-permission: platform:firstleg:loading-list:view
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document:
|
||||
post:
|
||||
summary: 上传或替换装柜清单出口报关文档
|
||||
x-permission: platform:firstleg:customs:manage
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete:
|
||||
post:
|
||||
summary: 点击完成出口报关
|
||||
x-permission: platform:firstleg:customs:manage
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCompleteRequest'}}}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
|
||||
components:
|
||||
parameters:
|
||||
PlanId:
|
||||
@@ -132,6 +159,9 @@ components:
|
||||
ConfirmedDetailResponse:
|
||||
description: 装柜清单详情查询或确认成功,也可能返回业务失败
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
|
||||
BusinessDocumentResponse:
|
||||
description: 报关文档查询或操作结果
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/BusinessDocumentEnvelope'}}}
|
||||
schemas:
|
||||
LongId:
|
||||
type: string
|
||||
@@ -182,6 +212,14 @@ components:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
|
||||
nullable: true
|
||||
BusinessDocumentEnvelope:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiEnvelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf: [{$ref: '#/components/schemas/BusinessDocument'}]
|
||||
nullable: true
|
||||
PreloadPage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
@@ -319,3 +357,34 @@ components:
|
||||
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
|
||||
DocumentAttachRequest:
|
||||
type: object
|
||||
required: [fileId, expectedVersion]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/LongId'}
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
DocumentCompleteRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
expectedVersion: {type: integer, format: int32, minimum: 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
BusinessDocument:
|
||||
type: object
|
||||
required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version]
|
||||
properties:
|
||||
id: {$ref: '#/components/schemas/LongId'}
|
||||
documentType: {type: string, enum: [CUSTOMS_DECLARATION]}
|
||||
fileId: {$ref: '#/components/schemas/LongId'}
|
||||
fileName: {type: string}
|
||||
fileHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||
uploadedBy: {$ref: '#/components/schemas/LongId'}
|
||||
uploadedByName: {type: string, nullable: true}
|
||||
uploadedAt: {type: string, format: date-time}
|
||||
completedBy: {$ref: '#/components/schemas/NullableLongId'}
|
||||
completedByName: {type: string, nullable: true}
|
||||
completedAt: {type: string, format: date-time, nullable: true}
|
||||
version: {type: integer, format: int32, minimum: 0}
|
||||
|
||||
+13
-2
@@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -32,6 +34,11 @@ class LoadingListControllerContractTest {
|
||||
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||
ConfirmedQuery.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
long.class, long.class, String.class, DocumentAttachRequest.class);
|
||||
authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
long.class, long.class, String.class, DocumentCompleteRequest.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -91,13 +98,17 @@ class LoadingListControllerContractTest {
|
||||
"/api/platform/v1/first-leg/loading-plans/{planId}",
|
||||
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
|
||||
"/api/platform/v1/first-leg/loading-lists",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}");
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
|
||||
Map<String, Object> components = stringMap(document.get("components"));
|
||||
Map<String, Object> schemas = stringMap(components.get("schemas"));
|
||||
assertThat(schemas).containsKeys(
|
||||
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
|
||||
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
|
||||
"ConfirmedDetail", "TransportUpdateRequest");
|
||||
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
|
||||
"DocumentAttachRequest", "DocumentCompleteRequest", "BusinessDocument");
|
||||
assertThat(paths)
|
||||
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
|
||||
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
|
||||
|
||||
@@ -6,6 +6,11 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
|
||||
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
@@ -38,6 +43,8 @@ class LoadingListServiceTest {
|
||||
private LoadingListRepository repository;
|
||||
@MockBean
|
||||
private LoadingPickRepository pickRepository;
|
||||
@MockBean
|
||||
private LoadingDocumentFileClient documentFiles;
|
||||
@Resource
|
||||
private LoadingListService service;
|
||||
|
||||
@@ -189,6 +196,116 @@ class LoadingListServiceTest {
|
||||
.isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachesRealContainerClearanceFileAndWritesImmutableHistory() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE))
|
||||
.thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata(
|
||||
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", "abc", "application/pdf", 20L)));
|
||||
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
|
||||
|
||||
var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance",
|
||||
new DocumentAttachRequest(99L, 0, "上传清关资料"));
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("clearance.pdf");
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("ATTACH_DESTINATION_CLEARANCE"),
|
||||
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
|
||||
eq("上传清关资料"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDeclarationBeforeLoadingListIsCompleted() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "key",
|
||||
new DocumentAttachRequest(99L, 0, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(99L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFileMetadataFromAnotherBusinessResource() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata(
|
||||
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", "abc",
|
||||
"application/pdf", 20L)));
|
||||
|
||||
assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key",
|
||||
new DocumentAttachRequest(99L, 0, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.BAD_REQUEST);
|
||||
verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesDeclarationOnlyWhenDocumentVersionMatches() {
|
||||
AggregateRow plan = row("COMPLETED", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
ContainerDocumentEntity before = document(40L, LoadingListService.CUSTOMS_DECLARATION, 3, false);
|
||||
ContainerDocumentEntity after = document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(before, after);
|
||||
when(repository.completeDocument(40L, 3, 9L, "operator")).thenReturn(1);
|
||||
|
||||
var result = service.completeLoadingDeclaration(10L, 100L, 9L, "operator", "complete-key",
|
||||
new DocumentCompleteRequest(3, "点击报关"));
|
||||
|
||||
assertThat(result.completedAt()).isNotNull();
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("COMPLETE_CUSTOMS_DECLARATION"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"),
|
||||
eq("点击报关"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompleteWithoutUploadedFile() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.completeContainerClearance(30L, 9L, "operator", "key",
|
||||
new DocumentCompleteRequest(0, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCompleteWithCurrentVersionIsNaturallyIdempotent() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
plan.setContainerBookingId(30L);
|
||||
ContainerDocumentEntity completed = document(40L, LoadingListService.DESTINATION_CLEARANCE, 4, true);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(completed);
|
||||
|
||||
var result = service.completeContainerClearance(30L, 9L, "operator", "new-key",
|
||||
new DocumentCompleteRequest(4, null));
|
||||
|
||||
assertThat(result.completedAt()).isNotNull();
|
||||
verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
|
||||
}
|
||||
|
||||
private static AggregateRow row(String status, int version) {
|
||||
AggregateRow row = new AggregateRow();
|
||||
row.setPlanId(10L);
|
||||
@@ -198,4 +315,14 @@ class LoadingListServiceTest {
|
||||
row.setVersion(version);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) {
|
||||
ContainerDocumentEntity value = new ContainerDocumentEntity();
|
||||
value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type);
|
||||
value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash("abc");
|
||||
value.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version);
|
||||
value.setCreatedAt(java.time.LocalDateTime.now());
|
||||
if (completed) value.setCompletedAt(java.time.LocalDateTime.now());
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user