实现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

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