Test #1

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