实现V4 Payment附件预览前端

This commit is contained in:
andy
2026-07-21 16:52:49 +07:00
parent 4b869a0ecd
commit 61380a9ed8
9 changed files with 878 additions and 24 deletions

View File

@@ -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 notificationDebug 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 notificationDebug 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 / 附加费表高亮行业务 JSONDebug 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 来源通知 ackM002 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 来源通知 ackM002 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.',

View File

@@ -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: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',

View File

@@ -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: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',

View File

@@ -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

View File

@@ -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

View File

@@ -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],