实现V4 Payment附件预览前端
This commit is contained in:
@@ -4,16 +4,16 @@
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-07-21 |
|
||||
| 当前分支 | `feature/huangting` |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览后端安全摘要、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
|
||||
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;Payment 卡已在 `display_payload.payment_attachments[]` 返回付款凭证附件安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否理论可预览 / 下载和可选 `external_media_id`,`attachment_ids[]` 第一版仍只读,前端只展示并确认卡片,不增删或替换附件集合,附件缩略图、大图和非图片下载真实 URL 仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Payment 前端联动、Rooming List 前端轻量卡展示、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览后端安全摘要与前端预览接入、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
|
||||
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;Payment 卡已在 `display_payload.payment_attachments[]` 返回付款凭证附件安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否理论可预览 / 下载和可选 `external_media_id`,前端在 V4 任务详情 Payment 卡中按当前触发 SourceMessage 的 conversation 附件匹配缩略图、大图预览和非图片下载,匹配优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;`attachment_ids[]` 第一版仍只读,前端不增删或替换附件集合,Payment 确认只提交 `version`,不提交附件 ID、附件 URL 或完整附件对象;真实 URL 仍只来自 SourceMessage 原文权限链路,权限不足或 conversation 失败时降级展示不可预览 / 不可下载。已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`M002-V4-payment-attachment-preview-backend`
|
||||
- 状态:In Progress,正在实现 Payment 卡付款凭证附件安全摘要。
|
||||
- 目标:`GET /api/reservation/order-tasks/{orderTaskId}` 对 V4 Payment 卡返回 `display_payload.payment_attachments[]` 安全摘要,`attachment_ids[]` 保持只读,确认接口不接受前端替换附件集合。
|
||||
- 边界:本 checkpoint 不做前端、不返回附件 URL / 签名 URL / OSS URL、不做 Payment 附件编辑、不做付款状态、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。
|
||||
- 联调备注:Payment 缩略图、大图预览和非图片下载真实 URL 仍必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 在 `SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ` 权限下读取;V4 task detail 本身只返回附件 ID、文件名、类型、大小、图片标记、预览 / 下载可用性和可选 `external_media_id`。
|
||||
- 名称:`M002-V4-payment-attachment-preview-frontend`
|
||||
- 状态:Done,本地已实现 V4 Payment 卡付款凭证附件预览第一版。
|
||||
- 目标:V4 任务详情 Payment 卡消费 `display_payload.payment_attachments[]` 安全摘要,图片按当前触发 SourceMessage 的 conversation 附件匹配缩略图和大图预览,非图片提供下载入口;`attachment_ids[]` 保持只读且不进入编辑 / 确认 payload。
|
||||
- 边界:本 checkpoint 不改后端、不返回或展示附件 URL 字符串、不做附件选择器、不做 PDF / Word / Excel 内嵌预览、不做付款状态、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。
|
||||
- 联调备注:真实预览 / 下载 URL 仍必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 在 `SOURCE_MESSAGE_READ + SOURCE_MESSAGE_ORIGINAL_READ` 权限下读取;前端只匹配当前触发 order task 的 SourceMessage,优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;Payment 普通确认只提交 `{ version }`。
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
- 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。
|
||||
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
|
||||
- M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。
|
||||
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成;Payment 附件安全摘要后端已完成。Account + booking type 过滤 Rate Code 仍是待补齐缺口;Payment 前端缩略图 / 下载联动、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
|
||||
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成;Payment 附件安全摘要后端和前端预览接入均已完成。Account + booking type 过滤 Rate Code 仍是待补齐缺口;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
|
||||
- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Rooming List 卡第一版只做事项确认;Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。
|
||||
|
||||
## 5. Next Steps
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="attachments.length"
|
||||
class="payment-attachments"
|
||||
data-testid="payment-attachment-preview"
|
||||
>
|
||||
<div class="payment-attachments__header">
|
||||
<div>
|
||||
<h3>{{ t('taskV4.paymentAttachments.title') }}</h3>
|
||||
<p v-if="loading">
|
||||
{{ t('taskV4.paymentAttachments.loading') }}
|
||||
</p>
|
||||
<p v-else-if="loadErrorMessage">
|
||||
{{ loadErrorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="payment-attachments__grid">
|
||||
<li
|
||||
v-for="attachment in resolvedAttachments"
|
||||
:key="attachment.key"
|
||||
class="payment-attachment"
|
||||
>
|
||||
<button
|
||||
v-if="canPreview(attachment)"
|
||||
type="button"
|
||||
class="payment-attachment__thumbnail"
|
||||
data-testid="payment-attachment-thumbnail"
|
||||
:aria-label="t('taskV4.paymentAttachments.openPreview')"
|
||||
@click="selectedPreview = attachment"
|
||||
>
|
||||
<img
|
||||
:src="attachment.mediaUrl"
|
||||
:alt="attachment.fileName"
|
||||
>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="payment-attachment__file"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ attachment.isImage ? 'IMG' : 'FILE' }}
|
||||
</div>
|
||||
|
||||
<div class="payment-attachment__body">
|
||||
<strong>{{ attachment.fileName }}</strong>
|
||||
<small>{{ attachmentMeta(attachment) }}</small>
|
||||
<small
|
||||
v-if="attachmentStatus(attachment)"
|
||||
class="payment-attachment__status"
|
||||
>
|
||||
{{ attachmentStatus(attachment) }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="canDownload(attachment)"
|
||||
class="payment-attachment__download"
|
||||
data-testid="payment-attachment-download"
|
||||
:href="attachment.mediaUrl"
|
||||
:download="attachment.fileName"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.download') }}
|
||||
</a>
|
||||
<span
|
||||
v-else-if="!attachment.isImage"
|
||||
class="payment-attachment__disabled"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.downloadUnavailable') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="selectedPreview"
|
||||
class="payment-lightbox"
|
||||
data-testid="payment-attachment-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="selectedPreview = null"
|
||||
>
|
||||
<div class="payment-lightbox__panel">
|
||||
<button
|
||||
type="button"
|
||||
class="payment-lightbox__close"
|
||||
:aria-label="t('taskV4.paymentAttachments.closePreview')"
|
||||
@click="selectedPreview = null"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.closePreview') }}
|
||||
</button>
|
||||
<img
|
||||
:src="selectedPreview.mediaUrl"
|
||||
:alt="selectedPreview.fileName"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationRecord,
|
||||
ReservationV4PaymentAttachmentSummary,
|
||||
ReservationV4TaskCardResult,
|
||||
SourceMessageOriginalMedia,
|
||||
} from '@/types/reservation'
|
||||
|
||||
type PaymentAttachmentItem = {
|
||||
key: string
|
||||
attachmentId: string
|
||||
externalMediaId: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number | null
|
||||
isImage: boolean
|
||||
previewAvailable: boolean
|
||||
downloadAvailable: boolean
|
||||
unavailableReason: string
|
||||
}
|
||||
|
||||
type ResolvedPaymentAttachment = PaymentAttachmentItem & {
|
||||
media: SourceMessageOriginalMedia | null
|
||||
mediaUrl: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
card: ReservationV4TaskCardResult
|
||||
sourceMessageId?: string | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const currentMessageMedia = ref<SourceMessageOriginalMedia[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const selectedPreview = ref<ResolvedPaymentAttachment | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const attachments = computed(() => normalizePaymentAttachments(props.card.display_payload?.payment_attachments))
|
||||
const attachmentSignature = computed(() =>
|
||||
attachments.value
|
||||
.map((attachment) => `${attachment.externalMediaId}:${attachment.attachmentId}:${attachment.fileName}`)
|
||||
.join('|'),
|
||||
)
|
||||
const loadErrorMessage = computed(() => {
|
||||
if (!attachments.value.length) {
|
||||
return ''
|
||||
}
|
||||
if (!props.sourceMessageId) {
|
||||
return t('taskV4.paymentAttachments.missingSourceMessage')
|
||||
}
|
||||
return loadError.value ? t('taskV4.paymentAttachments.loadFailed') : ''
|
||||
})
|
||||
const resolvedAttachments = computed<ResolvedPaymentAttachment[]>(() =>
|
||||
attachments.value.map((attachment) => {
|
||||
const media = matchCurrentMessageMedia(attachment)
|
||||
return {
|
||||
...attachment,
|
||||
media,
|
||||
mediaUrl: media ? safeMediaUrl(media) : '',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.sourceMessageId, attachmentSignature],
|
||||
() => {
|
||||
void loadCurrentMessageMedia()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadCurrentMessageMedia(): Promise<void> {
|
||||
const currentRequest = ++requestSequence
|
||||
currentMessageMedia.value = []
|
||||
loadError.value = false
|
||||
selectedPreview.value = null
|
||||
|
||||
if (!props.sourceMessageId || !attachments.value.length) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const conversation = await fetchSourceMessageConversation(props.sourceMessageId)
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
const currentMessage = conversation.messages.find((message) =>
|
||||
String(message.id) === String(props.sourceMessageId),
|
||||
)
|
||||
currentMessageMedia.value = currentMessage
|
||||
? [...currentMessage.attachments, ...currentMessage.inline_images]
|
||||
: []
|
||||
} catch {
|
||||
if (currentRequest === requestSequence) {
|
||||
loadError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (currentRequest === requestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePaymentAttachments(value: unknown): PaymentAttachmentItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.map((item, index) => normalizePaymentAttachment(item, index))
|
||||
.filter((item): item is PaymentAttachmentItem => Boolean(item))
|
||||
}
|
||||
|
||||
function normalizePaymentAttachment(value: unknown, index: number): PaymentAttachmentItem | null {
|
||||
const record = readRecord(value) as ReservationV4PaymentAttachmentSummary | null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attachmentId = readFirstString(record, ['attachment_id', 'attachmentId', 'id'])
|
||||
const externalMediaId = readFirstString(record, ['external_media_id', 'externalMediaId'])
|
||||
const fileName = safeFileName(readFirstString(record, ['file_name', 'fileName', 'name', 'display_name']))
|
||||
const contentType = readFirstString(record, ['content_type', 'contentType', 'mime_type']) ?? ''
|
||||
const sizeBytes = readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size')
|
||||
const isImage = readBoolean(record, 'is_image') ?? contentType.toLowerCase().startsWith('image/')
|
||||
|
||||
return {
|
||||
key: externalMediaId || attachmentId || `${fileName}-${index}`,
|
||||
attachmentId,
|
||||
externalMediaId,
|
||||
fileName,
|
||||
contentType,
|
||||
sizeBytes,
|
||||
isImage,
|
||||
previewAvailable: readBoolean(record, 'preview_available') ?? true,
|
||||
downloadAvailable: readBoolean(record, 'download_available') ?? true,
|
||||
unavailableReason: readFirstString(record, ['unavailable_reason_code', 'unavailableReasonCode']) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function matchCurrentMessageMedia(attachment: PaymentAttachmentItem): SourceMessageOriginalMedia | null {
|
||||
if (attachment.externalMediaId) {
|
||||
const matchedByExternalMediaId = currentMessageMedia.value.find((media) =>
|
||||
mediaExternalMediaId(media) === attachment.externalMediaId,
|
||||
)
|
||||
if (matchedByExternalMediaId) {
|
||||
return matchedByExternalMediaId
|
||||
}
|
||||
}
|
||||
if (attachment.attachmentId) {
|
||||
return currentMessageMedia.value.find((media) =>
|
||||
mediaAttachmentIds(media).includes(attachment.attachmentId),
|
||||
) ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function canPreview(attachment: ResolvedPaymentAttachment): boolean {
|
||||
return attachment.isImage &&
|
||||
attachment.previewAvailable &&
|
||||
Boolean(attachment.mediaUrl) &&
|
||||
!loadError.value
|
||||
}
|
||||
|
||||
function canDownload(attachment: ResolvedPaymentAttachment): boolean {
|
||||
return !attachment.isImage &&
|
||||
attachment.downloadAvailable &&
|
||||
Boolean(attachment.mediaUrl) &&
|
||||
!loadError.value
|
||||
}
|
||||
|
||||
function attachmentStatus(attachment: ResolvedPaymentAttachment): string {
|
||||
if (loadErrorMessage.value) {
|
||||
return loadErrorMessage.value
|
||||
}
|
||||
if (!attachment.media) {
|
||||
return t('taskV4.paymentAttachments.noMatchedMedia')
|
||||
}
|
||||
if (attachment.unavailableReason) {
|
||||
return t('taskV4.paymentAttachments.unavailableReason', { reason: attachment.unavailableReason })
|
||||
}
|
||||
if (attachment.isImage && !attachment.previewAvailable) {
|
||||
return t('taskV4.paymentAttachments.previewUnavailable')
|
||||
}
|
||||
if (!attachment.isImage && !attachment.downloadAvailable) {
|
||||
return t('taskV4.paymentAttachments.downloadUnavailable')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function attachmentMeta(attachment: PaymentAttachmentItem): string {
|
||||
const type = attachment.contentType || (attachment.isImage ? 'image' : 'file')
|
||||
const size = formatFileSize(attachment.sizeBytes)
|
||||
return size
|
||||
? t('taskV4.paymentAttachments.fileMeta', { type, size })
|
||||
: type
|
||||
}
|
||||
|
||||
function mediaExternalMediaId(media: SourceMessageOriginalMedia): string {
|
||||
return media.externalMediaId ?? readFirstString(media as unknown as ReservationRecord, ['external_media_id']) ?? ''
|
||||
}
|
||||
|
||||
function mediaAttachmentIds(media: SourceMessageOriginalMedia): string[] {
|
||||
const record = media as unknown as ReservationRecord
|
||||
return [
|
||||
readFirstString(record, ['attachment_id', 'attachmentId', 'id']),
|
||||
mediaExternalMediaId(media),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
function safeMediaUrl(media: SourceMessageOriginalMedia): string {
|
||||
return media.externalUrl ?? readFirstString(media as unknown as ReservationRecord, ['external_url', 'url']) ?? ''
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): ReservationRecord | null {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as ReservationRecord
|
||||
: null
|
||||
}
|
||||
|
||||
function readFirstString(record: ReservationRecord, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function readNumber(record: ReservationRecord, key: string): number | null {
|
||||
const value = record[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && Number.isFinite(Number(value))) {
|
||||
return Number(value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readBoolean(record: ReservationRecord, key: string): boolean | null {
|
||||
const value = record[key]
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase()
|
||||
if (normalizedValue === 'true') {
|
||||
return true
|
||||
}
|
||||
if (normalizedValue === 'false') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
return value || t('conversation.unnamedAttachment')
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | null): string {
|
||||
if (!size) {
|
||||
return ''
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${Math.round(size / 1024)} KB`
|
||||
}
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-attachments {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0 20px 18px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.payment-attachments__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-attachments__header h3,
|
||||
.payment-attachments__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payment-attachments__header h3 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.payment-attachments__header p {
|
||||
margin-top: 4px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-attachments__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.payment-attachment {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
list-style: none;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail,
|
||||
.payment-attachment__file {
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail {
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.payment-attachment__file {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.payment-attachment__body {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.payment-attachment__body strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.payment-attachment__body small,
|
||||
.payment-attachment__disabled {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-attachment__status {
|
||||
color: var(--th-color-warning);
|
||||
}
|
||||
|
||||
.payment-attachment__download {
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.payment-attachment__download:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.payment-lightbox {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(15 23 42 / 68%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.payment-lightbox__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: min(960px, 92vw);
|
||||
max-height: 92vh;
|
||||
}
|
||||
|
||||
.payment-lightbox__panel img {
|
||||
max-width: 100%;
|
||||
max-height: 78vh;
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.payment-lightbox__close {
|
||||
justify-self: end;
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.payment-attachment {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail,
|
||||
.payment-attachment__file {
|
||||
width: 72px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.payment-attachment__download,
|
||||
.payment-attachment__disabled {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -45,17 +45,24 @@
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-else
|
||||
class="task-card-section__fields"
|
||||
:fields="card.fields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
:validation-errors="validationErrors"
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<template v-else>
|
||||
<ReservationV4PaymentAttachmentPreview
|
||||
v-if="isPaymentCard"
|
||||
:card="card"
|
||||
:source-message-id="sourceMessageId"
|
||||
/>
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-else
|
||||
class="task-card-section__fields"
|
||||
:fields="visibleFields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
:validation-errors="validationErrors"
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<details
|
||||
v-if="!isRoomInformationCard && safePayloadRows.length"
|
||||
@@ -164,6 +171,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationV4PaymentAttachmentPreview from '@/components/reservation/ReservationV4PaymentAttachmentPreview.vue'
|
||||
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
|
||||
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
|
||||
@@ -187,6 +195,7 @@ const props = withDefaults(defineProps<{
|
||||
validationErrors?: Record<string, string>
|
||||
hotelId?: string
|
||||
orderTaskId?: string | null
|
||||
sourceMessageId?: string | null
|
||||
actionErrors?: string[]
|
||||
successMessage?: string
|
||||
reviewForm?: ReviewForm
|
||||
@@ -198,6 +207,7 @@ const props = withDefaults(defineProps<{
|
||||
validationErrors: () => ({}),
|
||||
hotelId: undefined,
|
||||
orderTaskId: null,
|
||||
sourceMessageId: null,
|
||||
actionErrors: () => [],
|
||||
successMessage: '',
|
||||
reviewForm: () => ({
|
||||
@@ -216,6 +226,12 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
|
||||
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
|
||||
const visibleFields = computed(() =>
|
||||
isPaymentCard.value
|
||||
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
|
||||
: props.card.fields,
|
||||
)
|
||||
|
||||
const availabilityReason = computed(() => {
|
||||
return props.card.availability.readonly_reason_message ??
|
||||
@@ -248,6 +264,11 @@ function updateReviewForm(field: keyof ReviewForm, event: Event): void {
|
||||
[field]: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
||||
const normalizedKey = `${field.field_pointer || ''} ${field.field_path || ''}`.toLowerCase()
|
||||
return normalizedKey.includes('attachment_ids')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: 'Payment voucher attachments',
|
||||
loading: 'Loading attachment preview access...',
|
||||
loadFailed: 'Attachment preview is unavailable right now. Check source email permission and retry.',
|
||||
empty: 'No payment voucher attachments.',
|
||||
openPreview: 'Open image preview',
|
||||
closePreview: 'Close preview',
|
||||
download: 'Download',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
downloadUnavailable: 'Download unavailable',
|
||||
noMatchedMedia: 'No matching attachment was found in the current email.',
|
||||
missingSourceMessage: 'Source email is missing, so preview and download are unavailable.',
|
||||
unavailableReason: 'Unavailable reason: {reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: 'Loading catalog',
|
||||
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: 'ใช่',
|
||||
no: 'ไม่ใช่',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: 'ไฟล์แนบหลักฐานการชำระเงิน',
|
||||
loading: 'กำลังโหลดสิทธิ์ดูตัวอย่างไฟล์แนบ...',
|
||||
loadFailed: 'ยังดูตัวอย่างไฟล์แนบไม่ได้ โปรดตรวจสิทธิ์อีเมลต้นทางแล้วลองใหม่',
|
||||
empty: 'ไม่มีไฟล์แนบหลักฐานการชำระเงิน',
|
||||
openPreview: 'เปิดดูภาพขนาดใหญ่',
|
||||
closePreview: 'ปิดตัวอย่าง',
|
||||
download: 'ดาวน์โหลด',
|
||||
previewUnavailable: 'ดูตัวอย่างไม่ได้',
|
||||
downloadUnavailable: 'ดาวน์โหลดไม่ได้',
|
||||
noMatchedMedia: 'ไม่พบไฟล์แนบที่ตรงกันในอีเมลปัจจุบัน',
|
||||
missingSourceMessage: 'ไม่มีอีเมลต้นทาง จึงดูตัวอย่างหรือดาวน์โหลดไม่ได้',
|
||||
unavailableReason: 'เหตุผลที่ใช้ไม่ได้: {reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: 'กำลังโหลดแค็ตตาล็อก',
|
||||
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: '是',
|
||||
no: '否',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: '付款凭证附件',
|
||||
loading: '正在加载附件预览权限...',
|
||||
loadFailed: '附件预览暂不可用,可检查来源邮件权限后重试。',
|
||||
empty: '暂无付款凭证附件。',
|
||||
openPreview: '查看大图',
|
||||
closePreview: '关闭预览',
|
||||
download: '下载',
|
||||
previewUnavailable: '不可预览',
|
||||
downloadUnavailable: '不可下载',
|
||||
noMatchedMedia: '当前邮件中未找到匹配附件。',
|
||||
missingSourceMessage: '缺少来源邮件,暂不可预览或下载。',
|
||||
unavailableReason: '不可用原因:{reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: '目录加载中',
|
||||
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ReservationV4SourceNotificationDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
SourceMessageConversationResult,
|
||||
SourceMessageOriginalMedia,
|
||||
} from '@/types/reservation'
|
||||
import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue'
|
||||
import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue'
|
||||
@@ -287,6 +288,104 @@ describe('reservation V4 pages', () => {
|
||||
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
|
||||
})
|
||||
|
||||
it('renders Payment attachments with image preview and non-image download from current source message media', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
attachments: createPaymentConversationMedia(),
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
|
||||
const attachmentPreview = wrapper.find('[data-testid="payment-attachment-preview"]')
|
||||
expect(attachmentPreview.exists()).toBe(true)
|
||||
expect(attachmentPreview.text()).toContain('voucher-image.jpg')
|
||||
expect(attachmentPreview.text()).toContain('voucher-document.pdf')
|
||||
expect(attachmentPreview.text()).toContain(zhCN.taskV4.paymentAttachments.noMatchedMedia)
|
||||
expect(attachmentPreview.text()).not.toContain('https://oss.example')
|
||||
|
||||
expect(attachmentPreview.findAll('[data-testid="payment-attachment-thumbnail"]')).toHaveLength(1)
|
||||
const thumbnail = attachmentPreview.find('[data-testid="payment-attachment-thumbnail"]')
|
||||
expect(thumbnail.exists()).toBe(true)
|
||||
expect(thumbnail.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg')
|
||||
await thumbnail.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const modal = wrapper.find('[data-testid="payment-attachment-modal"]')
|
||||
expect(modal.exists()).toBe(true)
|
||||
expect(modal.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg')
|
||||
expect(wrapper.text()).not.toContain('https://oss.example/private/payment-image.jpg')
|
||||
|
||||
const downloadLink = attachmentPreview.find('[data-testid="payment-attachment-download"]')
|
||||
expect(downloadLink.exists()).toBe(true)
|
||||
expect(downloadLink.attributes('href')).toBe('https://oss.example/private/payment-document.pdf')
|
||||
})
|
||||
|
||||
it('degrades Payment attachment preview when conversation media cannot be loaded', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, {
|
||||
code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN',
|
||||
message: 'Forbidden',
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('voucher-image.jpg')
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.paymentAttachments.loadFailed)
|
||||
expect(wrapper.find('[data-testid="payment-attachment-thumbnail"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="payment-attachment-download"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms Payment cards with version only and never submits attachment ids or attachment URLs', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
attachments: createPaymentConversationMedia(),
|
||||
}))
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||
...detail,
|
||||
business_cards: [
|
||||
{
|
||||
...detail.business_cards[0]!,
|
||||
card_status: 'CONFIRMED',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const paymentCard = wrapper
|
||||
.findAll('.task-card-section')
|
||||
.find((section) => section.text().includes(zhCN.cardType.PAYMENT))
|
||||
expect(paymentCard).toBeTruthy()
|
||||
await paymentCard!.find('button.primary-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-payment', {
|
||||
version: 9,
|
||||
})
|
||||
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
|
||||
expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids')
|
||||
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||
})
|
||||
|
||||
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
businessEventType: 'NEW_BOOKING',
|
||||
@@ -1131,6 +1230,7 @@ function createSourceMessageConversationResult(options: {
|
||||
sanitizedHtml?: string | null
|
||||
rawHtml?: string | null
|
||||
htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode']
|
||||
attachments?: SourceMessageOriginalMedia[]
|
||||
} = {}): SourceMessageConversationResult {
|
||||
const sourceMessageId = options.sourceMessageId ?? '30001'
|
||||
const currentBody = options.currentBody ?? 'Current trigger email body.'
|
||||
@@ -1157,7 +1257,7 @@ function createSourceMessageConversationResult(options: {
|
||||
html_body_sanitized: options.sanitizedHtml ?? null,
|
||||
html_render_mode: options.htmlRenderMode,
|
||||
received_at: '2026-07-08T03:00:00Z',
|
||||
attachments: [
|
||||
attachments: options.attachments ?? [
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'current-message.pdf',
|
||||
@@ -1200,6 +1300,95 @@ function createConversationMessage(
|
||||
}
|
||||
}
|
||||
|
||||
function usePaymentBusinessCard(detail: ReservationV4OrderTaskDetailResult): void {
|
||||
detail.business_cards = [
|
||||
createCard('card-payment', 'PAYMENT', 'PENDING_CONFIRM', {
|
||||
version: 9,
|
||||
display_payload: {
|
||||
payment_attachments: [
|
||||
{
|
||||
attachment_id: 'att-img-1',
|
||||
file_name: 'voucher-image.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size_bytes: 123456,
|
||||
is_image: true,
|
||||
preview_available: true,
|
||||
download_available: true,
|
||||
external_media_id: 'media-payment-image',
|
||||
},
|
||||
{
|
||||
attachment_id: 'att-pdf-1',
|
||||
file_name: 'voucher-document.pdf',
|
||||
content_type: 'application/pdf',
|
||||
size_bytes: 2048,
|
||||
is_image: false,
|
||||
preview_available: false,
|
||||
download_available: true,
|
||||
external_media_id: 'media-payment-document',
|
||||
},
|
||||
{
|
||||
attachment_id: 'att-missing-1',
|
||||
file_name: 'missing-voucher.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size_bytes: 4096,
|
||||
is_image: true,
|
||||
preview_available: false,
|
||||
download_available: false,
|
||||
external_media_id: 'media-payment-missing',
|
||||
unavailable_reason_code: 'MEDIA_NOT_AVAILABLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
createField('/payment/attachment_ids', {
|
||||
display_name: 'attachment_ids',
|
||||
value: ['att-img-1', 'att-pdf-1'],
|
||||
editable: true,
|
||||
raw_readonly: false,
|
||||
edit_scope: 'CONFIRM',
|
||||
write_target: 'CONFIRMED_PAYLOAD_JSON',
|
||||
}),
|
||||
createField('/payment/payment_note', {
|
||||
display_name: 'Payment Note',
|
||||
value: 'Should not submit for Payment confirm.',
|
||||
editable: true,
|
||||
edit_scope: 'CONFIRM',
|
||||
write_target: 'CONFIRMED_PAYLOAD_JSON',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createPaymentConversationMedia(): SourceMessageOriginalMedia[] {
|
||||
return [
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'voucher-image.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
sizeBytes: 123456,
|
||||
externalUrl: 'https://oss.example/private/payment-image.jpg',
|
||||
externalMediaId: 'media-payment-image',
|
||||
},
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'voucher-document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: 2048,
|
||||
externalUrl: 'https://oss.example/private/payment-document.pdf',
|
||||
externalMediaId: 'media-payment-document',
|
||||
},
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'missing-voucher.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
sizeBytes: 4096,
|
||||
externalUrl: 'https://oss.example/private/same-file-name-should-not-match.jpg',
|
||||
externalMediaId: 'media-different-same-file-name',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createRoomInformationDisplayPayload(overrides: {
|
||||
event_type?: string
|
||||
booking_type?: string
|
||||
|
||||
@@ -404,6 +404,18 @@ export interface ReservationV4RoomInformationDisplayModel extends ReservationRec
|
||||
group_booking_status_options: ReservationV4RoomInformationStatusOption[]
|
||||
}
|
||||
|
||||
export interface ReservationV4PaymentAttachmentSummary extends ReservationRecord {
|
||||
attachment_id?: string | null
|
||||
file_name?: string | null
|
||||
content_type?: string | null
|
||||
size_bytes?: number | string | null
|
||||
is_image?: boolean | null
|
||||
preview_available?: boolean | null
|
||||
download_available?: boolean | null
|
||||
external_media_id?: string | null
|
||||
unavailable_reason_code?: string | null
|
||||
}
|
||||
|
||||
export interface ReservationV4TaskCardResult {
|
||||
card_id: string
|
||||
card_type: ReservationV4CardType
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
:validation-errors="cardFieldErrors[detail.basic_information_card.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:source-message-id="detail.order_task.source_message_id"
|
||||
:action-errors="cardActionErrors[detail.basic_information_card.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[detail.basic_information_card.card_id] ?? ''"
|
||||
:review-form="reviewForms[detail.basic_information_card.card_id]"
|
||||
@@ -130,6 +131,7 @@
|
||||
:validation-errors="cardFieldErrors[businessCard.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:source-message-id="detail.order_task.source_message_id"
|
||||
:action-errors="cardActionErrors[businessCard.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[businessCard.card_id] ?? ''"
|
||||
:review-form="reviewForms[businessCard.card_id]"
|
||||
@@ -198,6 +200,7 @@ import {
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type {
|
||||
ReservationAiTransitionDisplayResult,
|
||||
ReservationV4CardConfirmRequest,
|
||||
ReservationRecord,
|
||||
ReservationV4OrderTaskDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
@@ -367,10 +370,11 @@ async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
|
||||
[card.card_id]: '',
|
||||
}
|
||||
try {
|
||||
const result = await confirmReservationV4OrderTaskCard(orderTaskId.value, card.card_id, {
|
||||
version: card.version,
|
||||
confirmed_payload: buildReservationV4ConfirmedPayload(submissionFields, cardValues.value[card.card_id] ?? {}),
|
||||
})
|
||||
const result = await confirmReservationV4OrderTaskCard(
|
||||
orderTaskId.value,
|
||||
card.card_id,
|
||||
buildConfirmRequest(card, submissionFields),
|
||||
)
|
||||
applyDetail(result)
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
@@ -455,6 +459,9 @@ function editableFieldKeys(card: ReservationV4TaskCardResult): string[] {
|
||||
}
|
||||
|
||||
function submissionFieldsForCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult['fields'] {
|
||||
if (isPaymentCard(card)) {
|
||||
return []
|
||||
}
|
||||
return card.card_type === 'ROOM_INFORMATION'
|
||||
? card.fields.filter((field) =>
|
||||
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
|
||||
@@ -462,6 +469,25 @@ function submissionFieldsForCard(card: ReservationV4TaskCardResult): Reservation
|
||||
: card.fields
|
||||
}
|
||||
|
||||
function buildConfirmRequest(
|
||||
card: ReservationV4TaskCardResult,
|
||||
submissionFields: ReservationV4TaskCardResult['fields'],
|
||||
): ReservationV4CardConfirmRequest {
|
||||
if (isPaymentCard(card)) {
|
||||
return {
|
||||
version: card.version,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: card.version,
|
||||
confirmed_payload: buildReservationV4ConfirmedPayload(submissionFields, cardValues.value[card.card_id] ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isPaymentCard(card: ReservationV4TaskCardResult): boolean {
|
||||
return card.card_type === 'PAYMENT'
|
||||
}
|
||||
|
||||
function isGroupRoomInformationBreakfastField(
|
||||
card: ReservationV4TaskCardResult,
|
||||
field: ReservationV4TaskCardResult['fields'][number],
|
||||
|
||||
Reference in New Issue
Block a user