Phase 11: version PDF (FR-15)

- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -0,0 +1,75 @@
import { onBeforeUnmount, ref } from 'vue'
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */
export function useVersionOpen() {
const { apiJson } = useApi()
const openingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fallbackUrl = ref<string | null>(null)
const openNotice = ref<string | null>(null)
let cleanup = () => {}
let generation = 0
let pendingTab: Window | null = null
function resetOpen() {
generation++
pendingTab?.close()
pendingTab = null
cleanup()
openingVersionId.value = null
downloadError.value = null
fallbackUrl.value = null
openNotice.value = null
}
async function openVersion(documentId: string, versionId: string, format: VersionFormat = 'docx') {
if (openingVersionId.value) return
resetOpen()
const current = generation
openingVersionId.value = versionId
// Reserve a tab during the user gesture, before the authenticated request loses activation.
let tab: Window | null = null
try {
tab = format === 'pdf' ? window.open('about:blank', '_blank') : null
if (tab) tab.opener = null
pendingTab = tab
const link = await apiJson<{ url: string; expiresAt: string }>(versionDownloadPath(documentId, versionId, format))
if (current !== generation) { tab?.close(); return }
if (!link) throw new Error('ขอลิงก์เปิดไฟล์ไม่สำเร็จ')
fallbackUrl.value = link.url
if (format === 'pdf') {
if (tab && !tab.closed) {
tab.location.replace(link.url)
pendingTab = null
}
else openNotice.value = 'เบราว์เซอร์บล็อกแท็บใหม่ กรุณากดลิงก์เปิดไฟล์ด้านล่าง'
} else {
let switched = false
const onBlur = () => { switched = true }
const onVisibility = () => { if (document.hidden) switched = true }
window.addEventListener('blur', onBlur)
document.addEventListener('visibilitychange', onVisibility)
const timer = window.setTimeout(() => {
if (!switched) openNotice.value = 'หาก Word ไม่เปิด กรุณาดาวน์โหลดไฟล์แล้วเปิดใน Word'
cleanup()
}, 1500)
cleanup = () => {
window.clearTimeout(timer)
window.removeEventListener('blur', onBlur)
document.removeEventListener('visibilitychange', onVisibility)
}
window.location.assign(wordOpenUrl(link.url))
}
} catch (err) {
tab?.close()
if (pendingTab === tab) pendingTab = null
if (current === generation) downloadError.value = err instanceof Error ? err.message : 'เปิดไฟล์ไม่สำเร็จ'
} finally {
if (current === generation) openingVersionId.value = null
}
}
onBeforeUnmount(resetOpen)
return { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen }
}

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v1 (ไม่แตะเอกสารเดิม) */
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v0 (ไม่แตะเอกสารเดิม) */
import type { DocumentDetail, VersionListItem } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion } = useVersionOpen()
const documentId = computed(() => String(route.params.documentId))
@ -14,8 +15,6 @@ const loading = ref(true)
const loadError = ref<string | null>(null)
const expandedVersionId = ref<string | null>(null)
const downloadingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? [])
@ -52,19 +51,6 @@ function regenerate() {
navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`)
}
async function download(v: VersionListItem) {
downloadingVersionId.value = v.versionId
downloadError.value = null
try {
const base = (detail.value?.fileName ?? 'document').replace(/\.docx$/i, '')
await downloadFile(`/api/documents/${documentId.value}/versions/${v.versionId}/download`, `${base}_v${v.versionNumber}.docx`)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloadingVersionId.value = null
}
}
onMounted(load)
</script>
@ -190,12 +176,14 @@ onMounted(load)
<div class="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
:disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<button
type="button"
class="text-xs text-muted"
@ -257,12 +245,14 @@ onMounted(load)
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
:disabled="openingVersionId !== null"
class="btn btn-secondary btn-xs"
@click.stop="download(v)"
@click.stop="openVersion(documentId, v.versionId)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
{{ openingVersionId !== null ? '…' : 'เปิดใน Word' }}
</button>
<button v-if="v.hasPdf" type="button" class="btn btn-secondary btn-xs ml-2"
:disabled="openingVersionId !== null" @click.stop="openVersion(documentId, v.versionId, 'pdf')">PDF</button>
<span class="ml-2 text-xs text-muted">
{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}
</span>
@ -292,6 +282,11 @@ onMounted(load)
</table>
</div>
</template>
<AlertBox v-if="fallbackUrl">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">เปิด / ดาวน์โหลดไฟล์สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดไฟล์อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div>
</template>

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const { apiJson } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null)
@ -23,8 +24,6 @@ const loadError = ref<string | null>(null)
const submitting = ref(false)
const submitError = ref<string | null>(null)
const result = ref<GenerateDocumentResponse | null>(null)
const downloading = ref(false)
const downloadError = ref<string | null>(null)
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
@ -98,22 +97,9 @@ async function submit() {
}
}
async function download() {
if (!result.value) return
downloading.value = true
downloadError.value = null
try {
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
await downloadFile(result.value.downloadUrl, name)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloading.value = false
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
function resetForm() {
resetOpen()
result.value = null
submitError.value = null
downloadError.value = null
@ -139,7 +125,7 @@ onMounted(load)
</AlertBox>
<template v-else-if="detail">
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว -->
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v0 แล้ว -->
<template v-if="result">
<AlertBox type="success">
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
@ -165,13 +151,18 @@ onMounted(load)
</button>
<button
type="button"
:disabled="downloading"
:disabled="openingVersionId !== null"
class="btn btn-primary w-full sm:w-auto"
@click="download"
@click="openVersion(result.documentId, result.versionId)"
>
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }}
{{ openingVersionId !== null ? 'กำลังเตรียมไฟล์…' : 'เปิดใน Word' }}
</button>
</div>
<AlertBox v-if="fallbackUrl" class="mt-4">
<p v-if="openNotice">{{ openNotice }}</p>
<a :href="fallbackUrl" target="_blank" rel="noopener noreferrer" class="link">ดาวน์โหลด .docx สำรอง</a>
<p class="text-xs text-muted">ลิงก์มีอายุสั้น หากหมดอายุให้กดเปิดใน Word อีกครั้ง</p>
</AlertBox>
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
</div>
@ -181,7 +172,7 @@ onMounted(load)
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
<li>
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning
tracked document และแสดง <strong>version 0</strong> ทันที โดยไม่ต้องกด Start Versioning
</li>
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save &amp; Version</strong> เพื่อบันทึก version ถัดไป</li>
</ol>
@ -278,7 +269,7 @@ onMounted(load)
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="btn btn-primary w-full sm:w-auto"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
</button>
</div>
</form>

View file

@ -97,6 +97,7 @@ export interface DocumentDetail {
/** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว
* (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */
export interface VersionListItem {
hasPdf: boolean;
versionId: string
versionNumber: number
createdAt: string

View file

@ -0,0 +1,14 @@
export type VersionFormat = 'docx' | 'pdf'
export function versionFileName(fileName: string, versionNumber: number, format: VersionFormat = 'docx'): string {
return `${fileName.replace(/\.docx$/i, '')}_v${versionNumber}.${format}`
}
export function versionDownloadPath(documentId: string, versionId: string, format: VersionFormat = 'docx'): string {
return `/api/documents/${documentId}/versions/${versionId}/download-link${format === 'pdf' ? '?format=pdf' : ''}`
}
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
export function wordOpenUrl(presignedUrl: string): string {
return `ms-word:ofe|u|${presignedUrl}`
}

View file

@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, type App } from 'vue'
import { useVersionOpen } from '../app/composables/useVersionOpen'
let app: App
let host: HTMLDivElement
const apiJson = vi.fn()
const replace = vi.fn()
const close = vi.fn()
let tab: { opener: unknown; closed: boolean; location: { replace: typeof replace }; close: typeof close }
function mount() {
let result!: ReturnType<typeof useVersionOpen>
host = document.createElement('div')
document.body.append(host)
app = createApp({ setup() { result = useVersionOpen(); return () => null } })
app.mount(host)
return result
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('useApi', () => ({ apiJson }))
tab = { opener: {}, closed: false, location: { replace }, close }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
})
afterEach(() => {
app?.unmount()
host?.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
vi.useRealTimers()
})
describe('version open lifecycle', () => {
it('reserves PDF tab synchronously, navigates after auth, leaves completed PDF open on unmount', async () => {
let resolve!: (value: unknown) => void
apiJson.mockReturnValue(new Promise(r => { resolve = r }))
const state = mount()
const pending = state.openVersion('d', 'v', 'pdf')
expect(window.open).toHaveBeenCalledWith('about:blank', '_blank')
expect(tab.opener).toBeNull()
expect(state.openingVersionId.value).toBe('v')
resolve({ url: 'https://files.example/v.pdf' })
await pending
expect(replace).toHaveBeenCalledWith('https://files.example/v.pdf')
expect(state.fallbackUrl.value).toBe('https://files.example/v.pdf')
app.unmount()
expect(close).not.toHaveBeenCalled()
})
it('keeps a fallback when popup is blocked', async () => {
vi.mocked(window.open).mockReturnValue(null)
apiJson.mockResolvedValue({ url: 'https://files.example/v.pdf' })
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(state.openNotice.value).toBeTruthy()
expect(state.fallbackUrl.value).toBe('https://files.example/v.pdf')
})
it('closes pending tab immediately on reset and ignores a late response', async () => {
let resolve!: (value: unknown) => void
apiJson.mockReturnValue(new Promise(r => { resolve = r }))
const state = mount()
const pending = state.openVersion('d', 'v', 'pdf')
state.resetOpen()
expect(close).toHaveBeenCalledTimes(1)
resolve({ url: 'https://files.example/v.pdf' })
await pending
expect(replace).not.toHaveBeenCalled()
expect(state.fallbackUrl.value).toBeNull()
})
it('closes reserved tab and reports request failure', async () => {
apiJson.mockRejectedValue(new Error('offline'))
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(close).toHaveBeenCalled()
expect(state.downloadError.value).toBe('offline')
expect(state.openingVersionId.value).toBeNull()
})
it('handles synchronous popup errors and restores controls', async () => {
vi.mocked(window.open).mockImplementation(() => { throw new Error('popup error') })
const state = mount()
await state.openVersion('d', 'v', 'pdf')
expect(state.downloadError.value).toBe('popup error')
expect(state.openingVersionId.value).toBeNull()
})
it('offers Word fallback after timeout and removes listeners on unmount', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const remove = vi.spyOn(window, 'removeEventListener')
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
const state = mount()
await state.openVersion('d', 'v')
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
vi.advanceTimersByTime(1500)
expect(state.openNotice.value).toBeTruthy()
expect(state.fallbackUrl.value).toBeTruthy()
app.unmount()
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
})
})

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { versionFileName, versionDownloadPath, wordOpenUrl } from '../app/utils/versionDownload'
describe('version downloads', () => {
it('names v0 and later artifacts', () => {
expect(versionFileName('คำร้อง.DOCX', 0)).toBe('คำร้อง_v0.docx')
expect(versionFileName('คำร้อง.docx', 1, 'pdf')).toBe('คำร้อง_v1.pdf')
})
it('requests the authenticated download-link format', () => {
expect(versionDownloadPath('d', 'v')).toBe('/api/documents/d/versions/v/download-link')
expect(versionDownloadPath('d', 'v', 'pdf')).toBe('/api/documents/d/versions/v/download-link?format=pdf')
})
it('preserves the signed URL exactly inside the Office URI', () => {
const url = 'https://files.example/d.docx?X-Amz-Signature=abc&response-content-disposition=attachment%3B%20filename%2A%3DUTF-8%27%27'
expect(wordOpenUrl(url)).toBe(`ms-word:ofe|u|${url}`)
})
})