Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-09 15:41:50 +08:00
50 changed files with 1045 additions and 51 deletions
@@ -24,4 +24,10 @@ public interface WarehouseSupplierClient {
record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
@GetMapping("/{supplierId}/trucking-contact")
ApiResponse<TruckingContact> truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId, @RequestParam boolean requireActive);
record TruckingContact(long supplierId, String name, String email) {}
}
@@ -44,9 +44,6 @@ public class OceanBookingPrintService {
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(
@@ -97,7 +94,8 @@ public class OceanBookingPrintService {
fields.put("containerSummary", requirementSummary(detail.containerRequirements()));
fields.put("notes", text(booking.getRemark()));
}
fields.values().removeIf(String::isEmpty);
// Missing booking data is displayed explicitly, including required PDF template fields.
fields.replaceAll((key, value) -> value.isEmpty() ? "—" : value);
return fields;
}
@@ -9,6 +9,10 @@ import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectio
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService.Mailbox;
import jakarta.annotation.Resource;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -36,6 +40,33 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
@Resource private TerminalDrayageMailService mails;
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<View> sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody EmailRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.sendPickupEmail(containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@GetMapping("/{containerBookingId}/emails")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<Mailbox> emails(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
return ApiResponse.success(mails.list(containerBookingId));
}
@PostMapping("/{containerBookingId}/emails/{mailId}/retry")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<Void> retryEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @PathVariable @Positive long mailId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
mails.retry(containerBookingId, mailId, user.userId());
return ApiResponse.success(null);
}
private final TerminalDrayageService service;
@GetMapping
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerAc
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
public record EmailRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long fleetSupplierId,
@@ -110,4 +110,65 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
LocalDateTime unloadingCompletedAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
record MailTask(Long id, Long containerBookingId, Long fleetSupplierId, String fleetName,
String recipient, String kind, String subject, String body, String status,
Integer attempts, String failureReason, LocalDateTime createdAt, LocalDateTime sentAt) {}
@Insert("""
INSERT INTO fl_terminal_drayage_mail(id,container_booking_id,fleet_supplier_id,fleet_name,
recipient,kind,subject,body,status,created_by,updated_by)
VALUES(#{id},#{containerId},#{supplierId},#{name},#{email},#{kind},#{subject},#{body},'PENDING',#{operatorId},#{operatorId})
""")
int insertMail(@Param("id") long id, @Param("containerId") long containerId,
@Param("supplierId") long supplierId, @Param("name") String name, @Param("email") String email,
@Param("kind") String kind, @Param("subject") String subject, @Param("body") String body,
@Param("operatorId") long operatorId);
@Select("SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
List<MailTask> mails(@Param("containerId") long containerId);
@Select("""
SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
AND fleet_supplier_id=#{supplierId} AND kind='REQUEST_PICKUP' ORDER BY id DESC LIMIT 1
""")
MailTask lastPickupMail(@Param("containerId") long containerId, @Param("supplierId") long supplierId);
@Select("""
SELECT m.id,m.container_booking_id AS containerBookingId,m.fleet_supplier_id AS fleetSupplierId,m.fleet_name AS fleetName,m.recipient,m.kind,m.subject,m.body,m.status,m.attempts,m.failure_reason AS failureReason,m.created_at AS createdAt,m.sent_at AS sentAt FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
AND NOT EXISTS(SELECT 1 FROM fl_terminal_drayage_mail earlier
WHERE earlier.container_booking_id=m.container_booking_id AND earlier.id<m.id AND earlier.status<>'SENT')
ORDER BY m.id LIMIT 20
""")
List<MailTask> pendingMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENDING',attempts=attempts+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='PENDING'
""")
int claimMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),failure_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='SENDING'
""")
int completeMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason=#{reason},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='SENDING'
""")
int failMail(@Param("id") long id, @Param("reason") String reason);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason='发送中断,结果待核对;确认未收到后再重试',
updated_at=CURRENT_TIMESTAMP(3) WHERE status='SENDING' AND updated_at<DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL 5 MINUTE)
""")
int expireMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='PENDING',failure_reason=NULL,updated_by=#{operatorId},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND container_booking_id=#{containerId} AND status='FAILED'
""")
int retryMail(@Param("containerId") long containerId, @Param("id") long id, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name="baiyue.terminal-drayage.mail.enabled", havingValue="true")
public class TerminalDrayageMailDispatcher {
private final TerminalDrayageMapper mapper;
@Value("${baiyue.terminal-drayage.mail.host}") private String host;
@Value("${baiyue.terminal-drayage.mail.port:587}") private int port;
@Value("${baiyue.terminal-drayage.mail.username}") private String username;
@Value("${baiyue.terminal-drayage.mail.password}") private String password;
@Value("${baiyue.terminal-drayage.mail.from}") private String from;
@Value("${baiyue.terminal-drayage.mail.ssl:false}") private boolean ssl;
JavaMailSenderImpl sender() {
TerminalDrayageMailService.validateAddress(from);
var sender = new JavaMailSenderImpl();
sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
var properties = sender.getJavaMailProperties();
properties.setProperty("mail.smtp.auth", "true");
properties.setProperty("mail.smtp.ssl.enable", String.valueOf(ssl));
properties.setProperty("mail.smtp.starttls.enable", String.valueOf(!ssl));
properties.setProperty("mail.smtp.starttls.required", String.valueOf(!ssl));
properties.setProperty("mail.smtp.ssl.checkserveridentity", "true");
properties.setProperty("mail.smtp.connectiontimeout", "10000");
properties.setProperty("mail.smtp.timeout", "10000");
properties.setProperty("mail.smtp.writetimeout", "10000");
return sender;
}
@Scheduled(fixedDelayString="${baiyue.terminal-drayage.mail.scan-delay:10000}")
public void dispatch() {
mapper.expireMails();
for (var mail : mapper.pendingMails()) {
if (mapper.claimMail(mail.id()) != 1) continue;
try {
var message = new SimpleMailMessage();
message.setFrom(from); message.setTo(mail.recipient());
message.setSubject(mail.subject()); message.setText(mail.body());
sender().send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
} catch (Exception exception) {
// SMTP 超时不等于未送达,禁止自动重发;失败阻断同柜后续邮件,交由人工核对。
mapper.failMail(mail.id(), "发送失败或结果不确定,请核对收件情况及发信配置后重试");
log.warn("Terminal drayage mail requires review: {}, {}", mail.id(), exception.getClass().getSimpleName());
}
}
}
}
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.terminaldrayage.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.WarehouseSupplierClient;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask;
import feign.FeignException;
import jakarta.mail.internet.InternetAddress;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TerminalDrayageMailService {
private final TerminalDrayageMapper mapper;
private final WarehouseSupplierClient suppliers;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.terminal-drayage.mail.enabled:false}") private boolean enabled;
public record Mailbox(boolean deliveryEnabled, List<MailTask> records) {}
public Mailbox list(long containerBookingId) { return new Mailbox(enabled, mapper.mails(containerBookingId)); }
public void request(long containerId, String containerNo, long supplierId, long operatorId) {
var contact = contact(supplierId, true);
enqueue(containerId, containerNo, supplierId, contact.name(), contact.email(), "REQUEST_PICKUP", operatorId);
}
public void replaceFleet(long containerId, String containerNo, long oldSupplierId, long newSupplierId, long operatorId) {
var next = contact(newSupplierId, true);
// 取消通知发往原请求的收件地址;尚未发过请求则读取原车队资料,允许原车队已停用。
MailTask previous = mapper.lastPickupMail(containerId, oldSupplierId);
var old = previous == null ? contact(oldSupplierId, false)
: new WarehouseSupplierClient.TruckingContact(oldSupplierId, previous.fleetName(), previous.recipient());
enqueue(containerId, containerNo, oldSupplierId, old.name(), old.email(), "CANCEL_PICKUP", operatorId);
enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
}
private WarehouseSupplierClient.TruckingContact contact(long supplierId, boolean requireActive) {
try {
var response = suppliers.truckingContact(internalToken, supplierId, requireActive);
if (response == null || !response.successful() || response.data() == null
|| response.data().supplierId() != supplierId) {
throw new BizException(ResultCode.CONFLICT, "车队联系资料不可用,请检查供应商资料");
}
var value = response.data();
validateAddress(value.email());
if (!StringUtils.hasText(value.name())) throw new BizException(ResultCode.CONFLICT, "车队名称缺失");
return value;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "车队联系资料服务暂不可用");
}
}
static void validateAddress(String email) {
try {
if (!StringUtils.hasText(email) || email.length() > 254 || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
InternetAddress[] parsed = InternetAddress.parse(email.trim(), true);
if (parsed.length != 1 || parsed[0].isGroup() || !email.trim().equals(parsed[0].getAddress())) throw new IllegalArgumentException();
parsed[0].validate();
if (!parsed[0].getAddress().contains("@")) throw new IllegalArgumentException();
} catch (Exception exception) {
throw new BizException(ResultCode.BAD_REQUEST, "车队未配置有效邮箱,请先在供应商资料中维护单个收件邮箱");
}
}
private void enqueue(long containerId, String containerNo, long supplierId, String fleetName,
String email, String kind, long operatorId) {
if (!StringUtils.hasText(containerNo)) throw new BizException(ResultCode.CONFLICT, "请先补齐真实集装箱号再发送邮件");
validateAddress(email);
var content = TerminalDrayageMailTemplates.render(kind, containerNo, fleetName);
mapper.insertMail(IdWorker.getId(), containerId, supplierId, fleetName, email.trim(), kind,
content.subject(), content.body(), operatorId);
}
@Transactional
public void retry(long containerId, long mailId, long operatorId) {
if (mapper.retryMail(containerId, mailId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "只有发送失败的邮件可以重试,请刷新邮件记录");
}
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.terminaldrayage.service;
/** 临时英文模板。正式邮件文案到位后只替换此处,不改变任务与发送流程。 */
public final class TerminalDrayageMailTemplates {
private TerminalDrayageMailTemplates() {}
public record Content(String subject, String body) {}
public static Content render(String kind, String containerNo, String fleetName) {
boolean cancel = "CANCEL_PICKUP".equals(kind);
String subject = (cancel ? "Cancel port pickup - " : "Port pickup request - ") + containerNo;
String body = "Dear " + fleetName + ",\n\n"
+ (cancel ? "Please cancel our port pickup request for container "
: "Please arrange port pickup for container ")
+ containerNo + ".\n"
+ (cancel ? "Please do not dispatch a truck for this request.\n"
: "Please contact our operations team to confirm the pickup details before dispatch.\n")
+ "\nPlease reply to confirm receipt.\nThank you.\nOperations Team";
return new Content(subject, body);
}
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +47,7 @@ public class TerminalDrayageService {
private final WarehouseSupplierSnapshotService suppliers;
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -95,6 +97,10 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
}
int fromVersion = version(value);
String fromStatus = status(context, value);
if (value == null) value = create(containerBookingId, operatorId, operatorName);
@@ -197,6 +203,23 @@ public class TerminalDrayageService {
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
EmailRequest request) {
String action = "SEND_PICKUP_EMAIL";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (!allowedActions(context, value).contains(action)) throw conflict("当前货柜不能发送提柜请求邮件");
int fromVersion = version(value);
String fromStatus = status(context, value);
mails.request(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), operatorId);
persist(value, fromVersion, operatorId, operatorName);
return finish(context, value, action, key, request, fromStatus, fromVersion, "提柜邮件已加入发送队列", operatorId, operatorName);
}
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
@@ -267,6 +290,8 @@ public class TerminalDrayageService {
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -371,6 +396,7 @@ public class TerminalDrayageService {
private static String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
@@ -30,6 +30,16 @@ spring:
size: ${FIRSTLEG_SCHEDULING_POOL_SIZE:5}
thread-name-prefix: firstleg-scheduler-
baiyue:
terminal-drayage:
mail:
enabled: ${TERMINAL_DRAYAGE_MAIL_ENABLED:false}
host: ${TERMINAL_DRAYAGE_MAIL_HOST:}
port: ${TERMINAL_DRAYAGE_MAIL_PORT:587}
username: ${TERMINAL_DRAYAGE_MAIL_USERNAME:}
password: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:}
from: ${TERMINAL_DRAYAGE_MAIL_FROM:}
ssl: ${TERMINAL_DRAYAGE_MAIL_SSL:false}
scan-delay: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:10000}
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
@@ -23,10 +23,33 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '归属物理货柜ID',
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT '车队供应商ID快照',
fleet_name VARCHAR(128) NOT NULL COMMENT '车队名称快照',
recipient VARCHAR(254) NOT NULL COMMENT '收件人邮箱',
kind VARCHAR(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜',
subject VARCHAR(255) NOT NULL COMMENT '邮件主题',
body TEXT NOT NULL COMMENT '邮件正文',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败',
attempts INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
failure_reason VARCHAR(500) NULL COMMENT '发送失败原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
PRIMARY KEY(id),
KEY idx_terminal_mail_dispatch(status,id),
KEY idx_terminal_mail_container(container_booking_id,id,status),
CONSTRAINT fk_terminal_mail_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_mail_kind CHECK(kind IN ('REQUEST_PICKUP','CANCEL_PICKUP')),
CONSTRAINT chk_terminal_mail_status CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))
) ENGINE=InnoDB COMMENT='码头车队邮件任务与投递记录';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
@@ -2033,7 +2033,7 @@ components:
allowedActions:
type: array
description: 已同时按真实业务状态与当前用户权限裁剪
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
ContainerWorkbenchStatusSummary:
@@ -2080,7 +2080,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {type: object, nullable: true}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
@@ -88,16 +88,16 @@ paths:
'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.
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) 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.
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) 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:
@@ -52,11 +52,63 @@ paths:
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email:
post:
summary: 将当前车队提柜邮件加入发送队列
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。首次更换车队自动原子入队取消和新提柜两封邮件。同一车队重复选择不发信。
x-permission: platform:firstleg:terminal-drayage:fleet:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/emails:
get:
summary: 查询最近100封车队邮件及发信开关
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses:
'200':
description: 邮件记录;SENT表示SMTP已接受,不表示收件人已阅读
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
deliveryEnabled: {type: boolean}
records: {type: array, items: {$ref: '#/components/schemas/EmailTask'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/emails/{mailId}/retry:
post:
summary: 核对失败邮件未收到后手动重试
description: 仅FAILED状态可重试,服务端校验mailId归属本柜;失败可能已经送达,须人工核对。同柜邮件依次发送,失败阻断后续邮件。
x-permission: platform:firstleg:terminal-drayage:fleet:edit
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {name: mailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {description: 已重新入队}}
components:
parameters:
ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}}
schemas:
EmailTask:
type: object
properties:
id: {type: string}
containerBookingId: {type: string}
fleetSupplierId: {type: string}
fleetName: {type: string}
recipient: {type: string, format: email}
kind: {type: string, enum: [REQUEST_PICKUP, CANCEL_PICKUP]}
subject: {type: string}
body: {type: string}
status: {type: string, enum: [PENDING, SENDING, SENT, FAILED]}
attempts: {type: integer}
failureReason: {type: string, nullable: true}
createdAt: {type: string, format: date-time}
sentAt: {type: string, format: date-time, nullable: true}
FleetSelectionRequest:
type: object
required: [expectedVersion, fleetSupplierId]
@@ -104,7 +156,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
updatedAt: {type: string, format: date-time, nullable: true}
ContainerActualFacts:
@@ -127,7 +179,7 @@ components:
type: object
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
properties:
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
fromStatus: {type: string}
toStatus: {type: string}
fromVersion: {type: integer, minimum: 0}
@@ -1,6 +1,5 @@
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;
@@ -22,7 +21,6 @@ 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.*;
@@ -60,11 +58,34 @@ class OceanBookingPrintServiceTest {
});
}
@Test void printRejectsDraftWithoutCallingPrintService() {
when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
.isInstanceOf(BizException.class).hasMessageContaining("放舱后");
verifyNoInteractions(print);
@Test void bothDocumentsPrintDraftWithMissingReleaseData() {
Detail detail = detail("DRAFT");
detail.oceanBooking().setBillOfLadingNo(null);
detail.oceanBooking().setPlaceOfDelivery(null);
detail.oceanBooking().setServiceTerm(null);
detail.oceanBooking().setFreightTerm(null);
detail = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)),
detail.containerRequirements(), List.of(), List.of(), List.of());
when(bookings.detail(10L)).thenReturn(detail);
when(print.generateFreightA4Documents(eq("token"), eq("req-2"), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1, 2, 3}));
assertThat(service.shippingInstruction(10L, "req-2").content()).isNotEmpty();
assertThat(service.billConfirmation(10L, "req-2").content()).isNotEmpty();
ArgumentCaptor<PrintPdfClient.FreightA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print, times(2)).generateFreightA4Documents(eq("token"), eq("req-2"), request.capture());
for (var captured : request.getAllValues()) {
assertThat(captured.documents().get(0).fields())
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("consignee", "—")
.containsEntry("notifyParty", "—")
.containsEntry("placeOfDelivery", "—")
.containsEntry("serviceTerm", "—")
.containsEntry("freightTerm", "—");
}
assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—");
}
private static Detail detail(String status) {
@@ -28,6 +28,9 @@ class TerminalDrayageOpenApiContractTest {
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
assertPermission("history", "platform:firstleg:terminal-drayage:list");
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("sendPickupEmail", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("emails", "platform:firstleg:terminal-drayage:list");
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
@@ -8,6 +8,39 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
@Test
void mailProjectionMatchesRecordAndPendingQueryBlocksEarlierUnsentMail() throws Exception {
var mapperType = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
var fields = java.util.Arrays.stream(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName).toList();
for (String name : java.util.List.of("mails", "lastPickupMail", "pendingMails")) {
var method = java.util.Arrays.stream(mapperType.getMethods()).filter(value -> value.getName().equals(name)).findFirst().orElseThrow();
String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
String projection = sql.substring(sql.indexOf("SELECT")+6, sql.indexOf("FROM"));
var selected = java.util.Arrays.stream(projection.split(",")).map(String::trim)
.map(value -> value.contains(" AS ") ? value.substring(value.indexOf(" AS ")+4) : value.replace("m.", "")).toList();
assertThat(selected).isEqualTo(fields);
if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<m.id", "earlier.status<>'SENT'");
}
for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
assertThat(Files.readString(path)).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))");
}
}
@Test
void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
for (Path path : java.util.List.of(
Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"),
Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
String sql = Files.readString(path);
assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at")
.doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL");
}
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(");
}
@Test
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
@@ -103,7 +103,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
@@ -148,7 +148,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TerminalDrayageMailDispatcherTest {
TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
JavaMailSenderImpl sender = mock(JavaMailSenderImpl.class);
TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper));
void setup() {
ReflectionTestUtils.setField(worker,"from","operations@example.com");
doReturn(sender).when(worker).sender();
when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask(
1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null)));
when(mapper.claimMail(1L)).thenReturn(1);
when(mapper.completeMail(1L)).thenReturn(1);
}
@Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() {
setup(); worker.dispatch();
var ordered = inOrder(mapper,sender);
ordered.verify(mapper).claimMail(1L);
ordered.verify(sender).send(any(SimpleMailMessage.class));
ordered.verify(mapper).completeMail(1L);
when(mapper.claimMail(1L)).thenReturn(0);
worker.dispatch();
verify(sender,times(1)).send(any(SimpleMailMessage.class));
}
@Test void smtpFailureRequiresReviewInsteadOfReportingSuccess() {
setup(); doThrow(new MailSendException("uncertain")).when(sender).send(any(SimpleMailMessage.class));
worker.dispatch();
verify(mapper).failMail(eq(1L),contains("结果不确定"));
verify(mapper,never()).completeMail(anyLong());
}
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TerminalDrayageMailServiceTest {
TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
WarehouseSupplierClient suppliers = mock(WarehouseSupplierClient.class);
TerminalDrayageMailService service = new TerminalDrayageMailService(mapper, suppliers);
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
when(suppliers.truckingContact("test-token", 1L, false)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(1L, "Old Fleet", "old@example.com")));
when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", "new@example.com")));
}
@Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() {
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
var order = inOrder(mapper);
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"),
eq("CANCEL_PICKUP"), eq("Cancel port pickup - MSCU1234567"), contains("Please cancel"), eq(7L));
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
}
@Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() {
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
verify(suppliers, never()).truckingContact("test-token", 1L, false);
verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Frozen Fleet"), eq("original@example.com"),
eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
}
@Test void missingNewEmailQueuesNeitherMail() {
when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", null)));
assertThatThrownBy(() -> service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L)).isInstanceOf(BizException.class);
verify(mapper, never()).insertMail(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
}
@ParameterizedTest
@ValueSource(strings={"", "not-an-email", "a@example.com,b@example.com", "a@example.com\r\nBcc: b@example.com", "Name <a@example.com>"})
void rejectsInvalidOrMultipleRecipients(String email) {
assertThatThrownBy(() -> TerminalDrayageMailService.validateAddress(email)).isInstanceOf(BizException.class);
}
@Test void retriesOnlyFailedMailBelongingToContainer() {
assertThatThrownBy(() -> service.retry(11L, 55L, 7L)).isInstanceOf(BizException.class);
when(mapper.retryMail(11L,55L,7L)).thenReturn(1);
service.retry(11L,55L,7L);
}
}
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TerminalDrayageServiceTest {
@@ -45,6 +46,7 @@ class TerminalDrayageServiceTest {
private WarehouseSupplierSnapshotService suppliers;
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
private TerminalDrayageMailService mails;
private Deque<ContainerActualFacts> facts;
@BeforeEach
@@ -57,7 +59,8 @@ class TerminalDrayageServiceTest {
if (facts.isEmpty()) return List.of();
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
});
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
mails = mock(TerminalDrayageMailService.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -95,7 +98,7 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(1);
assertThat(result.fleetSupplierId()).isEqualTo(91L);
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP", "SEND_PICKUP_EMAIL");
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
anyInt(), anyInt(), any(), anyLong(), anyString());
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
@@ -343,6 +346,48 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(4);
}
@Test
void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
when(mapper.lock(11L)).thenReturn(row(3));
when(suppliers.trucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of()));
service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
verifyNoInteractions(mails);
verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
}
@Test
void fleetReplacementQueuesBothEmailsBeforeSavingNewFleet() {
Context context = context("ARRIVED_DESTINATION_PORT",null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
TerminalDrayageEntity row = row(3);
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1);
when(suppliers.trucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of()));
service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
var ordered = org.mockito.Mockito.inOrder(mails,mapper);
ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L);
ordered.verify(mapper).updateAggregate(any(),org.mockito.ArgumentMatchers.eq(3),org.mockito.ArgumentMatchers.eq(7L),org.mockito.ArgumentMatchers.eq("admin"));
assertThat(row.getFleetSupplierId()).isEqualTo(92L);
}
@Test
void manualPickupEmailUsesLockedVersionAndRejectsStaleClick() {
Context context = context("ARRIVED_DESTINATION_PORT",null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
TerminalDrayageEntity row = row(3);
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {row.setVersion(4);return 1;});
var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3);
service.sendPickupEmail(11L,7L,"admin","mail-1",request);
assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class);
verify(mails,times(1)).request(11L,"MSCU1234567",91L,7L);
}
private Context context(String node, LocalDateTime unloadedAt) {
int sequenceVersion = switch (node) {
case "PICKED_UP" -> 5;