eCourt-Doc-Edit/docs/plans/version-pdf.md
Suphonchai Phoonsawat 39d610a781 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>
2026-09-08 14:01:13 +07:00

135 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Version PDF + เลข version v0/v1+ + เปิดไฟล์จากเว็บ — FR-15 / AC-18 / Spec v7
> **Status: เสร็จสมบูรณ์ — implementation + automated QA + manual acceptance ผ่านบน Word for Mac (2026-09-08)** · ค้างเฉพาะ Word for Windows / Word on the web ตามรายการใน [acceptance-review](../acceptance-review.md); spec v7
## Context
ระบบเก็บทุก version เป็น .docx ใน MinIO แต่การนำเอกสารไปใช้จริง โดยเฉพาะ**สำเนาเชิงกฎหมาย/ยื่นศาล** ต้องการ PDF ที่ฟอร์แมตเที่ยงเท่ากับที่ Word เอง export — ตัวแปลงกลาง (LibreOffice/Gotenberg, Aspose, Microsoft Graph, Word Interop บน server) ล้วนเป็น layout engine คนละตัวหรือมีต้นทุน/topology ที่ไม่เข้ากัน จึงตัดทิ้งทั้งหมด
**กลไกที่เลือก (ยืนยันจาก MS Learn):** `Office.context.document.getFileAsync(Office.FileType.Pdf)` — PdfFile requirement set รองรับ Word Windows/Mac/**web**/iPad · add-in ที่รันใน Word สั่ง export ได้โดยตรง และ `fileService` มี slice loop + `sliceDataToBytes()` รองรับ slice หลายรูปแบบอยู่แล้ว (กติกาข้อ 13) · known issue PDF ฝัง JS เป็นของ Excel/PPT เท่านั้น
พร้อมกันนี้ผู้ใช้ขอปรับสองเรื่อง: **(1) ระบบเลข version** — เว็บ-generate เป็น **v0 ฉบับตั้งต้น** (ไม่มี PDF), ทุกการ save จาก add-in เริ่ม **v1** และมี PDF เสมอ → เลข version บอกที่มา (0 = ฟอร์ม, ≥1 = ผ่าน Word) · **(2) คลิกลิงก์บนหน้าเว็บให้เปิดไฟล์ด้วยแอปของไฟล์นั้น** — PDF เปิดแท็บใหม่ (ตัวดู PDF ของเบราว์เซอร์), docx เปิด Word จริงผ่าน [`ms-word:ofe|u|<url>`](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes) (นิ่งบน Windows, Mac อาจต้องยืนยัน/พังบ้าง — มี fallback ดาวน์โหลดเสมอ)
## ข้อตกลง fixed (ยืนยันครบใน grill session)
1. **ผู้ผลิต PDF เดียวในระบบ = Word จริง** ผ่าน add-in — ไม่มี server-side converter ไม่ต้องเพิ่ม infra
2. **เลข version**: generate เว็บ → **v0** ฉบับตั้งต้น ไม่มี PDF · add-in save (**ทั้ง** Start Versioning และ Save & Version) → **v1, v2, …** มี PDF เสมอ · ไม่ backfill · ยังไม่มี production → ไม่ต้อง migration ข้อมูล dev เดิม (คงเลขเดิมหรือล้าง DB ก็ได้)
3. **Best-effort**: PDF capture/upload ล้ม → version ของ docx ผ่านเสมอ (`PdfObjectKey` = null) + MessageBar เตือนใน task pane "บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ" — กด Save & Version ใหม่ได้ version ใหม่พร้อม PDF
4. **ที่เก็บ**: bucket `docx-versions` เดิม, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf` (stem เดียวกับ docx)
5. **UI**: ปุ่ม/ลิงก์ PDF เฉพาะ version ที่มี PDF จริง · add-in: presigned + `openBrowserWindow` (กติกาข้อ 12) · เว็บ: PDF แท็บใหม่ / docx ผ่าน `ms-word:ofe|u|` + heuristic ตรวจว่าเปิดจริง (visibilitychange/blur ~1.5s) ไม่เปิด → ลิงก์ presigned ดาวน์โหลดสำรอง
6. `pdf` part ที่ส่งมาไม่ใช่ .pdf → **400** (บั๊ก client ควรเห็นชัด) — ต่างจากความล้มสิ่งแวดล้อม (MinIO) ที่ best-effort
7. **manifest ไม่ declare `PdfFile`** เป็น requirement (ทำให้ host ไม่รองรับโหลด add-in ไม่ได้ทั้งอัน ขัด best-effort) — runtime-guard ด้วย `Office.context.requirements.isSetSupported("PdfFile", "1.1")` เท่านั้น
## ขั้นที่ 0 — บันทึกแผน + แก้เอกสาร requirement ก่อน (กติกา repo)
1. บันทึกแผนนี้ที่ `docs/plans/version-pdf.md` (ธรรมเนียมเดิม) — **ทำแล้ว**
2. **[specs/spec-officejs.md](../../specs/spec-officejs.md)** → v7: changelog บรรทัดบนสุด (FR-15 + เลข v0/v1 + เปิดไฟล์จากเว็บ) · **แก้ของเดิม**: FR-5 (response + pdf part + เลขเริ่ม v1 สำหรับ upload / v0 generate), FR-7 (download/download-link `?format=pdf` + presigned ฝังชื่อไฟล์ Content-Disposition), FR-10 ("version 1" → "version 0 ฉบับตั้งต้น"), AC 12/15 ที่อ้าง v1 ของ generate → v0 · `### FR-15: Version PDF — สำเนา PDF ที่ Word ผลิต (เพิ่มเติม v7)` ต่อท้ายหมวด 6: ผลิตจาก Word จริงเท่านั้นใน task pane (Save & Version + Start Versioning) · multipart part `pdf` ทางเลือก · sibling key `.pdf` · best-effort + คำเตือน · download `?format=pdf` (proxy + presigned 404 เมื่อไม่มี) · เว็บเปิด docx ผ่าน `ms-word:ofe|u|` และ PDF แท็บใหม่ · v0 ไม่มี PDF เสมอ · หมวด 7: `DocumentVersion.PdfObjectKey` + ปรับคำอธิบาย `VersionNumber` · หมวด 10: AC-18 (v7) · หมวด 11: task items (v7) ทั้ง 3 list
3. **[docs/plans/officejs-implementation-plan.md](officejs-implementation-plan.md)**: `## Phase 11: Version PDF + เลข version v0/v1+ — FR-15 (เพิ่มทีหลัง, v7)` + checklist + แถว `| 11 (ทีหลัง, v7) | FR-15 (+แก้ FR-5/FR-7/FR-10) | AC-18 |`
4. **[docs/acceptance-review.md](acceptance-review.md)**: AC-18 (verdict 🔶 รอ implement) + หมายเหตุว่า AC เดิมที่อ้าง "generate = v1" อ่านใหม่ตาม v7
5. **สร้าง `CONTEXT.md`** (glossary เท่านั้น ห้ามใส่ implementation): **Version PDF** = สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort · **ฉบับตั้งต้น (v0)** = version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word · **เลข version** = จำนวนรอบที่ผ่าน Word · **ต้นฉบับ (docx)** = ตัว version จริง; PDF เป็น derived artifact
6. **สร้าง `docs/adr/0001-version-pdf-from-word-only.md`**: Decision (Word เท่านั้น + v0 ไม่มี PDF + ไม่ backfill) · rejected: Gotenberg/LibreOffice, Aspose, Graph cloud, Word Interop server · consequences (v0/version เก่าไม่มี PDF, request โตขึ้น, ms-word พึ่ง protocol handler ของเครื่องผู้ใช้)
## ขั้นที่ 1 — Backend (`backend/WordVersioning.Api`)
1. **เลข version ใหม่**: [DocumentsController.cs](../../backend/WordVersioning.Api/Controllers/DocumentsController.cs) `Generate` — `VersionNumber = 0`, ObjectKey `v0000_{Guid}.docx` (สังเกต: `GenerateDocumentResponse.VersionNumber` คืน 0) · `UploadVersion` ใช้ `max+1` เดิมได้ทันที (0→1 และ ไม่มี→1 ได้ v1 ทั้งคู่) · add-in เดาเลข `latest+1` ใช้ได้กับ v0 ด้วย
2. [Models/DocumentVersion.cs](../../backend/WordVersioning.Api/Models/DocumentVersion.cs): `public string? PdfObjectKey { get; set; }` (+ doc comment FR-15: null = version นี้ไม่มี PDF — v0 หรือ capture/upload ไม่สำเร็จ)
3. [Data/AppDbContext.cs](../../backend/WordVersioning.Api/Data/AppDbContext.cs): `entity.Property(x => x.PdfObjectKey).HasMaxLength(1024);` · migration `dotnet ef migrations add AddVersionPdf` (tests ใช้ MigrateAsync อยู่แล้ว)
4. **สร้าง `Services/PdfUploadValidation.cs`** มิเรอร์ `DocxUploadValidation`: นามสกุล `.pdf` + content-type (`application/pdf`/octet-stream/ว่าง) — ความลึกเท่าของเดิม ไม่ต้อง magic-byte
5. [Models/VersionDtos.cs](../../backend/WordVersioning.Api/Models/VersionDtos.cs): `UploadVersionRequest.Pdf` (`IFormFile?` — bind part `pdf`) · `UploadVersionResponse` เพิ่ม `bool HasPdf` ท้าย · `VersionListItem` เพิ่ม `bool HasPdf` ท้าย · `GenerateDocumentResponse` **ไม่แตะ** (v0 ไม่มี PDF — asymmetry ตั้งใจ)
6. `UploadVersion`: validate part `pdf` หลัง message check (ไม่ใช่ .pdf/ว่าง → 400, ใหญ่เกิน → 413) · buffer เป็น MemoryStream (มิเรอร์ path docx) · ใน retry loop: `PdfObjectKey = มี pdf ? $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.pdf" : null` (คำนวณใน loop ให้ตรงเลขที่จองได้) · **หลัง** docx `PutAsync` (โค้ดเดิมไม่แตะ): put PDF ด้วย `application/pdf` — `AmazonServiceException` → log warning + `version.PdfObjectKey = null; SaveChangesAsync()` (ultra-rare fail → row ชี้ object ไม่มี = 404 ตอนโหลด ยอมรับได้) + **201 ต่อ** · response `UploadVersionResponse(..., PdfObjectKey is not null)` · matrix: docx put ล้ม → 502 rollback เหมือนเดิม / pdf put ล้ม → 201 hasPdf:false
7. `GetVersions`: projection เพิ่ม `PdfObjectKey` → `HasPdf` ท้าย VersionListItem
8. **Download ทั้งสอง endpoint เพิ่ม `string? format`**: `"pdf"` (case-insensitive; อื่น/null = docx — lenient ตามสไตล์ Clamp) → เลือก key จาก `PdfObjectKey`; ไม่มี → 404 "version นี้ไม่มีสำเนา PDF" · `DownloadVersion`: filename `.pdf` (`Path.ChangeExtension`) + content-type `application/pdf` · `GetVersionDownloadLink`: presign key ที่เลือก + **`ResponseHeaderOverrides.ContentDisposition`** ชื่อสวย (`inline; filename*=UTF-8''{base}_v{N}.pdf` / `attachment; filename*=UTF-8''{base}_v{N}.docx` — RFC 5987 สำหรับชื่อไทย, compose จาก `Document.FileName` + `VersionNumber` ฝั่ง server; add-in เจ้าเดิมก็ได้ชื่อสวยขึ้นไปด้วย) · update Swagger XML comments
## ขั้นที่ 2 — Add-in (`addin`)
1. [fileService.ts](../../addin/src/services/fileService.ts): parametrize capture เป็น private `readFileAsBlob(fileType, contentType)` (slice loop + `sliceDataToBytes` + `closeAsync` ใน finally เดิม) · `getCurrentDocumentFile()` public API เดิม · **เพิ่ม `getCurrentDocumentPdf()`** — guard `Office.context.requirements.isSetSupported("PdfFile", "1.1")` (throw ถ้าไม่รองรับ), คืน Blob type `application/pdf` · export `PDF_CONTENT_TYPE` · จับทีละ File object ตามลำดับ (Word เปิดพร้อมกันได้ 2 ตัว)
2. [versioningService.ts](../../addin/src/services/versioningService.ts): `uploadVersion(..., pdf?: Blob)` ท้าย signature → `if (pdf) form.append("pdf", pdf, \`${documentId}.pdf\`)` · `buildDownloadUrl`/`getDownloadLink`/`downloadVersion` เพิ่ม `format?: "docx" | "pdf"` → `?format=pdf`
3. [models/versionRecord.ts](../../addin/src/models/versionRecord.ts): `UploadVersionResult.hasPdf: boolean` · `VersionRecord.hasPdf?: boolean` (optional กัน payload เก่า)
4. [versionFlow.ts](../../addin/src/services/versionFlow.ts) — **ทั้งสอง flow** (v1+ มี PDF ทุกตัว): แก้ `uploadCurrentFile` ให้ทำเองทั้ง capture: docx → progress "กำลังสร้างสำเนา PDF…" → capture PDF ใน try/catch (**พัง = ข้าม + `console.warn` — กติกาข้อ 10, ห้ามบล็อกการบันทึก**) + guard `pdf.size === 0` (build เก่าคืนไฟล์เปล่า — [office-js#5950](https://github.com/OfficeDev/office-js/issues/5950)) → `uploadVersion(..., pdf)` ครั้งเดียว · `SaveAndVersionResult`/`StartVersioningResult` พา `hasPdf` จาก response ตรงๆ (backend echo คือ source เดียวของ warning — ไม่ต้องมี flag ท้องถิ่น) · step แก้เลข XML part หลัง upload เดิมทั้งหมด
5. [App.tsx](../../addin/src/taskpane/components/App.tsx): state `flowWarning` (เคลียร์พร้อม `flowSuccess`/`flowError`) — หลัง `saveAndVersion`/`startVersioning` สำเร็จ: `if (!result.hasPdf) setFlowWarning("บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ — version นี้ไม่มีสำเนา PDF (กด Save & Version อีกครั้งเพื่อสร้าง version ใหม่ที่มี PDF)")` → render `<MessageBar intent="warning">` ใต้ success (Fluent UI v9) · `handleDownload(version, format)` + state `downloadingPdfVersionId` มิเรอร์ของเดิม (ส่ง format ให้ `getDownloadLink`/`downloadVersion`, ข้อความ/ชื่อไฟล์ fallback ตาม format)
6. [VersionHistoryPanel.tsx](../../addin/src/taskpane/components/VersionHistoryPanel.tsx): props เพิ่ม `downloadingPdfVersionId: string | null` + `onDownloadPdf: (version) => void` · แถวที่ `version.hasPdf` → Button `DocumentPdf24Regular` label "PDF" (`size="small" appearance="subtle"`, disabled ระหว่างโหลดตัวใดตัวหนึ่ง, wrap flex column ให้อยู่รอด task pane แคบ)
7. **manifest.xml ไม่แก้** (ข้อตกลง #7)
## ขั้นที่ 3 — Web (`web`)
1. [types/api.ts](../../web/app/types/api.ts): `VersionListItem.hasPdf: boolean` · `GenerateDocumentResponse` เดิม
2. **สร้าง `web/app/utils/versionDownload.ts`** (pure — ทดสอบได้ตาม pattern vitest ที่มี): `versionFileName(fileName, versionNumber, format)` (strip `.docx` + `_v{N}` + `.pdf`/`.docx`) · `versionDownloadPath(documentId, versionId, format)` (`?format=pdf` เมื่อ pdf) · `wordOpenUrl(presignedUrl)` → `ms-word:ofe|u|${url}` (ตรวจระดับ encode กับ [MS-DOCSTRURI](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes) ตอน implement)
3. [documents/[documentId].vue](../../web/app/pages/documents/[documentId].vue) — **เปลี่ยนพฤติกรรมจาก blob-download เป็นเปิดไฟล์**: `openVersion(v, format)` = `GET download-link` (Bearer) → PDF: `window.open(url, "_blank")` · DOCX: `window.open(wordOpenUrl(url))` + heuristic `visibilitychange`/`blur` ใน ~1.5s — ไม่เกิด = `AlertBox` พร้อมลิงก์ presigned ดาวน์โหลดสำรอง · state `openingVersionId`/`downloadError` แทน `downloadingVersionId` (proxy-blob flow ของหน้านี้เลิกใช้ — endpoint proxy ยังอยู่เพราะ add-in/spec ใช้) · ปุ่ม history (ตาราง + การ์ดมือถือ): "เปิด" ทุก version + "PDF" `v-if="v.hasPdf"` · Badge แสดง v0 ได้ตามข้อมูลจริง
4. [documents/create/[templateId].vue](../../web/app/pages/documents/create/[templateId].vue): ปุ่ม success เปลี่ยนเป็น **"เปิดใน Word"** (`ms-word` กับ v0 — next step ธรรมชาติของ flow: เปิด → แก้ → Save & Version เป็น v1) + ลิงก์ดาวน์โหลดสำรอง
## ขั้นที่ 4 — Tests
**Backend — ไฟล์ใหม่ `VersionPdfTests.cs`** (pattern `VersionMessageTests`) + **แก้ test เดิมที่ assert เลข version ของ generate** (GenerateTests ฯลฯ คาด v1 → v0, key `v0000_...`):
1. ขยาย helper ก่อน: `TestDtos` (+`HasPdf` ทั้ง upload/history, `DownloadLinkResult`) · `ApiExtensions.VersionForm` เพิ่ม `byte[]? pdf = null` (part `pdf` + content-type) · `TestDocx.BuildPdf()` (%PDF-1.7 + payload)
2. `Upload_WithPdf_PersistsSiblingKey_AndEchoesHasPdf` (key `{documentId}/v0001_{clientVersionId}.pdf`)
3. `Upload_WithoutPdf_StoresNullKey_HasPdfFalse`
4. `Upload_InvalidPdfPart_Returns400_NoSideEffect` (ชื่อไฟล์ไม่ใช่ .pdf)
5. `DownloadPdf_ReturnsExactBytes_PdfContentType_PdfFilename`
6. `DownloadPdf_WhenNoPdf_Returns404` · `DownloadLink_FormatPdf_NoPdf_Returns404`
7. `DownloadLink_FormatPdf_PresignedUrlWithFilenameDisposition`
8. `Generate_CreatesVersionZero_NoPdf` (VersionNumber == 0, PdfObjectKey null, `v0000_` key) · `UploadAfterGenerate_StartsAtVersionOne_WithPdf`
9. Regression: default (ไม่ส่ง format) ยังเป็น docx — ครอบโดย test download เดิม
**Add-in (jest)**: ขยาย `installOfficeFileMock` (fileService.test) / `installOfficeDocumentMock` (versionFlow.test) เพิ่ม `FileType.Pdf` + ตัวเลือก fail · [fileService.test.ts](../../addin/src/services/fileService.test.ts): `getCurrentDocumentPdf` คืน Blob type ใช่/เรียก FileType.Pdf/slice-shape อีกรูปแบบหนึ่ง/`isSetSupported` false → reject/`closeAsync` ถูกเรียกครั้งเดียวแม้พัง · [versionFlow.test.ts](../../addin/src/services/versionFlow.test.ts): **ทั้ง saveAndVersion และ startVersioning** แนบ part `pdf` ชื่อ `.pdf` + `requestedFileTypes` เรียง compressed → pdf · `failPdf` → ยัง upload สำเร็จ ไม่มี part pdf + `hasPdf:false` · test เดิม 5 ตัวไม่ต้องแก้ (assert เฉพาะ docx) · **สร้าง `versioningService.test.ts`**: `getDownloadLink(id, vid, "pdf")` ชน `?format=pdf` · `uploadVersion` แนบ pdf เมื่อได้รับ
**Web (vitest)**: `web/test/versionDownload.spec.ts` — ชื่อไฟล์ v0/v1 + format, path `?format=pdf`, `wordOpenUrl` encode ถูก · component เป็น manual verify (ไม่มี component-test infra — ไม่แกล้งทดสอบ)
## ขั้นที่ 5 — ปิดงาน
1. รันชุดเต็ม: `dotnet test` (backend) · `npm test` (addin) · `npm test` + `npm run build` (web)
2. [CLAUDE.md](../../CLAUDE.md): สถานะ Phase 0–11 · เพิ่มย่อหน้า "Version PDF (FR-15)" ในหมวดสถาปัตยกรรม + ปรับประโยค "เห็น v1" เป็นระบบ v0/v1+ · อัปเดต AGENTS.md ให้ตรงกัน
3. [docs/acceptance-review.md](acceptance-review.md): verdict AC-18 + หลักฐาน test + รายการยืนยันมือที่เหลือ
## ยืนยันมือที่ทำแทนไม่ได้ (ไว้ตรวจตอนปิด phase)
1. Word Windows: generate จากเว็บ (ได้ **v0** ไม่มี PDF) → "เปิดใน Word" จากหน้า success (ทดสอบ `ms-word` จริง) → แก้ → Save & Version → **v1 + PDF** ไม่มี warning + ปุ่ม PDF ขึ้นเฉพาะ v1
2. Start Versioning เอกสารนำเข้า → v1 **มี PDF** ทันที
3. **Fidelity**: เทียบ PDF กับ File → Save As PDF ของ Word — ฟอนต์ไทย (TH Sarabun New) render + embed (Acrobat → Properties → Fonts), หัว-ท้ายกระดาษตรง
4. **Tracked changes**: เปิด Track Changes → แก้ → Save & Version → PDF มี markup ปนไหม (ไม่มี API คุม — ถ้ามี จดใน AC-18 + spec แนะนำ accept/reject ก่อนบันทึกสำเนาศาล)
5. Word Mac + **Word on the web**: ซ้ำข้อ 1 (web = path ใหม่ของ PdfFile; slice shape ต่าง host)
6. **Warning path**: บังคับ PDF capture ล้ม → version ผ่าน + MessageBar เตือน + ไม่มีปุ่ม PDF ทั้งสอง UI + บันทึกใหม่ได้ PDF
7. **เว็บเปิดไฟล์**: PDF → แท็บใหม่เรนเดอร์ทันที · docx → Word เปิดจริง (Windows) / เครื่องไม่มี handler → เห็นลิงก์สำรอง · ชื่อไฟล์สวยทั้งสองกรณี
8. Regression: flow docx เดิม (AC-7/8/13) + history ทุกหน้าเรียง/แสดงเลขถูกตามระบบใหม่
## ความเสี่ยงที่รับไว้ (ตัดสินแล้ว)
- 100MB request cap ครอบ docx+pdf รวมกัน — คงตาม spec เดิม, เจอจริงค่อยปรับ
- `ms-word:` พึ่ง protocol handler ของเครื่องผู้ใช้ — Windows นิ่ง, Mac อาจต้องยืนยัน/พัง → heuristic fallback เสมอ
- apiClient ไม่มี timeout (เป็นมาก่อนกับ docx) — ไม่ขยายงานนี้
- Dev data เก่าใช้เลขเดิม (generate=v1) — ยอมรับได้เพราะยังไม่มี production หรือล้าง DB dev
## Implementation record — 2026-09-08
- [x] ขั้น 0: spec v7 + Phase 11 + glossary + ADR
- [x] Backend: PdfObjectKey migration, optional PDF validation/storage/download, hasPdf, generate v0
- [x] Add-in: PDF ทั้งสอง flow + warnings + history PDF; ไม่มี blob fallback ใน task pane
- [x] Web: shared `useVersionOpen`, เปิด Word/PDF + fallback + pending-tab cleanup
- [x] Automated checks: backend 68 / add-in 63 / web 55; builds ผ่าน, add-in lint ผ่าน
- [x] QA รอบยืนยัน — PASS
- [x] Manual acceptance บน Word for Mac ผ่าน (2026-09-08) — ผลละเอียดในหมวด Manual acceptance ด้านล่าง; Windows / Word on the web คงค้างตามรายการเดิม
ข้อปรับรายละเอียดจากแผน: ใช้ `DocumentPdf24Regular` ที่ package มีจริง; คง signed URL โดยไม่ encode ทั้งก้อนตาม [Microsoft Office URI Schemes](https://learn.microsoft.com/en-us/office/client-developer/office-uri-schemes); reserve PDF tab ก่อน await เพื่อรองรับ popup policy; เพิ่ม composable lifecycle tests โดยไม่เพิ่ม component-test framework.
## QA
### Dev database follow-up — 2026-09-08
พบ `42703: column "PdfObjectKey" does not exist` ตอน Generate: migration ถูกสร้างและผ่าน Testcontainers แต่ยังไม่ได้ apply กับฐานข้อมูล dev `wordversioning`. ตรวจซ้ำด้วย `SELECT "PdfObjectKey" FROM "DocumentVersions" LIMIT 0` แล้วได้ error เดียวกัน; migration history มีถึง `AddTemplateFieldEditor` เท่านั้น และ config ชี้ฐานข้อมูลเดียวกัน. ลง `20260908053250_AddVersionPdf` ใน dev แล้วสำเร็จ. ก่อนทดสอบกับฐานข้อมูลเดิมหลังเพิ่ม migration ต้องรัน `dotnet ef database update` ตาม README; automated tests ที่สร้าง DB ใหม่ไม่ยืนยันสถานะ migration ของ dev DB.
2026-09-08 · fresh-eyes reviewer `qa_fr15` · 2 rounds · พบ 3 ข้อ แก้ครบ 3 ไม่มี rejected findings.
1. Build: test ใช้ `.at(-1)` เกิน TS target → indexed access; build ผ่าน
2. Lifecycle: pending PDF tab ค้างเมื่อออกจากหน้า → ปิดทันทีตอน reset/unmount; test ครอบ cleanup/late response
3. Copy: PDF inline แต่ข้อความบอกดู Downloads → ข้อความระบุเปิด PDF ใน browser
**Final verdict: PASS (implementation/automated QA + manual acceptance บน Word for Mac).** Phase 11 ปิดแล้ว.
## Manual acceptance — 2026-09-08 (Word for Mac)
ผู้ใช้ยืนยันผ่านครบตามรายการ "ยืนยันมือที่ทำแทนไม่ได้" บน Mac (ข้อ 1–4, 6–8):
- **ข้อ 1–2 flow:** เว็บ generate → v0 ไม่มี PDF → เปิดใน Word → Save & Version → **v1 + PDF** ไม่มี warning, ปุ่ม PDF ขึ้นเฉพาะ v1 · Start Versioning เอกสารนำเข้า → v1 มี PDF ทันที
- **ข้อ 3 fidelity:** เทียบ PDF จาก add-in กับ File → Save As PDF ของ Word — ฟอนต์ไทย (TH Sarabun New) render + embed ครบ, หัว-ท้ายกระดาษตรง
- **ข้อ 4 Track Changes:** เปิด Track Changes ทิ้งไว้ → แก้ → Save & Version → **PDF ไม่มี markup ของ revision ปน** — ไม่มีข้อจำกัดต้องบันทึกเพิ่มใน AC-18/spec
- **ข้อ 6 warning path · ข้อ 7 เว็บเปิดไฟล์** (PDF แท็บใหม่, docx ผ่าน `ms-word` พร้อม fallback, ชื่อไฟล์สวย) **· ข้อ 8 regression** flow docx เดิม — ผ่าน
- **ค้างตามเดิม (ข้อ 5):** Word for Windows + Word on the web — ต้องมีเครื่อง/บัญชีทดสอบ เป็นข้อค้างเดิมตั้งแต่ Phase 8 (AC-1 ฯลฯ) ไม่ใช่สิ่งที่ Phase 11 เพิ่มเข้ามา