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"); + } +}