From 1a092bc9d71de07c3bf3a2bafafb348b7e509274 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 13:51:01 +0800
Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 7 +++++--
firstleg/pom.xml | 1 +
.../firstleg/client/WarehouseSupplierClient.java | 6 ++++++
.../db/phase3-terminal-drayage-schema.sql | 3 +--
.../mapper/TerminalDrayageSqlContractTest.java | 14 ++++++++++++++
.../controller/InternalSupplierController.java | 12 ++++++++++++
6 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 07b97973b..47161a1ca 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18131,8 +18131,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
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))
+ 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_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
@@ -18158,6 +18157,10 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
ALTER TABLE fl_terminal_drayage
+ DROP CHECK chk_terminal_drayage_times,
+ ADD 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)),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
diff --git a/firstleg/pom.xml b/firstleg/pom.xml
index 1a35216ef..98dc43916 100644
--- a/firstleg/pom.xml
+++ b/firstleg/pom.xml
@@ -5,6 +5,7 @@
com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
firstleg-service
+ org.springframework.bootspring-boot-starter-mail
com.baiyue.sharedbaiyue-common-core${project.version}
com.baiyue.sharedcommon-event-contract${project.version}
com.baiyue.sharedbaiyue-common-audit${project.version}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
index 7559f7e14..a0874578a 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java
@@ -24,4 +24,10 @@ public interface WarehouseSupplierClient {
record Snapshot(long supplierId, String name, String category, String status,
List carrierCodes) {}
+
+ @GetMapping("/{supplierId}/trucking-contact")
+ ApiResponse truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable long supplierId, @RequestParam boolean requireActive);
+
+ record TruckingContact(long supplierId, String name, String email) {}
}
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index f68365ccd..4b779b7fa 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -23,8 +23,7 @@ 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_history (
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
index fd6635a2b..cac6e7aaf 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
@@ -8,6 +8,20 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
+ @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"));
diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
index be193cc9c..78cfb51e8 100644
--- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
+++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java
@@ -37,5 +37,17 @@ public class InternalSupplierController {
return ApiResponse.success(service.internalBookingCandidates(carrierCode));
}
+ public record TruckingContact(long supplierId, String name, String email) {}
+
+ @GetMapping("/{supplierId}/trucking-contact")
+ public ApiResponse truckingContact(
+ @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
+ @PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) {
+ requireToken(token);
+ service.internalSnapshot(supplierId, requireActive, "TRUCKING");
+ var supplier = service.detail(supplierId);
+ return ApiResponse.success(new TruckingContact(supplierId, supplier.name(), supplier.email()));
+ }
+
private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); }
}
From f31a0494448af13c36c20e458fd495ad53cd0225 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 13:54:47 +0800
Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 24 +++++
.../PlatformTerminalDrayageController.java | 27 ++++++
.../dto/TerminalDrayageDtos.java | 1 +
.../mapper/TerminalDrayageMapper.java | 61 +++++++++++++
.../TerminalDrayageMailDispatcher.java | 62 +++++++++++++
.../service/TerminalDrayageMailService.java | 88 +++++++++++++++++++
.../service/TerminalDrayageMailTemplates.java | 20 +++++
.../service/TerminalDrayageService.java | 25 ++++++
.../db/phase3-terminal-drayage-schema.sql | 24 +++++
.../ContainerWorkbenchServiceTest.java | 4 +-
.../service/TerminalDrayageServiceTest.java | 4 +-
11 files changed, 336 insertions(+), 4 deletions(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 47161a1ca..3b6c100d1 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -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 '业务动作',
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
index 3bcf2952d..bfbfb36d7 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
@@ -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 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 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 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
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
index 60d2c36b3..4db1f2972 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java
@@ -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,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
index 6bf23c0dd..f2d7f3182 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
@@ -110,4 +110,65 @@ public interface TerminalDrayageMapper extends BaseMapper
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 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'SENT')
+ ORDER BY m.id LIMIT 20
+ """)
+ List 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 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, "只有发送失败的邮件可以重试,请刷新邮件记录");
+ }
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
new file mode 100644
index 000000000..2c4b73f2d
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailTemplates.java
@@ -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);
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
index fe95c4aed..d6a6a272b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
@@ -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 allowedActions(WorkContext context, TerminalDrayageEntity value) {
List 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";
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index 4b779b7fa..ab8619c7c 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -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',
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
index 628f38447..ef54e5fd8 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java
@@ -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);
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
index 16fae4d27..d8e86def6 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
@@ -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());
From 3727ff141c2bb6fd1ed50310447192a7ebaf3205 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:00:03 +0800
Subject: [PATCH 3/6] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=A0=81=E5=A4=B4?=
=?UTF-8?q?=E8=BD=A6=E9=98=9F=E9=82=AE=E4=BB=B6=E8=A1=A8=E5=AD=97=E6=AE=B5?=
=?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 32 +++++++++----------
.../db/phase3-terminal-drayage-schema.sql | 32 +++++++++----------
2 files changed, 32 insertions(+), 32 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 3b6c100d1..e1d15656f 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18134,22 +18134,22 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
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,
+ 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),
diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
index ab8619c7c..4edf7ade9 100644
--- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql
@@ -27,22 +27,22 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
) 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,
+ 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),
From 250b623f3a2277b0fd76b3c27a039ac084bde74d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:26:34 +0800
Subject: [PATCH 4/6] =?UTF-8?q?=E9=99=90=E5=88=B6=E5=AE=B9=E5=99=A8?=
=?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E5=90=8E=E6=9C=80=E5=A4=9A?=
=?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E5=90=AF=E4=B8=89=E6=AC=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/docker-compose.overseas.yml | 2 +-
deploy/docker-compose.rocketmq.yml | 6 +++---
deploy/docker-compose.yml | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml
index 274f284f9..a9910006c 100644
--- a/deploy/docker-compose.overseas.yml
+++ b/deploy/docker-compose.overseas.yml
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
- restart: unless-stopped
+ restart: "on-failure:3"
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml
index f15310c7a..7775ff3b9 100644
--- a/deploy/docker-compose.rocketmq.yml
+++ b/deploy/docker-compose.rocketmq.yml
@@ -9,14 +9,14 @@ x-bounded-logging: &bounded-logging
services:
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
- restart: unless-stopped
+ restart: "on-failure:3"
command: sh mqnamesrv
logging: *bounded-logging
networks: [infra-network]
rocketmq-broker:
image: apache/rocketmq:5.3.1
- restart: unless-stopped
+ restart: "on-failure:3"
depends_on: [rocketmq-namesrv]
environment:
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
@@ -53,7 +53,7 @@ services:
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
- restart: on-failure
+ restart: "on-failure:3"
logging: *bounded-logging
networks: [infra-network]
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 59079dc6a..ec6953f55 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}
x-common: &common
- restart: unless-stopped
+ restart: "on-failure:3"
networks: [firstleg-network, infra-network]
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
extra_hosts:
From 5f671e164f95471c5655c319b34a4c76fb108bd0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 14:31:59 +0800
Subject: [PATCH 5/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/README.md | 4 ++
deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +
firstleg/AGENTS.md | 2 +-
firstleg/docs/terminal-drayage-mail.md | 34 ++++++++++
.../service/OceanBookingPrintService.java | 6 +-
.../PlatformTerminalDrayageController.java | 10 ++-
.../mapper/TerminalDrayageMapper.java | 6 +-
.../TerminalDrayageMailDispatcher.java | 3 +-
.../service/TerminalDrayageMailService.java | 2 +-
.../service/TerminalDrayageService.java | 3 +-
firstleg/src/main/resources/application.yml | 10 +++
.../main/resources/openapi/firstleg-v1.yaml | 4 +-
.../resources/openapi/ocean-bookings-v1.yaml | 8 +--
.../openapi/terminal-drayage-v1.yaml | 56 +++++++++++++++-
.../service/OceanBookingPrintServiceTest.java | 35 ++++++++--
.../TerminalDrayageOpenApiContractTest.java | 3 +
.../TerminalDrayageSqlContractTest.java | 19 ++++++
.../TerminalDrayageMailDispatcherTest.java | 44 +++++++++++++
.../TerminalDrayageMailServiceTest.java | 65 +++++++++++++++++++
.../service/TerminalDrayageServiceTest.java | 47 +++++++++++++-
warehouse/AGENTS.md | 2 +-
.../main/resources/openapi/supplier-v1.yaml | 23 +++++++
.../InternalSupplierContactTest.java | 27 ++++++++
23 files changed, 384 insertions(+), 32 deletions(-)
create mode 100644 firstleg/docs/terminal-drayage-mail.md
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
diff --git a/deploy/sql/README.md b/deploy/sql/README.md
index a462cdee7..501c91676 100644
--- a/deploy/sql/README.md
+++ b/deploy/sql/README.md
@@ -244,3 +244,7 @@ SELECT
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
+
+## 码头车队邮件
+
+国内统一入口包含 `fl_terminal_drayage_mail`,用于冻结收件地址、两类邮件正文、投递状态和失败信息;创建后才可发布相关 Firstleg 功能。发信配置默认关闭,配置说明见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。邮件功能涉及 Admin、Firstleg 和 Warehouse 内部联系资料接口,无新增权限码。
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index e1d15656f..f0ae5bd88 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -18229,6 +18229,9 @@ DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
DELIMITER $$
CREATE PROCEDURE assert_terminal_drayage_schema()
BEGIN
+ IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg' AND TABLE_NAME='fl_terminal_drayage_mail') <> 1 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一基线校验失败:码头车队邮件任务表缺失';
+ END IF;
IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg'
AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车表不完整';
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index ee9eb076b..b28569360 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -44,7 +44,7 @@
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
-| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` |
+| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
diff --git a/firstleg/docs/terminal-drayage-mail.md b/firstleg/docs/terminal-drayage-mail.md
new file mode 100644
index 000000000..59a202dcc
--- /dev/null
+++ b/firstleg/docs/terminal-drayage-mail.md
@@ -0,0 +1,34 @@
+# 码头车队邮件
+
+## 行为
+
+- 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。
+- 更换为另一车队时,与车队变更在同一数据库事务中生成 `CANCEL_PICKUP`、`REQUEST_PICKUP` 两个任务。取消邮件使用原请求的收件地址快照;没有原请求时读取原车队当前资料,允许原车队已停用。新车队必须为 ACTIVE/TRUCKING。
+- 同一车队重复选择不发邮件、不改变版本。发送操作递增拖车版本,同版本的重复点击不会再次入队。
+- 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。
+- `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。
+- 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。
+
+## 代码与存储
+
+- `terminaldrayage/service/TerminalDrayageMailTemplates.java`:临时英文取消/提柜模板;正式文案在这里替换。已排队邮件保持原始正文快照。
+- `TerminalDrayageMailService.java`:联系资料校验、邮件入队与重试;`TerminalDrayageMailDispatcher.java`:SMTP 投递。
+- `terminaldrayage/mapper/TerminalDrayageMapper.java`:邮件存储与状态抢占;`fl_terminal_drayage_mail` 定义于 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 和国内统一 SQL 入口。
+- Warehouse `/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact` 仅供内部令牌调用;地址不由浏览器提交。
+- 公网接口及动作以 `src/main/resources/openapi/terminal-drayage-v1.yaml`、Warehouse `supplier-v1.yaml` 为准。
+
+## 启用配置
+
+Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮件平台。默认 `TERMINAL_DRAYAGE_MAIL_ENABLED=false`,可保存任务但不会发信。启用前需配置正式发件账号并核对待发送任务;启用后会投递已排队邮件。
+
+| 环境变量 | 用途 |
+|---|---|
+| `TERMINAL_DRAYAGE_MAIL_ENABLED` | 默认 false,true 启用投递 |
+| `TERMINAL_DRAYAGE_MAIL_HOST` | SMTP 主机 |
+| `TERMINAL_DRAYAGE_MAIL_PORT` | 默认 587 |
+| `TERMINAL_DRAYAGE_MAIL_USERNAME` / `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 账号与凭据,仅环境注入 |
+| `TERMINAL_DRAYAGE_MAIL_FROM` | 单个发件邮箱 |
+| `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 |
+| `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 |
+
+发布涉及 Admin、Firstleg、Warehouse 和新增邮件表;没有新增 IAM 权限。代码开发不授权部署、执行环境 SQL或向真实车队试发。UI 按现有列表与居中弹框承载,此新增交互依据当前用户需求,历史原型未定义邮件功能;待人工在 1920×1080、100% 缩放下截图确认。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
index 6452a9ce7..1b98b7535 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
@@ -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;
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
index bfbfb36d7..494789da7 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java
@@ -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,20 +40,20 @@ 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;
+ @Resource private TerminalDrayageMailService mails;
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
- @Valid @RequestBody com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ @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 emails(
+ public ApiResponse emails(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
return ApiResponse.success(mails.list(containerBookingId));
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
index f2d7f3182..e50cb8ec3 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java
@@ -125,17 +125,17 @@ public interface TerminalDrayageMapper extends BaseMapper
@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")
+ @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 mails(@Param("containerId") long containerId);
@Select("""
- SELECT * FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
+ 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.* FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
+ 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'SENT')
ORDER BY m.id LIMIT 20
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
index b0b34fc6c..d44d0ed46 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcher.java
@@ -43,14 +43,13 @@ public class TerminalDrayageMailDispatcher {
@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);
+ sender().send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
} catch (Exception exception) {
// SMTP 超时不等于未送达,禁止自动重发;失败阻断同柜后续邮件,交由人工核对。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
index 6cb5d76f8..afd2ede88 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailService.java
@@ -60,7 +60,7 @@ public class TerminalDrayageMailService {
static void validateAddress(String email) {
try {
- if (!StringUtils.hasText(email) || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
+ 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();
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
index d6a6a272b..7956ff126 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java
@@ -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;
@@ -204,7 +205,7 @@ public class TerminalDrayageService {
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
- com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest request) {
+ EmailRequest request) {
String action = "SEND_PICKUP_EMAIL";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml
index fbb976456..aa8f58cd5 100644
--- a/firstleg/src/main/resources/application.yml
+++ b/firstleg/src/main/resources/application.yml
@@ -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}
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index d20509348..51f3c9c4e 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -2032,7 +2032,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:
@@ -2079,7 +2079,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]
diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
index babc43d70..1ef8cff2c 100644
--- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
+++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
@@ -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:
diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
index ad98b0d01..e13196f78 100644
--- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
+++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml
@@ -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}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
index 24a26ad4d..25068db02 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
@@ -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 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) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
index 2e35fc928..9c33e839a 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java
@@ -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");
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
index cac6e7aaf..e98489c6d 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java
@@ -8,6 +8,25 @@ 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'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(
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
new file mode 100644
index 000000000..50c12bebc
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailDispatcherTest.java
@@ -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());
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
new file mode 100644
index 000000000..e8863fdc7
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageMailServiceTest.java
@@ -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 "})
+ 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);
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
index d8e86def6..babd5ddca 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java
@@ -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 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(), mock(TerminalDrayageMailService.class));
+ 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));
}
@@ -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;
diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md
index 044a7d7b7..5e9a9083f 100644
--- a/warehouse/AGENTS.md
+++ b/warehouse/AGENTS.md
@@ -21,7 +21,7 @@
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
| 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` |
| 海外仓阶梯费用 | `controller/PlatformOverseasStorageFeeController.java`、`service/OverseasStorageFeeService.java` |
-| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;契约见 `src/main/resources/openapi/supplier-v1.yaml` |
+| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;契约见 `src/main/resources/openapi/supplier-v1.yaml` `controller/InternalSupplierController.java` 的 `/{supplierId}/trucking-contact` 为 Firstleg 提供平台 TRUCKING 邮箱资料,取消旧请求允许读取已停用车队邮箱,仍验证内部令牌及平台归属。 |
| 国内整柜业务 | `domesticcontainer/controller/PlatformDomesticContainerController.java`、`domesticcontainer/service/DomesticContainerService.java`;契约见 `src/main/resources/openapi/domestic-container-v1.yaml` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
diff --git a/warehouse/src/main/resources/openapi/supplier-v1.yaml b/warehouse/src/main/resources/openapi/supplier-v1.yaml
index 2d760ac77..434d0f86a 100644
--- a/warehouse/src/main/resources/openapi/supplier-v1.yaml
+++ b/warehouse/src/main/resources/openapi/supplier-v1.yaml
@@ -81,6 +81,29 @@ paths:
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { $ref: '#/components/schemas/Status' } } } } } }
responses: { '200': { description: 成功 } }
+ /internal/warehouse/v1/suppliers/{supplierId}/trucking-contact:
+ get:
+ summary: 内部查询码头车队收件资料
+ description: 固定校验TRUCKING类别与平台归属;取消旧车队请求允许requireActive=false。
+ security: [{internalServiceToken: []}]
+ parameters:
+ - {name: supplierId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
+ - {name: requireActive, in: query, schema: {type: boolean, default: true}}
+ responses:
+ '200':
+ description: 联系资料
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code: {type: string}
+ data:
+ type: object
+ properties:
+ supplierId: {type: string}
+ name: {type: string}
+ email: {type: string, nullable: true}
/internal/warehouse/v1/suppliers/{supplierId}:
get:
summary: 内部服务查询并校验供应商快照
diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
new file mode 100644
index 000000000..df136ffc2
--- /dev/null
+++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierContactTest.java
@@ -0,0 +1,27 @@
+package com.baiyue.warehouse.controller;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.warehouse.dto.SupplierDtos.SupplierResource;
+import com.baiyue.warehouse.service.SupplierService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.mockito.Mockito.*;
+import static org.assertj.core.api.Assertions.*;
+
+class InternalSupplierContactTest {
+ @Test void contactRequiresInternalTokenAndChecksTruckingCategory() {
+ var service = mock(SupplierService.class);
+ var controller = new InternalSupplierController();
+ ReflectionTestUtils.setField(controller,"service",service);
+ ReflectionTestUtils.setField(controller,"internalToken","test-token");
+ controller.initializeToken();
+ assertThatThrownBy(() -> controller.truckingContact("wrong",1L,true)).isInstanceOf(BizException.class);
+ verifyNoInteractions(service);
+ var supplier = mock(SupplierResource.class);
+ when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com");
+ when(service.detail(1L)).thenReturn(supplier);
+ var result = controller.truckingContact("test-token",1L,false).data();
+ assertThat(result.email()).isEqualTo("fleet@example.com");
+ verify(service).internalSnapshot(1L,false,"TRUCKING");
+ }
+}
From 2980308f4d2b608ac8255010a4ec3d75e872cae2 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 9 Sep 2026 15:27:10 +0800
Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/BusinessFeeSourceChangedV1.java | 9 +++
.../PlatformFinanceVoucherController.java | 65 +++++++++++++++++++
.../BusinessFeeSourceEventService.java | 11 +++-
.../BusinessFeeSourceEventServiceTest.java | 6 +-
.../PlatformFinanceVoucherControllerTest.java | 51 +++++++++++++++
.../financefee/dto/OverseasFeeSourceDtos.java | 9 ++-
.../financefee/entity/OverseasFeeSource.java | 9 +++
.../service/OverseasFeeCurrencyConverter.java | 47 ++++++++++++++
.../service/OverseasFeeOutboxService.java | 5 +-
.../service/OverseasFeeSourceService.java | 18 ++++-
.../service/DestuffingPalletCostService.java | 4 ++
.../src/main/resources/db/lastleg.sql | 11 +++-
.../openapi/overseas-finance-fees-v1.yaml | 9 ++-
...OverseasContainerFeeSourceServiceTest.java | 8 ++-
.../service/OverseasFeeBusinessGuardTest.java | 6 +-
.../OverseasFeeCurrencyConverterTest.java | 37 +++++++++++
.../service/OverseasFeeOutboxServiceTest.java | 4 ++
17 files changed, 292 insertions(+), 17 deletions(-)
create mode 100644 finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
create mode 100644 finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
index 8ae7a67e5..012d99fc3 100644
--- a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
+++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java
@@ -44,6 +44,15 @@ public record BusinessFeeSourceChangedV1(
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
+ String originalCurrency,
+ BigDecimal originalAmount,
+ BigDecimal exchangeRate,
+ String exchangeRateMonth,
+ BigDecimal chargeableQuantity,
+ String quantityUnit,
+ BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd,
String reviewStatus,
String publicationStatus,
String sourceStatus,
diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
new file mode 100644
index 000000000..2ad9c3bac
--- /dev/null
+++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java
@@ -0,0 +1,65 @@
+package com.baiyue.finance.controller;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.finance.client.FinanceFileClient;
+import org.springframework.beans.factory.annotation.Value;
+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 org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.nio.charset.StandardCharsets;
+
+@RestController
+@RequestMapping("/api/platform/finance/v1/vouchers")
+@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
+public class PlatformFinanceVoucherController {
+ private final FinanceFileClient files;
+ private final String token;
+
+ public PlatformFinanceVoucherController(FinanceFileClient files,
+ @Value("${baiyue.security.internal-service-token:}") String token) {
+ this.files = files;
+ this.token = token;
+ }
+
+ public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {}
+
+ @GetMapping("/{fileId}")
+ public ApiResponse metadata(@PathVariable long fileId, @RequestParam long customerId) {
+ var file = scopedFile(customerId, fileId);
+ return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes()));
+ }
+
+ @GetMapping("/{fileId}/content")
+ public ResponseEntity content(@PathVariable long fileId, @RequestParam long customerId) {
+ var file = scopedFile(customerId, fileId);
+ var response = files.download(token, customerId, customerId, fileId);
+ if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试");
+ }
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .header("X-Content-Type-Options", "nosniff")
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
+ .filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString())
+ .contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody());
+ }
+
+ private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) {
+ if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效");
+ var response = files.metadata(token, customerId, customerId, fileId);
+ var file = response == null ? null : response.data();
+ if (file == null || !Long.valueOf(fileId).equals(file.fileId())
+ || !Long.valueOf(customerId).equals(file.tenantId())
+ || !"FINANCE_VOUCHER".equals(file.businessType())
+ || !String.valueOf(customerId).equals(file.businessId())) {
+ throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户");
+ }
+ return file;
+ }
+}
diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
index a1f421fe9..946bfce8e 100644
--- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java
@@ -115,6 +115,13 @@ public class BusinessFeeSourceEventService {
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0
+ || !Set.of("CNY", "USD").contains(value.originalCurrency())
+ || value.originalAmount() == null || value.originalAmount().signum() <= 0
+ || value.exchangeRate() == null || value.exchangeRate().signum() <= 0
+ || blank(value.exchangeRateMonth())
+ || (value.periodStart() == null) != (value.periodEnd() == null)
+ || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())
+ || (value.chargeableQuantity() == null) != blank(value.quantityUnit())
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
}
@@ -148,7 +155,9 @@ public class BusinessFeeSourceEventService {
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
- value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
+ value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
+ value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
+ value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
index a997a3a92..5392b1a73 100644
--- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java
@@ -69,7 +69,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
- new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
+ new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
+ "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
@@ -77,7 +78,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
- new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
+ new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
+ "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
new file mode 100644
index 000000000..62a70024d
--- /dev/null
+++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java
@@ -0,0 +1,51 @@
+package com.baiyue.finance;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.finance.client.FinanceFileClient;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.finance.controller.PlatformFinanceVoucherController;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class PlatformFinanceVoucherControllerTest {
+ private final FinanceFileClient files = mock(FinanceFileClient.class);
+ private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token");
+
+ private FinanceFileClient.Metadata file(long customerId, String type, String businessId) {
+ return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L);
+ }
+
+ @Test void returnsFilenameAndDownloadsWithinCustomerScope() {
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
+ when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
+ assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png");
+ var content = controller.content(91, 11);
+ assertThat(content.getBody()).containsExactly(1, 2, 3);
+ assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store");
+ assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;");
+ }
+
+ @Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() {
+ for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) {
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test"));
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ }
+ verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong());
+ }
+
+ @Test void rejectsMissingMetadataAndFailedContent() {
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
+ when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build());
+ assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
+ }
+
+ @Test void requiresPlatformListPermission() {
+ assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value())
+ .isEqualTo("hasAuthority('platform:finance:customer-arap:list')");
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
index af3d859bf..0c4a4b379 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java
@@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos {
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate,
- @NotBlank @Pattern(regexp = "CNY") String currency,
- @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
+ @NotBlank @Pattern(regexp = "CNY|USD") String currency,
+ @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
+ @Size(max = 32) String quantityUnit,
+ @DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
+ LocalDate periodStart,
+ LocalDate periodEnd
) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
index 59507799f..860dd5991 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java
@@ -46,6 +46,15 @@ public class OverseasFeeSource {
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
+ private String originalCurrency;
+ private BigDecimal originalAmount;
+ private BigDecimal exchangeRate;
+ private String exchangeRateMonth;
+ private BigDecimal chargeableQuantity;
+ private String quantityUnit;
+ private BigDecimal unitPrice;
+ private LocalDate periodStart;
+ private LocalDate periodEnd;
private String reviewStatus;
private String publicationStatus;
private String sourceStatus;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
new file mode 100644
index 000000000..69263374b
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java
@@ -0,0 +1,47 @@
+package com.baiyue.lastleg.financefee.service;
+
+import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+@Component
+@RequiredArgsConstructor
+public class OverseasFeeCurrencyConverter {
+ private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
+ private final MonthlyExchangeRateMapper rates;
+
+ public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
+ String originalCurrency = currency == null ? null : currency.trim().toUpperCase();
+ BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP);
+ if ("CNY".equals(originalCurrency)) {
+ return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6),
+ businessDate.format(MONTH), originalAmount);
+ }
+ if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD");
+ String month = businessDate.format(MONTH);
+ MonthlyExchangeRate rate = rates.findMonth(month);
+ if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null
+ || rate.getUsdToCnyAverage().signum() <= 0) {
+ throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用");
+ }
+ BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP);
+ return new Conversion(originalCurrency, originalAmount, exchangeRate, month,
+ originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP));
+ }
+
+ private static ResponseStatusException bad(String message) {
+ return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
+ }
+
+ public record Conversion(String originalCurrency, BigDecimal originalAmount,
+ BigDecimal exchangeRate, String exchangeRateMonth,
+ BigDecimal settlementAmount) { }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
index ed5410c7e..349dab9e1 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java
@@ -66,7 +66,10 @@ public class OverseasFeeOutboxService {
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
- value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
+ value.getBusinessDate(), value.getCurrency(), value.getAmount(),
+ value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
+ value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
+ value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
index 8318c2813..123f3f3c5 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java
@@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
-import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,6 +39,7 @@ public class OverseasFeeSourceService {
private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox;
+ private final OverseasFeeCurrencyConverter currencyConverter;
private final ObjectMapper objectMapper;
public List list(String customerId, String businessType, String businessNo,
@@ -151,8 +151,14 @@ public class OverseasFeeSourceService {
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
- value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
- value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
+ value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
+ OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
+ value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
+ value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
+ value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
+ value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit()));
+ value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd());
+ value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now());
return value;
@@ -172,6 +178,12 @@ public class OverseasFeeSourceService {
if ("OVERSEAS_CONTAINER".equals(value.businessType())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持");
+ if ((value.periodStart() == null) != (value.periodEnd() == null)
+ || value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()))
+ throw bad("计费期间必须同时填写且开始日期不能晚于结束日期");
+ if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null)
+ || value.unitPrice() != null && value.chargeableQuantity() == null)
+ throw bad("计费数量、单位和单价信息不完整");
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
index 2ad342942..946ded305 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java
@@ -83,6 +83,10 @@ public class DestuffingPalletCostService {
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
+ v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
+ v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
+ v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
+ v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
}
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index 5480c02c4..9e97b8de2 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -832,8 +832,13 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
- business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
- amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
+ business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
+ amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD',
+ original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
+ exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数',
+ quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价',
+ period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期',
+ review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
@@ -842,6 +847,8 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
+ CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')),
+ CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
index 6c2072bf1..13c19aa0d 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml
@@ -85,6 +85,11 @@ components:
feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
businessDate: { type: string, format: date }
- currency: { type: string, enum: [CNY] }
- amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true }
+ currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 }
+ amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, description: 业务原币金额 }
+ chargeableQuantity: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
+ quantityUnit: { type: string, maxLength: 32, nullable: true }
+ unitPrice: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
+ periodStart: { type: string, format: date, nullable: true }
+ periodEnd: { type: string, format: date, nullable: true }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
index a0a883740..59c3f2e4c 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -22,7 +23,9 @@ class OverseasContainerFeeSourceServiceTest {
private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class);
private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
private final OverseasFeeSourceService service = new OverseasFeeSourceService(
- mapper, commands, guard, outbox, new ObjectMapper().findAndRegisterModules());
+ mapper, commands, guard, outbox,
+ new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)),
+ new ObjectMapper().findAndRegisterModules());
@Test
void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() {
@@ -50,7 +53,8 @@ class OverseasContainerFeeSourceServiceTest {
source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
- source.businessDate(), source.currency(), source.amount());
+ source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
+ source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null);
assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request))
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
index a3ae4605a..d91032261 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java
@@ -57,7 +57,8 @@ class OverseasFeeBusinessGuardTest {
source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
- source.businessDate(), source.currency(), source.amount());
+ source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
+ source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
StubJdbc jdbc = new StubJdbc();
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent))
@@ -83,7 +84,8 @@ class OverseasFeeBusinessGuardTest {
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
"PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null,
- List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"));
+ List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"),
+ null, null, null, null, null);
}
private static final class StubJdbc extends JdbcTemplate {
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
new file mode 100644
index 000000000..40358bd1f
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java
@@ -0,0 +1,37 @@
+package com.baiyue.lastleg.financefee.service;
+
+import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
+import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OverseasFeeCurrencyConverterTest {
+ private final MonthlyExchangeRateMapper rates = mock(MonthlyExchangeRateMapper.class);
+ private final OverseasFeeCurrencyConverter converter = new OverseasFeeCurrencyConverter(rates);
+
+ @Test
+ void convertsUsdUsingTheActiveRateForTheBusinessMonth() {
+ MonthlyExchangeRate rate = new MonthlyExchangeRate();
+ rate.setStatus("ACTIVE"); rate.setUsdToCnyAverage(new BigDecimal("7.120000"));
+ when(rates.findMonth("2026-09")).thenReturn(rate);
+
+ var result = converter.convert("USD", new BigDecimal("12.34"), LocalDate.of(2026, 9, 9));
+
+ assertThat(result.originalAmount()).isEqualByComparingTo("12.34");
+ assertThat(result.exchangeRate()).isEqualByComparingTo("7.120000");
+ assertThat(result.settlementAmount()).isEqualByComparingTo("87.86");
+ }
+
+ @Test
+ void refusesUsdWhenTheBusinessMonthHasNoActiveRate() {
+ assertThatThrownBy(() -> converter.convert("USD", BigDecimal.TEN, LocalDate.of(2026, 9, 9)))
+ .hasMessageContaining("缺少有效USD兑CNY汇率");
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
index e76b49736..6f9a6acdf 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java
@@ -38,6 +38,8 @@ class OverseasFeeOutboxServiceTest {
source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费");
source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2));
source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00"));
+ source.setOriginalCurrency("USD"); source.setOriginalAmount(new BigDecimal("16.85"));
+ source.setExchangeRate(new BigDecimal("7.122000")); source.setExchangeRateMonth("2026-09");
source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED");
source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of());
@@ -52,5 +54,7 @@ class OverseasFeeOutboxServiceTest {
assertThat(payload.inboundId()).isNull();
assertThat(payload.inboundCode()).isNull();
assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71);
+ assertThat(payload.originalCurrency()).isEqualTo("USD");
+ assertThat(payload.exchangeRateMonth()).isEqualTo("2026-09");
}
}