Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
27 changed files with 1318 additions and 86 deletions
Showing only changes of commit 0cc4eae209 - Show all commits
@@ -9197,6 +9197,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_document (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱文件关联ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:BOOKING_CONFIRMATION订舱确认、BILL_OF_LADING提单', file_name VARCHAR(255) NOT NULL COMMENT 'File返回的原始文件名快照', file_hash CHAR(64) NULL COMMENT 'File返回的SHA-256快照',
size_bytes BIGINT UNSIGNED NULL COMMENT 'File返回的文件大小快照', content_type VARCHAR(128) NULL COMMENT 'File返回的内容类型快照', valid TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否为有效文件关联',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或关联操作人ID', uploaded_by_name VARCHAR(64) NULL COMMENT '上传或关联操作人姓名快照', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传或关联时间',
invalidated_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID', invalidated_by_name VARCHAR(64) NULL COMMENT '作废操作人姓名快照', invalidated_at DATETIME(3) NULL COMMENT '作废时间',
PRIMARY KEY(id), KEY idx_ocean_document_booking(ocean_booking_id,valid,uploaded_at,id), KEY idx_ocean_document_file(file_id),
CONSTRAINT fk_ocean_document_booking FOREIGN KEY(ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_document_type CHECK(document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING')), CONSTRAINT chk_ocean_document_valid CHECK(valid IN (0,1))
) ENGINE=InnoDB COMMENT='订舱聚合拥有的订舱确认与提单文件关联';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
@@ -9292,6 +9303,21 @@ CREATE TRIGGER trg_ocean_history_before_delete BEFORE DELETE ON fl_ocean_booking
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_document_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_document_before_update;
DROP TRIGGER IF EXISTS trg_ocean_document_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_document_before_insert BEFORE INSERT ON fl_ocean_booking_document FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读'; END IF; END$$
CREATE TRIGGER trg_ocean_document_before_update BEFORE UPDATE ON fl_ocean_booking_document FOR EACH ROW
BEGIN
IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读'; END IF;
IF NEW.ocean_booking_id <> OLD.ocean_booking_id OR NEW.file_id <> OLD.file_id OR NEW.document_type <> OLD.document_type OR NEW.uploaded_by <> OLD.uploaded_by OR NEW.uploaded_at <> OLD.uploaded_at OR NEW.valid > OLD.valid THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件只允许在草稿期作废'; END IF;
END$$
CREATE TRIGGER trg_ocean_document_before_delete BEFORE DELETE ON fl_ocean_booking_document FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件关联不可物理删除'; END$$
DELIMITER ;
USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145);
DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145);
@@ -9364,9 +9390,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他',
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他;订舱确认与提单归订舱聚合',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
@@ -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;}
@@ -139,6 +139,30 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_document (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱文件关联ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:BOOKING_CONFIRMATION订舱确认、BILL_OF_LADING提单',
file_name VARCHAR(255) NOT NULL COMMENT 'File返回的原始文件名快照',
file_hash CHAR(64) NULL COMMENT 'File返回的SHA-256快照',
size_bytes BIGINT UNSIGNED NULL COMMENT 'File返回的文件大小快照',
content_type VARCHAR(128) NULL COMMENT 'File返回的内容类型快照',
valid TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否为有效文件关联',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或关联操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传或关联操作人姓名快照',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传或关联时间',
invalidated_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
invalidated_by_name VARCHAR(64) NULL COMMENT '作废操作人姓名快照',
invalidated_at DATETIME(3) NULL COMMENT '作废时间',
PRIMARY KEY (id),
KEY idx_ocean_document_booking (ocean_booking_id,valid,uploaded_at,id),
KEY idx_ocean_document_file (file_id),
CONSTRAINT fk_ocean_document_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_document_type CHECK (document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING')),
CONSTRAINT chk_ocean_document_valid CHECK (valid IN (0,1))
) ENGINE=InnoDB COMMENT='订舱聚合拥有的订舱确认与提单文件关联';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
@@ -227,6 +251,9 @@ DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_history_before_update;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
DROP TRIGGER IF EXISTS trg_ocean_document_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_document_before_update;
DROP TRIGGER IF EXISTS trg_ocean_document_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_history_before_update BEFORE UPDATE ON fl_ocean_booking_status_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可修改'; END$$
@@ -234,6 +261,28 @@ CREATE TRIGGER trg_ocean_history_before_delete BEFORE DELETE ON fl_ocean_booking
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ;
DELIMITER $$
CREATE TRIGGER trg_ocean_document_before_insert BEFORE INSERT ON fl_ocean_booking_document FOR EACH ROW
BEGIN
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读';
END IF;
END$$
CREATE TRIGGER trg_ocean_document_before_update BEFORE UPDATE ON fl_ocean_booking_document FOR EACH ROW
BEGIN
IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱文件永久只读';
END IF;
IF NEW.ocean_booking_id <> OLD.ocean_booking_id OR NEW.file_id <> OLD.file_id
OR NEW.document_type <> OLD.document_type OR NEW.uploaded_by <> OLD.uploaded_by
OR NEW.uploaded_at <> OLD.uploaded_at OR NEW.valid > OLD.valid THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件只允许在草稿期作废';
END IF;
END$$
CREATE TRIGGER trg_ocean_document_before_delete BEFORE DELETE ON fl_ocean_booking_document FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱文件关联不可物理删除'; END$$
DELIMITER ;
USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145);
DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145);
@@ -53,9 +53,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他',
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他;订舱确认与提单归订舱聚合',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
@@ -1,5 +1,8 @@
openapi: 3.0.3
info: {title: Ocean Booking API, version: 2.0.0}
info:
title: Ocean Booking API
version: 2.1.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only.
paths:
/api/platform/v1/ocean-bookings:
get:
@@ -29,10 +32,76 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Validate and permanently release a DRAFT
description: Release validates the persisted aggregate and never requires or creates a real container number.
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. It never requires or creates a real container number.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/documents:
get:
summary: List current and invalidated booking-owned file references
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
'200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}}
post:
summary: Reference an existing File object in a DRAFT booking
description: File scope must be customerId + OCEAN_BOOKING_DOCUMENT + oceanBookingId. The booking version is incremented atomically.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}}
responses:
'200': {description: Created reference and latest booking version, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentMutationResponse'}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/documents/upload:
post:
summary: Upload and associate a file with a DRAFT booking
description: Uploads through File businessType OCEAN_BOOKING_DOCUMENT and increments the booking version atomically.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [expectedVersion, documentType, file]
properties:
expectedVersion: {type: integer, minimum: 0}
documentType: {$ref: '#/components/schemas/DocumentType'}
file: {type: string, format: binary}
responses:
'200': {description: Created reference and latest booking version, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentMutationResponse'}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/documents/{documentId}:
delete:
summary: Logically invalidate a booking file reference while DRAFT
description: The File object and immutable reference history are retained; RELEASED bookings are read-only.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentActionRequest'}}}}
responses:
'200': {description: Invalidation result and latest booking version, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentInvalidationResponse'}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/documents/{documentId}/download:
get:
summary: Download an active booking-owned file
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/DocumentId'}]
responses:
'200': {description: Original file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the released booking as an export shipping instruction PDF
description: Firstleg maps the authoritative released aggregate to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
'200': {description: PDF or a business-error JSON envelope, content: {application/pdf: {schema: {type: string, format: binary}}, application/json: {schema: {type: object}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the released booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative released aggregate to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
'200': {description: PDF or a business-error JSON envelope, content: {application/pdf: {schema: {type: string, format: binary}}, application/json: {schema: {type: object}}}}
/api/platform/v1/ocean-bookings/link-candidates:
get:
summary: Page released booking requirement candidates for one container
@@ -84,6 +153,7 @@ paths:
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
DocumentId: {name: documentId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
CreateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, enum: [0]}, data: {$ref: '#/components/schemas/DraftData'}}}
@@ -131,6 +201,55 @@ components:
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
DocumentRequest:
type: object
required: [expectedVersion, fileId, documentType]
properties:
expectedVersion: {type: integer, minimum: 0}
fileId: {type: integer, format: int64, minimum: 1}
documentType: {$ref: '#/components/schemas/DocumentType'}
DocumentActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}
OceanBookingDocument:
type: object
required: [id, oceanBookingId, fileId, documentType, fileName, valid, uploadedBy, uploadedAt]
properties:
id: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64}
fileId: {type: integer, format: int64}
documentType: {$ref: '#/components/schemas/DocumentType'}
fileName: {type: string}
fileHash: {type: string, nullable: true}
sizeBytes: {type: integer, format: int64, nullable: true}
contentType: {type: string, nullable: true}
valid: {type: boolean}
uploadedBy: {type: integer, format: int64}
uploadedByName: {type: string, nullable: true}
uploadedAt: {type: string, format: date-time}
invalidatedBy: {type: integer, format: int64, nullable: true}
invalidatedByName: {type: string, nullable: true}
invalidatedAt: {type: string, format: date-time, nullable: true}
DocumentMutation:
type: object
required: [document, bookingVersion]
properties:
document: {$ref: '#/components/schemas/OceanBookingDocument'}
bookingVersion: {type: integer, minimum: 1}
DocumentInvalidation:
type: object
required: [documentId, bookingVersion]
properties:
documentId: {type: integer, format: int64}
bookingVersion: {type: integer, minimum: 1}
DocumentListResponse:
type: object
properties: {code: {type: string, example: '200'}, data: {type: array, items: {$ref: '#/components/schemas/OceanBookingDocument'}}}
DocumentMutationResponse:
type: object
properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/DocumentMutation'}}
DocumentInvalidationResponse:
type: object
properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/DocumentInvalidation'}}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
@@ -189,7 +308,7 @@ components:
description: Sum of all container requirement quantities; independent of allocation, linking or unlinking.
Detail:
type: object
required: [oceanBooking, parties, containerRequirements, containers, histories]
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
properties:
oceanBooking:
type: object
@@ -198,4 +317,5 @@ components:
parties: {type: array}
containerRequirements: {type: array}
containers: {type: array, description: Existing containers allocated after port pickup linking}
documents: {type: array, items: {$ref: '#/components/schemas/OceanBookingDocument'}, description: Current and invalidated booking-owned file references}
histories: {type: array}
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Truck Appointment API
version: 1.2.0
description: Truck appointments are independent operational records. Ocean booking, container booking and loading plan are optional links. Draft creation/editing does not require a bill of lading or attachment; DISPATCH requires a nonblank billOfLadingNo and at least one active BILL_OF_LADING attachment owned by the appointment customer. When linked to a RELEASED booking, the server freezes and validates the booking bill of lading snapshot. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
version: 1.3.0
description: Every truck appointment must select one RELEASED ocean booking and one ACTIVE real-numbered container already allocated to that booking. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
paths:
/api/platform/v1/truck-appointments:
get:
@@ -22,7 +22,7 @@ paths:
properties:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post:
summary: Create an independent truck appointment with optional fulfillment links
summary: Create a truck appointment for a released booking and its allocated real container
requestBody:
required: true
content:
@@ -88,7 +88,7 @@ paths:
/api/platform/v1/truck-appointments/{id}/attachments:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
summary: Query attachment references owned by the appointment
summary: Query non-booking operational attachment references owned by the appointment
description: 'Requires platform:firstleg:truck-appointment:list.'
responses:
'200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}}
@@ -111,7 +111,7 @@ paths:
type: object
required: [attachmentType, file]
properties:
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH}
attachmentType: {type: string, enum: [DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Booking confirmations and bills of lading belong to the ocean-booking aggregate}
file: {type: string, format: binary}
responses:
'200': {description: Uploaded attachment reference}
@@ -155,7 +155,7 @@ components:
required: [fileId, attachmentType]
properties:
fileId: {type: integer, format: int64, minimum: 1}
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH}
attachmentType: {type: string, enum: [DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER]}
fileName: {type: string, maxLength: 255, description: Ignored; authoritative metadata comes from File service}
fileHash: {type: string, maxLength: 128, description: Ignored; authoritative metadata comes from File service}
Attachment:
@@ -164,7 +164,7 @@ components:
id: {type: integer, format: int64}
appointmentId: {type: integer, format: int64}
fileId: {type: integer, format: int64}
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER]}
attachmentType: {type: string, enum: [DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER]}
fileName: {type: string}
fileHash: {type: string}
visibility: {type: string, enum: [PLATFORM]}
@@ -178,24 +178,24 @@ components:
reason: {type: string, maxLength: 500}
SaveRequest:
type: object
required: [customerId, carrierId, plannedLoadingAt, expectedVersion]
required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion]
properties:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE container already allocated to oceanBookingId and carrying a valid unique ISO 6346 container number.}
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.}
customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.}
customerName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; persisted value is resolved from IAM customer master data.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional on draft; required before DISPATCH and server-frozen from a linked RELEASED booking when present.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
plannedLoadingAt: {type: string, format: date-time}
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences:
type: object
required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount]
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment.
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Ocean booking and container associations are always present.
properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true}
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","BILL_OF_LADING","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,8 +30,8 @@ class TruckAppointmentControllerContractTest {
}
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainer() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -24,6 +24,13 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("bookingAgents", "platform:firstleg:ocean-booking:list"),
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("documents", "platform:firstleg:ocean-booking:list"),
Map.entry("downloadDocument", "platform:firstleg:ocean-booking:list"),
Map.entry("printShippingInstruction", "platform:firstleg:ocean-booking:list"),
Map.entry("printBillConfirmation", "platform:firstleg:ocean-booking:list"),
Map.entry("attachDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
@@ -33,7 +40,8 @@ class PlatformOceanBookingControllerContractTest {
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") && !name.equals("bookingAgents")
&& !name.equals("linkCandidates")) {
&& !name.equals("linkCandidates") && !name.equals("documents") && !name.equals("downloadDocument")
&& !name.startsWith("print")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -45,7 +53,8 @@ class PlatformOceanBookingControllerContractTest {
.filter(method -> !method.getName().equals("page") && !method.getName().equals("create"))
.flatMap(method -> Arrays.stream(method.getParameters()))
.filter(parameter -> parameter.isAnnotationPresent(org.springframework.web.bind.annotation.PathVariable.class))
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
.allMatch(parameter -> parameter.getName().equals("oceanBookingId")
|| parameter.getName().equals("documentId"))).isTrue();
}
@Test
@@ -55,6 +64,8 @@ class PlatformOceanBookingControllerContractTest {
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release a DRAFT")
.contains("OCEAN_BOOKING_DOCUMENT", "BOOKING_CONFIRMATION", "BILL_OF_LADING")
.contains("/prints/shipping-instruction:", "/prints/bill-of-lading-confirmation:")
.contains("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
@@ -2,9 +2,11 @@ package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingDocumentMapper;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Mapper;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
@@ -76,4 +78,13 @@ class OceanBookingMapperContractTest {
.contains("allocation.ocean_booking_id=#{oceanBookingId}")
.contains("allocation.requirement_id=#{requirementId}");
}
@Test void documentMapperIsRegisteredAndOnlyLogicallyInvalidatesActiveReference() throws Exception {
assertThat(OceanBookingDocumentMapper.class.getAnnotation(Mapper.class)).isNotNull();
String sql = String.join("\n", OceanBookingDocumentMapper.class
.getDeclaredMethod("invalidate", long.class, long.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("SET valid=0", "ocean_booking_id=#{oceanBookingId}",
"id=#{documentId}", "valid=1", "invalidated_at=CURRENT_TIMESTAMP(3)");
}
}
@@ -16,6 +16,7 @@ class OceanBookingSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_document")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
@@ -42,6 +43,9 @@ class OceanBookingSqlContractTest {
.contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
.contains("document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING')")
.contains("订舱文件关联不可物理删除")
.contains("已放舱订舱文件永久只读")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
}
@@ -0,0 +1,133 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OceanBookingFileClient;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingDocumentServiceTest {
@Mock OceanBookingRepository repository;
@Mock OceanBookingFileClient files;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks OceanBookingDocumentService service;
@BeforeEach void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "internalToken", "token");
lenient().when(repository.find(10L)).thenReturn(draft(0));
}
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
OceanBookingEntity booking = draft(2);
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(true, false);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
.thenReturn(ApiResponse.success(metadata(91L)));
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(1);
var first = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(repository).claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), hash.capture());
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash.getValue()); operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.operation(7L, "UPLOAD_DOCUMENT", "upload-1")).thenReturn(operation);
var replay = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
assertThat(replay.bookingVersion()).isEqualTo(3);
assertThat(replay.document().getFileId()).isEqualTo(91L);
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
}
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"attach-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void sameIdempotencyKeyWithDifferentPayloadIsRejectedWithoutFileLookup() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(false);
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash("different"); operation.setStatus("SUCCESS"); operation.setResultJson("{}");
when(repository.operation(7L, "ATTACH_DOCUMENT", "same-key")).thenReturn(operation);
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"same-key", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他请求");
verifyNoInteractions(files);
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
}
@Test void invalidationIsRejectedAfterReleaseWithoutDrift() {
OceanBookingEntity released = draft(4); released.setStatus("RELEASED");
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(released);
assertThatThrownBy(() -> service.invalidate(10L, 20L, 4, "delete-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("永久只读");
verify(repository, never()).invalidateDocument(anyLong(), anyLong(), anyLong(), anyString());
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void activeDocumentMustStillExistInFileService() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
service.requireActiveValidDocument(booking);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
return booking;
}
private static OceanBookingFileClient.Metadata metadata(long fileId) {
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
"booking.pdf", "hash", "application/pdf", 3L);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.oceanbooking.service;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingPrintServiceTest {
@Mock OceanBookingService bookings;
@Mock PrintPdfClient print;
@InjectMocks OceanBookingPrintService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); }
@Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() {
when(bookings.detail(10L)).thenReturn(detail("RELEASED"));
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
headers.set("X-Print-Page-Count", "1");
when(print.generateFreightA4Documents(eq("token"), eq("req-1"), any()))
.thenReturn(new ResponseEntity<>(new byte[]{1, 2, 3}, headers, 200));
var pdf = service.shippingInstruction(10L, "req-1");
assertThat(pdf.fileName()).isEqualTo("OB-1-出口货物托运单.pdf");
ArgumentCaptor<PrintPdfClient.FreightA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print).generateFreightA4Documents(eq("token"), eq("req-1"), request.capture());
assertThat(request.getValue().templateCode()).isEqualTo("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1");
var document = request.getValue().documents().get(0);
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
assertThat(document.cargoRows()).singleElement().satisfies(row -> {
assertThat(row.quantity()).isEqualTo("2");
assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2");
assertThat(row.grossWeight()).isEqualTo("56000.000 KGS");
});
}
@Test void printRejectsDraftWithoutCallingPrintService() {
when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
.isInstanceOf(BizException.class).hasMessageContaining("放舱后");
verifyNoInteractions(print);
}
private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001");
booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA");
booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY");
booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent");
booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
OceanBookingPartyEntity shipper = new OceanBookingPartyEntity();
shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address");
OceanBookingPartyEntity consignee = new OceanBookingPartyEntity();
consignee.setPartyType("CONSIGNEE"); consignee.setPartyName("Consignee");
OceanBookingPartyEntity notify = new OceanBookingPartyEntity();
notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2);
requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("28000"));
return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of());
}
}
@@ -39,6 +39,7 @@ class OceanBookingServiceTest {
@Mock private WarehouseMasterDataClient warehouses;
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Mock private OceanBookingDocumentService documents;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -53,7 +54,9 @@ class OceanBookingServiceTest {
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.documents(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
lenient().when(repository.findForUpdate(anyLong())).thenAnswer(invocation -> repository.find(invocation.getArgument(0)));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@@ -115,6 +118,23 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void rejectsReleaseWithoutActiveBookingOwnedFileWithoutStateDrift() {
stubMasterData();
OceanBookingEntity draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(documents).requireActiveValidDocument(draft);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-file",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
stubMasterData();
@@ -331,6 +351,9 @@ class OceanBookingServiceTest {
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setCarrierCode("MATSON");
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳");
value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶");
value.setPlaceOfDelivery("LAX");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
@@ -47,6 +48,7 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@@ -60,6 +62,13 @@ class TruckAppointmentServiceTest {
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking();
ContainerBookingEntity container = realContainer();
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
lenient().when(containers.find(88L)).thenReturn(container);
lenient().when(oceanBookings.allocations(99L)).thenReturn(List.of(allocation));
}
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@@ -90,7 +99,7 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository);
}
@Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() {
@Test void createRejectsMissingBookingAndContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L);
@@ -101,28 +110,20 @@ class TruckAppointmentServiceTest {
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
TruckAppointmentEntity created = service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getOceanBookingId()).isNull();
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
assertThat(created.getAttachmentCount()).isZero();
verifyNoInteractions(oceanBookings, containers, loadingPlans);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("oceanBookingId");
verify(repository, never()).insert(any());
}
@Test void createAllowsDraftWithoutBillOfLadingOrAttachment() {
@Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
SaveRequest request = validRequest();
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isNull();
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
verifyNoInteractions(files);
}
@@ -147,35 +148,28 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致");
}
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() {
@Test void createRejectsBookingThatIsNotReleased() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L);
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已放舱");
verify(repository, never()).insert(any());
}
@Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(17L);
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(17L); booking.setBillOfLadingNo("BL-1");
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -188,9 +182,9 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L);
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L);
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
@@ -200,13 +194,61 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanWithoutContainerAssociation() {
@Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号");
verify(repository, never()).insert(any());
when(containers.find(88L)).thenReturn(realContainer());
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).insert(any());
}
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
existing.setCustomerId(7L); existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(existing, existing);
SaveRequest request = validRequest();
when(request.expectedVersion()).thenReturn(3);
when(request.oceanBookingId()).thenReturn(199L);
when(request.containerBookingId()).thenReturn(188L);
OceanBookingEntity booking = releasedBooking(); booking.setId(199L); booking.setBillOfLadingNo("BL-NEW");
ContainerBookingEntity container = realContainer(); container.setId(188L); container.setOceanBookingId(199L);
container.setContainerNo("MSCU6639870");
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(188L);
when(oceanBookings.find(199L)).thenReturn(booking);
when(containers.find(188L)).thenReturn(container);
when(oceanBookings.allocations(199L)).thenReturn(List.of(allocation));
when(repository.updateEdit(any(), eq(3))).thenReturn(1);
service.edit(1L, 7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> updated = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).updateEdit(updated.capture(), eq(3));
assertThat(updated.getValue().getOceanBookingId()).isEqualTo(199L);
assertThat(updated.getValue().getContainerBookingId()).isEqualTo(188L);
assertThat(updated.getValue().getBillOfLadingNo()).isEqualTo("BL-NEW");
container.setOceanBookingId(99L);
assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未关联至所选订舱");
}
@Test void createRejectsMissingContainerBeforeLoadingPlanValidation() {
SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L);
when(request.containerBookingId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId")
.hasMessageContaining("containerBookingId");
.isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@@ -220,9 +262,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
@@ -245,9 +287,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
@@ -293,8 +335,11 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() {
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null);
when(oceanBookings.find(99L)).thenReturn(bookingWithoutBill);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
@@ -303,34 +348,30 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setBillOfLadingNo("BL-1");
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
missingAttachment.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
when(repository.attachments(1L)).thenReturn(List.of());
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效提单附件");
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setBillOfLadingNo(null);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setBillOfLadingNo("BL-BOOKING-1");
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
TruckAppointmentAttachmentEntity attachment = new TruckAppointmentAttachmentEntity();
attachment.setFileId(88L); attachment.setAttachmentType("BILL_OF_LADING");
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(repository.attachments(1L)).thenReturn(List.of(attachment));
when(files.metadata("test-internal-token", 7L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 88L))
.thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(88L, 7L,
"TRUCK_APPOINTMENT_ATTACHMENT", "1", "bill.pdf", "hash", "application/pdf", 10L)));
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
@@ -457,10 +498,24 @@ class TruckAppointmentServiceTest {
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
private static OceanBookingEntity releasedBooking() {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-BOOKING-1");
return booking;
}
private static ContainerBookingEntity realContainer() {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L);
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
return container;
}
}