feat(firstleg): enforce booking-owned truck workflow

This commit is contained in:
2026-08-31 19:55:58 +08:00
parent f2a980e5c1
commit 0cc4eae209
27 changed files with 1318 additions and 86 deletions
@@ -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) CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID', id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_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$$ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ; 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; USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); 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); 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) 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='拖车状态与改约历史'; ) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( 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', 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='拖车附件文件引用'; ) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( 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摘要;用于识别同键不同请求', 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.math.BigDecimal;
import java.util.List; 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", @FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient { public interface PrintPdfClient {
@@ -19,6 +20,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId, @RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request); @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, record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<Label> labels) { String downloadFileName, List<Label> labels) {
} }
@@ -29,4 +36,12 @@ public interface PrintPdfClient {
record Label(String topCode, String warehouseText, String cartonText, record Label(String topCode, String warehouseText, String cartonText,
String routeText, String qrContent) { String routeText, String qrContent) {
} }
record FreightA4PdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<FreightDocument> documents) {}
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
record CargoRow(String marksAndNos, String quantity, String packaging,
String descriptionOfGoods, String grossWeight, String measurement) {}
} }
@@ -6,7 +6,7 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));} @GetMapping("/{id}/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));} @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("/{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);} @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));} @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));} @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; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} 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 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 ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
} }
@@ -7,6 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingService; 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 com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; 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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; 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; import java.util.List;
@Validated @Validated
@@ -24,6 +33,8 @@ import java.util.List;
@RequestMapping("/api/platform/v1/ocean-bookings") @RequestMapping("/api/platform/v1/ocean-bookings")
public class PlatformOceanBookingController { public class PlatformOceanBookingController {
@Resource private OceanBookingService service; @Resource private OceanBookingService service;
@Resource private OceanBookingDocumentService documentService;
@Resource private OceanBookingPrintService printService;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") @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)); } 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')") @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)); } 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')") @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱草稿", captureBefore=false) @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)); } 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="确认放舱") @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)); } 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 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.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
@@ -12,6 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -86,6 +88,12 @@ public final class OceanBookingDtos {
@Size(max = 1000) String reason) {} @Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion, public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {} @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, public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType, Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg, String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
@@ -96,7 +104,14 @@ public final class OceanBookingDtos {
List<OceanBookingPartyEntity> parties, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements, List<OceanBookingContainerRequirementEntity> containerRequirements,
List<RealContainer> containers, 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, public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
String priceModel, List<String> pricingTaxModes) {} String priceModel, List<String> pricingTaxModes) {}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_ocean_booking_document")
public class OceanBookingDocumentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private Long fileId;
private String documentType;
private String fileName;
private String fileHash;
private Long sizeBytes;
private String contentType;
private Boolean valid;
private Long uploadedBy;
private String uploadedByName;
private LocalDateTime uploadedAt;
private Long invalidatedBy;
private String invalidatedByName;
private LocalDateTime invalidatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OceanBookingDocumentMapper extends BaseMapper<OceanBookingDocumentEntity> {
@Update("""
UPDATE fl_ocean_booking_document
SET valid=0,invalidated_by=#{operatorId},invalidated_by_name=#{operatorName},
invalidated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{documentId} AND ocean_booking_id=#{oceanBookingId} AND valid=1
""")
int invalidate(@Param("oceanBookingId") long oceanBookingId,
@Param("documentId") long documentId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -15,6 +15,16 @@ import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> { 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}") @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); 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}") @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
@@ -27,6 +27,7 @@ public class OceanBookingRepository {
@Resource private OceanBookingContainerAllocationMapper allocations; @Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private OceanBookingStatusHistoryMapper histories; @Resource private OceanBookingStatusHistoryMapper histories;
@Resource private OceanBookingOperationMapper operations; @Resource private OceanBookingOperationMapper operations;
@Resource private OceanBookingDocumentMapper documents;
@Resource private ContainerRepository containerBookings; @Resource private ContainerRepository containerBookings;
public PageResult<OceanBookingEntity> page(Query query) { 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)); return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
} }
public OceanBookingEntity find(long id) { return bookings.selectById(id); } 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 void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, 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) { public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value.getId(), value, version, operatorId, 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) { public int deleteDraft(long id, int version) {
return bookings.deleteDraft(id, version); return bookings.deleteDraft(id, version);
} }
@@ -73,6 +78,27 @@ public class OceanBookingRepository {
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); .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<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 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 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)); } public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
@@ -0,0 +1,288 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OceanBookingFileClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentInvalidation;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentMutation;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
@Service
public class OceanBookingDocumentService {
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
@Resource private OceanBookingRepository repository;
@Resource private OceanBookingFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public DocumentMutation upload(long oceanBookingId, int expectedVersion, String documentType, String key,
MultipartFile file, long operatorId, String operatorName) {
requireExisting(oceanBookingId);
String action = "UPLOAD_DOCUMENT";
String requestHash = hash(action, oceanBookingId, expectedVersion + "|" + normalizeType(documentType)
+ "|" + fileDigest(file));
DocumentMutation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
DocumentMutation.class);
if (replay != null) return replay;
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
OceanBookingFileClient.Metadata metadata;
try {
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE,
Long.toString(oceanBookingId), file);
if (response == null || !response.successful() || response.data() == null) {
throw bad("订舱文件上传失败");
}
metadata = response.data();
} catch (FeignException exception) {
throw unavailable();
}
DocumentMutation result = save(booking, expectedVersion, normalizeType(documentType), metadata, operatorId, operatorName);
return complete(oceanBookingId, operatorId, action, key, result);
}
@Transactional
public DocumentMutation attach(long oceanBookingId, DocumentRequest request, String key,
long operatorId, String operatorName) {
requireExisting(oceanBookingId);
String action = "ATTACH_DOCUMENT";
String requestHash = hash(action, oceanBookingId, request);
DocumentMutation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
DocumentMutation.class);
if (replay != null) return replay;
OceanBookingEntity booking = lockDraft(oceanBookingId, request.expectedVersion());
OceanBookingFileClient.Metadata metadata = metadata(booking, request.fileId());
DocumentMutation result = save(booking, request.expectedVersion(), normalizeType(request.documentType()),
metadata, operatorId, operatorName);
return complete(oceanBookingId, operatorId, action, key, result);
}
@Transactional
public DocumentInvalidation invalidate(long oceanBookingId, long documentId, int expectedVersion, String key,
long operatorId, String operatorName) {
requireExisting(oceanBookingId);
String action = "INVALIDATE_DOCUMENT";
String requestHash = hash(action, oceanBookingId, documentId + "|" + expectedVersion);
DocumentInvalidation replay = claimOrReplay(oceanBookingId, operatorId, action, key, requestHash,
DocumentInvalidation.class);
if (replay != null) return replay;
lockDraft(oceanBookingId, expectedVersion);
if (repository.invalidateDocument(oceanBookingId, documentId, operatorId, operatorName) != 1) {
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
}
touch(oceanBookingId, expectedVersion, operatorId, operatorName);
return complete(oceanBookingId, operatorId, action, key,
new DocumentInvalidation(documentId, expectedVersion + 1));
}
public List<OceanBookingDocumentEntity> documents(long oceanBookingId) {
if (repository.find(oceanBookingId) == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
return repository.documents(oceanBookingId);
}
public Download download(long oceanBookingId, long documentId) {
OceanBookingEntity booking = repository.find(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
OceanBookingDocumentEntity document = repository.document(oceanBookingId, documentId);
if (document == null || !Boolean.TRUE.equals(document.getValid())) {
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
}
metadata(booking, document.getFileId());
try {
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
Long.toString(oceanBookingId), document.getFileId());
byte[] content = response.getBody();
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
throw unavailable();
}
MediaType contentType = response.getHeaders().getContentType();
return new Download(content, document.getFileName(), contentType == null
? MediaType.APPLICATION_OCTET_STREAM : contentType);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND, "有效订舱文件不存在");
} catch (FeignException exception) {
throw unavailable();
}
}
public void requireActiveValidDocument(OceanBookingEntity booking) {
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw conflict("订舱缺少客户归属,无法校验文件");
}
boolean valid = false;
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
try {
OceanBookingFileClient.Metadata metadata = metadata(booking, document.getFileId());
if (Objects.equals(metadata.fileId(), document.getFileId())) {
valid = true;
break;
}
} catch (BizException exception) {
if (exception.getResultCode() == ResultCode.SERVICE_UNAVAILABLE) throw exception;
}
}
if (!valid) throw conflict("订舱缺少有效订舱确认或提单文件");
}
private DocumentMutation save(OceanBookingEntity booking, int expectedVersion, String documentType,
OceanBookingFileClient.Metadata metadata,
long operatorId, String operatorName) {
validateMetadata(booking, metadata);
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(IdWorker.getId());
document.setOceanBookingId(booking.getId());
document.setFileId(metadata.fileId());
document.setDocumentType(documentType);
document.setFileName(metadata.fileName());
document.setFileHash(metadata.sha256());
document.setSizeBytes(metadata.sizeBytes());
document.setContentType(metadata.contentType());
document.setValid(true);
document.setUploadedBy(operatorId);
document.setUploadedByName(operatorName);
document.setUploadedAt(LocalDateTime.now());
repository.insert(document);
touch(booking.getId(), expectedVersion, operatorId, operatorName);
return new DocumentMutation(document, expectedVersion + 1);
}
private OceanBookingEntity lockDraft(long id, int expectedVersion) {
OceanBookingEntity booking = repository.findForUpdate(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw bad("请先保存订舱客户后再上传文件");
}
return booking;
}
private OceanBookingEntity requireExisting(long id) {
OceanBookingEntity booking = repository.find(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
return booking;
}
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
try {
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
Long.toString(booking.getId()), fileId);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
}
validateMetadata(booking, response.data());
return response.data();
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
} catch (FeignException exception) {
throw unavailable();
}
}
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId())
|| !BUSINESS_TYPE.equals(metadata.businessType())
|| !Long.toString(booking.getId()).equals(metadata.businessId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
}
}
private void touch(long id, int version, long operatorId, String operatorName) {
if (repository.touchDraft(id, version, operatorId, operatorName) != 1) throw concurrent();
}
private static String normalizeType(String value) {
String normalized = StringUtils.hasText(value) ? value.trim().toUpperCase() : null;
if (!DOCUMENT_TYPES.contains(normalized)) throw bad("不支持的订舱文件类型");
return normalized;
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("订舱版本已变化,请刷新后重试"); }
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
private <T> T claimOrReplay(long id, long operatorId, String action, String key, String requestHash,
Class<T> responseType) {
validateKey(key);
String normalized = key.trim();
if (repository.claimOperation(id, operatorId, action, normalized, requestHash)) return null;
OceanBookingOperationEntity existing = repository.operation(operatorId, action, normalized);
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
throw conflict("幂等键已用于其他请求");
}
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
throw conflict("相同请求正在处理中,请稍后重试");
}
try {
return objectMapper.readValue(existing.getResultJson(), responseType);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法读取订舱文件幂等结果", exception);
}
}
private <T> T complete(long id, long operatorId, String action, String key, T result) {
try {
if (repository.completeOperation(id, operatorId, action, key.trim(),
objectMapper.writeValueAsString(result)) != 1) {
throw conflict("订舱文件幂等请求完成状态冲突");
}
return result;
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法保存订舱文件幂等结果", exception);
}
}
private String hash(String action, long id, Object payload) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((action + '|' + id + '|' + objectMapper.writeValueAsString(payload))
.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("无法计算订舱文件请求摘要", exception);
}
}
private static String fileDigest(MultipartFile file) {
try (var input = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) digest.update(buffer, 0, read);
}
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
throw bad("无法读取上传文件");
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
throw bad("Idempotency-Key不能为空且不能超过128个字符");
}
}
public record Download(byte[] content, String fileName, MediaType contentType) {}
}
@@ -0,0 +1,141 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class OceanBookingPrintService {
public static final String SHIPPING_INSTRUCTION = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
public static final String BILL_CONFIRMATION = "BILL_OF_LADING_CONFIRMATION_A4_V1";
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(
new BigDecimal("210"), new BigDecimal("297"));
@Resource private OceanBookingService bookings;
@Resource private PrintPdfClient print;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public GeneratedPdf shippingInstruction(long oceanBookingId, String requestId) {
return generate(oceanBookingId, requestId, SHIPPING_INSTRUCTION, "出口货物托运单");
}
public GeneratedPdf billConfirmation(long oceanBookingId, String requestId) {
return generate(oceanBookingId, requestId, BILL_CONFIRMATION, "提单确认件");
}
private GeneratedPdf generate(long id, String requestId, String template, String suffix) {
Detail detail = bookings.detail(id);
OceanBookingEntity booking = detail.oceanBooking();
if (!"RELEASED".equals(booking.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "订舱放舱后才可打印正式单据");
}
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(cargo(detail.containerRequirements())))));
try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
byte[] content = response.getBody();
if (!response.getStatusCode().is2xxSuccessful() || contentType == null
|| !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)
|| content == null || content.length == 0) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF");
}
return new GeneratedPdf(content, fileName,
response.getHeaders().getFirst("X-Print-Page-Count"));
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用");
}
}
private static Map<String, String> fields(String template, Detail detail) {
OceanBookingEntity booking = detail.oceanBooking();
Map<String, OceanBookingPartyEntity> parties = detail.parties().stream()
.collect(Collectors.toMap(OceanBookingPartyEntity::getPartyType, value -> value));
Map<String, String> fields = new LinkedHashMap<>();
fields.put("shipper", party(parties.get("SHIPPER")));
fields.put("consignee", party(parties.get("CONSIGNEE")));
fields.put("notifyParty", party(parties.get("NOTIFY_PARTY")));
fields.put("oceanVesselVoyage", join(booking.getVesselName(), booking.getVoyageNo()));
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
} else {
fields.put("bookingNumber", text(booking.getBillOfLadingNo()));
fields.put("expectedDepartureDate", date(booking.getEstimatedSailingAt()));
fields.put("containerSpec", requirementSummary(detail.containerRequirements()));
fields.put("containerSummary", requirementSummary(detail.containerRequirements()));
fields.put("notes", text(booking.getRemark()));
}
fields.values().removeIf(String::isEmpty);
return fields;
}
private static PrintPdfClient.CargoRow cargo(List<OceanBookingContainerRequirementEntity> requirements) {
BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
"CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
decimal(volume, 4) + " CBM");
}
private static int totalContainers(List<OceanBookingContainerRequirementEntity> values) {
return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
}
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
return values.stream().map(value -> value.getContainerType() + " " + value.getContainerKind()
+ " x " + value.getQuantity()).collect(Collectors.joining("\n"));
}
private static String party(OceanBookingPartyEntity value) {
if (value == null) return "";
return List.of(text(value.getPartyName()), text(value.getAddress()),
join(value.getContactName(), value.getContactPhone())).stream()
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
}
private static String date(java.time.LocalDateTime value) {
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private static String decimal(BigDecimal value, int scale) {
return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
}
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
private static String text(String value) { return value == null ? "" : value.trim(); }
public record GeneratedPdf(byte[] content, String fileName, String pageCount) {}
}
@@ -49,6 +49,7 @@ public class OceanBookingService {
@Resource private WarehouseMasterDataClient warehouses; @Resource private WarehouseMasterDataClient warehouses;
@Resource private WarehousePartnerClient partners; @Resource private WarehousePartnerClient partners;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private OceanBookingDocumentService documents;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -147,10 +148,11 @@ public class OceanBookingService {
String action = "RELEASE"; String action = "RELEASE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay; if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT); requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id)); validateRelease(before, repository.parties(id), repository.requirements(id));
validateMasterData(before); validateMasterData(before);
documents.requireActiveValidDocument(before);
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(), if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
request.expectedVersion(), operatorId, name) != 1) throw concurrent(); request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id); OceanBookingEntity after = required(id);
@@ -189,9 +191,10 @@ public class OceanBookingService {
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history); 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 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 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 static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
private void validateDraftStructure(DraftData data) { private void validateDraftStructure(DraftData data) {
@@ -224,6 +227,9 @@ public class OceanBookingService {
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == 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()) || !StringUtils.hasText(booking.getCarrierCode())
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); || 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.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.TruckAppointmentStatus; import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; 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.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
@@ -32,9 +34,10 @@ import org.springframework.web.multipart.MultipartFile;
@Service @Service
public class TruckAppointmentService { 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 TruckAppointmentRepository repo;
@Resource OceanBookingRepository oceanBookings; @Resource OceanBookingRepository oceanBookings;
@Resource OceanBookingDocumentService oceanBookingDocuments;
@Resource SmartLoadingRepository loadingPlans; @Resource SmartLoadingRepository loadingPlans;
@Resource ContainerRepository containers; @Resource ContainerRepository containers;
@Resource TruckAppointmentFileClient files; @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 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 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);} @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 AssociationContext validateAssociations(SaveRequest r,long customerId){
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不合法");} 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 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 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);} 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 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 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 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 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 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;} 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) CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID', id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_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_update;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete; 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 $$ DELIMITER $$
CREATE TRIGGER trg_ocean_history_before_update BEFORE UPDATE ON fl_ocean_booking_status_history FOR EACH ROW 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$$ 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$$ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ; 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; USE baiyue_iam;
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); 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); 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) 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='拖车状态与改约历史'; ) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( 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', 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='拖车附件文件引用'; ) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( 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摘要;用于识别同键不同请求', 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 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: paths:
/api/platform/v1/ocean-bookings: /api/platform/v1/ocean-bookings:
get: get:
@@ -29,10 +32,76 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/release: /api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post: post:
summary: Validate and permanently release a DRAFT 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'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}} 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: /api/platform/v1/ocean-bookings/link-candidates:
get: get:
summary: Page released booking requirement candidates for one container summary: Page released booking requirement candidates for one container
@@ -84,6 +153,7 @@ paths:
components: components:
parameters: parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} 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}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas: schemas:
CreateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, enum: [0]}, data: {$ref: '#/components/schemas/DraftData'}}} 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}}} 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}}} 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}}} 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: ChannelOption:
type: object type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes] required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
@@ -189,7 +308,7 @@ components:
description: Sum of all container requirement quantities; independent of allocation, linking or unlinking. description: Sum of all container requirement quantities; independent of allocation, linking or unlinking.
Detail: Detail:
type: object type: object
required: [oceanBooking, parties, containerRequirements, containers, histories] required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
properties: properties:
oceanBooking: oceanBooking:
type: object type: object
@@ -198,4 +317,5 @@ components:
parties: {type: array} parties: {type: array}
containerRequirements: {type: array} containerRequirements: {type: array}
containers: {type: array, description: Existing containers allocated after port pickup linking} 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} histories: {type: array}
@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Truck Appointment API title: Truck Appointment API
version: 1.2.0 version: 1.3.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. 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: paths:
/api/platform/v1/truck-appointments: /api/platform/v1/truck-appointments:
get: get:
@@ -22,7 +22,7 @@ paths:
properties: properties:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post: 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: requestBody:
required: true required: true
content: content:
@@ -88,7 +88,7 @@ paths:
/api/platform/v1/truck-appointments/{id}/attachments: /api/platform/v1/truck-appointments/{id}/attachments:
parameters: [{$ref: '#/components/parameters/AppointmentId'}] parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get: 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.' description: 'Requires platform:firstleg:truck-appointment:list.'
responses: responses:
'200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}}
@@ -111,7 +111,7 @@ paths:
type: object type: object
required: [attachmentType, file] required: [attachmentType, file]
properties: 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} file: {type: string, format: binary}
responses: responses:
'200': {description: Uploaded attachment reference} '200': {description: Uploaded attachment reference}
@@ -155,7 +155,7 @@ components:
required: [fileId, attachmentType] required: [fileId, attachmentType]
properties: properties:
fileId: {type: integer, format: int64, minimum: 1} 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} 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} fileHash: {type: string, maxLength: 128, description: Ignored; authoritative metadata comes from File service}
Attachment: Attachment:
@@ -164,7 +164,7 @@ components:
id: {type: integer, format: int64} id: {type: integer, format: int64}
appointmentId: {type: integer, format: int64} appointmentId: {type: integer, format: int64}
fileId: {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} fileName: {type: string}
fileHash: {type: string} fileHash: {type: string}
visibility: {type: string, enum: [PLATFORM]} visibility: {type: string, enum: [PLATFORM]}
@@ -178,24 +178,24 @@ components:
reason: {type: string, maxLength: 500} reason: {type: string, maxLength: 500}
SaveRequest: SaveRequest:
type: object type: object
required: [customerId, carrierId, plannedLoadingAt, expectedVersion] required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion]
properties: properties:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} 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.'} 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, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} 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.} 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.} 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.} 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.} 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.} 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.} 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} plannedLoadingAt: {type: string, format: date-time}
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences: DetailStableReferences:
type: object type: object
required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount] 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: properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号} appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true} oceanBookingId: {type: integer, format: int64, nullable: true}
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest { 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 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 { @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); 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 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 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 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 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() { @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries( Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -24,6 +24,13 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("bookingAgents", "platform:firstleg:ocean-booking:list"), Map.entry("bookingAgents", "platform:firstleg:ocean-booking:list"),
Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "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("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"), Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
@@ -33,7 +40,8 @@ class PlatformOceanBookingControllerContractTest {
assertThat(method.getAnnotation(PreAuthorize.class).value()) assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')"); .isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") && !name.equals("bookingAgents") 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(); assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
} }
}); });
@@ -45,7 +53,8 @@ class PlatformOceanBookingControllerContractTest {
.filter(method -> !method.getName().equals("page") && !method.getName().equals("create")) .filter(method -> !method.getName().equals("page") && !method.getName().equals("create"))
.flatMap(method -> Arrays.stream(method.getParameters())) .flatMap(method -> Arrays.stream(method.getParameters()))
.filter(parameter -> parameter.isAnnotationPresent(org.springframework.web.bind.annotation.PathVariable.class)) .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 @Test
@@ -55,6 +64,8 @@ class PlatformOceanBookingControllerContractTest {
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT") .contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release 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("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number") .contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
@@ -2,9 +2,11 @@ package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingDocumentMapper;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Mapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Arrays; import java.util.Arrays;
@@ -76,4 +78,13 @@ class OceanBookingMapperContractTest {
.contains("allocation.ocean_booking_id=#{oceanBookingId}") .contains("allocation.ocean_booking_id=#{oceanBookingId}")
.contains("allocation.requirement_id=#{requirementId}"); .contains("allocation.requirement_id=#{requirementId}");
} }
@Test void documentMapperIsRegisteredAndOnlyLogicallyInvalidatesActiveReference() throws Exception {
assertThat(OceanBookingDocumentMapper.class.getAnnotation(Mapper.class)).isNotNull();
String sql = String.join("\n", OceanBookingDocumentMapper.class
.getDeclaredMethod("invalidate", long.class, long.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("SET valid=0", "ocean_booking_id=#{oceanBookingId}",
"id=#{documentId}", "valid=1", "invalidated_at=CURRENT_TIMESTAMP(3)");
}
} }
@@ -16,6 +16,7 @@ class OceanBookingSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement") .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_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_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
@@ -42,6 +43,9 @@ class OceanBookingSqlContractTest {
.contains("<> 'RELEASED' THEN") .contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改") .contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)") .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"); .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
} }
@@ -0,0 +1,133 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OceanBookingFileClient;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingDocumentServiceTest {
@Mock OceanBookingRepository repository;
@Mock OceanBookingFileClient files;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks OceanBookingDocumentService service;
@BeforeEach void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "internalToken", "token");
lenient().when(repository.find(10L)).thenReturn(draft(0));
}
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
OceanBookingEntity booking = draft(2);
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(true, false);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
.thenReturn(ApiResponse.success(metadata(91L)));
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(1);
var first = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(repository).claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), hash.capture());
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash.getValue()); operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.operation(7L, "UPLOAD_DOCUMENT", "upload-1")).thenReturn(operation);
var replay = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
assertThat(replay.bookingVersion()).isEqualTo(3);
assertThat(replay.document().getFileId()).isEqualTo(91L);
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
}
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"attach-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void sameIdempotencyKeyWithDifferentPayloadIsRejectedWithoutFileLookup() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(false);
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash("different"); operation.setStatus("SUCCESS"); operation.setResultJson("{}");
when(repository.operation(7L, "ATTACH_DOCUMENT", "same-key")).thenReturn(operation);
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"same-key", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他请求");
verifyNoInteractions(files);
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
}
@Test void invalidationIsRejectedAfterReleaseWithoutDrift() {
OceanBookingEntity released = draft(4); released.setStatus("RELEASED");
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(released);
assertThatThrownBy(() -> service.invalidate(10L, 20L, 4, "delete-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("永久只读");
verify(repository, never()).invalidateDocument(anyLong(), anyLong(), anyLong(), anyString());
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void activeDocumentMustStillExistInFileService() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
service.requireActiveValidDocument(booking);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
return booking;
}
private static OceanBookingFileClient.Metadata metadata(long fileId) {
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
"booking.pdf", "hash", "application/pdf", 3L);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingPrintServiceTest {
@Mock OceanBookingService bookings;
@Mock PrintPdfClient print;
@InjectMocks OceanBookingPrintService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); }
@Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() {
when(bookings.detail(10L)).thenReturn(detail("RELEASED"));
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
headers.set("X-Print-Page-Count", "1");
when(print.generateFreightA4Documents(eq("token"), eq("req-1"), any()))
.thenReturn(new ResponseEntity<>(new byte[]{1, 2, 3}, headers, 200));
var pdf = service.shippingInstruction(10L, "req-1");
assertThat(pdf.fileName()).isEqualTo("OB-1-出口货物托运单.pdf");
ArgumentCaptor<PrintPdfClient.FreightA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print).generateFreightA4Documents(eq("token"), eq("req-1"), request.capture());
assertThat(request.getValue().templateCode()).isEqualTo("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1");
var document = request.getValue().documents().get(0);
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
assertThat(document.cargoRows()).singleElement().satisfies(row -> {
assertThat(row.quantity()).isEqualTo("2");
assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2");
assertThat(row.grossWeight()).isEqualTo("56000.000 KGS");
});
}
@Test void printRejectsDraftWithoutCallingPrintService() {
when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
.isInstanceOf(BizException.class).hasMessageContaining("放舱后");
verifyNoInteractions(print);
}
private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001");
booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA");
booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY");
booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent");
booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
OceanBookingPartyEntity shipper = new OceanBookingPartyEntity();
shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address");
OceanBookingPartyEntity consignee = new OceanBookingPartyEntity();
consignee.setPartyType("CONSIGNEE"); consignee.setPartyName("Consignee");
OceanBookingPartyEntity notify = new OceanBookingPartyEntity();
notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2);
requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("28000"));
return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of());
}
}
@@ -39,6 +39,7 @@ class OceanBookingServiceTest {
@Mock private WarehouseMasterDataClient warehouses; @Mock private WarehouseMasterDataClient warehouses;
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners; @Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers; @Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Mock private OceanBookingDocumentService documents;
@InjectMocks private OceanBookingService service; @InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -53,7 +54,9 @@ class OceanBookingServiceTest {
when(repository.parties(anyLong())).thenReturn(List.of()); when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of()); when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(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()); 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.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1); 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()); 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 @Test
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception { void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
stubMasterData(); stubMasterData();
@@ -331,6 +351,9 @@ class OceanBookingServiceTest {
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setCarrierCode("MATSON"); value.setCarrierCode("MATSON");
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); 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.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0)); value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; 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.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient; import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.client.WarehouseSupplierClient;
@@ -47,6 +48,7 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers; @Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers; @Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files; @Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@Mock FirstlegDocumentNumberGenerator documentNumbers; @Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service; @InjectMocks TruckAppointmentService service;
@@ -60,6 +62,13 @@ class TruckAppointmentServiceTest {
when(suppliers.trucking(any(), any())).thenReturn( when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of())); new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(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() { @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@@ -90,7 +99,7 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository); verifyNoInteractions(repository);
} }
@Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() { @Test void createRejectsMissingBookingAndContainer() {
SaveRequest request = mock(SaveRequest.class); SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0); when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L); when(request.customerId()).thenReturn(7L);
@@ -101,28 +110,20 @@ class TruckAppointmentServiceTest {
when(request.loadingPlanId()).thenReturn(null); when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null); when(request.warehouseId()).thenReturn(null);
TruckAppointmentEntity created = service.create(7L, "admin", request); assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("oceanBookingId");
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); verify(repository, never()).insert(any());
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);
} }
@Test void createAllowsDraftWithoutBillOfLadingOrAttachment() { @Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
SaveRequest request = validRequest(); SaveRequest request = validRequest();
service.create(7L, "admin", request); service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture()); 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); verifyNoInteractions(files);
} }
@@ -147,35 +148,28 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致"); .isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致");
} }
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() { @Test void createRejectsBookingThatIsNotReleased() {
SaveRequest request = mock(SaveRequest.class); SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0); when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L); when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L); when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); 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.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null); when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking); when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request); assertThatThrownBy(() -> service.create(7L, "admin", request))
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); .isInstanceOf(BizException.class).hasMessageContaining("已放舱");
verify(repository).insert(entity.capture()); verify(repository, never()).insert(any());
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));
} }
@Test void createRejectsBookingFromAnotherCustomer() { @Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest(); SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity(); 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); when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request)) assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -188,9 +182,9 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L); when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity(); 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(); 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(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container); when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of()); when(oceanBookings.allocations(99L)).thenReturn(List.of());
@@ -200,13 +194,61 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any()); 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(); SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L); when(request.loadingPlanId()).thenReturn(77L);
when(request.containerBookingId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request)) assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId") .isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
.hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any()); verify(repository, never()).insert(any());
} }
@@ -220,9 +262,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L); when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null); 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); when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container); when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity(); var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L); allocation.setContainerBookingId(88L);
@@ -245,9 +287,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L); when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null); 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); when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container); when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity(); var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L); allocation.setContainerBookingId(88L);
@@ -293,8 +335,11 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any()); verify(repository, never()).history(any());
} }
@Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() { @Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); 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.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill); when(repository.find(1L)).thenReturn(missingBill);
@@ -303,34 +348,30 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("提单号"); .isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); 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); 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.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment); 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), assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin")) "dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效提单附件"); .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt()); verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
} }
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() { @Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); 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); 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(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1"); 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.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after); when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking); 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.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).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.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L); when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null); when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(null); when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null); when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null); when(request.warehouseId()).thenReturn(null);
return request; 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;
}
} }