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