feat(firstleg): implement container customs workflow
This commit is contained in:
-77
@@ -12,23 +12,13 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
|
||||
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;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -41,10 +31,6 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
|
||||
@@ -120,67 +106,4 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询装柜清单出口报关文档")
|
||||
@GetMapping("/loading-lists/{planId}/customs")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
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 = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
|
||||
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ResponseEntity<byte[]> downloadDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@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(value = "/loading-lists/{planId}/customs/document/upload",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
public ApiResponse<BusinessDocument> uploadDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
|
||||
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId,
|
||||
user.userId(), user.username(), key, expectedVersion, operationReason, file));
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.customs.container.controller;
|
||||
|
||||
import com.baiyue.common.core.api.*;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS")
|
||||
public class LoadingListBuyDeclarationExportController {
|
||||
@Resource private LoadingListBuyDeclarationExportService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String configuredToken;
|
||||
private byte[] token;
|
||||
@PostConstruct void init(){token=configuredToken==null?new byte[0]:configuredToken.getBytes(StandardCharsets.UTF_8);if(token.length<32)throw new IllegalStateException("INTERNAL_SERVICE_TOKEN至少需要32字节");}
|
||||
@PostMapping("/batches")
|
||||
public ApiResponse<ExportBatchResponse> batch(@RequestHeader(value="X-Internal-Service-Token",required=false)String supplied,
|
||||
@RequestBody ExportBatchRequest request){
|
||||
byte[] actual=supplied==null?new byte[0]:supplied.getBytes(StandardCharsets.UTF_8);
|
||||
if(!MessageDigest.isEqual(token,actual))throw new BizException(ResultCode.FORBIDDEN,"Invalid internal service token");
|
||||
return ApiResponse.success(service.batch(request));
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.firstleg.customs.container.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/customs")
|
||||
public class MerchantContainerCustomsController {
|
||||
@Resource private ContainerCustomsService service;
|
||||
|
||||
@GetMapping("/orders/{orderId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ApiResponse<ContainerView> order(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long orderId) {
|
||||
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
|
||||
}
|
||||
|
||||
@PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
long tenantId=FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.uploadSource(tenantId,planId,user.userId(),key,inboundIds,file));
|
||||
}
|
||||
|
||||
@PostMapping(value="/documents/{documentId}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<DocumentView> replace(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
|
||||
@RequestParam @Min(1) int expectedVersion,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
long tenantId=FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.replaceSource(tenantId,documentId,user.userId(),key,
|
||||
new ScopeRequest(inboundIds,expectedVersion),file));
|
||||
}
|
||||
|
||||
@PutMapping("/documents/{documentId}/inbounds")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<DocumentView> scope(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ScopeRequest request) {
|
||||
long tenantId=FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.updateScope(tenantId,documentId,user.userId(),key,request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{documentId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<ContainerView> delete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Min(1) int expectedVersion) {
|
||||
long tenantId=FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/completion/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> response(Download value) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
|
||||
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
|
||||
.contentLength(value.content().length).body(value.content());
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.firstleg.customs.container.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/customs/loading-plans")
|
||||
public class PlatformContainerCustomsController {
|
||||
@Resource private ContainerCustomsService service;
|
||||
|
||||
@GetMapping("/{planId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ApiResponse<ContainerView> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.platformView(planId));
|
||||
}
|
||||
|
||||
@PostMapping("/{planId}/start")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
|
||||
public ApiResponse<ContainerView> start(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
|
||||
}
|
||||
|
||||
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
|
||||
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Min(0) int expectedVersion,
|
||||
@RequestParam(defaultValue="false") boolean finalConfirmation,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key,
|
||||
expectedVersion,finalConfirmation,file));
|
||||
}
|
||||
|
||||
@PostMapping("/{planId}/complete-all-buy")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
|
||||
public ApiResponse<ContainerView> completeAllBuy(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return response(service.downloadSource(0,documentId,true));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/completion/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return response(service.downloadCompletion(0,documentId,true));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> response(Download value) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
|
||||
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
|
||||
.contentLength(value.content().length).body(value.content());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.customs.container.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class ContainerCustomsDtos {
|
||||
private ContainerCustomsDtos() {}
|
||||
|
||||
public record InboundView(long inboundId, long orderId, String orderNo, String inboundCode,
|
||||
long tenantId, String customerName, String taxMode, boolean covered) {}
|
||||
|
||||
public record FileView(long fileId, String fileName, String fileHash, String contentType,
|
||||
long sizeBytes, int version, LocalDateTime uploadedAt) {}
|
||||
|
||||
public record DocumentView(long documentId, long tenantId, FileView source,
|
||||
List<Long> inboundIds, List<String> inboundCodes,
|
||||
boolean frozen, FileView completion) {}
|
||||
|
||||
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
|
||||
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
|
||||
int version, int taxRefundCount, int coveredTaxRefundCount,
|
||||
List<InboundView> inbounds, List<DocumentView> documents,
|
||||
LocalDateTime startedAt, LocalDateTime completedAt) {}
|
||||
|
||||
public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package com.baiyue.firstleg.customs.container.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCustomsMapper {
|
||||
@Select("""
|
||||
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
||||
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo,
|
||||
i.id inboundId,i.order_id orderId,i.order_no orderNo,i.inbound_code inboundCode,
|
||||
i.tenant_id tenantId,i.customer_name customerName,i.tax_mode taxMode
|
||||
FROM fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE p.id=#{planId}
|
||||
ORDER BY i.tenant_id,i.id
|
||||
""")
|
||||
List<InboundRow> planInbounds(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id FROM fl_loading_plan p
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND'
|
||||
ORDER BY p.completed_at DESC,p.id DESC LIMIT 1
|
||||
""")
|
||||
Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
|
||||
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_customs_container
|
||||
(id,loading_plan_id,container_booking_id,status,created_at,updated_at,version)
|
||||
VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
|
||||
""")
|
||||
int insertContainer(@Param("id") long id,@Param("planId") long planId,
|
||||
@Param("containerBookingId") long containerBookingId,@Param("status") String status);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}")
|
||||
ContainerRow container(@Param("planId") long planId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}")
|
||||
ContainerRow containerById(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
|
||||
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
|
||||
ContainerRow lockContainer(@Param("planId") long planId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
|
||||
ContainerRow lockContainerById(@Param("id") long id);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_container SET status=#{status},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version} AND status IN ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START')
|
||||
""")
|
||||
int refreshStatus(@Param("id") long id,@Param("version") int version,@Param("status") String status);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_container SET status='DECLARING',started_by=#{operatorId},started_by_name=#{operatorName},
|
||||
started_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version} AND status='READY_TO_START'
|
||||
""")
|
||||
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
|
||||
completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version} AND status='DECLARING'
|
||||
""")
|
||||
int complete(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_customer_document
|
||||
(id,customs_container_id,tenant_id,file_id,file_name,file_hash,content_type,size_bytes,status,
|
||||
uploaded_by,uploaded_at,version,created_at,updated_at)
|
||||
VALUES (#{id},#{containerId},#{tenantId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},'ACTIVE',
|
||||
#{operatorId},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertDocument(@Param("id") long id,@Param("containerId") long containerId,@Param("tenantId") long tenantId,
|
||||
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id}")
|
||||
DocumentRow document(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id} FOR UPDATE")
|
||||
DocumentRow lockDocument(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
<script>SELECT * FROM fl_customs_customer_document
|
||||
WHERE customs_container_id=#{containerId} AND status='ACTIVE'
|
||||
<if test='tenantId != null'>AND tenant_id=#{tenantId}</if>
|
||||
ORDER BY created_at,id</script>
|
||||
""")
|
||||
List<DocumentRow> documents(@Param("containerId") long containerId,@Param("tenantId") Long tenantId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_customer_document WHERE customs_container_id=#{containerId} AND status='ACTIVE' ORDER BY id FOR UPDATE")
|
||||
List<DocumentRow> lockActiveDocuments(@Param("containerId") long containerId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_customer_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
|
||||
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_at=CURRENT_TIMESTAMP(3),
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
|
||||
""")
|
||||
int replaceDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
|
||||
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_customer_document SET updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
|
||||
""")
|
||||
int touchScope(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_customer_document SET status='DELETED',deleted_by=#{operatorId},deleted_at=CURRENT_TIMESTAMP(3),
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
|
||||
""")
|
||||
int deleteDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
|
||||
@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("""
|
||||
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
|
||||
FROM fl_customs_customer_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
|
||||
""")
|
||||
List<ScopeRow> scopes(@Param("documentId") long documentId);
|
||||
|
||||
@Delete("DELETE FROM fl_customs_customer_document_inbound WHERE document_id=#{documentId}")
|
||||
int deleteScopes(@Param("documentId") long documentId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_customer_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
|
||||
VALUES(#{documentId},#{inboundId},#{tenantId},#{orderId},#{inboundCode},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertScope(@Param("documentId") long documentId,@Param("inboundId") long inboundId,
|
||||
@Param("tenantId") long tenantId,@Param("orderId") long orderId,@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_customer_document_version
|
||||
(id,document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,scope_snapshot_json,
|
||||
action,operator_id,created_at)
|
||||
VALUES(#{id},#{documentId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
|
||||
CAST(#{scopeJson} AS JSON),#{action},#{operatorId},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertDocumentVersion(@Param("id") long id,@Param("documentId") long documentId,@Param("version") int version,
|
||||
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||
@Param("scopeJson") String scopeJson,@Param("action") String action,@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT i.id FROM fl_loading_plan_item item
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
|
||||
) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id
|
||||
LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE'
|
||||
WHERE d.id IS NULL
|
||||
""")
|
||||
int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId);
|
||||
|
||||
@Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " +
|
||||
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
|
||||
int freezeDocuments(@Param("containerId") long containerId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_frozen_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
|
||||
SELECT s.document_id,s.inbound_id,s.tenant_id,s.order_id,s.inbound_code,CURRENT_TIMESTAMP(3)
|
||||
FROM fl_customs_customer_document_inbound s
|
||||
JOIN fl_customs_customer_document d ON d.id=s.document_id
|
||||
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
|
||||
""")
|
||||
int freezeScopes(@Param("containerId") long containerId);
|
||||
|
||||
@Select("""
|
||||
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
|
||||
FROM fl_customs_frozen_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
|
||||
""")
|
||||
List<ScopeRow> frozenScopes(@Param("documentId") long documentId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId}")
|
||||
CompletionRow completion(@Param("documentId") long documentId);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId} FOR UPDATE")
|
||||
CompletionRow lockCompletion(@Param("documentId") long documentId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_completion_document
|
||||
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
|
||||
uploaded_at,version,created_at,updated_at)
|
||||
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
|
||||
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
|
||||
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
|
||||
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
|
||||
uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version}
|
||||
AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id
|
||||
WHERE d.id=source_document_id AND c.status='DECLARING')
|
||||
""")
|
||||
int replaceCompletion(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId,
|
||||
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_completion_document_version
|
||||
(id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,
|
||||
operator_id,operator_name,created_at)
|
||||
VALUES(#{id},#{completionId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
|
||||
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertCompletionVersion(@Param("id") long id,@Param("completionId") long completionId,@Param("version") int version,
|
||||
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL
|
||||
""")
|
||||
int missingCompletionCount(@Param("containerId") long containerId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_container_history
|
||||
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
|
||||
VALUES(#{id},#{containerId},#{action},#{fromStatus},#{toStatus},#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertHistory(@Param("id") long id,@Param("containerId") long containerId,@Param("action") String action,
|
||||
@Param("fromStatus") String fromStatus,@Param("toStatus") String toStatus,@Param("remark") String remark,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_customs_command
|
||||
(id,actor_id,action,idempotency_key,request_hash,status,created_at)
|
||||
VALUES(#{id},#{actorId},#{action},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int claimCommand(@Param("id") long id,@Param("actorId") long actorId,@Param("action") String action,
|
||||
@Param("key") String key,@Param("hash") String hash);
|
||||
|
||||
@Select("SELECT * FROM fl_customs_command WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key}")
|
||||
CommandRow command(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_command SET status='SUCCESS',result_id=#{resultId},completed_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key} AND status='PROCESSING'
|
||||
""")
|
||||
int completeCommand(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key,
|
||||
@Param("resultId") long resultId);
|
||||
|
||||
@Select("""
|
||||
SELECT c.container_booking_id FROM fl_customs_container c
|
||||
WHERE c.status='COMPLETED' AND NOT EXISTS(
|
||||
SELECT 1 FROM fl_loading_plan_item item
|
||||
LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id
|
||||
AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id
|
||||
WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0
|
||||
GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END)
|
||||
) ORDER BY c.completed_at,c.id LIMIT #{limit}
|
||||
""")
|
||||
List<Long> pendingAutoDispatch(@Param("limit") int limit);
|
||||
|
||||
@Select("""
|
||||
SELECT item.id itemId,p.plan_no planNo,b.cabinet_no cabinetNo,b.container_no containerNo,
|
||||
item.customer_name customerName,item.order_no orderNo,item.inbound_code inboundCode,item.sku_code skuCode,
|
||||
item.product_name_zh productNameZh,item.product_name_en productNameEn,item.package_count packageCount,
|
||||
item.product_quantity productQuantity,item.gross_weight_kg grossWeightKg,item.net_weight_kg netWeightKg,
|
||||
item.volume_cbm volumeCbm,item.shipping_mark shippingMark,item.customer_remark customerRemark
|
||||
FROM fl_loading_plan_item item
|
||||
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
|
||||
AND item.id>#{cursor} AND item.id<=#{maximumId}
|
||||
ORDER BY item.id LIMIT #{limit}
|
||||
""")
|
||||
List<BuyExportRow> buyRows(@Param("planId") long planId,@Param("cursor") long cursor,
|
||||
@Param("maximumId") long maximumId,@Param("limit") int limit);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id planId,p.plan_status planStatus,b.warehouse_id warehouseId
|
||||
FROM fl_loading_plan p JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
WHERE p.id=#{planId}
|
||||
""")
|
||||
PlanExportScope planExportScope(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(MAX(item.id),0) FROM fl_loading_plan_item item
|
||||
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
|
||||
""")
|
||||
long maximumBuyItemId(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_loading_plan_item item
|
||||
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId}
|
||||
""")
|
||||
long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId);
|
||||
|
||||
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
|
||||
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
|
||||
Long tenantId,String customerName,String taxMode) {}
|
||||
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
|
||||
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
|
||||
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
|
||||
record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash,
|
||||
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
|
||||
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
|
||||
record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {}
|
||||
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
|
||||
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
|
||||
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
|
||||
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
|
||||
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
|
||||
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
|
||||
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
|
||||
String shippingMark,String customerRemark) {}
|
||||
record PlanExportScope(Long planId,String planStatus,Long warehouseId) {}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package com.baiyue.firstleg.customs.container.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.customs.client.CustomsFileClient;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ContainerCustomsService {
|
||||
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
|
||||
public static final String READY = "READY_TO_START";
|
||||
public static final String DECLARING = "DECLARING";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
|
||||
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
|
||||
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
|
||||
|
||||
@Resource private ContainerCustomsMapper mapper;
|
||||
@Resource private CustomsFileClient files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private ApplicationEventPublisher events;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public ContainerView platformView(long planId) {
|
||||
ContainerRow container = ensure(planId);
|
||||
return view(container, null, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerView merchantOrderView(long tenantId, long orderId) {
|
||||
Long planId = mapper.completedPlanForOrder(tenantId, orderId);
|
||||
if (planId == null) throw notFound("订单尚未完成装柜");
|
||||
ContainerRow container = ensure(planId);
|
||||
return view(container, tenantId, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView uploadSource(long tenantId, long planId, long operatorId, String key,
|
||||
List<Long> inboundIds, MultipartFile file) {
|
||||
ensureMutable(planId);
|
||||
ContainerRow container = lockEditableContainerByPlan(planId);
|
||||
List<InboundRow> scope = validateScope(planId, tenantId, inboundIds);
|
||||
validateFile(file);
|
||||
long documentId = IdWorker.getId();
|
||||
String action = "UPLOAD_SOURCE";
|
||||
String requestHash = hash(action, planId, scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return documentView(requiredDocument(replay.resultId()), false);
|
||||
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(planId), operatorId, file);
|
||||
mapper.insertDocument(documentId, container.id(), tenantId, metadata.fileId(), metadata.fileName(),
|
||||
metadata.sha256(), metadata.contentType(), size(metadata), operatorId);
|
||||
replaceScopes(documentId, tenantId, scope);
|
||||
mapper.insertDocumentVersion(IdWorker.getId(), documentId, 1, metadata.fileId(), metadata.fileName(),
|
||||
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "UPLOAD", operatorId);
|
||||
refresh(container.loadingPlanId());
|
||||
completeCommand(operatorId, action, key, documentId);
|
||||
return documentView(requiredDocument(documentId), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView replaceSource(long tenantId, long documentId, long operatorId, String key,
|
||||
ScopeRequest request, MultipartFile file) {
|
||||
validateFile(file);
|
||||
DocumentRow readable = requiredDocument(documentId);
|
||||
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
|
||||
ContainerRow container = lockEditableContainer(readable.customsContainerId());
|
||||
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
|
||||
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
|
||||
String action = "REPLACE_SOURCE_" + documentId;
|
||||
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return documentView(requiredDocument(documentId), false);
|
||||
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
if (mapper.replaceDocument(documentId, tenantId, request.expectedVersion(), metadata.fileId(), metadata.fileName(),
|
||||
metadata.sha256(), metadata.contentType(), size(metadata), operatorId) != 1) throw concurrent();
|
||||
replaceScopes(documentId, tenantId, scope);
|
||||
int version = request.expectedVersion() + 1;
|
||||
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, metadata.fileId(), metadata.fileName(),
|
||||
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "REPLACE", operatorId);
|
||||
refresh(container.loadingPlanId());
|
||||
completeCommand(operatorId, action, key, documentId);
|
||||
return documentView(requiredDocument(documentId), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView updateScope(long tenantId, long documentId, long operatorId, String key, ScopeRequest request) {
|
||||
DocumentRow readable = requiredDocument(documentId);
|
||||
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
|
||||
ContainerRow container = lockEditableContainer(readable.customsContainerId());
|
||||
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
|
||||
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
|
||||
String action = "UPDATE_SOURCE_SCOPE_" + documentId;
|
||||
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return documentView(requiredDocument(documentId), false);
|
||||
if (mapper.touchScope(documentId, tenantId, request.expectedVersion()) != 1) throw concurrent();
|
||||
replaceScopes(documentId, tenantId, scope);
|
||||
int version = request.expectedVersion() + 1;
|
||||
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, current.fileId(), current.fileName(),
|
||||
current.fileHash(), current.contentType(), current.sizeBytes(), scopeJson(scope), "SCOPE_CHANGE", operatorId);
|
||||
refresh(container.loadingPlanId());
|
||||
completeCommand(operatorId, action, key, documentId);
|
||||
return documentView(requiredDocument(documentId), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerView deleteSource(long tenantId, long documentId, long operatorId, String key, int expectedVersion) {
|
||||
DocumentRow readable = requiredDocument(documentId);
|
||||
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
|
||||
ContainerRow container = lockEditableContainer(readable.customsContainerId());
|
||||
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
|
||||
String action = "DELETE_SOURCE_" + documentId;
|
||||
String requestHash = hash(action, expectedVersion);
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return view(mapper.container(container.loadingPlanId()), tenantId, true);
|
||||
List<InboundRow> deletedScope=validateScope(container.loadingPlanId(),tenantId,mapper.scopes(documentId).stream().map(ScopeRow::inboundId).toList());
|
||||
if (mapper.deleteDocument(documentId, tenantId, expectedVersion, operatorId) != 1) throw concurrent();
|
||||
mapper.insertDocumentVersion(IdWorker.getId(),documentId,expectedVersion+1,current.fileId(),current.fileName(),
|
||||
current.fileHash(),current.contentType(),current.sizeBytes(),scopeJson(deletedScope),"DELETE",operatorId);
|
||||
mapper.deleteScopes(documentId);
|
||||
history(container, "DELETE_CUSTOMER_DOCUMENT", container.status(), container.status(),
|
||||
"documentId=" + documentId, operatorId, null);
|
||||
refresh(container.loadingPlanId());
|
||||
completeCommand(operatorId, action, key, container.id());
|
||||
return view(mapper.container(container.loadingPlanId()), tenantId, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerView start(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
ContainerRow current = ensure(planId);
|
||||
String action = "START_CONTAINER_CUSTOMS_" + planId;
|
||||
if(!request.confirm())throw bad("请确认开始整柜报关");
|
||||
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return platformView(planId);
|
||||
ContainerRow locked = mapper.lockContainer(planId);
|
||||
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
|
||||
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
|
||||
mapper.lockActiveDocuments(locked.id());
|
||||
if (mapper.missingRefundCount(planId, locked.id()) != 0) throw conflict("仍有退税入仓码未上传报送资料");
|
||||
mapper.freezeScopes(locked.id());
|
||||
mapper.freezeDocuments(locked.id());
|
||||
if (mapper.start(locked.id(), locked.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
history(locked, "START_CUSTOMS", locked.status(), DECLARING, request.remark(), operatorId, operatorName);
|
||||
completeCommand(operatorId, action, key, locked.id());
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, boolean finalConfirmation, MultipartFile file) {
|
||||
validateFile(file);
|
||||
DocumentRow readable=requiredDocument(documentId);
|
||||
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
|
||||
DocumentRow source = requiredDocumentLocked(documentId, null);
|
||||
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
CompletionRow completion = mapper.lockCompletion(documentId);
|
||||
int missingBefore=mapper.missingCompletionCount(container.id());
|
||||
boolean lastNewCompletion=completion==null&&missingBefore==1;
|
||||
if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传");
|
||||
if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成");
|
||||
String action = "UPLOAD_COMPLETION_" + documentId;
|
||||
String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file));
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return documentView(requiredDocument(documentId), false);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long completionId;
|
||||
int version;
|
||||
if (completion == null) {
|
||||
if (expectedVersion != 0) throw concurrent();
|
||||
completionId = IdWorker.getId(); version = 1;
|
||||
mapper.insertCompletion(completionId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(),
|
||||
metadata.contentType(), size(metadata), operatorId, operatorName);
|
||||
} else {
|
||||
completionId = completion.id(); version = completion.version() + 1;
|
||||
if (mapper.replaceCompletion(completion.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(),
|
||||
metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(),
|
||||
metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
|
||||
if (lastNewCompletion) {
|
||||
completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件");
|
||||
}
|
||||
completeCommand(operatorId, action, key, completionId);
|
||||
return documentView(requiredDocument(documentId), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
|
||||
if(!request.confirm())throw bad("请二次确认全买单柜报关完成");
|
||||
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return platformView(planId);
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
List<InboundRow> actual=planRows(planId);
|
||||
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
|
||||
completeContainer(container, operatorId, operatorName, request.remark());
|
||||
completeCommand(operatorId, action, key, container.id());
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
public Download downloadSource(long tenantId, long documentId, boolean platform) {
|
||||
DocumentRow source = requiredDocument(documentId);
|
||||
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
|
||||
ContainerRow container=mapper.containerById(source.customsContainerId());
|
||||
return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName());
|
||||
}
|
||||
|
||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||
DocumentRow source = requiredDocument(documentId);
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
|
||||
throw notFound("报关完成文件不存在");
|
||||
}
|
||||
CompletionRow completion = mapper.completion(documentId);
|
||||
if (completion == null) throw notFound("报关完成文件不存在");
|
||||
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||
}
|
||||
|
||||
private ContainerRow ensure(long planId) {
|
||||
List<InboundRow> rows = planRows(planId);
|
||||
InboundRow first = rows.get(0);
|
||||
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
|
||||
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
|
||||
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
|
||||
return refresh(planId);
|
||||
}
|
||||
|
||||
private ContainerRow ensureMutable(long planId) {
|
||||
ContainerRow value = ensure(planId);
|
||||
if (!Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
|
||||
return value;
|
||||
}
|
||||
|
||||
private ContainerRow refresh(long planId) {
|
||||
ContainerRow value = mapper.container(planId);
|
||||
if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value;
|
||||
String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING;
|
||||
if (!status.equals(value.status())) {
|
||||
if (mapper.refreshStatus(value.id(), value.version(), status) != 1) throw concurrent();
|
||||
value = mapper.container(planId);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private ContainerRow lockEditableContainer(long containerId) {
|
||||
ContainerRow value = mapper.lockContainerById(containerId);
|
||||
if (value == null || !Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
|
||||
return value;
|
||||
}
|
||||
|
||||
private ContainerRow lockEditableContainerByPlan(long planId) {
|
||||
ContainerRow value=mapper.lockContainer(planId);
|
||||
if(value==null||!Set.of(WAITING,READY).contains(value.status()))throw conflict("整柜已开始报关,客户资料已锁定");
|
||||
return value;
|
||||
}
|
||||
|
||||
private List<InboundRow> planRows(long planId) {
|
||||
List<InboundRow> rows = mapper.planInbounds(planId);
|
||||
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
|
||||
Set<Long> requested = new LinkedHashSet<>(inboundIds);
|
||||
List<InboundRow> scope = planRows(planId).stream()
|
||||
.filter(row -> requested.contains(row.inboundId()) && row.tenantId() == tenantId && "TAX_REFUND".equals(row.taxMode()))
|
||||
.toList();
|
||||
if (scope.size() != requested.size()) throw bad("只能选择当前柜内属于本客户的退税入仓码");
|
||||
return scope;
|
||||
}
|
||||
|
||||
private void replaceScopes(long documentId, long tenantId, List<InboundRow> scope) {
|
||||
mapper.deleteScopes(documentId);
|
||||
for (InboundRow row : scope) mapper.insertScope(documentId, row.inboundId(), tenantId, row.orderId(), row.inboundCode());
|
||||
}
|
||||
|
||||
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
|
||||
List<InboundRow> rows = planRows(container.loadingPlanId());
|
||||
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
|
||||
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
|
||||
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
|
||||
List<InboundView> inbounds = visibleRows.stream()
|
||||
.map(row -> new InboundView(row.inboundId(), row.orderId(), row.orderNo(), row.inboundCode(), row.tenantId(),
|
||||
row.customerName(), row.taxMode(), covered.contains(row.inboundId()))).toList();
|
||||
List<DocumentView> documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList();
|
||||
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
|
||||
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
|
||||
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
|
||||
InboundRow first = rows.get(0);
|
||||
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
|
||||
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
|
||||
inbounds, documentViews, container.startedAt(), container.completedAt());
|
||||
}
|
||||
|
||||
private DocumentView documentView(DocumentRow source, boolean merchant) {
|
||||
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
|
||||
CompletionRow completion = mapper.completion(source.id());
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion);
|
||||
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
|
||||
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result);
|
||||
}
|
||||
|
||||
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
|
||||
if (mapper.complete(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
history(container, "COMPLETE_CUSTOMS", container.status(), COMPLETED, remark, operatorId, operatorName);
|
||||
events.publishEvent(new CustomsCompletedEvent(container.containerBookingId()));
|
||||
}
|
||||
|
||||
private void history(ContainerRow container,String action,String from,String to,String remark,long operatorId,String operatorName) {
|
||||
mapper.insertHistory(IdWorker.getId(), container.id(), action, from, to, trim(remark), operatorId, trim(operatorName));
|
||||
}
|
||||
|
||||
private CommandRow claim(long actorId, String action, String key, String requestHash) {
|
||||
validateKey(key);
|
||||
if (mapper.claimCommand(IdWorker.getId(), actorId, action, key, requestHash) == 1) return null;
|
||||
CommandRow existing = mapper.command(actorId, action, key);
|
||||
if (existing == null || !Objects.equals(existing.requestHash(), requestHash)) throw conflict("Idempotency-Key已用于不同请求");
|
||||
if (!"SUCCESS".equals(existing.status())) throw conflict("相同请求正在处理中");
|
||||
return existing;
|
||||
}
|
||||
|
||||
private void completeCommand(long actorId,String action,String key,long resultId) {
|
||||
if (mapper.completeCommand(actorId, action, key, resultId) != 1) throw concurrent();
|
||||
}
|
||||
|
||||
private CustomsFileClient.Metadata upload(long tenantId,String type,String businessId,long operatorId,MultipartFile file) {
|
||||
var response = files.upload(internalToken, operatorId, tenantId, type, businessId, file);
|
||||
if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) {
|
||||
throw conflict("File服务上传失败");
|
||||
}
|
||||
CustomsFileClient.Metadata metadata = response.data();
|
||||
if (metadata.tenantId() == null || metadata.tenantId() != tenantId || !type.equals(metadata.businessType())
|
||||
|| !businessId.equals(metadata.businessId())) throw conflict("File服务返回的文件归属不一致");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Download download(long tenantId,String type,String businessId,long fileId,String fileName) {
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, type, businessId, fileId);
|
||||
if (response == null || response.getBody() == null) throw notFound("文件不存在");
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
return new Download(fileName, contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString(), response.getBody());
|
||||
}
|
||||
|
||||
private DocumentRow requiredDocument(long id) {
|
||||
DocumentRow value = mapper.document(id);
|
||||
if (value == null || !"ACTIVE".equals(value.status())) throw notFound("客户报送资料不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private DocumentRow requiredDocumentLocked(long id, Long tenantId) {
|
||||
DocumentRow value = mapper.lockDocument(id);
|
||||
if (value == null || !"ACTIVE".equals(value.status()) || (tenantId != null && !tenantId.equals(value.tenantId()))) {
|
||||
throw notFound("客户报送资料不存在");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String scopeJson(List<InboundRow> scope) {
|
||||
try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(),
|
||||
"orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); }
|
||||
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
||||
}
|
||||
|
||||
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
||||
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
||||
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
|
||||
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
|
||||
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
|
||||
private static String hash(Object... values) { try { MessageDigest digest=MessageDigest.getInstance("SHA-256"); for(Object value:values) digest.update((Objects.toString(value,"")+"\u001f").getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } catch(Exception ex){ throw new IllegalStateException(ex); } }
|
||||
private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String merchantStatus(String containerStatus,int refund,int covered){if(COMPLETED.equals(containerStatus))return "CUSTOMS_COMPLETED";if(DECLARING.equals(containerStatus))return "DECLARING";return refund>0&&refund==covered?"DOCUMENT_UPLOADED":"WAITING_UPLOAD";}
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
|
||||
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
|
||||
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND,message); }
|
||||
|
||||
public record CustomsCompletedEvent(long containerBookingId) {}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.firstleg.customs.container.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService.CustomsCompletedEvent;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchAutoConfirmService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
@Component
|
||||
public class CustomsAutoDispatchCoordinator {
|
||||
private static final Logger log=LoggerFactory.getLogger(CustomsAutoDispatchCoordinator.class);
|
||||
private static final int BATCH_SIZE=50;
|
||||
@Resource private ContainerCustomsMapper mapper;
|
||||
@Resource private OutboundDispatchAutoConfirmService outbound;
|
||||
|
||||
@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)
|
||||
public void afterCustomsCompleted(CustomsCompletedEvent event) {
|
||||
tryConfirm(event.containerBookingId());
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString="${baiyue.customs.auto-dispatch.fixed-delay-ms:60000}")
|
||||
public void retryPending() {
|
||||
for(Long containerBookingId:mapper.pendingAutoDispatch(BATCH_SIZE)) tryConfirm(containerBookingId);
|
||||
}
|
||||
|
||||
private void tryConfirm(long containerBookingId) {
|
||||
try {
|
||||
outbound.confirmIfReady(containerBookingId);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("整柜报关完成后的自动离仓暂未收敛,稍后重试: containerBookingId={}",containerBookingId,ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.firstleg.customs.container.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.BuyExportRow;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class LoadingListBuyDeclarationExportService {
|
||||
public static final String TYPE="LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
private static final int MAX_BATCH=500;
|
||||
private static final List<ExportColumn> COLUMNS=List.of(
|
||||
c("customerName","客户名称",ExportColumnType.STRING,24),c("orderNo","订单号",ExportColumnType.STRING,20),
|
||||
c("inboundCode","入仓码",ExportColumnType.STRING,22),c("sku","SKU",ExportColumnType.STRING,18),
|
||||
c("chineseName","中文品名",ExportColumnType.STRING,24),c("englishName","英文品名",ExportColumnType.STRING,24),
|
||||
c("cartonCount","箱数",ExportColumnType.INTEGER,12),c("productQuantity","产品数量",ExportColumnType.INTEGER,14),
|
||||
c("grossWeight","毛重",ExportColumnType.DECIMAL,14),c("netWeight","净重",ExportColumnType.DECIMAL,14),
|
||||
c("volume","体积",ExportColumnType.DECIMAL,14),c("mark","唛头",ExportColumnType.STRING,20),
|
||||
c("customerRemark","客户备注",ExportColumnType.STRING,30));
|
||||
private static final String SCHEMA_HASH=schemaHash();
|
||||
@Resource private ContainerCustomsMapper mapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String token;
|
||||
private byte[] signingKey;
|
||||
@PostConstruct public void init(){signingKey=token.getBytes(StandardCharsets.UTF_8);}
|
||||
|
||||
public ExportBatchResponse batch(ExportBatchRequest request){
|
||||
if(request==null||!"0".equals(request.tenantId())||!"PLATFORM".equals(request.appCode())) throw invalid("仅支持平台导出");
|
||||
if(request.limit()<1||request.limit()>MAX_BATCH) throw invalid("limit必须为1到500");
|
||||
long planId=positive(parameter(request,"loadingPlanId"),"loadingPlanId");
|
||||
var scope=mapper.planExportScope(planId);
|
||||
if(scope==null||!"COMPLETED".equals(scope.planStatus())) throw invalid("只能导出已完成装柜的装柜清单");
|
||||
boolean first=request.snapshotToken()==null||request.snapshotToken().isBlank();
|
||||
long maximumId=first?mapper.maximumBuyItemId(planId):verify(request,planId);
|
||||
String snapshot=first?sign(request,planId,maximumId):request.snapshotToken();
|
||||
long cursor=request.cursor()==null||request.cursor().isBlank()?0:positiveOrZero(request.cursor(),"cursor");
|
||||
List<BuyExportRow> source=mapper.buyRows(planId,cursor,maximumId,request.limit()+1);
|
||||
boolean more=source.size()>request.limit();
|
||||
List<BuyExportRow> page=more?source.subList(0,request.limit()):source;
|
||||
String next=page.isEmpty()?String.valueOf(cursor):String.valueOf(page.get(page.size()-1).itemId());
|
||||
return new ExportBatchResponse(1,SCHEMA_HASH,snapshot,first?COLUMNS:List.of(),page.stream().map(this::row).toList(),
|
||||
next,more,first?mapper.countBuyRows(planId,maximumId):null);
|
||||
}
|
||||
|
||||
private Map<String,Object> row(BuyExportRow value){Map<String,Object> row=new LinkedHashMap<>();
|
||||
row.put("customerName",value.customerName());row.put("orderNo",value.orderNo());row.put("inboundCode",value.inboundCode());
|
||||
row.put("sku",value.skuCode());row.put("chineseName",value.productNameZh());row.put("englishName",value.productNameEn());
|
||||
row.put("cartonCount",value.packageCount());row.put("productQuantity",value.productQuantity());row.put("grossWeight",value.grossWeightKg());
|
||||
row.put("netWeight",value.netWeightKg());row.put("volume",value.volumeCbm());row.put("mark",value.shippingMark());
|
||||
row.put("customerRemark",value.customerRemark());return row;}
|
||||
private String sign(ExportBatchRequest r,long p,long m){return "v1."+m+"."+Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(subject(r,p,m)));}
|
||||
private long verify(ExportBatchRequest r,long p){String[] a=r.snapshotToken().split("\\.",-1);if(a.length!=3||!"v1".equals(a[0]))throw invalid("snapshotToken无效");
|
||||
try{long m=Long.parseLong(a[1]);if(!MessageDigest.isEqual(hmac(subject(r,p,m)),Base64.getUrlDecoder().decode(a[2])))throw invalid("snapshotToken不匹配");return m;}
|
||||
catch(IllegalArgumentException ex){throw invalid("snapshotToken无效");}}
|
||||
private String subject(ExportBatchRequest r,long p,long m){return String.join("\n",TYPE,r.taskId(),r.tenantId(),r.appCode(),r.requestedBy(),String.valueOf(r.dispatchVersion()),String.valueOf(p),String.valueOf(m));}
|
||||
private byte[] hmac(String s){try{Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(signingKey,"HmacSHA256"));return mac.doFinal(s.getBytes(StandardCharsets.UTF_8));}catch(Exception ex){throw new IllegalStateException(ex);}}
|
||||
private static Object parameter(ExportBatchRequest r,String k){if(r.parameters()==null||!r.parameters().keySet().equals(Set.of("loadingPlanId")))throw invalid("导出参数必须且只能包含loadingPlanId");return r.parameters().get(k);}
|
||||
private static long positive(Object v,String f){long n=v instanceof Number x?x.longValue():positiveOrZero(Objects.toString(v,""),f);if(n<=0)throw invalid(f+"必须为正整数");return n;}
|
||||
private static long positiveOrZero(String v,String f){try{long n=Long.parseLong(v);if(n<0)throw invalid(f+"不能为负数");return n;}catch(NumberFormatException ex){throw invalid(f+"必须为整数");}}
|
||||
private static ExportColumn c(String k,String t,ExportColumnType y,int w){return new ExportColumn(k,t,y,w);}
|
||||
private static String schemaHash(){try{MessageDigest d=MessageDigest.getInstance("SHA-256");for(ExportColumn c:COLUMNS)d.update((c.key()+"|"+c.title()+"|"+c.type()+"|"+c.width()+"\n").getBytes(StandardCharsets.UTF_8));return "sha256:"+HexFormat.of().formatHex(d.digest());}catch(Exception ex){throw new ExceptionInInitializerError(ex);}}
|
||||
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
|
||||
import com.baiyue.firstleg.customs.service.CustomsService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Tag(name = "客户端报关资料与回执")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/customs")
|
||||
public class MerchantCustomsController {
|
||||
@Resource private CustomsService service;
|
||||
|
||||
@GetMapping("/orders/{orderId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ApiResponse<CaseDetail> byOrder(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long orderId) {
|
||||
return ApiResponse.success(service.merchantOrder(FirstlegScopeGuard.merchant(user), orderId));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId) {
|
||||
return ApiResponse.success(service.merchantDetail(FirstlegScopeGuard.merchant(user), caseId));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<CustomsDocumentEntity> upload(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @NotBlank String itemCode,
|
||||
@RequestParam @Min(0) int expectedVersion,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
long tenantId = FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.uploadDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key,
|
||||
new DocumentUploadData(itemCode, expectedVersion), file));
|
||||
}
|
||||
|
||||
@PostMapping("/cases/{caseId}/documents")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<CustomsDocumentEntity> reference(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentReferenceRequest request) {
|
||||
long tenantId = FirstlegScopeGuard.merchant(user);
|
||||
return ApiResponse.success(service.referenceDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@PathVariable @Positive long documentId) {
|
||||
return response(service.downloadDocument(FirstlegScopeGuard.merchant(user), caseId, documentId, true));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@PathVariable @Positive long receiptId) {
|
||||
return response(service.downloadReceipt(FirstlegScopeGuard.merchant(user), caseId, receiptId, true));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> response(Download value) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
|
||||
.contentLength(value.content().length).body(value.content());
|
||||
}
|
||||
|
||||
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
|
||||
}
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.*;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.entity.*;
|
||||
import com.baiyue.firstleg.customs.service.CustomsService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Tag(name = "三期报关协同")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/customs")
|
||||
public class PlatformCustomsController {
|
||||
@Resource private CustomsService service;
|
||||
|
||||
@GetMapping("/cases")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ApiResponse<PageResult<CustomsCaseEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
|
||||
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.platformDetail(caseId));
|
||||
}
|
||||
|
||||
@PostMapping("/checklist-versions")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event = "CUSTOMS_CHECKLIST_CREATE", resourceType = "CUSTOMS_CHECKLIST", captureBefore = false)
|
||||
public ApiResponse<ChecklistView> createChecklist(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody CreateChecklistRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.createChecklist(user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/cases")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event = "CUSTOMS_CASE_CREATE", resourceType = "CUSTOMS_CASE", captureBefore = false)
|
||||
public ApiResponse<CaseDetail> createCase(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody CreateCaseRequest request) {
|
||||
FirstlegScopeGuard.platform(user, request.customerId());
|
||||
return ApiResponse.success(service.createCase(user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
public ApiResponse<CustomsDocumentEntity> uploadDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @NotBlank String itemCode,
|
||||
@RequestParam @Min(0) int expectedVersion,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.uploadDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key,
|
||||
new DocumentUploadData(itemCode, expectedVersion), file));
|
||||
}
|
||||
|
||||
@PostMapping("/cases/{caseId}/documents")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
public ApiResponse<CustomsDocumentEntity> referenceDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentReferenceRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.referenceDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/cases/{caseId}/documents/{documentId}/review")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:review')")
|
||||
@BusinessAudit(event = "CUSTOMS_DOCUMENT_REVIEW", resourceType = "CUSTOMS_DOCUMENT")
|
||||
public ApiResponse<CaseDetail> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ReviewRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.review(caseId, documentId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/cases/{caseId}/submission-batches")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:submit')")
|
||||
@BusinessAudit(event = "CUSTOMS_AGENT_SUBMIT", resourceType = "CUSTOMS_SUBMISSION_BATCH")
|
||||
public ApiResponse<CustomsSubmissionBatchEntity> submit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody SubmitBatchRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.submitBatch(caseId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/submission-batches/{batchId}/receipts/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
|
||||
public ApiResponse<CustomsReceiptEntity> uploadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long batchId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
|
||||
@RequestParam @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
|
||||
@RequestParam(required = false) @Size(max = 500) String note,
|
||||
@RequestParam @Min(0) int expectedVersion,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.uploadReceipt(batchId, user.userId(), user.username(), key,
|
||||
new ReceiptUploadData(receiptType, visibility, note, expectedVersion), file));
|
||||
}
|
||||
|
||||
@PostMapping("/submission-batches/{batchId}/receipts")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
|
||||
public ApiResponse<CustomsReceiptEntity> referenceReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long batchId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ReceiptReferenceRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.referenceReceipt(batchId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@PathVariable @Positive long documentId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return response(service.downloadDocument(0, caseId, documentId, false));
|
||||
}
|
||||
|
||||
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
|
||||
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long caseId,
|
||||
@PathVariable @Positive long receiptId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return response(service.downloadReceipt(0, caseId, receiptId, false));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> response(Download value) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
|
||||
.contentLength(value.content().length).body(value.content());
|
||||
}
|
||||
|
||||
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.dto;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.*;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class CustomsDtos {
|
||||
private CustomsDtos() {}
|
||||
|
||||
public record ChecklistItem(@NotBlank @Size(max = 40) String code,
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
boolean required) {}
|
||||
|
||||
public record CreateChecklistRequest(@NotBlank @Size(max = 40) String checklistCode,
|
||||
@NotBlank @Size(max = 32) String versionNo,
|
||||
@NotBlank @Size(max = 32) String countryCode,
|
||||
@NotBlank @Size(max = 40) String channelCode,
|
||||
@NotBlank @Size(max = 40) String cargoType,
|
||||
@NotEmpty List<@Valid ChecklistItem> items) {}
|
||||
|
||||
public record ChecklistView(CustomsChecklistVersionEntity version, List<ChecklistItem> items) {}
|
||||
|
||||
public record CreateCaseRequest(@Positive long customerId,
|
||||
@Positive long orderId,
|
||||
@Positive long inboundId,
|
||||
@Positive Long containerBookingId,
|
||||
@Positive long checklistVersionId,
|
||||
@Size(max = 500) String remark,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record Query(Long customerId, Long orderId, Long inboundId, Long containerBookingId, String status,
|
||||
@Min(1) int page, @Min(1) @Max(200) int size) {
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
|
||||
public record DocumentReferenceRequest(@NotBlank @Size(max = 40) String itemCode,
|
||||
@Positive long fileId,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record DocumentUploadData(@NotBlank @Size(max = 40) String itemCode,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|RETURN") String decision,
|
||||
@Size(max = 500) String reason,
|
||||
LocalDateTime supplementDueAt,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record SubmitBatchRequest(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId,
|
||||
@Size(max = 128) String customsAgentName,
|
||||
@Size(max = 128) String externalReference,
|
||||
@Size(max = 500) String note,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record ReceiptReferenceRequest(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
|
||||
@Positive long fileId,
|
||||
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
|
||||
@Size(max = 500) String note,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record ReceiptUploadData(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
|
||||
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
|
||||
@Size(max = 500) String note,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
public record CaseDetail(CustomsCaseEntity customsCase,
|
||||
ChecklistView checklist,
|
||||
List<CustomsDocumentEntity> documents,
|
||||
List<CustomsSupplementRequestEntity> supplements,
|
||||
List<CustomsSubmissionBatchEntity> submissionBatches,
|
||||
List<CustomsReceiptEntity> receipts,
|
||||
List<CustomsHistoryEntity> history) {}
|
||||
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_case")
|
||||
public class CustomsCaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customsNo;
|
||||
private Long tenantId;
|
||||
private Long orderId;
|
||||
private Long inboundId;
|
||||
private Long containerBookingId;
|
||||
private Long checklistVersionId;
|
||||
private String checklistCode;
|
||||
private String checklistVersionNo;
|
||||
private String status;
|
||||
private String remark;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
private Long updatedBy;
|
||||
private String updatedByName;
|
||||
private LocalDateTime updatedAt;
|
||||
@Version private Integer version;
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_checklist_version")
|
||||
public class CustomsChecklistVersionEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String checklistCode;
|
||||
private String versionNo;
|
||||
private String countryCode;
|
||||
private String channelCode;
|
||||
private String cargoType;
|
||||
private String itemsJson;
|
||||
private String status;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_document")
|
||||
public class CustomsDocumentEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long customsCaseId;
|
||||
private String checklistItemCode;
|
||||
private Long fileId;
|
||||
private Integer documentVersion;
|
||||
private Long replacesDocumentId;
|
||||
private String fileName;
|
||||
private String fileHash;
|
||||
private String status;
|
||||
private String visibility;
|
||||
private Long uploadedBy;
|
||||
private String uploadedByType;
|
||||
private LocalDateTime uploadedAt;
|
||||
private Long reviewedBy;
|
||||
private String reviewedByName;
|
||||
private LocalDateTime reviewedAt;
|
||||
private String reviewReason;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_history")
|
||||
public class CustomsHistoryEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long customsCaseId;
|
||||
private String action;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String resourceType;
|
||||
private Long resourceId;
|
||||
private String note;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private String operatorType;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_operation")
|
||||
public class CustomsOperationEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long operatorId;
|
||||
private String action;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultType;
|
||||
private Long resultId;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_receipt")
|
||||
public class CustomsReceiptEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long customsCaseId;
|
||||
private Long submissionBatchId;
|
||||
private String receiptType;
|
||||
private Long fileId;
|
||||
private Integer receiptVersion;
|
||||
private Long replacesReceiptId;
|
||||
private String fileName;
|
||||
private String fileHash;
|
||||
private String visibility;
|
||||
private String note;
|
||||
private Long uploadedBy;
|
||||
private String uploadedByName;
|
||||
private LocalDateTime uploadedAt;
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_submission_batch")
|
||||
public class CustomsSubmissionBatchEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long customsCaseId;
|
||||
private String batchNo;
|
||||
private Integer batchVersion;
|
||||
private Long customsAgentId;
|
||||
private String customsAgentName;
|
||||
private String externalReference;
|
||||
private String fileSnapshotJson;
|
||||
private String status;
|
||||
private String note;
|
||||
private Long submittedBy;
|
||||
private String submittedByName;
|
||||
private LocalDateTime submittedAt;
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter @TableName("fl_customs_supplement_request")
|
||||
public class CustomsSupplementRequestEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long customsCaseId;
|
||||
private Long sourceDocumentId;
|
||||
private Long resolvedDocumentId;
|
||||
private String checklistItemCode;
|
||||
private String reason;
|
||||
private LocalDateTime dueAt;
|
||||
private String status;
|
||||
private Long requestedBy;
|
||||
private String requestedByName;
|
||||
private LocalDateTime requestedAt;
|
||||
private Long resolvedBy;
|
||||
private LocalDateTime resolvedAt;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
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);
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsChecklistVersionEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsChecklistVersionMapper extends BaseMapper<CustomsChecklistVersionEntity> {}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsDocumentMapper extends BaseMapper<CustomsDocumentEntity> {}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsHistoryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsHistoryMapper extends BaseMapper<CustomsHistoryEntity> {}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsOperationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsOperationMapper extends BaseMapper<CustomsOperationEntity> {}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsReceiptEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsReceiptMapper extends BaseMapper<CustomsReceiptEntity> {}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsSubmissionBatchEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsSubmissionBatchMapper extends BaseMapper<CustomsSubmissionBatchEntity> {}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsSupplementRequestEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsSupplementRequestMapper extends BaseMapper<CustomsSupplementRequestEntity> {}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.customs.dto.CustomsDtos.Query;
|
||||
import com.baiyue.firstleg.customs.entity.*;
|
||||
import com.baiyue.firstleg.customs.mapper.*;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class CustomsRepository {
|
||||
@Resource private CustomsChecklistVersionMapper checklists;
|
||||
@Resource private CustomsCaseMapper cases;
|
||||
@Resource private CustomsDocumentMapper documents;
|
||||
@Resource private CustomsSupplementRequestMapper supplements;
|
||||
@Resource private CustomsSubmissionBatchMapper batches;
|
||||
@Resource private CustomsReceiptMapper receipts;
|
||||
@Resource private CustomsHistoryMapper histories;
|
||||
@Resource private CustomsOperationMapper operations;
|
||||
@Resource private InboundOrderMapper inboundOrders;
|
||||
@Resource private LoadingPlanMapper loadingPlans;
|
||||
@Resource private LoadingPlanItemMapper loadingPlanItems;
|
||||
|
||||
public void insert(CustomsChecklistVersionEntity value) { checklists.insert(value); }
|
||||
public CustomsChecklistVersionEntity checklist(long id) { return checklists.selectById(id); }
|
||||
public CustomsChecklistVersionEntity checklist(String code, String version) {
|
||||
return checklists.selectOne(new LambdaQueryWrapper<CustomsChecklistVersionEntity>()
|
||||
.eq(CustomsChecklistVersionEntity::getChecklistCode, code)
|
||||
.eq(CustomsChecklistVersionEntity::getVersionNo, version));
|
||||
}
|
||||
|
||||
public void insert(CustomsCaseEntity value) { cases.insert(value); }
|
||||
public CustomsCaseEntity customsCase(long id) { return cases.selectById(id); }
|
||||
public CustomsCaseEntity customsCase(long tenantId, long id) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getTenantId, tenantId));
|
||||
}
|
||||
public CustomsCaseEntity caseByOrder(long tenantId, long orderId) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public PageResult<CustomsCaseEntity> page(Query query) {
|
||||
var wrapper = new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(query.customerId() != null, CustomsCaseEntity::getTenantId, query.customerId())
|
||||
.eq(query.orderId() != null, CustomsCaseEntity::getOrderId, query.orderId())
|
||||
.eq(query.inboundId() != null, CustomsCaseEntity::getInboundId, query.inboundId())
|
||||
.eq(query.containerBookingId() != null, CustomsCaseEntity::getContainerBookingId, query.containerBookingId())
|
||||
.eq(query.status() != null && !query.status().isBlank(), CustomsCaseEntity::getStatus, query.status())
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).orderByDesc(CustomsCaseEntity::getId);
|
||||
return PageResult.from(cases.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
public int transition(long id, int expectedVersion, String status, long operatorId, String operatorName) {
|
||||
return cases.update(null, new LambdaUpdateWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getVersion, expectedVersion)
|
||||
.set(CustomsCaseEntity::getStatus, status).set(CustomsCaseEntity::getUpdatedBy, operatorId)
|
||||
.set(CustomsCaseEntity::getUpdatedByName, operatorName).set(CustomsCaseEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.set(CustomsCaseEntity::getVersion, expectedVersion + 1));
|
||||
}
|
||||
public boolean inboundBelongsTo(long tenantId, long orderId, long inboundId) {
|
||||
return inboundOrders.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.InboundOrderEntity>()
|
||||
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getOrderId, orderId)
|
||||
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getId, inboundId)) > 0;
|
||||
}
|
||||
|
||||
public CustomsCaseEntity gateCase(long tenantId, long orderId, long inboundId, long containerBookingId) {
|
||||
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
|
||||
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
|
||||
.eq(CustomsCaseEntity::getInboundId, inboundId)
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.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>()
|
||||
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
|
||||
return plan != null && loadingPlanItems.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.LoadingPlanItemEntity>()
|
||||
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getLoadingPlanId, plan.getId())
|
||||
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getAccountId, tenantId)
|
||||
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getOrderId, orderId)
|
||||
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getDeleted, false)) > 0;
|
||||
}
|
||||
|
||||
public void insert(CustomsDocumentEntity value) { documents.insert(value); }
|
||||
public CustomsDocumentEntity document(long id) { return documents.selectById(id); }
|
||||
public List<CustomsDocumentEntity> documents(long caseId) {
|
||||
return documents.selectList(new LambdaQueryWrapper<CustomsDocumentEntity>()
|
||||
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
|
||||
.orderByAsc(CustomsDocumentEntity::getChecklistItemCode)
|
||||
.orderByAsc(CustomsDocumentEntity::getDocumentVersion));
|
||||
}
|
||||
public CustomsDocumentEntity latestDocument(long caseId, String itemCode) {
|
||||
return documents.selectOne(new LambdaQueryWrapper<CustomsDocumentEntity>()
|
||||
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
|
||||
.eq(CustomsDocumentEntity::getChecklistItemCode, itemCode)
|
||||
.orderByDesc(CustomsDocumentEntity::getDocumentVersion).last("LIMIT 1"));
|
||||
}
|
||||
public int markDocument(long id, String status, long reviewerId, String reviewerName, String reason) {
|
||||
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
|
||||
.eq(CustomsDocumentEntity::getId, id)
|
||||
.set(CustomsDocumentEntity::getStatus, status).set(CustomsDocumentEntity::getReviewedBy, reviewerId)
|
||||
.set(CustomsDocumentEntity::getReviewedByName, reviewerName)
|
||||
.set(CustomsDocumentEntity::getReviewedAt, LocalDateTime.now())
|
||||
.set(CustomsDocumentEntity::getReviewReason, reason));
|
||||
}
|
||||
public int supersede(long id) {
|
||||
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
|
||||
.eq(CustomsDocumentEntity::getId, id).in(CustomsDocumentEntity::getStatus, "UPLOADED", "RETURNED")
|
||||
.set(CustomsDocumentEntity::getStatus, "SUPERSEDED"));
|
||||
}
|
||||
public int freeze(long id) {
|
||||
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
|
||||
.eq(CustomsDocumentEntity::getId, id).eq(CustomsDocumentEntity::getStatus, "APPROVED")
|
||||
.set(CustomsDocumentEntity::getStatus, "FROZEN"));
|
||||
}
|
||||
|
||||
public void insert(CustomsSupplementRequestEntity value) { supplements.insert(value); }
|
||||
public List<CustomsSupplementRequestEntity> supplements(long caseId) {
|
||||
return supplements.selectList(new LambdaQueryWrapper<CustomsSupplementRequestEntity>()
|
||||
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
|
||||
.orderByAsc(CustomsSupplementRequestEntity::getRequestedAt));
|
||||
}
|
||||
public void resolveSupplements(long caseId, String itemCode, long documentId, long operatorId) {
|
||||
supplements.update(null, new LambdaUpdateWrapper<CustomsSupplementRequestEntity>()
|
||||
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
|
||||
.eq(CustomsSupplementRequestEntity::getChecklistItemCode, itemCode)
|
||||
.eq(CustomsSupplementRequestEntity::getStatus, "OPEN")
|
||||
.set(CustomsSupplementRequestEntity::getStatus, "RESOLVED")
|
||||
.set(CustomsSupplementRequestEntity::getResolvedDocumentId, documentId)
|
||||
.set(CustomsSupplementRequestEntity::getResolvedBy, operatorId)
|
||||
.set(CustomsSupplementRequestEntity::getResolvedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public void insert(CustomsSubmissionBatchEntity value) { batches.insert(value); }
|
||||
public CustomsSubmissionBatchEntity batch(long id) { return batches.selectById(id); }
|
||||
public List<CustomsSubmissionBatchEntity> batches(long caseId) {
|
||||
return batches.selectList(new LambdaQueryWrapper<CustomsSubmissionBatchEntity>()
|
||||
.eq(CustomsSubmissionBatchEntity::getCustomsCaseId, caseId)
|
||||
.orderByAsc(CustomsSubmissionBatchEntity::getBatchVersion));
|
||||
}
|
||||
|
||||
public void insert(CustomsReceiptEntity value) { receipts.insert(value); }
|
||||
public CustomsReceiptEntity receipt(long id) { return receipts.selectById(id); }
|
||||
public CustomsReceiptEntity latestReceipt(long batchId, String type) {
|
||||
return receipts.selectOne(new LambdaQueryWrapper<CustomsReceiptEntity>()
|
||||
.eq(CustomsReceiptEntity::getSubmissionBatchId, batchId)
|
||||
.eq(CustomsReceiptEntity::getReceiptType, type)
|
||||
.orderByDesc(CustomsReceiptEntity::getReceiptVersion).last("LIMIT 1"));
|
||||
}
|
||||
public List<CustomsReceiptEntity> receipts(long caseId, boolean customerOnly) {
|
||||
return receipts.selectList(new LambdaQueryWrapper<CustomsReceiptEntity>()
|
||||
.eq(CustomsReceiptEntity::getCustomsCaseId, caseId)
|
||||
.eq(customerOnly, CustomsReceiptEntity::getVisibility, "CUSTOMER")
|
||||
.orderByAsc(CustomsReceiptEntity::getUploadedAt));
|
||||
}
|
||||
|
||||
public void insert(CustomsHistoryEntity value) { histories.insert(value); }
|
||||
public List<CustomsHistoryEntity> history(long caseId) {
|
||||
return histories.selectList(new LambdaQueryWrapper<CustomsHistoryEntity>()
|
||||
.eq(CustomsHistoryEntity::getCustomsCaseId, caseId).orderByAsc(CustomsHistoryEntity::getCreatedAt));
|
||||
}
|
||||
|
||||
public void insert(CustomsOperationEntity value) { operations.insert(value); }
|
||||
public CustomsOperationEntity operation(long operatorId, String action, String key) {
|
||||
return operations.selectOne(new LambdaQueryWrapper<CustomsOperationEntity>()
|
||||
.eq(CustomsOperationEntity::getOperatorId, operatorId)
|
||||
.eq(CustomsOperationEntity::getAction, action)
|
||||
.eq(CustomsOperationEntity::getIdempotencyKey, key));
|
||||
}
|
||||
public int completeOperation(long id, String resultType, long resultId) {
|
||||
return operations.update(null, new LambdaUpdateWrapper<CustomsOperationEntity>()
|
||||
.eq(CustomsOperationEntity::getId, id).eq(CustomsOperationEntity::getStatus, "PROCESSING")
|
||||
.set(CustomsOperationEntity::getStatus, "SUCCESS")
|
||||
.set(CustomsOperationEntity::getResultType, resultType)
|
||||
.set(CustomsOperationEntity::getResultId, resultId)
|
||||
.set(CustomsOperationEntity::getCompletedAt, LocalDateTime.now()));
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
|
||||
@Resource private CustomsRepository repository;
|
||||
@Resource private ContainerCustomsMapper mapper;
|
||||
|
||||
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
|
||||
|
||||
@@ -17,8 +17,9 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
|
||||
if (context.tenantId() <= 0 || context.customerId() <= 0) {
|
||||
return gate(false, "INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId());
|
||||
return gate(completed, completed ? "COMPLETED" : "MISSING");
|
||||
var completed = mapper.containerByBooking(context.containerBookingId());
|
||||
boolean passed = completed != null && "COMPLETED".equals(completed.status());
|
||||
return gate(passed, passed ? "COMPLETED" : completed == null ? "MISSING" : completed.status());
|
||||
}
|
||||
|
||||
private static GateItem gate(boolean passed, String actual) {
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.customs.client.CustomsFileClient;
|
||||
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
|
||||
import com.baiyue.firstleg.customs.entity.*;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
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;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CustomsService {
|
||||
private static final String DOCUMENT_TYPE = "CUSTOMS_DOCUMENT";
|
||||
private static final String RECEIPT_TYPE = "CUSTOMS_RECEIPT";
|
||||
private static final Set<String> EDITABLE = Set.of("DOCUMENTS_PENDING", "REVIEWING", "SUPPLEMENT_REQUIRED", "READY_TO_SUBMIT");
|
||||
|
||||
@Resource private CustomsRepository repository;
|
||||
@Resource private CustomsFileClient files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public PageResult<CustomsCaseEntity> page(Query query) { return repository.page(query); }
|
||||
public CaseDetail platformDetail(long caseId) { return detail(required(caseId), false); }
|
||||
public CaseDetail merchantDetail(long tenantId, long caseId) { return detail(required(tenantId, caseId), true); }
|
||||
public CaseDetail merchantOrder(long tenantId, long orderId) {
|
||||
CustomsCaseEntity value = repository.caseByOrder(tenantId, orderId);
|
||||
if (value == null) throw notFound("订单报关协同单不存在");
|
||||
return detail(value, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ChecklistView createChecklist(long operatorId, String operatorName, String key, CreateChecklistRequest request) {
|
||||
validateChecklist(request.items());
|
||||
List<ChecklistItem> normalizedItems = request.items().stream()
|
||||
.map(item -> new ChecklistItem(normalize(item.code()), item.name().trim(), item.required())).toList();
|
||||
String action = "CREATE_CHECKLIST";
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
|
||||
if (completed(operation)) return checklist(requiredChecklist(operation.getResultId()));
|
||||
if (repository.checklist(normalize(request.checklistCode()), normalize(request.versionNo())) != null) {
|
||||
throw conflict("资料清单版本已存在");
|
||||
}
|
||||
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
|
||||
value.setId(IdWorker.getId()); value.setChecklistCode(normalize(request.checklistCode()));
|
||||
value.setVersionNo(normalize(request.versionNo())); value.setCountryCode(normalize(request.countryCode()));
|
||||
value.setChannelCode(normalize(request.channelCode())); value.setCargoType(normalize(request.cargoType()));
|
||||
value.setItemsJson(json(normalizedItems)); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId);
|
||||
value.setCreatedByName(operatorName); value.setCreatedAt(LocalDateTime.now());
|
||||
repository.insert(value); complete(operation, "CHECKLIST", value.getId());
|
||||
return checklist(value);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CaseDetail createCase(long operatorId, String operatorName, String key, CreateCaseRequest request) {
|
||||
if (request.expectedVersion() != 0) throw bad("新增 expectedVersion 必须为0");
|
||||
CustomsChecklistVersionEntity checklist = requiredChecklist(request.checklistVersionId());
|
||||
if (!"ACTIVE".equals(checklist.getStatus())) throw conflict("资料清单版本不可用");
|
||||
if (!repository.inboundBelongsTo(request.customerId(), request.orderId(), request.inboundId())) {
|
||||
throw bad("入库单、订单与目标客户归属不一致");
|
||||
}
|
||||
long containerId = request.containerBookingId() == null ? 0 : request.containerBookingId();
|
||||
if (containerId > 0 && !repository.containerCarriesOrder(containerId, request.customerId(), request.orderId())) {
|
||||
throw bad("柜次未承载该客户订单");
|
||||
}
|
||||
String action = "CREATE_CASE";
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
|
||||
if (completed(operation)) return platformDetail(operation.getResultId());
|
||||
CustomsCaseEntity value = new CustomsCaseEntity();
|
||||
value.setId(IdWorker.getId()); value.setCustomsNo("CD" + value.getId()); value.setTenantId(request.customerId());
|
||||
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setContainerBookingId(containerId);
|
||||
value.setChecklistVersionId(checklist.getId()); value.setChecklistCode(checklist.getChecklistCode());
|
||||
value.setChecklistVersionNo(checklist.getVersionNo()); value.setStatus("DOCUMENTS_PENDING");
|
||||
value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
||||
value.setUpdatedAt(value.getCreatedAt()); value.setVersion(0);
|
||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("订单和柜次已有报关协同单"); }
|
||||
history(value, "CREATE", null, value.getStatus(), "CASE", value.getId(), value.getRemark(), operatorId, operatorName, "PLATFORM");
|
||||
complete(operation, "CASE", value.getId());
|
||||
return platformDetail(value.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomsDocumentEntity uploadDocument(long tenantId, long caseId, String actorType, long operatorId,
|
||||
String operatorName, String key, DocumentUploadData data,
|
||||
MultipartFile file) {
|
||||
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
|
||||
String action = actorType + "_UPLOAD_DOCUMENT";
|
||||
String fileHash = multipartHash(file);
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key,
|
||||
hash(action, caseId, data, fileHash, file == null ? null : file.getOriginalFilename()));
|
||||
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
|
||||
validateDocumentCommand(customsCase, data.itemCode(), data.expectedVersion());
|
||||
CustomsFileClient.Metadata metadata = upload(customsCase, DOCUMENT_TYPE, Long.toString(caseId), operatorId, file);
|
||||
CustomsDocumentEntity value = addDocument(customsCase, data.itemCode(), metadata, actorType, operatorId, operatorName);
|
||||
complete(operation, "DOCUMENT", value.getId());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomsDocumentEntity referenceDocument(long tenantId, long caseId, String actorType, long operatorId,
|
||||
String operatorName, String key, DocumentReferenceRequest request) {
|
||||
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
|
||||
String action = actorType + "_REFERENCE_DOCUMENT";
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
|
||||
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
|
||||
validateDocumentCommand(customsCase, request.itemCode(), request.expectedVersion());
|
||||
CustomsFileClient.Metadata metadata = metadata(customsCase, DOCUMENT_TYPE, Long.toString(caseId), request.fileId());
|
||||
CustomsDocumentEntity value = addDocument(customsCase, request.itemCode(), metadata, actorType, operatorId, operatorName);
|
||||
complete(operation, "DOCUMENT", value.getId());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CaseDetail review(long caseId, long documentId, long operatorId, String operatorName,
|
||||
String key, ReviewRequest request) {
|
||||
CustomsCaseEntity customsCase = required(caseId);
|
||||
String action = "REVIEW_DOCUMENT_" + request.decision();
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, documentId, request));
|
||||
if (completed(operation)) return platformDetail(caseId);
|
||||
requiredVersion(customsCase, request.expectedVersion());
|
||||
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可审核资料");
|
||||
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
|
||||
CustomsDocumentEntity latest = repository.latestDocument(caseId, document.getChecklistItemCode());
|
||||
if (!Objects.equals(document.getId(), latest.getId()) || !"UPLOADED".equals(document.getStatus())) {
|
||||
throw conflict("只能审核当前待审文件版本");
|
||||
}
|
||||
if ("RETURN".equals(request.decision()) && !StringUtils.hasText(request.reason())) throw bad("退回原因不能为空");
|
||||
repository.markDocument(documentId, "APPROVE".equals(request.decision()) ? "APPROVED" : "RETURNED",
|
||||
operatorId, operatorName, trim(request.reason()));
|
||||
if ("RETURN".equals(request.decision())) {
|
||||
CustomsSupplementRequestEntity supplement = new CustomsSupplementRequestEntity();
|
||||
supplement.setId(IdWorker.getId()); supplement.setCustomsCaseId(caseId); supplement.setSourceDocumentId(documentId);
|
||||
supplement.setChecklistItemCode(document.getChecklistItemCode()); supplement.setReason(request.reason().trim());
|
||||
supplement.setDueAt(request.supplementDueAt()); supplement.setStatus("OPEN"); supplement.setRequestedBy(operatorId);
|
||||
supplement.setRequestedByName(operatorName); supplement.setRequestedAt(LocalDateTime.now()); repository.insert(supplement);
|
||||
}
|
||||
String next = calculateStatus(customsCase);
|
||||
transition(customsCase, next, operatorId, operatorName);
|
||||
history(customsCase, action, customsCase.getStatus(), next, "DOCUMENT", documentId, request.reason(),
|
||||
operatorId, operatorName, "PLATFORM");
|
||||
complete(operation, "CASE", caseId);
|
||||
return platformDetail(caseId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomsSubmissionBatchEntity submitBatch(long caseId, long operatorId, String operatorName,
|
||||
String key, SubmitBatchRequest request) {
|
||||
CustomsCaseEntity customsCase = required(caseId);
|
||||
String action = "SUBMIT_AGENT_BATCH";
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
|
||||
if (completed(operation)) return requiredBatch(operation.getResultId(), caseId);
|
||||
requiredVersion(customsCase, request.expectedVersion());
|
||||
if (!"READY_TO_SUBMIT".equals(customsCase.getStatus())) throw conflict("报关资料未全部审核通过");
|
||||
List<CustomsDocumentEntity> current = currentDocuments(customsCase);
|
||||
for (CustomsDocumentEntity document : current) {
|
||||
if (!"APPROVED".equals(document.getStatus()) || repository.freeze(document.getId()) != 1) {
|
||||
throw conflict("文件版本已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
|
||||
batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1);
|
||||
batch.setBatchNo("CB" + batch.getId());
|
||||
var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName());
|
||||
batch.setCustomsAgentId(customsAgent == null ? null : customsAgent.supplierId());
|
||||
batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name());
|
||||
batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream()
|
||||
.map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(),
|
||||
"documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList()));
|
||||
batch.setStatus("SUBMITTED"); batch.setNote(trim(request.note())); batch.setSubmittedBy(operatorId);
|
||||
batch.setSubmittedByName(operatorName); batch.setSubmittedAt(LocalDateTime.now()); repository.insert(batch);
|
||||
transition(customsCase, "RECEIPT_PENDING", operatorId, operatorName);
|
||||
history(customsCase, action, customsCase.getStatus(), "RECEIPT_PENDING", "SUBMISSION_BATCH", batch.getId(),
|
||||
request.note(), operatorId, operatorName, "PLATFORM");
|
||||
complete(operation, "BATCH", batch.getId());
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomsReceiptEntity uploadReceipt(long batchId, long operatorId, String operatorName, String key,
|
||||
ReceiptUploadData data, MultipartFile file) {
|
||||
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
|
||||
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
|
||||
String action = "UPLOAD_RECEIPT_" + data.receiptType();
|
||||
String fileHash = multipartHash(file);
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key,
|
||||
hash(action, batchId, data, fileHash, file == null ? null : file.getOriginalFilename()));
|
||||
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
|
||||
requiredVersion(customsCase, data.expectedVersion());
|
||||
validateReceiptStatus(customsCase);
|
||||
CustomsFileClient.Metadata metadata = upload(customsCase, RECEIPT_TYPE, Long.toString(batchId), operatorId, file);
|
||||
CustomsReceiptEntity value = addReceipt(customsCase, batch, data.receiptType(), data.visibility(), data.note(),
|
||||
metadata, operatorId, operatorName);
|
||||
complete(operation, "RECEIPT", value.getId());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomsReceiptEntity referenceReceipt(long batchId, long operatorId, String operatorName, String key,
|
||||
ReceiptReferenceRequest request) {
|
||||
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
|
||||
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
|
||||
String action = "REFERENCE_RECEIPT_" + request.receiptType();
|
||||
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, batchId, request));
|
||||
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
|
||||
requiredVersion(customsCase, request.expectedVersion());
|
||||
validateReceiptStatus(customsCase);
|
||||
CustomsFileClient.Metadata metadata = metadata(customsCase, RECEIPT_TYPE, Long.toString(batchId), request.fileId());
|
||||
CustomsReceiptEntity value = addReceipt(customsCase, batch, request.receiptType(), request.visibility(), request.note(),
|
||||
metadata, operatorId, operatorName);
|
||||
complete(operation, "RECEIPT", value.getId());
|
||||
return value;
|
||||
}
|
||||
|
||||
public Download downloadDocument(long tenantId, long caseId, long documentId, boolean merchant) {
|
||||
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
|
||||
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
|
||||
return download(customsCase, DOCUMENT_TYPE, Long.toString(caseId), document.getFileId(), document.getFileName());
|
||||
}
|
||||
|
||||
public Download downloadReceipt(long tenantId, long caseId, long receiptId, boolean merchant) {
|
||||
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
|
||||
CustomsReceiptEntity receipt = requiredReceipt(receiptId, caseId, merchant);
|
||||
CustomsSubmissionBatchEntity batch = requiredBatch(receipt.getSubmissionBatchId(), caseId);
|
||||
return download(customsCase, RECEIPT_TYPE, Long.toString(batch.getId()), receipt.getFileId(), receipt.getFileName());
|
||||
}
|
||||
|
||||
private CustomsDocumentEntity addDocument(CustomsCaseEntity customsCase, String rawItemCode,
|
||||
CustomsFileClient.Metadata metadata, String actorType,
|
||||
long operatorId, String operatorName) {
|
||||
String itemCode = normalize(rawItemCode);
|
||||
CustomsDocumentEntity previous = repository.latestDocument(customsCase.getId(), itemCode);
|
||||
if (previous != null && Set.of("UPLOADED", "RETURNED").contains(previous.getStatus())
|
||||
&& repository.supersede(previous.getId()) != 1) throw conflict("文件版本已变化,请刷新后重试");
|
||||
CustomsDocumentEntity value = new CustomsDocumentEntity();
|
||||
value.setId(IdWorker.getId()); value.setCustomsCaseId(customsCase.getId()); value.setChecklistItemCode(itemCode);
|
||||
value.setFileId(metadata.fileId()); value.setDocumentVersion(previous == null ? 1 : previous.getDocumentVersion() + 1);
|
||||
value.setReplacesDocumentId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
|
||||
value.setFileHash(metadata.sha256()); value.setStatus("UPLOADED"); value.setVisibility("CUSTOMER");
|
||||
value.setUploadedBy(operatorId); value.setUploadedByType(actorType); value.setUploadedAt(LocalDateTime.now());
|
||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("文件版本已存在"); }
|
||||
repository.resolveSupplements(customsCase.getId(), itemCode, value.getId(), operatorId);
|
||||
String next = hasEveryRequiredDocument(customsCase) ? "REVIEWING" : "DOCUMENTS_PENDING";
|
||||
transition(customsCase, next, operatorId, operatorName);
|
||||
history(customsCase, previous == null ? "UPLOAD_DOCUMENT" : "REPLACE_DOCUMENT", customsCase.getStatus(), next,
|
||||
"DOCUMENT", value.getId(), itemCode, operatorId, operatorName, actorType);
|
||||
return value;
|
||||
}
|
||||
|
||||
private CustomsReceiptEntity addReceipt(CustomsCaseEntity customsCase, CustomsSubmissionBatchEntity batch,
|
||||
String receiptType, String visibility, String note,
|
||||
CustomsFileClient.Metadata metadata, long operatorId, String operatorName) {
|
||||
CustomsReceiptEntity previous = repository.latestReceipt(batch.getId(), receiptType);
|
||||
CustomsReceiptEntity value = new CustomsReceiptEntity(); value.setId(IdWorker.getId());
|
||||
value.setCustomsCaseId(customsCase.getId()); value.setSubmissionBatchId(batch.getId());
|
||||
value.setReceiptType(receiptType); value.setFileId(metadata.fileId());
|
||||
value.setReceiptVersion(previous == null ? 1 : previous.getReceiptVersion() + 1);
|
||||
value.setReplacesReceiptId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
|
||||
value.setFileHash(metadata.sha256()); value.setVisibility(visibility); value.setNote(trim(note));
|
||||
value.setUploadedBy(operatorId); value.setUploadedByName(operatorName); value.setUploadedAt(LocalDateTime.now());
|
||||
repository.insert(value);
|
||||
String next = "REJECTED".equals(receiptType) ? "SUPPLEMENT_REQUIRED"
|
||||
: "RELEASED".equals(receiptType) ? "CLEARANCE_COMPLETED" : "RECEIPT_AVAILABLE";
|
||||
transition(customsCase, next, operatorId, operatorName);
|
||||
history(customsCase, "UPLOAD_RECEIPT", customsCase.getStatus(), next, "RECEIPT", value.getId(), note,
|
||||
operatorId, operatorName, "PLATFORM");
|
||||
return value;
|
||||
}
|
||||
|
||||
private List<CustomsDocumentEntity> currentDocuments(CustomsCaseEntity customsCase) {
|
||||
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
|
||||
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
|
||||
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
|
||||
return checklistItems(customsCase).stream().filter(ChecklistItem::required).map(v -> normalize(v.code()))
|
||||
.map(current::get).filter(Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
private String calculateStatus(CustomsCaseEntity customsCase) {
|
||||
List<CustomsSupplementRequestEntity> supplements = repository.supplements(customsCase.getId());
|
||||
if (supplements.stream().anyMatch(v -> "OPEN".equals(v.getStatus()))) return "SUPPLEMENT_REQUIRED";
|
||||
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
|
||||
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
|
||||
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
|
||||
List<String> required = checklistItems(customsCase).stream().filter(ChecklistItem::required)
|
||||
.map(v -> normalize(v.code())).toList();
|
||||
if (required.stream().allMatch(code -> current.containsKey(code) && "APPROVED".equals(current.get(code).getStatus()))) {
|
||||
return "READY_TO_SUBMIT";
|
||||
}
|
||||
return required.stream().allMatch(current::containsKey) ? "REVIEWING" : "DOCUMENTS_PENDING";
|
||||
}
|
||||
|
||||
private boolean hasEveryRequiredDocument(CustomsCaseEntity customsCase) {
|
||||
Set<String> codes = repository.documents(customsCase.getId()).stream()
|
||||
.filter(v -> !"SUPERSEDED".equals(v.getStatus()))
|
||||
.map(CustomsDocumentEntity::getChecklistItemCode).collect(Collectors.toSet());
|
||||
return checklistItems(customsCase).stream().filter(ChecklistItem::required)
|
||||
.allMatch(v -> codes.contains(normalize(v.code())));
|
||||
}
|
||||
|
||||
private void validateDocumentCommand(CustomsCaseEntity customsCase, String itemCode, int expectedVersion) {
|
||||
requiredVersion(customsCase, expectedVersion);
|
||||
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可上传或替换资料");
|
||||
String normalized = normalize(itemCode);
|
||||
if (checklistItems(customsCase).stream().noneMatch(v -> normalize(v.code()).equals(normalized))) {
|
||||
throw bad("文件类型不在本单资料清单中");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateReceiptStatus(CustomsCaseEntity customsCase) {
|
||||
if (!Set.of("RECEIPT_PENDING", "RECEIPT_AVAILABLE", "SUPPLEMENT_REQUIRED").contains(customsCase.getStatus())) {
|
||||
throw conflict("当前报关状态不可上传回执");
|
||||
}
|
||||
}
|
||||
|
||||
private CustomsCaseEntity scopedCase(long tenantId, long caseId, String actorType) {
|
||||
return "MERCHANT".equals(actorType) ? required(tenantId, caseId) : required(caseId);
|
||||
}
|
||||
|
||||
private CustomsFileClient.Metadata upload(CustomsCaseEntity customsCase, String type, String businessId,
|
||||
long operatorId, MultipartFile file) {
|
||||
try {
|
||||
var response = files.upload(internalToken, operatorId, customsCase.getTenantId(), type, businessId, file);
|
||||
if (response == null || !response.successful() || response.data() == null) throw bad("附件上传失败");
|
||||
return response.data();
|
||||
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
|
||||
}
|
||||
|
||||
private CustomsFileClient.Metadata metadata(CustomsCaseEntity customsCase, String type, String businessId, long fileId) {
|
||||
try {
|
||||
var response = files.metadata(internalToken, customsCase.getTenantId(), type, businessId, fileId);
|
||||
if (response == null || !response.successful() || response.data() == null) throw bad("附件不存在或归属不匹配");
|
||||
return response.data();
|
||||
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
|
||||
}
|
||||
|
||||
private Download download(CustomsCaseEntity customsCase, String type, String businessId, long fileId, String fileName) {
|
||||
try {
|
||||
var response = files.download(internalToken, customsCase.getTenantId(), type, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("附件内容不存在");
|
||||
}
|
||||
String contentType = response.getHeaders().getContentType() == null
|
||||
? "application/octet-stream" : response.getHeaders().getContentType().toString();
|
||||
if (contentType.toLowerCase(Locale.ROOT).contains("application/json")) throw notFound("附件内容不存在");
|
||||
return new Download(fileName, contentType, response.getBody());
|
||||
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
|
||||
}
|
||||
|
||||
private CustomsOperationEntity begin(long operatorId, String action, String key, String requestHash) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且不能超过128个字符");
|
||||
CustomsOperationEntity value = new CustomsOperationEntity(); value.setId(IdWorker.getId());
|
||||
value.setOperatorId(operatorId); value.setAction(action); value.setIdempotencyKey(key);
|
||||
value.setRequestHash(requestHash); value.setStatus("PROCESSING"); value.setCreatedAt(LocalDateTime.now());
|
||||
try { repository.insert(value); return value; }
|
||||
catch (DuplicateKeyException ex) {
|
||||
CustomsOperationEntity existing = repository.operation(operatorId, action, key);
|
||||
if (existing == null || !Objects.equals(requestHash, existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
|
||||
if (!"SUCCESS".equals(existing.getStatus()) || existing.getResultId() == null) throw conflict("相同请求正在处理中,请稍后重试");
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(CustomsOperationEntity operation, String resultType, long resultId) {
|
||||
if (repository.completeOperation(operation.getId(), resultType, resultId) != 1) throw conflict("幂等请求完成状态冲突");
|
||||
}
|
||||
|
||||
private static boolean completed(CustomsOperationEntity operation) { return "SUCCESS".equals(operation.getStatus()); }
|
||||
|
||||
private void transition(CustomsCaseEntity value, String next, long operatorId, String operatorName) {
|
||||
if (repository.transition(value.getId(), value.getVersion(), next, operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
|
||||
private void history(CustomsCaseEntity customsCase, String action, String from, String to,
|
||||
String resourceType, long resourceId, String note, long operatorId,
|
||||
String operatorName, String operatorType) {
|
||||
CustomsHistoryEntity value = new CustomsHistoryEntity(); value.setId(IdWorker.getId());
|
||||
value.setCustomsCaseId(customsCase.getId()); value.setAction(action); value.setFromStatus(from);
|
||||
value.setToStatus(to); value.setResourceType(resourceType); value.setResourceId(resourceId);
|
||||
value.setNote(trim(note)); value.setOperatorId(operatorId); value.setOperatorName(operatorName);
|
||||
value.setOperatorType(operatorType); value.setCreatedAt(LocalDateTime.now()); repository.insert(value);
|
||||
}
|
||||
|
||||
private CaseDetail detail(CustomsCaseEntity value, boolean customerOnly) {
|
||||
return new CaseDetail(value, checklist(requiredChecklist(value.getChecklistVersionId())),
|
||||
repository.documents(value.getId()), repository.supplements(value.getId()), repository.batches(value.getId()),
|
||||
repository.receipts(value.getId(), customerOnly), repository.history(value.getId()));
|
||||
}
|
||||
|
||||
private ChecklistView checklist(CustomsChecklistVersionEntity value) {
|
||||
return new ChecklistView(value, parseItems(value.getItemsJson()));
|
||||
}
|
||||
private List<ChecklistItem> checklistItems(CustomsCaseEntity value) {
|
||||
return parseItems(requiredChecklist(value.getChecklistVersionId()).getItemsJson());
|
||||
}
|
||||
private List<ChecklistItem> parseItems(String json) {
|
||||
try { return objectMapper.readValue(json, new TypeReference<>() {}); }
|
||||
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取报关清单版本", ex); }
|
||||
}
|
||||
|
||||
private CustomsCaseEntity required(long id) {
|
||||
CustomsCaseEntity value = repository.customsCase(id); if (value == null) throw notFound("报关协同单不存在"); return value;
|
||||
}
|
||||
private CustomsCaseEntity required(long tenantId, long id) {
|
||||
CustomsCaseEntity value = repository.customsCase(tenantId, id); if (value == null) throw notFound("报关协同单不存在"); return value;
|
||||
}
|
||||
private CustomsCaseEntity requiredVersion(CustomsCaseEntity value, int expectedVersion) {
|
||||
if (!Integer.valueOf(expectedVersion).equals(value.getVersion())) throw concurrent(); return value;
|
||||
}
|
||||
private CustomsChecklistVersionEntity requiredChecklist(long id) {
|
||||
CustomsChecklistVersionEntity value = repository.checklist(id); if (value == null) throw notFound("资料清单版本不存在"); return value;
|
||||
}
|
||||
private CustomsDocumentEntity requiredDocument(long id, long caseId) {
|
||||
CustomsDocumentEntity value = repository.document(id);
|
||||
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("报关文件版本不存在");
|
||||
return value;
|
||||
}
|
||||
private CustomsSubmissionBatchEntity requiredBatch(long id, Long caseId) {
|
||||
CustomsSubmissionBatchEntity value = repository.batch(id);
|
||||
if (value == null || caseId != null && !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("代理提交批次不存在");
|
||||
return value;
|
||||
}
|
||||
private CustomsReceiptEntity requiredReceipt(long id, long caseId, boolean customerOnly) {
|
||||
CustomsReceiptEntity value = repository.receipt(id);
|
||||
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)
|
||||
|| customerOnly && !"CUSTOMER".equals(value.getVisibility())) throw notFound("报关回执不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void validateChecklist(List<ChecklistItem> items) {
|
||||
Set<String> codes = new HashSet<>();
|
||||
for (ChecklistItem item : items) if (!codes.add(normalize(item.code()))) throw bad("资料清单项目编码不能重复");
|
||||
if (items.stream().noneMatch(ChecklistItem::required)) throw bad("资料清单至少包含一个必填项目");
|
||||
}
|
||||
|
||||
private String hash(Object... values) {
|
||||
try { return sha256(objectMapper.writeValueAsBytes(values)); }
|
||||
catch (JsonProcessingException ex) { throw new IllegalStateException("无法计算报关请求摘要", ex); }
|
||||
}
|
||||
private static String multipartHash(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
catch (Exception ex) { throw bad("附件内容读取失败"); }
|
||||
}
|
||||
private static String sha256(byte[] bytes) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
catch (Exception ex) { throw new IllegalStateException(ex); }
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存报关版本快照", ex); }
|
||||
}
|
||||
private static String normalize(String value) { return value.trim().toUpperCase(Locale.ROOT); }
|
||||
private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; }
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
|
||||
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); }
|
||||
}
|
||||
@@ -23,9 +23,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
|
||||
FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
|
||||
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
|
||||
FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
@@ -85,9 +83,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
|
||||
FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
|
||||
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
|
||||
FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
|
||||
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
|
||||
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
|
||||
|
||||
+3
-4
@@ -103,10 +103,9 @@ public interface OutboundDispatchReadMapper {
|
||||
SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
|
||||
FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
|
||||
UNION ALL
|
||||
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at
|
||||
FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id
|
||||
WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId}
|
||||
AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId}
|
||||
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
|
||||
FROM fl_customs_container_history h JOIN fl_customs_container customs_container ON customs_container.id=h.customs_container_id
|
||||
WHERE customs_container.container_booking_id=#{containerBookingId}
|
||||
UNION ALL
|
||||
SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at
|
||||
FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Real proxy-owned transaction boundary for repeated system convergence after customs completion. */
|
||||
@Service
|
||||
public class OutboundDispatchAutoConfirmService {
|
||||
@Resource private OutboundDispatchService dispatch;
|
||||
|
||||
@Transactional
|
||||
public boolean confirmIfReady(long containerBookingId) {
|
||||
var current=dispatch.detail(containerBookingId);
|
||||
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(current.aggregate().dispatchStatus()))return true;
|
||||
if(!current.aggregate().gate().passed())return false;
|
||||
try{
|
||||
dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId,
|
||||
new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓"));
|
||||
return true;
|
||||
}catch(BizException ex){
|
||||
var after=dispatch.detail(containerBookingId);
|
||||
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(after.aggregate().dispatchStatus()))return true;
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,11 +52,9 @@ import java.util.Set;
|
||||
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";
|
||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
||||
public static final String PICKUP_ORDER = "PICKUP_ORDER";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
|
||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
||||
@Resource
|
||||
@@ -198,89 +196,6 @@ public class LoadingListService {
|
||||
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));
|
||||
}
|
||||
|
||||
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("出口报关附件不存在或不属于当前装柜计划");
|
||||
}
|
||||
String businessId = Long.toString(planId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("出口报关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
planId, Long.toString(planId), operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_UPLOAD", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "直接上传出口报关文档")
|
||||
public BusinessDocument uploadLoadingDeclaration(long planId, long warehouseId, long operatorId,
|
||||
String operatorName, String key, int expectedVersion,
|
||||
String operationReason, MultipartFile file) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
return uploadDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, planId,
|
||||
Long.toString(planId), operatorId, operatorName, key, expectedVersion, operationReason, file);
|
||||
}
|
||||
|
||||
@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, planId, operatorId, operatorName, key, request);
|
||||
}
|
||||
|
||||
private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType,
|
||||
long identityId, String businessId, long operatorId, String operatorName,
|
||||
String key, DocumentAttachRequest request) {
|
||||
@@ -450,7 +365,7 @@ public class LoadingListService {
|
||||
}
|
||||
|
||||
private static void requireCompletedPlan(AggregateRow plan) {
|
||||
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关");
|
||||
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可维护后续单证");
|
||||
}
|
||||
|
||||
private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) {
|
||||
|
||||
Reference in New Issue
Block a user