feat(firstleg): enforce booking-owned truck workflow

This commit is contained in:
2026-08-31 19:55:58 +08:00
parent f2a980e5c1
commit 0cc4eae209
27 changed files with 1318 additions and 86 deletions
@@ -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) {}
}
@@ -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){ }
}
@@ -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) {}
@@ -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;
}
@@ -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);
}
@@ -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}")
@@ -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)); }
@@ -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) {}
}
@@ -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) {}
}
@@ -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;}