This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 12:15:20 +07:00
parent 9321f5a370
commit 25c2ce53cd
23 changed files with 1746 additions and 68 deletions

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: รอเริ่ม implement (repo ยังไม่มีโค้ด)
> สถานะ: Phase 0–3 เสร็จ (scaffold + infra + backend core + backend template & generation) — ต่อไป: phase 4–5 (add-in) หรือ phase 6 (web) ทำขนานกันได้
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
@ -245,23 +245,23 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** ระบบ template ครบวงจร — upload พร้อม auto-detect fields + generate เอกสาร v1 พร้อม metadata
### 3.1 Entities + migrations
- [ ] `Template` — Id (Guid), Name, Description, ObjectKey (ใน bucket `docx-templates`), IsActive (soft-delete), CreatedAt/UpdatedAt
- [ ] `TemplateField` — Id, TemplateId, Key (= SDT tag), Label, Type (`text|number|date`), IsRequired, SortOrder; unique `(TemplateId, Key)`
- [ ] `DocumentFieldValue` — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique `(DocumentId, FieldKey)` = latest value ต่อ field
- [ ] `Document` เพิ่ม `TemplateId?` + migration
- [x] `Template` — Id (Guid), Name, Description, ObjectKey (ใน bucket `docx-templates`), IsActive (soft-delete), CreatedAt/UpdatedAt
- [x] `TemplateField` — Id, TemplateId, Key (= SDT tag), Label, Type (`text|number|date`), IsRequired, SortOrder; unique `(TemplateId, Key)`
- [x] `DocumentFieldValue` — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique `(DocumentId, FieldKey)` = latest value ต่อ field (ทำเป็น composite PK)
- [x] `Document` เพิ่ม `TemplateId?` + migration (`TemplatesAndFieldSync`)
### 3.2 TemplatesController (FR-9)
- [ ] `GET /api/templates` — list (filter active/all)
- [ ] `POST /api/templates` — multipart: ไฟล์ .docx + name/description → upload ลง MinIO + **parse SDT tags คืนเป็น field list ที่ detect ได้** (response ให้ admin ไปตั้ง label/type/required ต่อ)
- [ ] `PUT /api/templates/{id}` — แก้ metadata + บันทึก field definitions
- [ ] `PUT /api/templates/{id}/file` — แทนที่ไฟล์ (re-parse fields, เตือนถ้า tag หายไป)
- [ ] `DELETE /api/templates/{id}` = deactivate (soft-delete)
- [x] `GET /api/templates` — list (filter active/all) + `GET /api/templates/{id}` (สำหรับหน้า edit เพิ่มเติมจากแผน)
- [x] `POST /api/templates` — multipart: ไฟล์ .docx + name/description → upload ลง MinIO + **parse SDT tags คืนเป็น field list ที่ detect ได้** (สร้าง TemplateField defaults ทันที: label=tag, type=text — admin ไปตั้งต่อผ่าน PUT)
- [x] `PUT /api/templates/{id}` — แก้ metadata (name/description/isActive) + บันทึก field definitions (key ต้องเป็นชุดที่ detect ไว้ — เพิ่ม/ลบ key ต้องผ่าน replace ไฟล์)
- [x] `PUT /api/templates/{id}/file` — แทนที่ไฟล์ (re-parse fields, response บอก `addedTags`/`removedTags`)
- [x] `DELETE /api/templates/{id}` = deactivate (soft-delete)
### 3.3 DocxGenerationService (FR-10)
- [ ] โหลด template จาก MinIO → เปิดด้วย `DocumentFormat.OpenXml`
- [ ] แทนที่ข้อความใน SDT แต่ละ field **ที่ run level** (รักษา run properties/formatting ของ template — ไม่ทับทั้ง paragraph)
- [ ] Stamp Custom Properties ผ่าน `CustomFilePropertiesPart`: `SavedByAddIn` = `ADDIN_SIGNATURE_GUID` (ค่าคงที่เดียวกับที่ add-in ใช้ตรวจ), `DocumentTrackingId` = Guid ใหม่
- [ ] `POST /api/documents/generate { templateId, values }`:
- [x] โหลด template จาก MinIO → เปิดด้วย `DocumentFormat.OpenXml`
- [x] แทนที่ข้อความใน SDT แต่ละ field **ที่ run level** (รักษา run properties/formatting ของ template — ไม่ทับทั้ง paragraph)
- [x] Stamp Custom Properties ผ่าน `CustomFilePropertiesPart`: `SavedByAddIn` = `ADDIN_SIGNATURE_GUID` (ค่าคงที่เดียวกับที่ add-in ใช้ตรวจ), `DocumentTrackingId` = Guid ใหม่
- [x] `POST /api/documents/generate { templateId, values }`:
1. Validate required + type
2. Generate ไฟล์จาก template
3. คำนวณ SHA256 + upload เป็น **version 1** (`ObjectStorageService`)
@ -269,11 +269,23 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
5. Response `{ documentId, versionId, versionNumber, downloadUrl }`
### 3.4 Field sync บน upload endpoint (FR-11 ฝั่ง backend)
- [ ] ขยาย `POST /api/documents/{documentId}/versions`: รับ `fields` (map tag→text) → เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update `DocumentFieldValue` (latest value) + เขียน `DocumentVersion.FieldsSnapshotJson` ของ version นั้น
- [ ] field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request**; field ที่ส่งมาเกินนิยาม → ignore
- [x] ขยาย `POST /api/documents/{documentId}/versions`: รับ `fields` (map tag→text) → เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update `DocumentFieldValue` (latest value) + เขียน `DocumentVersion.FieldsSnapshotJson` ของ version นั้น
- [x] field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request**; field ที่ส่งมาเกินนิยาม → ignore
หมายเหตุจากการทำจริง (2026-08-19):
- **ค่า `ADDIN_SIGNATURE_GUID` = `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D`** — ปักหมุดไว้ที่ `Services/AddInMetadata.cs` และ spec ส่วน 8 แล้ว — Phase 4 (add-in `metadataService`) ต้องใช้ค่านี้เป๊ะ
- OpenXML SDK 3.5.1: root ของ custom properties = `DocumentFormat.OpenXml.CustomProperties.Properties` + `CustomDocumentProperty` (property `Name`/`FormatId`/`PropertyId` + child `VTLPWSTR`); block SDT = `SdtBlock`/`SdtProperties` (ไม่ใช่ `Sdt`/`SdtPr`) — service ที่เขียนใช้ `Tag` + LocalName matching จึงไม่ผูกกับชื่อ class เหล่านี้ (กัน SDT ทั้ง block/run/cell)
- **ห้าม set `TemplateField.Id` เองตอน add เข้า `template.Fields` ของ tracked parent** — Guid ที่มีค่าทำให้ EF relationship-fixup ตีเป็น Modified (UPDATE แถวที่ไม่มี → `DbUpdateConcurrencyException`) ทิ้ง Id ว่างไว้ให้ EF generate ตาม convention
- **AWSSDK.S3 4.x dispose input stream ให้หลัง `PutObjectAsync`** — `ObjectStorageService.PutAsync` จับ `content.Length` ไว้ก่อนเรียก SDK แล้วใช้ค่านั้นทั้งตอน set ContentLength และ log
- snapshot ของเอกสารที่ผูก template เก็บแบบ structured `{ key: { value, missing } }` (camelCase) เพื่อรองรับ mark-missing; เอกสารไม่ผูก template ยังเก็บ map ดิบตาม Phase 2 — UI (Phase 5/6) ต้อง handle ทั้งสองรูป
- sync logic แชร์ผ่าน `FieldSyncService.StageFieldSyncAsync` (stage ลง tracker, caller เป็นคน SaveChanges) ใช้ทั้งตอน generate v1 และ upload version — UploadVersion ถอน staged field changes กลับด้วยตอน MinIO fail (ไม่ให้ค่าล้ำ version ที่ถูกถอน)
- generate endpoint upload MinIO **ก่อน** SaveChanges (ได้เลข v1 ล่วงหน้า ไม่ต้องจองเลข) — ตรงข้ามกับ UploadVersion ที่ insert ก่อนเพื่อจองเลข; template create/replace ก็ upload ก่อนเช่นกัน (ถ้า DB fail ทีหลังเหลือ orphan object ใน bucket — ยอมรับได้ เพราะทิศตรงข้าม (row ชี้ไฟล์ที่ไม่มี) คืออันที่อันตราย)
- ตรวจ DoD ผ่านจริง (2026-08-19): upload template (5 SDT: inline/run เดียว, multi-run คนละ formatting, block-level) → detect ครบ 5 + บันทึก defs ผ่าน PUT; validation ทำงาน (required ขาด / salary ไม่ใช่เลข / start_date ไม่ใช่วันที่ → 400 คนละข้อความ); generate → 201 + docx ค่าครบทุก field + multi-run ถูกรวมเป็น run เดียว (formatting ตัวแรก) + `docProps/custom.xml` มี `SavedByAddIn`/`DocumentTrackingId` ตรงกับ documentId + DB มี Document/v1/field values 5 แถว; upload v2 (แก้ 2 fields + ลบ position + ส่งเกิน 1 field) → 201, latest values อัปเดตเฉพาะ field ที่มี, position คงค่าเดิม + snapshot v2 `missing:true`, field เกินถูก ignore; replace-file → addedTags/removedTags ถูก; deactivate 204 → generate 400 แต่ history/download ของ document เก่ายัง 200
**DoD:** upload template จริง (เช่น สัญญาจ้างงานที่มี content controls) → detect fields ครบ → เรียก generate ผ่าน Swagger → ได้ docx ที่ (1) เปิดใน Word แล้วค่าถูกทุก field formatting ไม่พัง (2) Word บอก custom properties มี `SavedByAddIn`/`DocumentTrackingId` (ตรวจใน Advanced Properties หรือเปิดไฟล์ด้วย unzip) (3) DB มี Document + v1 + field values (AC-11, 12 ส่วน backend ผ่าน) และ (4) เรียก upload version ถัดไปพร้อม `fields` ที่เปลี่ยนค่า → latest values + snapshot อัปเดตถูก / ขาด field → ค่าเดิมคงอยู่ไม่ error (AC-13, 14 ส่วน backend ผ่าน)
> ✅ ตรวจผ่านจริง (2026-08-19): ครบทั้ง 4 ข้อตามรายละเอียดในหมายเหตุข้างบน (ตรวจ formatting/custom properties ด้วย OpenXML + unzip `docProps/custom.xml` ตามทางเลือกที่ DoD ยอมรับ — ส่วน "เปิดใน Word จริง" จะเกิดเต็มรูปแบบตอน AC-12 ใน Phase 6)
---
## Phase 4: Add-in services layer — FR-8 (ฝั่ง client), FR-11 (extraction), โครง FR-1/2