feat(firstleg): enforce booking-owned truck workflow
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
@FeignClient(name = "file-service", contextId = "oceanBookingFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
|
||||
public interface OceanBookingFileClient {
|
||||
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data")
|
||||
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@RequestPart("file") MultipartFile file);
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
|
||||
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Print Service contract for stateless inbound carton label rendering. */
|
||||
/** Print Service contract for stateless Firstleg PDF rendering. */
|
||||
@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
|
||||
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
|
||||
public interface PrintPdfClient {
|
||||
@@ -19,6 +20,12 @@ public interface PrintPdfClient {
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody InboundMarkPdfRequest request);
|
||||
|
||||
@PostMapping(value = "/freight-a4-documents", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateFreightA4Documents(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody FreightA4PdfRequest request);
|
||||
|
||||
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<Label> labels) {
|
||||
}
|
||||
@@ -29,4 +36,12 @@ public interface PrintPdfClient {
|
||||
record Label(String topCode, String warehouseText, String cartonText,
|
||||
String routeText, String qrContent) {
|
||||
}
|
||||
|
||||
record FreightA4PdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<FreightDocument> documents) {}
|
||||
|
||||
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
|
||||
|
||||
record CargoRow(String marksAndNos, String quantity, String packaging,
|
||||
String descriptionOfGoods, String grossWeight, String measurement) {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.
|
||||
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
|
||||
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
|
||||
@PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));}
|
||||
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam @NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
|
||||
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam @NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
|
||||
@DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<Void> deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);}
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:create')") @BusinessAudit(event="TRUCK_APPOINTMENT_CREATE",resourceType="TRUCK_APPOINTMENT",captureBefore=false) public ApiResponse<TruckAppointmentEntity> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(u.userId(),u.username(),r));}
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<TruckAppointmentEntity> edit(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.edit(id,u.userId(),u.username(),r));}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto;
|
||||
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
|
||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
+84
@@ -7,6 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingService;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingPrintService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -16,6 +18,13 @@ 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.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@Validated
|
||||
@@ -24,6 +33,8 @@ import java.util.List;
|
||||
@RequestMapping("/api/platform/v1/ocean-bookings")
|
||||
public class PlatformOceanBookingController {
|
||||
@Resource private OceanBookingService service;
|
||||
@Resource private OceanBookingDocumentService documentService;
|
||||
@Resource private OceanBookingPrintService printService;
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<PageResult<OceanBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); }
|
||||
@@ -45,6 +56,70 @@ public class PlatformOceanBookingController {
|
||||
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
|
||||
|
||||
@PostMapping("/{oceanBookingId}/documents") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_DOCUMENT_ATTACH", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="关联订舱文件")
|
||||
public ApiResponse<DocumentMutation> attachDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(documentService.attach(oceanBookingId, request, key, user.userId(), user.username()));
|
||||
}
|
||||
@GetMapping("/{oceanBookingId}/documents") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity>> documents(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(documentService.documents(oceanBookingId));
|
||||
}
|
||||
@PostMapping(value = "/{oceanBookingId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_DOCUMENT_UPLOAD", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="上传订舱文件")
|
||||
public ApiResponse<DocumentMutation> uploadDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestParam @jakarta.validation.constraints.Min(0) int expectedVersion,
|
||||
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "BOOKING_CONFIRMATION|BILL_OF_LADING") String documentType,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestPart("file") org.springframework.web.multipart.MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(documentService.upload(oceanBookingId, expectedVersion, documentType, key, file,
|
||||
user.userId(), user.username()));
|
||||
}
|
||||
@DeleteMapping("/{oceanBookingId}/documents/{documentId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_DOCUMENT_INVALIDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="作废订舱文件")
|
||||
public ApiResponse<DocumentInvalidation> invalidateDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(documentService.invalidate(oceanBookingId, documentId, request.expectedVersion(), key,
|
||||
user.userId(), user.username()));
|
||||
}
|
||||
@GetMapping("/{oceanBookingId}/documents/{documentId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @PathVariable @Positive long documentId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
OceanBookingDocumentService.Download download = documentService.download(oceanBookingId, documentId);
|
||||
return ResponseEntity.ok().contentType(download.contentType()).cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.PRAGMA, "no-cache").header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.header("X-Content-Type-Options", "nosniff").contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
@GetMapping(value = "/{oceanBookingId}/prints/shipping-instruction", produces = MediaType.APPLICATION_PDF_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ResponseEntity<byte[]> printShippingInstruction(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, HttpServletRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return pdf(printService.shippingInstruction(oceanBookingId, request.getHeader("X-Request-Id")));
|
||||
}
|
||||
@GetMapping(value = "/{oceanBookingId}/prints/bill-of-lading-confirmation", produces = MediaType.APPLICATION_PDF_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ResponseEntity<byte[]> printBillConfirmation(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, HttpServletRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return pdf(printService.billConfirmation(oceanBookingId, request.getHeader("X-Request-Id")));
|
||||
}
|
||||
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱草稿", captureBefore=false)
|
||||
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
|
||||
@@ -58,4 +133,13 @@ public class PlatformOceanBookingController {
|
||||
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
|
||||
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
|
||||
private static ResponseEntity<byte[]> pdf(OceanBookingPrintService.GeneratedPdf pdf) {
|
||||
ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.header("X-Content-Type-Options", "nosniff");
|
||||
if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount());
|
||||
return response.contentLength(pdf.content().length).body(pdf.content());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.oceanbooking.dto;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
@@ -12,6 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
@@ -86,6 +88,12 @@ public final class OceanBookingDtos {
|
||||
@Size(max = 1000) String reason) {}
|
||||
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 1000) String reason) {}
|
||||
public record DocumentRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull @Positive Long fileId,
|
||||
@NotBlank @Pattern(regexp = "BOOKING_CONFIRMATION|BILL_OF_LADING") String documentType) {}
|
||||
public record DocumentActionRequest(@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record DocumentMutation(OceanBookingDocumentEntity document, Integer bookingVersion) {}
|
||||
public record DocumentInvalidation(Long documentId, Integer bookingVersion) {}
|
||||
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
|
||||
Long requirementId, Long allocationId, String containerType,
|
||||
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
|
||||
@@ -96,7 +104,14 @@ public final class OceanBookingDtos {
|
||||
List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> containerRequirements,
|
||||
List<RealContainer> containers,
|
||||
List<OceanBookingStatusHistoryEntity> histories) {}
|
||||
List<OceanBookingDocumentEntity> documents,
|
||||
List<OceanBookingStatusHistoryEntity> histories) {
|
||||
public Detail(OceanBookingEntity oceanBooking, List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> containerRequirements,
|
||||
List<RealContainer> containers, List<OceanBookingStatusHistoryEntity> histories) {
|
||||
this(oceanBooking, parties, containerRequirements, containers, List.of(), histories);
|
||||
}
|
||||
}
|
||||
|
||||
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
|
||||
String priceModel, List<String> pricingTaxModes) {}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.oceanbooking.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_ocean_booking_document")
|
||||
public class OceanBookingDocumentEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long oceanBookingId;
|
||||
private Long fileId;
|
||||
private String documentType;
|
||||
private String fileName;
|
||||
private String fileHash;
|
||||
private Long sizeBytes;
|
||||
private String contentType;
|
||||
private Boolean valid;
|
||||
private Long uploadedBy;
|
||||
private String uploadedByName;
|
||||
private LocalDateTime uploadedAt;
|
||||
private Long invalidatedBy;
|
||||
private String invalidatedByName;
|
||||
private LocalDateTime invalidatedAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.oceanbooking.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface OceanBookingDocumentMapper extends BaseMapper<OceanBookingDocumentEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking_document
|
||||
SET valid=0,invalidated_by=#{operatorId},invalidated_by_name=#{operatorName},
|
||||
invalidated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{documentId} AND ocean_booking_id=#{oceanBookingId} AND valid=1
|
||||
""")
|
||||
int invalidate(@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("documentId") long documentId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
}
|
||||
+10
@@ -15,6 +15,16 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
@Select("SELECT * FROM fl_ocean_booking WHERE id=#{id} FOR UPDATE")
|
||||
OceanBookingEntity selectForUpdate(@Param("id") long id);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND status='DRAFT' AND version=#{expectedVersion}
|
||||
""")
|
||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
|
||||
+26
@@ -27,6 +27,7 @@ public class OceanBookingRepository {
|
||||
@Resource private OceanBookingContainerAllocationMapper allocations;
|
||||
@Resource private OceanBookingStatusHistoryMapper histories;
|
||||
@Resource private OceanBookingOperationMapper operations;
|
||||
@Resource private OceanBookingDocumentMapper documents;
|
||||
@Resource private ContainerRepository containerBookings;
|
||||
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
@@ -36,6 +37,7 @@ public class OceanBookingRepository {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
|
||||
public void insert(OceanBookingEntity value) { bookings.insert(value); }
|
||||
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
|
||||
return bookings.transition(id, from, to, version, operatorId, operatorName);
|
||||
@@ -43,6 +45,9 @@ public class OceanBookingRepository {
|
||||
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName);
|
||||
}
|
||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchDraft(id, version, operatorId, operatorName);
|
||||
}
|
||||
public int deleteDraft(long id, int version) {
|
||||
return bookings.deleteDraft(id, version);
|
||||
}
|
||||
@@ -73,6 +78,27 @@ public class OceanBookingRepository {
|
||||
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
|
||||
}
|
||||
public List<OceanBookingStatusHistoryEntity> histories(long id) { return histories.selectList(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); }
|
||||
public List<OceanBookingDocumentEntity> documents(long id) {
|
||||
return documents.selectList(new LambdaQueryWrapper<OceanBookingDocumentEntity>()
|
||||
.eq(OceanBookingDocumentEntity::getOceanBookingId, id)
|
||||
.orderByDesc(OceanBookingDocumentEntity::getUploadedAt)
|
||||
.orderByDesc(OceanBookingDocumentEntity::getId));
|
||||
}
|
||||
public List<OceanBookingDocumentEntity> activeDocuments(long id) {
|
||||
return documents.selectList(new LambdaQueryWrapper<OceanBookingDocumentEntity>()
|
||||
.eq(OceanBookingDocumentEntity::getOceanBookingId, id)
|
||||
.eq(OceanBookingDocumentEntity::getValid, true)
|
||||
.orderByAsc(OceanBookingDocumentEntity::getUploadedAt));
|
||||
}
|
||||
public OceanBookingDocumentEntity document(long id, long documentId) {
|
||||
return documents.selectOne(new LambdaQueryWrapper<OceanBookingDocumentEntity>()
|
||||
.eq(OceanBookingDocumentEntity::getOceanBookingId, id)
|
||||
.eq(OceanBookingDocumentEntity::getId, documentId));
|
||||
}
|
||||
public void insert(OceanBookingDocumentEntity value) { documents.insert(value); }
|
||||
public int invalidateDocument(long id, long documentId, long operatorId, String operatorName) {
|
||||
return documents.invalidate(id, documentId, operatorId, operatorName);
|
||||
}
|
||||
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
|
||||
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
|
||||
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package com.baiyue.firstleg.oceanbooking.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.client.OceanBookingFileClient;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentInvalidation;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentMutation;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
|
||||
@Service
|
||||
public class OceanBookingDocumentService {
|
||||
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
|
||||
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
|
||||
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@Resource private OceanBookingFileClient files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public DocumentMutation upload(long oceanBookingId, int expectedVersion, String documentType, String key,
|
||||
MultipartFile file, long operatorId, String operatorName) {
|
||||
requireExisting(oceanBookingId);
|
||||
String action = "UPLOAD_DOCUMENT";
|
||||
String requestHash = hash(action, oceanBookingId, expectedVersion + "|" + normalizeType(documentType)
|
||||
+ "|" + fileDigest(file));
|
||||
DocumentMutation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
|
||||
DocumentMutation.class);
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
|
||||
OceanBookingFileClient.Metadata metadata;
|
||||
try {
|
||||
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), file);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw bad("订舱文件上传失败");
|
||||
}
|
||||
metadata = response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
DocumentMutation result = save(booking, expectedVersion, normalizeType(documentType), metadata, operatorId, operatorName);
|
||||
return complete(oceanBookingId, operatorId, action, key, result);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentMutation attach(long oceanBookingId, DocumentRequest request, String key,
|
||||
long operatorId, String operatorName) {
|
||||
requireExisting(oceanBookingId);
|
||||
String action = "ATTACH_DOCUMENT";
|
||||
String requestHash = hash(action, oceanBookingId, request);
|
||||
DocumentMutation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
|
||||
DocumentMutation.class);
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity booking = lockDraft(oceanBookingId, request.expectedVersion());
|
||||
OceanBookingFileClient.Metadata metadata = metadata(booking, request.fileId());
|
||||
DocumentMutation result = save(booking, request.expectedVersion(), normalizeType(request.documentType()),
|
||||
metadata, operatorId, operatorName);
|
||||
return complete(oceanBookingId, operatorId, action, key, result);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentInvalidation invalidate(long oceanBookingId, long documentId, int expectedVersion, String key,
|
||||
long operatorId, String operatorName) {
|
||||
requireExisting(oceanBookingId);
|
||||
String action = "INVALIDATE_DOCUMENT";
|
||||
String requestHash = hash(action, oceanBookingId, documentId + "|" + expectedVersion);
|
||||
DocumentInvalidation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
|
||||
DocumentInvalidation.class);
|
||||
if (replay != null) return replay;
|
||||
lockDraft(oceanBookingId, expectedVersion);
|
||||
if (repository.invalidateDocument(oceanBookingId, documentId, operatorId, operatorName) != 1) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
|
||||
}
|
||||
touch(oceanBookingId, expectedVersion, operatorId, operatorName);
|
||||
return complete(oceanBookingId, operatorId, action, key,
|
||||
new DocumentInvalidation(documentId, expectedVersion + 1));
|
||||
}
|
||||
|
||||
public List<OceanBookingDocumentEntity> documents(long oceanBookingId) {
|
||||
if (repository.find(oceanBookingId) == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
return repository.documents(oceanBookingId);
|
||||
}
|
||||
|
||||
public Download download(long oceanBookingId, long documentId) {
|
||||
OceanBookingEntity booking = repository.find(oceanBookingId);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
OceanBookingDocumentEntity document = repository.document(oceanBookingId, documentId);
|
||||
if (document == null || !Boolean.TRUE.equals(document.getValid())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
|
||||
}
|
||||
metadata(booking, document.getFileId());
|
||||
try {
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), document.getFileId());
|
||||
byte[] content = response.getBody();
|
||||
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
return new Download(content, document.getFileName(), contentType == null
|
||||
? MediaType.APPLICATION_OCTET_STREAM : contentType);
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
public void requireActiveValidDocument(OceanBookingEntity booking) {
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw conflict("订舱缺少客户归属,无法校验文件");
|
||||
}
|
||||
boolean valid = false;
|
||||
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
|
||||
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
|
||||
try {
|
||||
OceanBookingFileClient.Metadata metadata = metadata(booking, document.getFileId());
|
||||
if (Objects.equals(metadata.fileId(), document.getFileId())) {
|
||||
valid = true;
|
||||
break;
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
if (exception.getResultCode() == ResultCode.SERVICE_UNAVAILABLE) throw exception;
|
||||
}
|
||||
}
|
||||
if (!valid) throw conflict("订舱缺少有效订舱确认或提单文件");
|
||||
}
|
||||
|
||||
private DocumentMutation save(OceanBookingEntity booking, int expectedVersion, String documentType,
|
||||
OceanBookingFileClient.Metadata metadata,
|
||||
long operatorId, String operatorName) {
|
||||
validateMetadata(booking, metadata);
|
||||
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
|
||||
document.setId(IdWorker.getId());
|
||||
document.setOceanBookingId(booking.getId());
|
||||
document.setFileId(metadata.fileId());
|
||||
document.setDocumentType(documentType);
|
||||
document.setFileName(metadata.fileName());
|
||||
document.setFileHash(metadata.sha256());
|
||||
document.setSizeBytes(metadata.sizeBytes());
|
||||
document.setContentType(metadata.contentType());
|
||||
document.setValid(true);
|
||||
document.setUploadedBy(operatorId);
|
||||
document.setUploadedByName(operatorName);
|
||||
document.setUploadedAt(LocalDateTime.now());
|
||||
repository.insert(document);
|
||||
touch(booking.getId(), expectedVersion, operatorId, operatorName);
|
||||
return new DocumentMutation(document, expectedVersion + 1);
|
||||
}
|
||||
|
||||
private OceanBookingEntity lockDraft(long id, int expectedVersion) {
|
||||
OceanBookingEntity booking = repository.findForUpdate(id);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
|
||||
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw bad("请先保存订舱客户后再上传文件");
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
private OceanBookingEntity requireExisting(long id) {
|
||||
OceanBookingEntity booking = repository.find(id);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
return booking;
|
||||
}
|
||||
|
||||
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
|
||||
try {
|
||||
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
Long.toString(booking.getId()), fileId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
|
||||
}
|
||||
validateMetadata(booking, response.data());
|
||||
return response.data();
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
|
||||
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId())
|
||||
|| !BUSINESS_TYPE.equals(metadata.businessType())
|
||||
|| !Long.toString(booking.getId()).equals(metadata.businessId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private void touch(long id, int version, long operatorId, String operatorName) {
|
||||
if (repository.touchDraft(id, version, operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
|
||||
private static String normalizeType(String value) {
|
||||
String normalized = StringUtils.hasText(value) ? value.trim().toUpperCase() : null;
|
||||
if (!DOCUMENT_TYPES.contains(normalized)) throw bad("不支持的订舱文件类型");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
|
||||
private <T> T claimOrReplay(long id, long operatorId, String action, String key, String requestHash,
|
||||
Class<T> responseType) {
|
||||
validateKey(key);
|
||||
String normalized = key.trim();
|
||||
if (repository.claimOperation(id, operatorId, action, normalized, requestHash)) return null;
|
||||
OceanBookingOperationEntity existing = repository.operation(operatorId, action, normalized);
|
||||
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
|
||||
throw conflict("幂等键已用于其他请求");
|
||||
}
|
||||
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
|
||||
throw conflict("相同请求正在处理中,请稍后重试");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(existing.getResultJson(), responseType);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("无法读取订舱文件幂等结果", exception);
|
||||
}
|
||||
}
|
||||
private <T> T complete(long id, long operatorId, String action, String key, T result) {
|
||||
try {
|
||||
if (repository.completeOperation(id, operatorId, action, key.trim(),
|
||||
objectMapper.writeValueAsString(result)) != 1) {
|
||||
throw conflict("订舱文件幂等请求完成状态冲突");
|
||||
}
|
||||
return result;
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("无法保存订舱文件幂等结果", exception);
|
||||
}
|
||||
}
|
||||
private String hash(String action, long id, Object payload) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest((action + '|' + id + '|' + objectMapper.writeValueAsString(payload))
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("无法计算订舱文件请求摘要", exception);
|
||||
}
|
||||
}
|
||||
private static String fileDigest(MultipartFile file) {
|
||||
try (var input = file.getInputStream()) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) digest.update(buffer, 0, read);
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (Exception exception) {
|
||||
throw bad("无法读取上传文件");
|
||||
}
|
||||
}
|
||||
private static void validateKey(String key) {
|
||||
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
|
||||
throw bad("Idempotency-Key不能为空且不能超过128个字符");
|
||||
}
|
||||
}
|
||||
public record Download(byte[] content, String fileName, MediaType contentType) {}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.baiyue.firstleg.oceanbooking.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PrintPdfClient;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class OceanBookingPrintService {
|
||||
public static final String SHIPPING_INSTRUCTION = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
|
||||
public static final String BILL_CONFIRMATION = "BILL_OF_LADING_CONFIRMATION_A4_V1";
|
||||
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(
|
||||
new BigDecimal("210"), new BigDecimal("297"));
|
||||
|
||||
@Resource private OceanBookingService bookings;
|
||||
@Resource private PrintPdfClient print;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
public GeneratedPdf shippingInstruction(long oceanBookingId, String requestId) {
|
||||
return generate(oceanBookingId, requestId, SHIPPING_INSTRUCTION, "出口货物托运单");
|
||||
}
|
||||
|
||||
public GeneratedPdf billConfirmation(long oceanBookingId, String requestId) {
|
||||
return generate(oceanBookingId, requestId, BILL_CONFIRMATION, "提单确认件");
|
||||
}
|
||||
|
||||
private GeneratedPdf generate(long id, String requestId, String template, String suffix) {
|
||||
Detail detail = bookings.detail(id);
|
||||
OceanBookingEntity booking = detail.oceanBooking();
|
||||
if (!"RELEASED".equals(booking.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订舱放舱后才可打印正式单据");
|
||||
}
|
||||
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
||||
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
||||
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
||||
fields(template, detail), List.of(cargo(detail.containerRequirements())))));
|
||||
try {
|
||||
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
byte[] content = response.getBody();
|
||||
if (!response.getStatusCode().is2xxSuccessful() || contentType == null
|
||||
|| !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)
|
||||
|| content == null || content.length == 0) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF");
|
||||
}
|
||||
return new GeneratedPdf(content, fileName,
|
||||
response.getHeaders().getFirst("X-Print-Page-Count"));
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> fields(String template, Detail detail) {
|
||||
OceanBookingEntity booking = detail.oceanBooking();
|
||||
Map<String, OceanBookingPartyEntity> parties = detail.parties().stream()
|
||||
.collect(Collectors.toMap(OceanBookingPartyEntity::getPartyType, value -> value));
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("shipper", party(parties.get("SHIPPER")));
|
||||
fields.put("consignee", party(parties.get("CONSIGNEE")));
|
||||
fields.put("notifyParty", party(parties.get("NOTIFY_PARTY")));
|
||||
fields.put("oceanVesselVoyage", join(booking.getVesselName(), booking.getVoyageNo()));
|
||||
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
||||
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
||||
fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
|
||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||
fields.put("jobNumber", text(booking.getBookingNo()));
|
||||
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
||||
fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
|
||||
fields.put("carrier", text(booking.getShippingCompanyName()));
|
||||
fields.put("bookingAgent", text(booking.getBookingAgentName()));
|
||||
fields.put("remark", text(booking.getRemark()));
|
||||
} else {
|
||||
fields.put("bookingNumber", text(booking.getBillOfLadingNo()));
|
||||
fields.put("expectedDepartureDate", date(booking.getEstimatedSailingAt()));
|
||||
fields.put("containerSpec", requirementSummary(detail.containerRequirements()));
|
||||
fields.put("containerSummary", requirementSummary(detail.containerRequirements()));
|
||||
fields.put("notes", text(booking.getRemark()));
|
||||
}
|
||||
fields.values().removeIf(String::isEmpty);
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static PrintPdfClient.CargoRow cargo(List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
|
||||
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
|
||||
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
|
||||
"CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
|
||||
decimal(volume, 4) + " CBM");
|
||||
}
|
||||
|
||||
private static int totalContainers(List<OceanBookingContainerRequirementEntity> values) {
|
||||
return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
|
||||
}
|
||||
|
||||
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
|
||||
return values.stream().map(value -> value.getContainerType() + " " + value.getContainerKind()
|
||||
+ " x " + value.getQuantity()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
private static String party(OceanBookingPartyEntity value) {
|
||||
if (value == null) return "";
|
||||
return List.of(text(value.getPartyName()), text(value.getAddress()),
|
||||
join(value.getContactName(), value.getContactPhone())).stream()
|
||||
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
private static String date(java.time.LocalDateTime value) {
|
||||
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
private static String decimal(BigDecimal value, int scale) {
|
||||
return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
|
||||
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
|
||||
private static String text(String value) { return value == null ? "" : value.trim(); }
|
||||
|
||||
public record GeneratedPdf(byte[] content, String fileName, String pageCount) {}
|
||||
}
|
||||
+8
-2
@@ -49,6 +49,7 @@ public class OceanBookingService {
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Resource private WarehousePartnerClient partners;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OceanBookingDocumentService documents;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
|
||||
@@ -147,10 +148,11 @@ public class OceanBookingService {
|
||||
String action = "RELEASE";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||
validateRelease(before, repository.parties(id), repository.requirements(id));
|
||||
validateMasterData(before);
|
||||
documents.requireActiveValidDocument(before);
|
||||
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
|
||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
@@ -189,9 +191,10 @@ public class OceanBookingService {
|
||||
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
|
||||
}
|
||||
|
||||
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); }
|
||||
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.containers(value.getId()), repository.documents(value.getId()), repository.histories(value.getId())); }
|
||||
private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; }
|
||||
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
||||
|
||||
private void validateDraftStructure(DraftData data) {
|
||||
@@ -224,6 +227,9 @@ public class OceanBookingService {
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|
||||
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|
||||
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|
||||
|| (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode()))
|
||||
|| (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode()))
|
||||
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
||||
|| !StringUtils.hasText(booking.getCarrierCode())
|
||||
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||
|
||||
@@ -5,10 +5,12 @@ 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.domain.TruckAppointmentStatus;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
@@ -32,9 +34,10 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class TruckAppointmentService {
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("BILL_OF_LADING", "DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@Resource SmartLoadingRepository loadingPlans;
|
||||
@Resource ContainerRepository containers;
|
||||
@Resource TruckAppointmentFileClient files;
|
||||
@@ -53,8 +56,27 @@ public class TruckAppointmentService {
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List<TruckAppointmentEntity> active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);}
|
||||
private AssociationContext validateAssociations(SaveRequest r,long customerId){OceanBookingEntity booking=null;ContainerBookingEntity container=null;Long warehouseId=r.warehouseId();String billOfLadingNo=trim(r.billOfLadingNo());if(r.oceanBookingId()!=null){booking=oceanBookings.find(r.oceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");warehouseId=mergeWarehouse(warehouseId,booking.getWarehouseId(),"订舱");if("RELEASED".equals(booking.getStatus()))billOfLadingNo=releasedBookingBill(booking,billOfLadingNo);}if(r.containerBookingId()!=null){container=containers.find(r.containerBookingId());if(container==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜次不存在");if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");}if(booking!=null&&container!=null){long containerId=container.getId();boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));if(!allocated)throw bad("containerBookingId未分配至该订舱");}if(r.loadingPlanId()!=null){if(container==null)throw bad("选择loadingPlanId时必须同时选择containerBookingId");LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}return new AssociationContext(booking,container,warehouseId,billOfLadingNo);}
|
||||
private static void validateRequired(SaveRequest r){if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
|
||||
private AssociationContext validateAssociations(SaveRequest r,long customerId){
|
||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
|
||||
if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");
|
||||
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
|
||||
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
|
||||
ContainerBookingEntity container=containers.find(r.containerBookingId());
|
||||
if(container==null||!"ACTIVE".equals(container.getRecordStatus()))throw new BizException(ResultCode.NOT_FOUND,"关联有效柜次不存在");
|
||||
if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");
|
||||
warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");
|
||||
if(!Objects.equals(container.getOceanBookingId(),booking.getId()))throw bad("柜次未关联至所选订舱");
|
||||
long containerId=container.getId();
|
||||
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));
|
||||
if(!allocated)throw bad("containerBookingId未分配至该订舱");
|
||||
Iso6346ContainerNumber.require(container.getContainerNo());
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}
|
||||
return new AssociationContext(booking,container,warehouseId,billOfLadingNo);
|
||||
}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
|
||||
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
@@ -66,7 +88,20 @@ public class TruckAppointmentService {
|
||||
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
||||
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
|
||||
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
|
||||
private String requireDispatchReady(TruckAppointmentEntity appointment){String bill=trim(appointment.getBillOfLadingNo());if(appointment.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if("RELEASED".equals(booking.getStatus()))bill=releasedBookingBill(booking,bill);}if(!StringUtils.hasText(bill))throw conflict("派车前必须填写提单号");boolean active=false;java.util.List<TruckAppointmentAttachmentEntity> references=repo.attachments(appointment.getId());if(references!=null)for(TruckAppointmentAttachmentEntity attachment:references){if(!"BILL_OF_LADING".equals(attachment.getAttachmentType()))continue;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(appointment.getId()),attachment.getFileId());if(response!=null&&response.successful()&&response.data()!=null){active=true;break;}}catch(FeignException.NotFound ignored){}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}if(!active)throw conflict("派车前必须上传有效提单附件");return bill;}
|
||||
private String requireDispatchReady(TruckAppointmentEntity appointment){
|
||||
if(appointment.getOceanBookingId()==null||appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少订舱或柜次关联");
|
||||
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
|
||||
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
||||
ContainerBookingEntity container=containers.find(appointment.getContainerBookingId());
|
||||
if(container==null||!"ACTIVE".equals(container.getRecordStatus())||!Objects.equals(container.getOceanBookingId(),booking.getId()))throw conflict("关联柜次已失效或不属于当前订舱");
|
||||
Iso6346ContainerNumber.require(container.getContainerNo());
|
||||
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),container.getId()));
|
||||
if(!allocated)throw conflict("关联柜次未分配至当前订舱");
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return bill;
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
||||
|
||||
Reference in New Issue
Block a user