This commit is contained in:
2026-09-09 13:54:47 +08:00
parent 1a092bc9d7
commit f31a049444
11 changed files with 336 additions and 4 deletions
@@ -18133,6 +18133,30 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
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 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,
container_booking_id BIGINT UNSIGNED NOT NULL,
fleet_supplier_id BIGINT UNSIGNED NOT NULL,
fleet_name VARCHAR(128) NOT NULL,
recipient VARCHAR(254) NOT NULL,
kind VARCHAR(32) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
attempts INT NOT NULL DEFAULT 0,
failure_reason VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
sent_at DATETIME(3) NULL,
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',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作',
@@ -36,6 +36,33 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
@jakarta.annotation.Resource private com.baiyue.firstleg.terminaldrayage.service.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 com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.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<com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService.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 * 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 * 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.* 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,62 @@
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();
var sender = sender();
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.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);
}
}
@@ -46,6 +46,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 +96,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 +202,23 @@ public class TerminalDrayageService {
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.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 +289,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 +395,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";
@@ -26,6 +26,30 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
(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,
container_booking_id BIGINT UNSIGNED NOT NULL,
fleet_supplier_id BIGINT UNSIGNED NOT NULL,
fleet_name VARCHAR(128) NOT NULL,
recipient VARCHAR(254) NOT NULL,
kind VARCHAR(32) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
attempts INT NOT NULL DEFAULT 0,
failure_reason VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
sent_at DATETIME(3) NULL,
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',
@@ -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);
@@ -57,7 +57,7 @@ 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());
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -95,7 +95,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());