Phase 12: draft generation (FR-16)
ไฟล์ร่าง (draft) — generate docx จาก template แบบ stateless ไม่สร้าง
Document/version; stamp เฉพาะ custom property TemplateId เพื่อให้ add-in
เห็นเป็น untracked; Start Versioning ส่ง form field templateId ให้ backend
ผูก template ตอน auto-create — เอกสารเริ่มที่ v1 (ไม่มี v0) + field sync เต็มระบบ
- Backend: POST /api/templates/{id}/generate-draft (200 คืนไฟล์) +
DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation ใช้ร่วมกับ
generate + UploadVersion รับ templateId (non-GUID/unknown → 400,
deactivated → ผูกได้, document เดิม → เฉย) + CORS expose Content-Disposition
- Add-in: readTrackedProperties อ่าน TemplateId + ส่ง templateId เฉพาะ
startVersioning + hint ไฟล์ร่างใน task pane
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + downloadFile รับ init (POST → blob)
- Docs: spec v8 (FR-16, AC-19) + Phase 12 + CONTEXT.md + ADR 0002 +
acceptance-review AC-19
- Tests: backend 82 / add-in 66 / web 59 + build ครบ; QA reviewer PASS
(2 rounds); ค้าง manual บน Word จริง
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
0edb70fd38
commit
b3c1c667f7
30 changed files with 879 additions and 93 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -14,13 +14,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||||
|
|
||||||
**สถานะปัจจุบัน: Phase 0–11 เสร็จครบ — FR-15 Version PDF: automated QA PASS (backend 68 / add-in 63 / web 55) + manual acceptance ผ่านบน Word for Mac (2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 82 / add-in 66 / web 59 + build ครบ), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
||||||
|
|
||||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||||
|
|
||||||
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–15, acceptance criteria 1–18, data models, platform constraints)
|
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–16, acceptance criteria 1–19, data models, platform constraints)
|
||||||
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 12 phases (0–11) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
|
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 13 phases (0–12) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
|
||||||
- `docs/acceptance-review.md` — ผลตรวจ AC 1–18 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
|
- `docs/acceptance-review.md` — ผลตรวจ AC 1–19 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
|
||||||
|
|
||||||
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
|
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
|
||||||
|
|
||||||
|
|
@ -96,3 +96,7 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี
|
||||||
เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF. Start Versioning และ Save & Version สร้าง v1+ พร้อม PDF จาก Word (`PdfFile 1.1` runtime guard; ไม่เพิ่ม requirement ใน manifest). PDF capture/upload เป็น best-effort: docx สำเร็จได้แม้ PDF ล้ม และ task pane เตือนตาม `hasPdf` จาก backend.
|
เว็บ generate/re-generate เป็น **v0 ฉบับตั้งต้น** ไม่มี PDF. Start Versioning และ Save & Version สร้าง v1+ พร้อม PDF จาก Word (`PdfFile 1.1` runtime guard; ไม่เพิ่ม requirement ใน manifest). PDF capture/upload เป็น best-effort: docx สำเร็จได้แม้ PDF ล้ม และ task pane เตือนตาม `hasPdf` จาก backend.
|
||||||
|
|
||||||
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.
|
`DocumentVersion.PdfObjectKey` nullable เก็บ sibling `.pdf` ใน bucket เดิม; history มี `hasPdf`. `/download` และ `/download-link` รับ `?format=pdf`, ไม่มี PDF → 404. Presigned URLs กำหนดชื่อไฟล์ไทย + `_vN`; PDF inline, docx attachment. เว็บเปิด Word ผ่าน `ms-word:ofe|u|<url>` พร้อมดาวน์โหลดสำรอง; PDF เปิดแท็บใหม่. Add-in ใช้ `openBrowserWindow` เท่านั้น. ข้อมูลเก่าไม่ backfill หรือเปลี่ยนเลข.
|
||||||
|
|
||||||
|
## ไฟล์ร่าง (FR-16 / spec v8)
|
||||||
|
|
||||||
|
ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` **stateless** (คืน docx ทันที ไม่สร้าง Document/version ไม่แตะ MinIO; ไม่มีปุ่มเปิดใน Word เพราะไม่มี URL) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทย. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`.
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
|
|
||||||
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
|
- **Version PDF**: สำเนา PDF ที่ Word ผลิตขณะเปิดเอกสาร; ขาดได้แบบ best-effort
|
||||||
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
|
- **ฉบับตั้งต้น (v0)**: version ที่เว็บสร้างจากฟอร์ม ยังไม่เคยผ่าน Word
|
||||||
|
- **ไฟล์ร่าง (draft)**: ไฟล์ .docx ที่สร้างจาก template แบบ stateless — ยังไม่มีเอกสาร/version ใดๆ ในระบบ; ระบุ template ต้นทางผ่าน custom property และเริ่ม track จริงตอนกด Start Versioning ใน Word (ได้เป็น version 1)
|
||||||
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
|
- **เลข version**: จำนวนรอบการบันทึก version ผ่าน Word หลังฉบับตั้งต้น
|
||||||
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact
|
- **ต้นฉบับ (docx)**: ตัว version จริง; PDF เป็น derived artifact
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ describe("readTrackedProperties", () => {
|
||||||
expect(properties.lastVersionId).toBeUndefined();
|
expect(properties.lastVersionId).toBeUndefined();
|
||||||
expect(properties.lastVersionTimestamp).toBeUndefined();
|
expect(properties.lastVersionTimestamp).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FR-16 — ไฟล์ร่าง: อ่าน TemplateId ได้โดยไม่มี properties ของ tracked document", async () => {
|
||||||
|
installWordMock({ properties: { TemplateId: "33333333-3333-3333-3333-333333333333" } });
|
||||||
|
|
||||||
|
const properties = await readTrackedProperties();
|
||||||
|
expect(properties.templateId).toBe("33333333-3333-3333-3333-333333333333");
|
||||||
|
expect(properties.savedByAddIn).toBeUndefined();
|
||||||
|
expect(properties.documentTrackingId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isTrackedDocument (FR-1)", () => {
|
describe("isTrackedDocument (FR-1)", () => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
|
||||||
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
||||||
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
||||||
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
||||||
|
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
||||||
|
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
||||||
|
|
||||||
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||||
export interface TrackedDocumentProperties {
|
export interface TrackedDocumentProperties {
|
||||||
|
|
@ -26,6 +28,8 @@ export interface TrackedDocumentProperties {
|
||||||
documentTrackingId?: string;
|
documentTrackingId?: string;
|
||||||
lastVersionId?: string;
|
lastVersionId?: string;
|
||||||
lastVersionTimestamp?: string;
|
lastVersionTimestamp?: string;
|
||||||
|
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
||||||
|
templateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** item ใน Custom XML Part `<VersionHistory>` */
|
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||||
|
|
@ -36,7 +40,7 @@ export interface XmlVersionEntry {
|
||||||
user: string;
|
user: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */
|
/** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */
|
||||||
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||||
return Word.run(async (context) => {
|
return Word.run(async (context) => {
|
||||||
const properties = context.document.properties.customProperties;
|
const properties = context.document.properties.customProperties;
|
||||||
|
|
@ -44,11 +48,13 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
|
||||||
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
||||||
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||||
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||||
|
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
||||||
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||||
savedByAddIn.load("value");
|
savedByAddIn.load("value");
|
||||||
documentTrackingId.load("value");
|
documentTrackingId.load("value");
|
||||||
lastVersionId.load("value");
|
lastVersionId.load("value");
|
||||||
lastVersionTimestamp.load("value");
|
lastVersionTimestamp.load("value");
|
||||||
|
templateId.load("value");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
const read = (property: Word.CustomProperty): string | undefined =>
|
const read = (property: Word.CustomProperty): string | undefined =>
|
||||||
|
|
@ -61,11 +67,15 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
|
||||||
documentTrackingId: read(documentTrackingId),
|
documentTrackingId: read(documentTrackingId),
|
||||||
lastVersionId: read(lastVersionId),
|
lastVersionId: read(lastVersionId),
|
||||||
lastVersionTimestamp: read(lastVersionTimestamp),
|
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||||
|
templateId: read(templateId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) */
|
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว)
|
||||||
|
* หมายเหตุ: App.detect() ทำนายค่านี้ inline จาก readTrackedProperties() อยู่แล้ว (ต้องอ่าน properties
|
||||||
|
* ครั้งเดียวทั้งชุดเพื่อให้ไฟล์ร่าง untracked ได้อ่าน TemplateId ด้วย — FR-16) ฟังก์ชันนี้จึงเหลือเป็น
|
||||||
|
* service API ที่มี test คุม semantics ของ FR-1 ไว้ */
|
||||||
export async function isTrackedDocument(): Promise<boolean> {
|
export async function isTrackedDocument(): Promise<boolean> {
|
||||||
const properties = await readTrackedProperties();
|
const properties = await readTrackedProperties();
|
||||||
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,22 @@ describe("startVersioning (FR-1 / AC-5)", () => {
|
||||||
|
|
||||||
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก");
|
expect(String(form.get("message"))).toBe("เริ่มติดตามฉบับแรก");
|
||||||
|
// ไม่ใช่ไฟล์ร่าง → ไม่แนบ templateId
|
||||||
|
expect(form.get("templateId")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FR-16 — ไฟล์ร่าง: แนบ templateId ไปกับ form ของ v1", async () => {
|
||||||
|
installWordMock({});
|
||||||
|
installOfficeDocumentMock(undefined);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await startVersioning("เริ่มติดตามฉบับแรก", "44444444-4444-4444-4444-444444444444");
|
||||||
|
|
||||||
|
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
|
expect(String(form.get("templateId"))).toBe("44444444-4444-4444-4444-444444444444");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -187,11 +203,12 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
|
||||||
expect(xml).toContain('number="1"');
|
expect(xml).toContain('number="1"');
|
||||||
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
|
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
|
||||||
|
|
||||||
// upload ไปยัง document เดิม + fields ล่าสุด
|
// upload ไปยัง document เดิม + fields ล่าสุด (ไม่ส่ง templateId — FR-16 ใช้เฉพาะ Start Versioning)
|
||||||
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
|
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
|
||||||
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
|
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
|
||||||
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
|
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
|
||||||
|
expect(form.get("templateId")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
|
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ async function uploadCurrentFile(
|
||||||
clientVersionId: string,
|
clientVersionId: string,
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
message?: string,
|
message?: string,
|
||||||
onProgress?: (progress: string) => void
|
onProgress?: (progress: string) => void,
|
||||||
|
templateId?: string
|
||||||
): Promise<SaveAndVersionResult> {
|
): Promise<SaveAndVersionResult> {
|
||||||
const file = await getCurrentDocumentFile();
|
const file = await getCurrentDocumentFile();
|
||||||
let pdf: Blob | undefined;
|
let pdf: Blob | undefined;
|
||||||
|
|
@ -94,14 +95,17 @@ async function uploadCurrentFile(
|
||||||
fields,
|
fields,
|
||||||
guessDocumentFileName(),
|
guessDocumentFileName(),
|
||||||
message,
|
message,
|
||||||
pdf
|
pdf,
|
||||||
|
templateId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
|
||||||
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน) */
|
* (FR-13: message คือ commit message ทางเลือกจาก dialog ยืนยัน)
|
||||||
|
* (FR-16: templateId ส่งเฉพาะไฟล์ร่างที่เว็บสร้าง — backend ผูก Document กับ template ต้นทางตอน auto-create) */
|
||||||
export async function startVersioning(
|
export async function startVersioning(
|
||||||
message?: string,
|
message?: string,
|
||||||
|
templateId?: string,
|
||||||
onProgress?: (progress: string) => void
|
onProgress?: (progress: string) => void
|
||||||
): Promise<StartVersioningResult> {
|
): Promise<StartVersioningResult> {
|
||||||
const documentId = generateGuid();
|
const documentId = generateGuid();
|
||||||
|
|
@ -128,9 +132,9 @@ export async function startVersioning(
|
||||||
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||||
const fields = await readContentControlFields();
|
const fields = await readContentControlFields();
|
||||||
|
|
||||||
// 4) upload version แรก
|
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
|
||||||
onProgress?.("กำลังอัปโหลด version 1…");
|
onProgress?.("กำลังอัปโหลด version 1…");
|
||||||
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
|
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId);
|
||||||
return { documentId, ...result };
|
return { documentId, ...result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,22 @@ it("uploads optional PDF alongside the docx in one request", async () => {
|
||||||
expect((form.get("pdf") as File).name).toBe("d.pdf");
|
expect((form.get("pdf") as File).name).toBe("d.pdf");
|
||||||
expect((form.get("pdf") as File).type).toBe("application/pdf");
|
expect((form.get("pdf") as File).type).toBe("application/pdf");
|
||||||
expect((form.get("file") as File).name).toBe("d.docx");
|
expect((form.get("file") as File).name).toBe("d.docx");
|
||||||
|
expect(form.get("templateId")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FR-16 — appends optional templateId form field (Start Versioning ของไฟล์ร่าง)", async () => {
|
||||||
|
await uploadVersion(
|
||||||
|
"d",
|
||||||
|
new Blob(["docx"]),
|
||||||
|
"v",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"44444444-4444-4444-4444-444444444444"
|
||||||
|
);
|
||||||
|
// mock แชร์กันทั้งไฟล์ — เอา call ล่าสุด (ของ test นี้เอง)
|
||||||
|
const calls = (apiSendExpectJson as jest.Mock).mock.calls;
|
||||||
|
const form = calls[calls.length - 1][1].body as FormData;
|
||||||
|
expect(String(form.get("templateId"))).toBe("44444444-4444-4444-4444-444444444444");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import { generateGuid } from "./metadataService";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี)
|
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี)
|
||||||
* + FR-13: แนบ commit message ทางเลือกของผู้ใช้ */
|
* + FR-13: แนบ commit message ทางเลือกของผู้ใช้
|
||||||
|
* + FR-16: แนบ templateId เฉพาะตอน Start Versioning ของไฟล์ร่าง (backend ใช้ตอน auto-create Document) */
|
||||||
export async function uploadVersion(
|
export async function uploadVersion(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
file: Blob,
|
file: Blob,
|
||||||
|
|
@ -17,7 +18,8 @@ export async function uploadVersion(
|
||||||
fields?: Record<string, string>,
|
fields?: Record<string, string>,
|
||||||
fileName?: string,
|
fileName?: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
pdf?: Blob
|
pdf?: Blob,
|
||||||
|
templateId?: string
|
||||||
): Promise<UploadVersionResult> {
|
): Promise<UploadVersionResult> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, fileName || `${documentId}.docx`);
|
form.append("file", file, fileName || `${documentId}.docx`);
|
||||||
|
|
@ -30,6 +32,9 @@ export async function uploadVersion(
|
||||||
if (trimmedMessage) {
|
if (trimmedMessage) {
|
||||||
form.append("message", trimmedMessage);
|
form.append("message", trimmedMessage);
|
||||||
}
|
}
|
||||||
|
if (templateId) {
|
||||||
|
form.append("templateId", templateId);
|
||||||
|
}
|
||||||
return apiSendExpectJson<UploadVersionResult>(
|
return apiSendExpectJson<UploadVersionResult>(
|
||||||
`/api/documents/${documentId}/versions`,
|
`/api/documents/${documentId}/versions`,
|
||||||
{ method: "POST", body: form },
|
{ method: "POST", body: form },
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ const App: React.FC = () => {
|
||||||
const [username, setUsername] = React.useState<string | null>(null);
|
const [username, setUsername] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [documentId, setDocumentId] = React.useState<string | null>(null);
|
const [documentId, setDocumentId] = React.useState<string | null>(null);
|
||||||
|
/** FR-16 — template ต้นทางของไฟล์ร่าง (มีเฉพาะไฟล์ที่เว็บสร้างแบบ draft และยังไม่ได้ track) */
|
||||||
|
const [documentTemplateId, setDocumentTemplateId] = React.useState<string | null>(null);
|
||||||
const [versions, setVersions] = React.useState<VersionRecord[]>([]);
|
const [versions, setVersions] = React.useState<VersionRecord[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = React.useState(false);
|
const [historyLoading, setHistoryLoading] = React.useState(false);
|
||||||
const [historyError, setHistoryError] = React.useState<string | null>(null);
|
const [historyError, setHistoryError] = React.useState<string | null>(null);
|
||||||
|
|
@ -101,6 +103,7 @@ const App: React.FC = () => {
|
||||||
if (error instanceof AuthRequiredError) {
|
if (error instanceof AuthRequiredError) {
|
||||||
setPhase("signed-out");
|
setPhase("signed-out");
|
||||||
setDocumentId(null);
|
setDocumentId(null);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
|
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -125,7 +128,9 @@ const App: React.FC = () => {
|
||||||
[handleAuthRequired]
|
[handleAuthRequired]
|
||||||
);
|
);
|
||||||
|
|
||||||
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */
|
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history
|
||||||
|
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||||
|
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย */
|
||||||
const detect = React.useCallback(async () => {
|
const detect = React.useCallback(async () => {
|
||||||
setPhase("checking");
|
setPhase("checking");
|
||||||
setBootError(null);
|
setBootError(null);
|
||||||
|
|
@ -138,19 +143,17 @@ const App: React.FC = () => {
|
||||||
authService.startAutoRefresh();
|
authService.startAutoRefresh();
|
||||||
setUsername(authService.getUsername() ?? null);
|
setUsername(authService.getUsername() ?? null);
|
||||||
|
|
||||||
const tracked = await metadataService.isTrackedDocument();
|
|
||||||
if (!tracked) {
|
|
||||||
setPhase("untracked");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const properties = await metadataService.readTrackedProperties();
|
const properties = await metadataService.readTrackedProperties();
|
||||||
const id = properties.documentTrackingId;
|
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||||
|
const id = tracked ? properties.documentTrackingId : undefined;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked
|
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
|
||||||
|
setDocumentTemplateId(properties.templateId ?? null);
|
||||||
setPhase("untracked");
|
setPhase("untracked");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDocumentId(id);
|
setDocumentId(id);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
setPhase("tracking");
|
setPhase("tracking");
|
||||||
await refreshHistory(id);
|
await refreshHistory(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -207,20 +210,23 @@ const App: React.FC = () => {
|
||||||
authService.signOut();
|
authService.signOut();
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
setDocumentId(null);
|
setDocumentId(null);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
setPhase("signed-out");
|
setPhase("signed-out");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13) */
|
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) · message คือ commit message จาก dialog (FR-13)
|
||||||
|
* FR-16 — ไฟล์ร่าง: แนบ templateId ไปให้ backend ผูก Document กับ template ต้นทาง */
|
||||||
const handleStartVersioning = async (message: string) => {
|
const handleStartVersioning = async (message: string) => {
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
setFlowError(null);
|
setFlowError(null);
|
||||||
setFlowSuccess(null);
|
setFlowSuccess(null);
|
||||||
setFlowWarning(null);
|
setFlowWarning(null);
|
||||||
try {
|
try {
|
||||||
const result = await startVersioning(message, setProgressMessage);
|
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
|
||||||
setDocumentId(result.documentId);
|
setDocumentId(result.documentId);
|
||||||
|
setDocumentTemplateId(null);
|
||||||
setPhase("tracking");
|
setPhase("tracking");
|
||||||
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||||
if (!result.hasPdf)
|
if (!result.hasPdf)
|
||||||
|
|
@ -361,6 +367,12 @@ const App: React.FC = () => {
|
||||||
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||||
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||||
</Text>
|
</Text>
|
||||||
|
{documentTemplateId ? (
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
|
||||||
|
และ sync ค่า fields ให้อัตโนมัติ
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<DocumentArrowUp24Regular />}
|
icon={<DocumentArrowUp24Regular />}
|
||||||
|
|
@ -454,7 +466,11 @@ const App: React.FC = () => {
|
||||||
open={startConfirmOpen}
|
open={startConfirmOpen}
|
||||||
busy={starting}
|
busy={starting}
|
||||||
title="เริ่มติดตามเอกสารนี้?"
|
title="เริ่มติดตามเอกสารนี้?"
|
||||||
description="ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
description={
|
||||||
|
documentTemplateId
|
||||||
|
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
|
||||||
|
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
|
||||||
|
}
|
||||||
confirmLabel="เริ่มติดตาม (version 1)"
|
confirmLabel="เริ่มติดตาม (version 1)"
|
||||||
onConfirm={(message) => void handleStartVersioning(message)}
|
onConfirm={(message) => void handleStartVersioning(message)}
|
||||||
onDismiss={() => setStartConfirmOpen(false)}
|
onDismiss={() => setStartConfirmOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ public sealed class AuthTests(IntegrationTestFixture fixture)
|
||||||
[InlineData("GET", "api/templates")]
|
[InlineData("GET", "api/templates")]
|
||||||
[InlineData("POST", "api/templates")]
|
[InlineData("POST", "api/templates")]
|
||||||
[InlineData("GET", "api/templates/{0}/documents")]
|
[InlineData("GET", "api/templates/{0}/documents")]
|
||||||
|
[InlineData("POST", "api/templates/{0}/generate-draft")]
|
||||||
[InlineData("POST", "api/documents/generate")]
|
[InlineData("POST", "api/documents/generate")]
|
||||||
[InlineData("GET", "api/documents/{0}/versions")]
|
[InlineData("GET", "api/documents/{0}/versions")]
|
||||||
[InlineData("POST", "api/documents/{0}/versions")]
|
[InlineData("POST", "api/documents/{0}/versions")]
|
||||||
|
|
|
||||||
129
backend/WordVersioning.Api.IntegrationTests/DraftTests.cs
Normal file
129
backend/WordVersioning.Api.IntegrationTests/DraftTests.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using WordVersioning.Api.Services;
|
||||||
|
|
||||||
|
namespace WordVersioning.Api.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
|
||||||
|
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft หลัก");
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{templateId}/generate-draft",
|
||||||
|
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง
|
||||||
|
|
||||||
|
// คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment)
|
||||||
|
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
|
||||||
|
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
|
||||||
|
|
||||||
|
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
|
||||||
|
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
|
||||||
|
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
|
||||||
|
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(fileBytes, "position"));
|
||||||
|
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||||
|
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||||
|
|
||||||
|
// (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold)
|
||||||
|
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||||
|
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||||
|
Assert.True(runWithText.Bold);
|
||||||
|
|
||||||
|
// (3) stamp เฉพาะ TemplateId — ไม่มี SavedByAddIn/DocumentTrackingId เพื่อให้ add-in เห็นเป็น untracked (FR-16)
|
||||||
|
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||||
|
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
|
||||||
|
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||||
|
|
||||||
|
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
|
||||||
|
await using var db = fixture.CreateDb();
|
||||||
|
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
|
||||||
|
Assert.False(await db.DocumentVersions.AsNoTracking()
|
||||||
|
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
|
||||||
|
Assert.False(await db.DocumentFieldValues.AsNoTracking()
|
||||||
|
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_MissingRequiredField_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft required ขาด");
|
||||||
|
|
||||||
|
var values = new Dictionary<string, string>(TestFlow.DefaultValues);
|
||||||
|
values.Remove("employee_name"); // required field หาย
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("employee_name", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("salary", "ไม่ใช่เลข")]
|
||||||
|
[InlineData("start_date", "ไม่ใช่วันที่")]
|
||||||
|
public async Task Draft_WrongFieldType_Returns400(string key, string badValue)
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"draft type ผิด {key}");
|
||||||
|
|
||||||
|
var values = new Dictionary<string, string>(TestFlow.DefaultValues) { [key] = badValue };
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_UnknownTemplate_Returns404()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{Guid.NewGuid()}/generate-draft",
|
||||||
|
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_DeactivatedTemplate_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft deactivate");
|
||||||
|
|
||||||
|
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{templateId}/generate-draft",
|
||||||
|
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Draft_IgnoresUndefinedValues()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "draft ค่าเกินนิยาม");
|
||||||
|
|
||||||
|
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
|
||||||
|
{
|
||||||
|
["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace WordVersioning.Api.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>FR-16 — ผูก template ตอน Start Versioning: form field `templateId` ทางเลือกของ upload
|
||||||
|
/// ใช้เฉพาะตอน auto-create Document · upload แรกของ Document ใหม่ = version 1 เสมอ + field sync เต็มระบบ</summary>
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public sealed class TemplateLinkingTests(IntegrationTestFixture fixture)
|
||||||
|
{
|
||||||
|
private static string DefaultFieldsJson() => JsonSerializer.Serialize(TestFlow.DefaultValues);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking หลัก");
|
||||||
|
|
||||||
|
// Start Versioning ของไฟล์ร่าง: upload แรกพร้อม templateId + fields ที่ extract จาก content controls
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
using var upload = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildTemplateDocx(), "ไฟล์ร่างทดสอบ.docx", Guid.NewGuid().ToString(),
|
||||||
|
fieldsJson: DefaultFieldsJson(), templateId: templateId.ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||||
|
var v1 = (await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
|
||||||
|
|
||||||
|
// upload แรกของ Document ใหม่ = version 1 เสมอ (ไม่มี v0 ใน flow ไฟล์ร่าง)
|
||||||
|
Assert.Equal(1, v1.VersionNumber);
|
||||||
|
|
||||||
|
await using var db = fixture.CreateDb();
|
||||||
|
var document = await db.Documents.AsNoTracking()
|
||||||
|
.Include(d => d.Versions)
|
||||||
|
.SingleAsync(d => d.Id == documentId);
|
||||||
|
Assert.Equal(templateId, document.TemplateId); // ผูก template ต้นทาง
|
||||||
|
Assert.Equal(string.Empty, document.AddInSignature); // ฝั่ง add-in stamp ในไฟล์เอง — ค่าเดิมของ auto-create
|
||||||
|
|
||||||
|
var version = Assert.Single(document.Versions);
|
||||||
|
Assert.Equal(1, version.VersionNumber);
|
||||||
|
Assert.NotNull(version.FieldsSnapshotJson); // snapshot แบบมีนิยาม (ไม่ใช่ raw map)
|
||||||
|
|
||||||
|
// field sync เต็มระบบ (FR-11): DocumentFieldValue ครบทุก field ของ template
|
||||||
|
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
|
||||||
|
.Where(v => v.DocumentId == documentId)
|
||||||
|
.ToDictionaryAsync(v => v.FieldKey);
|
||||||
|
Assert.Equal(4, fieldValues.Count);
|
||||||
|
Assert.Equal("สมหญิง รักงาน", fieldValues["employee_name"].Value);
|
||||||
|
Assert.Equal("75000", fieldValues["salary"].Value);
|
||||||
|
Assert.All(fieldValues.Values, v => Assert.Equal(v1.VersionId, v.SourceVersionId));
|
||||||
|
Assert.All(fieldValues.Values, v => Assert.Equal(fixture.TestUserId, v.UpdatedByUserId));
|
||||||
|
|
||||||
|
// Save & Version ครั้งถัดไป (ไม่ส่ง templateId) = v2 และ TemplateId คงเดิม
|
||||||
|
using var second = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildTemplateDocx(), "ไฟล์ร่างทดสอบ.docx", Guid.NewGuid().ToString(),
|
||||||
|
fieldsJson: DefaultFieldsJson(), message: "แก้ไขรอบสอง"));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
|
||||||
|
var v2 = (await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
|
||||||
|
Assert.Equal(2, v2.VersionNumber);
|
||||||
|
|
||||||
|
await using var db2 = fixture.CreateDb();
|
||||||
|
var docAfter = await db2.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
|
||||||
|
Assert.Equal(templateId, docAfter.TemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_TemplateIdNotGuid_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking ไม่ใช่ GUID");
|
||||||
|
|
||||||
|
using var response = await client.PostAsync(
|
||||||
|
$"api/documents/{Guid.NewGuid()}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildPlainDocx(), "a.docx", Guid.NewGuid().ToString(), templateId: "not-a-guid"));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("templateId", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_TemplateIdUnknown_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
_ = await TestFlow.CreateConfiguredTemplateAsync(client, "linking unknown"); // ตั้ง field defs ไว้ก่อน (ชุดเดียวกับที่ใช้จริง)
|
||||||
|
|
||||||
|
using var response = await client.PostAsync(
|
||||||
|
$"api/documents/{Guid.NewGuid()}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildPlainDocx(), "a.docx", Guid.NewGuid().ToString(),
|
||||||
|
templateId: Guid.NewGuid().ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_TemplateIdDeactivated_StillLinks()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking deactivate");
|
||||||
|
|
||||||
|
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
|
||||||
|
|
||||||
|
// ไฟล์ร่างถูกสร้างตอน template ยัง active — deactivate หลังจากนั้นต้องไม่ขวางการเริ่ม track (นโยบาย FR-9/FR-12)
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
using var upload = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildTemplateDocx(), "ไฟล์ร่าง.docx", Guid.NewGuid().ToString(),
|
||||||
|
fieldsJson: DefaultFieldsJson(), templateId: templateId.ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||||
|
|
||||||
|
await using var db = fixture.CreateDb();
|
||||||
|
var document = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
|
||||||
|
Assert.Equal(templateId, document.TemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_ExistingDocument_IgnoresTemplateId()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "linking เฉย");
|
||||||
|
|
||||||
|
// upload แรกแบบ standalone (ไม่มี templateId) → Document ไม่ผูก template
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
using var first = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||||
|
|
||||||
|
// upload ที่สองส่ง templateId มาด้วย — document มีอยู่แล้วจึงเฉย (TemplateId คง null)
|
||||||
|
using var second = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString(), templateId: templateId.ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
|
||||||
|
var v2 = (await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
|
||||||
|
Assert.Equal(2, v2.VersionNumber);
|
||||||
|
|
||||||
|
await using var db = fixture.CreateDb();
|
||||||
|
var document = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
|
||||||
|
Assert.Null(document.TemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_ExistingDocument_BadTemplateId_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
_ = await TestFlow.CreateConfiguredTemplateAsync(client, "linking เฉย bad id");
|
||||||
|
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
using var first = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString()));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||||
|
|
||||||
|
// validate ทุกครั้งที่ส่งมา — แม้ document มีอยู่แล้วและค่าจะไม่ถูกใช้ก็ตาม
|
||||||
|
using var second = await client.PostAsync(
|
||||||
|
$"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(
|
||||||
|
TestDocx.BuildPlainDocx(), "plain.docx", Guid.NewGuid().ToString(), templateId: "not-a-guid"));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,9 +31,11 @@ public record DocDetail(Guid Id, string FileName, DateTimeOffset CreatedAt, Guid
|
||||||
|
|
||||||
public static class ApiExtensions
|
public static class ApiExtensions
|
||||||
{
|
{
|
||||||
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)</summary>
|
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option) + message (option)
|
||||||
|
/// + templateId (option — FR-16 ไฟล์ร่าง: ส่งเฉพาะตอน Start Versioning)</summary>
|
||||||
public static MultipartFormDataContent VersionForm(
|
public static MultipartFormDataContent VersionForm(
|
||||||
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null, byte[]? pdf = null)
|
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null, string? message = null,
|
||||||
|
byte[]? pdf = null, string? templateId = null)
|
||||||
{
|
{
|
||||||
var form = new MultipartFormDataContent();
|
var form = new MultipartFormDataContent();
|
||||||
var file = new ByteArrayContent(docx);
|
var file = new ByteArrayContent(docx);
|
||||||
|
|
@ -50,6 +52,8 @@ public static class ApiExtensions
|
||||||
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
part.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||||
form.Add(part, "pdf", "document.pdf");
|
form.Add(part, "pdf", "document.pdf");
|
||||||
}
|
}
|
||||||
|
if (templateId is not null)
|
||||||
|
form.Add(new StringContent(templateId), "templateId");
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Amazon.Runtime;
|
using Amazon.Runtime;
|
||||||
using DocumentFormat.OpenXml.Packaging;
|
using DocumentFormat.OpenXml.Packaging;
|
||||||
|
|
@ -52,6 +51,18 @@ public class DocumentsController(
|
||||||
return BadRequest("ต้องแนบไฟล์ `file` (docx)");
|
return BadRequest("ต้องแนบไฟล์ `file` (docx)");
|
||||||
if (!Guid.TryParse(request.ClientVersionId, out _))
|
if (!Guid.TryParse(request.ClientVersionId, out _))
|
||||||
return BadRequest("`clientVersionId` ต้องเป็น GUID");
|
return BadRequest("`clientVersionId` ต้องเป็น GUID");
|
||||||
|
|
||||||
|
// FR-16 — templateId ทางเลือก (ส่งจาก Start Versioning ของไฟล์ร่าง): validate ทุกครั้งที่ส่งมา
|
||||||
|
// (ใช้จริงเฉพาะตอน auto-create Document ด้านล่าง — document มีอยู่แล้วจะถูกเฉย)
|
||||||
|
Guid? templateIdToLink = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(request.TemplateId, out var parsedTemplateId))
|
||||||
|
return BadRequest("`templateId` ต้องเป็น GUID");
|
||||||
|
if (!await db.Templates.AsNoTracking().AnyAsync(t => t.Id == parsedTemplateId, ct))
|
||||||
|
return BadRequest($"ไม่พบ template {parsedTemplateId}");
|
||||||
|
templateIdToLink = parsedTemplateId; // template ที่ deactivate ผูกได้ — ไฟล์ร่างถูกสร้างตอน template ยัง active (นโยบายเดียวกับ FR-9/FR-12)
|
||||||
|
}
|
||||||
if (request.File.Length > MaxUploadBytes)
|
if (request.File.Length > MaxUploadBytes)
|
||||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB");
|
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB");
|
||||||
if (!DocxUploadValidation.IsValidDocx(request.File))
|
if (!DocxUploadValidation.IsValidDocx(request.File))
|
||||||
|
|
@ -97,7 +108,7 @@ public class DocumentsController(
|
||||||
var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct);
|
var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct);
|
||||||
uploadStream.Seek(0, SeekOrigin.Begin);
|
uploadStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
// สร้าง Document ถ้ายังไม่มี (FR-5)
|
// สร้าง Document ถ้ายังไม่มี (FR-5) — ไฟล์ร่าง (FR-16) ผูก template ต้นทางจาก templateId ที่ส่งมา
|
||||||
var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct);
|
var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct);
|
||||||
var createdDoc = false;
|
var createdDoc = false;
|
||||||
if (doc is null)
|
if (doc is null)
|
||||||
|
|
@ -107,6 +118,7 @@ public class DocumentsController(
|
||||||
Id = documentId,
|
Id = documentId,
|
||||||
FileName = fileName,
|
FileName = fileName,
|
||||||
AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง
|
AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง
|
||||||
|
TemplateId = templateIdToLink, // null = เอกสาร track เอง · มีค่า = ไฟล์ร่างจาก template (field sync ทำงานเต็มระบบที่ v1)
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
};
|
};
|
||||||
db.Documents.Add(doc);
|
db.Documents.Add(doc);
|
||||||
|
|
@ -257,32 +269,10 @@ public class DocumentsController(
|
||||||
if (message is { Length: > MaxVersionMessageLength })
|
if (message is { Length: > MaxVersionMessageLength })
|
||||||
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
|
return BadRequest($"`message` ยาวเกิน {MaxVersionMessageLength} อักขระ");
|
||||||
|
|
||||||
// validate required + type (FR-10) — field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11
|
// validate required + type (FR-10) — ชุดเดียวกับ generate-draft (FR-16)
|
||||||
var missingRequired = fieldDefs
|
var validationError = TemplateFieldValidation.Validate(fieldDefs, values);
|
||||||
.Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty)))
|
if (validationError is not null)
|
||||||
.Select(f => f.Key)
|
return BadRequest(validationError);
|
||||||
.ToList();
|
|
||||||
if (missingRequired.Count > 0)
|
|
||||||
return BadRequest($"กรอก required fields ให้ครบ: {string.Join(", ", missingRequired)}");
|
|
||||||
|
|
||||||
var typeErrors = new List<string>();
|
|
||||||
foreach (var def in fieldDefs)
|
|
||||||
{
|
|
||||||
if (!values.TryGetValue(def.Key, out var v) || string.IsNullOrWhiteSpace(v))
|
|
||||||
continue;
|
|
||||||
var valid = def.Type switch
|
|
||||||
{
|
|
||||||
"number" => decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
|
|
||||||
"date" => DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out _),
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
if (!valid)
|
|
||||||
typeErrors.Add(def.Type == "number"
|
|
||||||
? $"`{def.Key}` ต้องเป็นตัวเลข"
|
|
||||||
: $"`{def.Key}` ต้องเป็นวันที่ที่ parse ได้ (ISO-8601 เช่น 2026-08-19)");
|
|
||||||
}
|
|
||||||
if (typeErrors.Count > 0)
|
|
||||||
return BadRequest(string.Join("; ", typeErrors));
|
|
||||||
|
|
||||||
// โหลด template จาก MinIO → generate ไฟล์ + stamp custom properties (FR-10)
|
// โหลด template จาก MinIO → generate ไฟล์ + stamp custom properties (FR-10)
|
||||||
Stream templateStream;
|
Stream templateStream;
|
||||||
|
|
@ -326,7 +316,7 @@ public class DocumentsController(
|
||||||
var doc = new Document
|
var doc = new Document
|
||||||
{
|
{
|
||||||
Id = documentId,
|
Id = documentId,
|
||||||
FileName = BuildGeneratedFileName(template.Name, now),
|
FileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now),
|
||||||
AddInSignature = AddInMetadata.AddInSignatureGuid,
|
AddInSignature = AddInMetadata.AddInSignatureGuid,
|
||||||
TemplateId = template.Id,
|
TemplateId = template.Id,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
|
|
@ -578,16 +568,6 @@ public class DocumentsController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>ชื่อไฟล์ของเอกสารที่ generate: ชื่อ template + timestamp — ตัดอักขระที่ filename ใช้ไม่ได้ออก</summary>
|
|
||||||
private static string BuildGeneratedFileName(string templateName, DateTimeOffset now)
|
|
||||||
{
|
|
||||||
var invalid = Path.GetInvalidFileNameChars();
|
|
||||||
var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
|
|
||||||
if (clean.Length == 0)
|
|
||||||
clean = "document";
|
|
||||||
return $"{clean}_{now:yyyyMMdd-HHmmss}.docx";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object? ParseSnapshot(string? json)
|
private static object? ParseSnapshot(string? json)
|
||||||
{
|
{
|
||||||
if (json is null)
|
if (json is null)
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,74 @@ public class TemplatesController(
|
||||||
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
|
return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless — ไม่สร้าง
|
||||||
|
/// Document/DocumentVersion/DocumentFieldValue และไม่เขียน MinIO; คืนไฟล์ทันที
|
||||||
|
/// (ผู้ใช้เปิดใน Word แล้วกด Start Versioning เพื่อเริ่ม track เป็น v1 พร้อมผูกกลับมาที่ template นี้)</summary>
|
||||||
|
[HttpPost("{id:guid}/generate-draft")]
|
||||||
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||||
|
public async Task<IActionResult> GenerateDraft(Guid id, [FromBody] GenerateDraftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var template = await db.Templates.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||||
|
if (template is null)
|
||||||
|
return NotFound($"ไม่พบ template {id}");
|
||||||
|
if (!template.IsActive)
|
||||||
|
return BadRequest("template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)");
|
||||||
|
|
||||||
|
var fieldDefs = await db.TemplateFields.AsNoTracking()
|
||||||
|
.Where(f => f.TemplateId == template.Id)
|
||||||
|
.OrderBy(f => f.SortOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (!FieldMapJson.TryNormalize(request.Values, out var values, out var valuesError))
|
||||||
|
return BadRequest($"`values` ผิด format: {valuesError}");
|
||||||
|
|
||||||
|
// validate required + type ชุดเดียวกับ generate (FR-10) — ไฟล์ร่างไม่มี message เพราะไม่เกิด version
|
||||||
|
var validationError = TemplateFieldValidation.Validate(fieldDefs, values);
|
||||||
|
if (validationError is not null)
|
||||||
|
return BadRequest(validationError);
|
||||||
|
|
||||||
|
Stream templateStream;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
templateStream = await storage.GetStreamAsync(minioOptions.Value.TemplatesBucket, template.ObjectKey, ct);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
logger.LogError("Template object missing in storage templateId={TemplateId} key={ObjectKey}",
|
||||||
|
template.Id, template.ObjectKey);
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template ไม่พบใน storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] fileBytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (templateStream)
|
||||||
|
{
|
||||||
|
// null (JSON null) มองเป็นค่าว่าง — field "หาย" คือไม่มี key ใน map เท่านั้น (เหมือน Generate)
|
||||||
|
fileBytes = docxGeneration.GenerateDraft(
|
||||||
|
templateStream,
|
||||||
|
values.ToDictionary(kv => kv.Key, kv => kv.Value ?? string.Empty),
|
||||||
|
template.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OpenXmlPackageException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Template file corrupt templateId={TemplateId} key={ObjectKey}",
|
||||||
|
template.Id, template.ObjectKey);
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size}",
|
||||||
|
template.Id, currentUser.UserId, fileBytes.Length);
|
||||||
|
|
||||||
|
return File(fileBytes, DocxUploadValidation.DocxContentType,
|
||||||
|
DocxGenerationService.BuildGeneratedFileName(template.Name, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace WordVersioning.Api.Models;
|
namespace WordVersioning.Api.Models;
|
||||||
|
|
||||||
|
|
@ -52,6 +53,14 @@ public class ReplaceTemplateFileRequest
|
||||||
public IFormFile File { get; set; } = null!;
|
public IFormFile File { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>body ของ POST /api/templates/{id}/generate-draft (FR-16) — ไม่มี message เพราะไฟล์ร่างยังไม่เกิด version</summary>
|
||||||
|
public class GenerateDraftRequest
|
||||||
|
{
|
||||||
|
/// <summary>map fieldKey → value (ค่า scalar ทั้งหมด) — pattern เดียวกับ GenerateDocumentRequest</summary>
|
||||||
|
[Required]
|
||||||
|
public JsonElement Values { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
|
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
|
||||||
|
|
||||||
public record TemplateListItem(
|
public record TemplateListItem(
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ public class UploadVersionRequest
|
||||||
|
|
||||||
/// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary>
|
/// <summary>คำอธิบาย version แบบ commit message (FR-13) — optional</summary>
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>GUID ของ template ต้นทาง (FR-16 ไฟล์ร่าง) — ใช้เฉพาะตอน auto-create Document;
|
||||||
|
/// ไม่ส่ง = เอกสาร track เอง · ส่งมาตอน document มีอยู่แล้ว = เฉย (validate ทุกครั้งที่ส่ง)</summary>
|
||||||
|
public string? TemplateId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf);
|
public record UploadVersionResponse(Guid VersionId, int VersionNumber, bool HasPdf);
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,14 @@ builder.Services
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
|
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
|
||||||
|
// expose Content-Disposition ให้ fetch ฝั่งเว็บอ่านชื่อไฟล์ (ไทย) ของไฟล์ร่างที่ดาวน์โหลดได้ (FR-16)
|
||||||
|
// — ไม่ expose แล้ว header จะเป็น null บน cross-origin และชื่อไฟล์หายเงียบๆ
|
||||||
const string corsPolicy = "frontends";
|
const string corsPolicy = "frontends";
|
||||||
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
||||||
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()));
|
.AllowAnyMethod()
|
||||||
|
.WithExposedHeaders("Content-Disposition")));
|
||||||
|
|
||||||
// --- dev HTTPS -------------------------------------------------------------
|
// --- dev HTTPS -------------------------------------------------------------
|
||||||
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,9 @@ public static class AddInMetadata
|
||||||
public const string SavedByAddInPropertyName = "SavedByAddIn";
|
public const string SavedByAddInPropertyName = "SavedByAddIn";
|
||||||
|
|
||||||
public const string DocumentTrackingIdPropertyName = "DocumentTrackingId";
|
public const string DocumentTrackingIdPropertyName = "DocumentTrackingId";
|
||||||
|
|
||||||
|
/// <summary>FR-16 — stamp ลงไฟล์ร่าง (draft) เท่านั้น: template ต้นทาง · ไม่ stamp
|
||||||
|
/// SavedByAddIn/DocumentTrackingId เพื่อให้ add-in มองไฟล์ร่างเป็น untracked
|
||||||
|
/// (ฝั่ง add-in ใช้ชื่อ TEMPLATE_ID_PROPERTY ค่าเดียวกันนี้ ส่งต่อเป็น form field ตอน Start Versioning)</summary>
|
||||||
|
public const string TemplateIdPropertyName = "TemplateId";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ public interface IDocxGenerationService
|
||||||
/// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp
|
/// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp
|
||||||
/// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary>
|
/// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary>
|
||||||
byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId);
|
byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId);
|
||||||
|
|
||||||
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): แทนที่ SDT เหมือน Generate แต่ stamp custom property
|
||||||
|
/// <b>เฉพาะ `TemplateId`</b> (GUID ของ template ต้นทาง) — ไม่ stamp SavedByAddIn/DocumentTrackingId
|
||||||
|
/// เพื่อให้ add-in มองไฟล์ร่างเป็น untracked จนกว่าผู้ใช้จะกด Start Versioning</summary>
|
||||||
|
byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary>
|
/// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary>
|
||||||
|
|
@ -42,32 +47,67 @@ public class DocxGenerationService : IDocxGenerationService
|
||||||
|
|
||||||
public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId)
|
public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId)
|
||||||
{
|
{
|
||||||
// ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose
|
var buffer = CopyToMemory(templateStream);
|
||||||
var buffer = new MemoryStream();
|
|
||||||
templateStream.CopyTo(buffer);
|
|
||||||
buffer.Position = 0;
|
|
||||||
|
|
||||||
using (var doc = WordprocessingDocument.Open(buffer, true))
|
using (var doc = WordprocessingDocument.Open(buffer, true))
|
||||||
{
|
{
|
||||||
foreach (var root in AllContentRoots(doc))
|
ReplaceSdtValues(doc, values);
|
||||||
{
|
|
||||||
foreach (var tag in root.Descendants<Tag>().ToList())
|
|
||||||
{
|
|
||||||
var key = tag.Val?.Value?.Trim();
|
|
||||||
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
|
|
||||||
continue;
|
|
||||||
ReplaceSdtContent(tag, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid);
|
SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid);
|
||||||
SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString());
|
SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString());
|
||||||
} // dispose เขียน package กลับลง buffer
|
} // dispose เขียน package กลับลง buffer
|
||||||
|
|
||||||
buffer.Position = 0;
|
|
||||||
return buffer.ToArray();
|
return buffer.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid templateId)
|
||||||
|
{
|
||||||
|
var buffer = CopyToMemory(templateStream);
|
||||||
|
using (var doc = WordprocessingDocument.Open(buffer, true))
|
||||||
|
{
|
||||||
|
ReplaceSdtValues(doc, values);
|
||||||
|
|
||||||
|
// stamp ตัวเดียว (FR-16) — ไฟล์ร่างต้องเป็น untracked ในสายตา add-in
|
||||||
|
SetCustomProperty(doc, AddInMetadata.TemplateIdPropertyName, templateId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose</summary>
|
||||||
|
private static MemoryStream CopyToMemory(Stream stream)
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
stream.CopyTo(buffer);
|
||||||
|
buffer.Position = 0;
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ชื่อไฟล์ของเอกสาร/ไฟล์ร่างที่ generate จาก template: ชื่อ template + timestamp
|
||||||
|
/// — ตัดอักขระที่ filename ใช้ไม่ได้ออก (ใช้ร่วมทั้ง generate และ generate-draft)</summary>
|
||||||
|
public static string BuildGeneratedFileName(string templateName, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
|
||||||
|
if (clean.Length == 0)
|
||||||
|
clean = "document";
|
||||||
|
return $"{clean}_{now:yyyyMMdd-HHmmss}.docx";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>แทนที่ค่าใน SDT ทุกตัวที่ tag ตรง key ใน values (ใช้ร่วมทั้ง Generate และ GenerateDraft)</summary>
|
||||||
|
private static void ReplaceSdtValues(WordprocessingDocument doc, IReadOnlyDictionary<string, string> values)
|
||||||
|
{
|
||||||
|
foreach (var root in AllContentRoots(doc))
|
||||||
|
{
|
||||||
|
foreach (var tag in root.Descendants<Tag>().ToList())
|
||||||
|
{
|
||||||
|
var key = tag.Val?.Value?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
|
||||||
|
continue;
|
||||||
|
ReplaceSdtContent(tag, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
|
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
|
||||||
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
|
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
|
||||||
private static void ReplaceSdtContent(Tag tag, string value)
|
private static void ReplaceSdtContent(Tag tag, string value)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using WordVersioning.Api.Models;
|
||||||
|
|
||||||
|
namespace WordVersioning.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>FR-10/FR-16 — validate required + type ของค่าที่กรอก (ใช้ร่วมทั้ง generate และ generate-draft)
|
||||||
|
/// field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11</summary>
|
||||||
|
public static class TemplateFieldValidation
|
||||||
|
{
|
||||||
|
/// <summary>null = ผ่าน; ไม่ null = ข้อความ error สำหรับ BadRequest (รูปแบบเดียวกับที่เคยอยู่ใน Generate)</summary>
|
||||||
|
public static string? Validate(IEnumerable<TemplateField> fieldDefs, IReadOnlyDictionary<string, string?> values)
|
||||||
|
{
|
||||||
|
var missingRequired = fieldDefs
|
||||||
|
.Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty)))
|
||||||
|
.Select(f => f.Key)
|
||||||
|
.ToList();
|
||||||
|
if (missingRequired.Count > 0)
|
||||||
|
return $"กรอก required fields ให้ครบ: {string.Join(", ", missingRequired)}";
|
||||||
|
|
||||||
|
var typeErrors = new List<string>();
|
||||||
|
foreach (var def in fieldDefs)
|
||||||
|
{
|
||||||
|
if (!values.TryGetValue(def.Key, out var v) || string.IsNullOrWhiteSpace(v))
|
||||||
|
continue;
|
||||||
|
var valid = def.Type switch
|
||||||
|
{
|
||||||
|
"number" => decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
|
||||||
|
"date" => DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out _),
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if (!valid)
|
||||||
|
typeErrors.Add(def.Type == "number"
|
||||||
|
? $"`{def.Key}` ต้องเป็นตัวเลข"
|
||||||
|
: $"`{def.Key}` ต้องเป็นวันที่ที่ parse ได้ (ISO-8601 เช่น 2026-08-19)");
|
||||||
|
}
|
||||||
|
return typeErrors.Count > 0 ? string.Join("; ", typeErrors) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# ตรวจ Acceptance Criteria 1–18 (Phase 8 + 9 + 10 + 11)
|
# ตรวจ Acceptance Criteria 1–19 (Phase 8 + 9 + 10 + 11 + 12)
|
||||||
|
|
||||||
> วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · 2026-09-08 (AC-18) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
|
> วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · 2026-09-08 (AC-18) · 2026-09-10 (AC-19) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
|
||||||
>
|
>
|
||||||
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
|
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
|
||||||
|
|
||||||
|
|
@ -135,3 +135,32 @@
|
||||||
รายการเต็ม: [แผน FR-15](plans/version-pdf.md). ไม่มีการอ้างว่า unit tests ยืนยัน layout/Word protocol handler ได้.
|
รายการเต็ม: [แผน FR-15](plans/version-pdf.md). ไม่มีการอ้างว่า unit tests ยืนยัน layout/Word protocol handler ได้.
|
||||||
|
|
||||||
QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending PDF tab cleanup, PDF success copy); 2026-09-08. Manual acceptance ผ่านบน Word for Mac 2026-09-08 — **Phase 11 ปิดแล้ว**; ค้างเฉพาะ Word for Windows / Word on the web ตามรายการด้านบน.
|
QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending PDF tab cleanup, PDF success copy); 2026-09-08. Manual acceptance ผ่านบน Word for Mac 2026-09-08 — **Phase 11 ปิดแล้ว**; ค้างเฉพาะ Word for Windows / Word on the web ตามรายการด้านบน.
|
||||||
|
|
||||||
|
## AC-19 — ไฟล์ร่าง (v8)
|
||||||
|
|
||||||
|
**ผล: 🔶 ผ่านด้วย automated tests — ค้างยืนยันด้วยมือบน Word จริง** (ตาม pattern เดิม: unit/integration tests ยืนยัน contract และข้อมูลใน DB ได้ แต่ไม่ยืนยันพฤติกรรม add-in ใน Word จริงได้)
|
||||||
|
|
||||||
|
| เกณฑ์ | หลักฐาน |
|
||||||
|
|---|---|
|
||||||
|
| สร้างไฟล์ร่างแล้ว stateless จริง (ไม่เกิด Document/version/field values) + ค่าครบ + formatting รักษาไว้ | `DraftTests.Draft_FillsAllFields_PreservesFormatting_StampsOnlyTemplateId_CreatesNothing` |
|
||||||
|
| stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) |
|
||||||
|
| validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) |
|
||||||
|
| Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` |
|
||||||
|
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
|
||||||
|
| templateId ไม่ใช่ GUID / ไม่พบ → 400 (แม้ document มีอยู่แล้ว) | `Upload_TemplateIdNotGuid_Returns400`, `Upload_TemplateIdUnknown_Returns400`, `Upload_ExistingDocument_BadTemplateId_Returns400` |
|
||||||
|
| template deactivate หลังสร้างไฟล์ร่าง → ยังผูกได้ | `Upload_TemplateIdDeactivated_StillLinks` |
|
||||||
|
| document เดิม (standalone) ส่ง templateId มา → เฉย | `Upload_ExistingDocument_IgnoresTemplateId` |
|
||||||
|
| endpoint ใหม่ต้อง [Authorize] | `AuthTests` เพิ่ม `POST api/templates/{0}/generate-draft` |
|
||||||
|
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
|
||||||
|
| เว็บ: ปุ่มไฟล์ร่าง POST → blob, ชื่อไทยจาก Content-Disposition, 401 retry, !ok → error | `useApi.spec.ts` (4 tests) + CORS expose `Content-Disposition` ที่ `Program.cs` |
|
||||||
|
|
||||||
|
ชุดเต็ม: backend **82 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน.
|
||||||
|
|
||||||
|
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
|
||||||
|
|
||||||
|
- [ ] Mac: เว็บสร้างไฟล์ร่าง → ได้ .docx ชื่อไทยถูกต้อง → เปิดใน Word → task pane แสดง untracked + hint ไฟล์ร่าง → Start Versioning → v1 + หน้า document detail ของเว็บแสดง template และค่า fields
|
||||||
|
- [ ] Word for Windows / Word on the web — ตามข้อค้างเดิมตั้งแต่ AC-1
|
||||||
|
|
||||||
|
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
|
||||||
|
|
||||||
|
QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน.
|
||||||
|
|
|
||||||
14
docs/adr/0002-draft-stateless.md
Normal file
14
docs/adr/0002-draft-stateless.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# ADR 0002: ไฟล์ร่าง stateless — stamp TemplateId ตัวเดียว ผูก template ทีหลังตอน Start Versioning
|
||||||
|
|
||||||
|
วันที่: 2026-09-10 · สถานะ: Accepted
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
ไฟล์ร่าง (FR-16) สร้างแบบ stateless: `POST /api/templates/{id}/generate-draft` คืนไฟล์ทันที ไม่สร้าง Document/version ไม่เขียน MinIO. ไฟล์ถูก stamp custom property `TemplateId` ตัวเดียว (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. การผูกกลับเข้า template เกิดทีหลังผ่าน form field `templateId` ทางเลือกของ upload ซึ่งใช้เฉพาะตอน auto-create Document — เอกสารเกิดเป็น version 1 โดยไม่มี v0.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
- สร้าง Document สถานะ "ร่าง" ค้างไว้ใน DB + ไฟล์ใน MinIO รอผู้ใช้เปิด: ต้องมี lifecycle/cleanup ของเอกสารที่ไม่ถูกเปิด และเอกสารขยะในหน้ารายการ
|
||||||
|
- stamp SavedByAddIn + DocumentTrackingId ตั้งแต่ตอนสร้างไฟล์ร่าง: add-in จะมองเป็น tracked document แต่ backend ไม่มี record → history ติด 404 ถาวร (ไม่มี 404-handling ในปัจจุบัน)
|
||||||
|
- ปุ่ม "เปิดใน Word" สำหรับไฟล์ร่างแบบ flow v0 (`ms-word:ofe|u|<url>`): ไม่มี URL เพราะไม่มี object ใน storage — ต้องเสีย statelessness ไปเก็บไฟล์ชั่วคราวพร้อม TTL
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
ไฟล์ร่างหาย/ไม่ถูกเปิด = ไม่มีอะไรค้างในระบบ (ต้องกรอกฟอร์มใหม่). เอกสารจากไฟล์ร่างเริ่มที่ v1 ไม่มี v0 — เลข version ยังกำหนดเป็น "รอบบันทึกผ่าน Word หลังฉบับตั้งต้น" โดย v0 เป็นทางเลือกของ flow เดิม. Field sync เริ่มทำงานที่ v1 จากค่าที่ extract ใน Word. CORS ต้อง expose `Content-Disposition` เพื่อให้เว็บอ่านชื่อไฟล์ไทยได้.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# แผนงาน: ไฟล์ร่าง (draft) — generate docx จาก template โดยยังไม่สร้าง version (FR-16 / spec v8 / Phase 12)
|
# แผนงาน: ไฟล์ร่าง (draft) — generate docx จาก template โดยยังไม่สร้าง version (FR-16 / spec v8 / Phase 12)
|
||||||
|
|
||||||
> สถานะ: **ยังไม่ implement** — แผนผ่านการ review กับเจ้าของระบบแล้ว (grill session 2026-09-10) พร้อมสั่งทำงานได้ทันที
|
> สถานะ: **implement แล้ว (2026-09-10)** — ติดตามผลที่ `officejs-implementation-plan.md` Phase 12 และ `acceptance-review.md` AC-19 (ค้าง manual บน Word จริง)
|
||||||
> เขียนทับแผนนี้หรือลบทิ้งเมื่อ Phase 12 ถูก merge และ sync เข้า `officejs-implementation-plan.md` เรียบร้อย
|
> แผนนี้เก็บไว้เป็นหลักฐานการตัดสินใจของ grill session — รายละเอียด decision เพิ่มที่ `docs/adr/0002-draft-stateless.md`
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||||
|
|
||||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||||
> สถานะ: **Phase 0–10 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
|
> สถานะ: **Phase 0–12** (Phase 0–11 เสร็จครบ; Phase 12 เหลือ manual บน Word จริง — backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
|
||||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -631,3 +631,18 @@ npm test # vitest (46 ตัว)
|
||||||
- [x] Web เปิด Word/PDF พร้อม fallback
|
- [x] Web เปิด Word/PDF พร้อม fallback
|
||||||
- [x] Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55)
|
- [x] Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55)
|
||||||
- [x] ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม
|
- [x] ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม
|
||||||
|
|
||||||
|
## Phase 12: ไฟล์ร่าง (draft) — FR-16 (เพิ่มทีหลัง, v8)
|
||||||
|
|
||||||
|
| Phase | FR | AC |
|
||||||
|
|---|---|---|
|
||||||
|
| 12 (ทีหลัง, v8) | FR-16 (+ขยาย FR-5) | AC-19 |
|
||||||
|
|
||||||
|
รายละเอียดเต็ม: [fr16-draft-generation-plan.md](fr16-draft-generation-plan.md) — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field `templateId` ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง")
|
||||||
|
- [x] Spec v8 + glossary + ADR 0002
|
||||||
|
- [x] Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload + CORS expose `Content-Disposition`
|
||||||
|
- [x] Add-in: อ่าน `TemplateId` + ส่ง `templateId` เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane
|
||||||
|
- [x] Web: ปุ่ม "สร้างไฟล์ร่าง" + `downloadFile` รับ `init` (POST → blob)
|
||||||
|
- [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
|
||||||
|
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
|
||||||
|
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → เปิดใน Word → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
||||||
|
|
||||||
|
> ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0
|
||||||
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง
|
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง
|
||||||
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
|
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
|
||||||
> ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า
|
> ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า
|
||||||
|
|
@ -292,6 +293,14 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
|
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ
|
||||||
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
|
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
|
||||||
|
|
||||||
|
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8)
|
||||||
|
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — **stateless**: ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` และไม่เขียน MinIO; คืนไฟล์ .docx ทันที (200 + attachment, ชื่อ `ชื่อtemplate_yyyyMMdd-HHmmss.docx` — ไม่ใช่ 201 เพราะไม่มีอะไรถูกสร้าง)
|
||||||
|
- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message)
|
||||||
|
- stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404")
|
||||||
|
- **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0)
|
||||||
|
- Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template
|
||||||
|
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → ดาวน์โหลดไฟล์ทันที (browser download ผ่าน Bearer — ชื่อไฟล์อ่านจาก `Content-Disposition` ที่ CORS ต้อง expose); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์/ปุ่มเปิดใน Word (ไม่มี URL ให้ `ms-word:ofe`) · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
|
||||||
|
|
||||||
## 7. Data Model (Backend)
|
## 7. Data Model (Backend)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|
@ -362,6 +371,7 @@ Custom Document Properties:
|
||||||
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
||||||
| `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
|
| `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
|
||||||
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
||||||
|
| `TemplateId` | GUID ของ template ต้นทาง — มีเฉพาะไฟล์ร่าง (FR-16); backend stamp ตัวเดียวตอน generate-draft, add-in อ่านตอน untracked เพื่อแนบ `templateId` ตอน Start Versioning (เมื่อเริ่ม track แล้วค่านี้ยังอยู่ในไฟล์แต่ไม่ถูกใช้) |
|
||||||
|
|
||||||
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
||||||
```xml
|
```xml
|
||||||
|
|
@ -419,6 +429,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
||||||
---
|
---
|
||||||
|
|
||||||
18. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง
|
18. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง
|
||||||
|
19. (v8, AC-19) สร้างไฟล์ร่างจากฟอร์มเว็บ → ได้ .docx ค่าครบ ชื่อไทยถูกต้อง โดย**ไม่เกิด Document/version ใดๆ ในระบบ** → เปิดใน Word แล้ว add-in แสดงสถานะ untracked พร้อม hint ไฟล์ร่าง → กด Start Versioning → Document ถูกสร้าง ผูก template ต้นทาง + **version 1** + field values sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · template deactivate ผูกได้ · Save & Version ถัดไปได้ v2, v3… โดยไม่ส่ง templateId · flow สร้างเอกสาร v0 (FR-10) ยังทำงานได้คู่กัน
|
||||||
|
|
||||||
## 11. งานที่ Claude Code ควรทำ
|
## 11. งานที่ Claude Code ควรทำ
|
||||||
|
|
||||||
|
|
@ -463,3 +474,8 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
||||||
- Add-in: PDF capture + multipart + warning + history PDF
|
- Add-in: PDF capture + multipart + warning + history PDF
|
||||||
- Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0
|
- Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0
|
||||||
- Web: เปิด Word/PDF + fallback + history v0/v1+
|
- Web: เปิด Word/PDF + fallback + history v0/v1+
|
||||||
|
|
||||||
|
### เพิ่มเติม v8 (FR-16)
|
||||||
|
- Backend: `POST /api/templates/{id}/generate-draft` (stateless) + `DocxGenerationService.GenerateDraft` (stamp เฉพาะ `TemplateId`) + แยก `TemplateFieldValidation` ใช้ร่วมกับ generate + form field `templateId` ทางเลือกของ upload (ใช้ตอน auto-create) + CORS expose `Content-Disposition`
|
||||||
|
- Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane
|
||||||
|
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม + `downloadFile` รับ `init` (POST + JSON → blob)
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,10 @@ export function useApi() {
|
||||||
/**
|
/**
|
||||||
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
|
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
|
||||||
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
|
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
|
||||||
|
* init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์
|
||||||
*/
|
*/
|
||||||
async function downloadFile(path: string, fallbackName: string): Promise<void> {
|
async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise<void> {
|
||||||
const res = await request(path)
|
const res = await request(path, init)
|
||||||
if (!res.ok) throw await toApiError(res)
|
if (!res.ok) throw await toApiError(res)
|
||||||
|
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
|
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v0 + ดาวน์โหลด
|
||||||
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
|
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง
|
||||||
|
* FR-16 — ปุ่ม "สร้างไฟล์ร่าง": generate docx แบบ stateless (ไม่เกิด Document/version) ดาวน์โหลดทันที */
|
||||||
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { apiJson } = useApi()
|
const { apiJson, downloadFile } = useApi()
|
||||||
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
|
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
|
||||||
|
|
||||||
const templateId = computed(() => String(route.params.templateId))
|
const templateId = computed(() => String(route.params.templateId))
|
||||||
|
|
@ -25,6 +26,11 @@ const submitting = ref(false)
|
||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
const result = ref<GenerateDocumentResponse | null>(null)
|
const result = ref<GenerateDocumentResponse | null>(null)
|
||||||
|
|
||||||
|
/** FR-16 — ไฟล์ร่าง: ฟอร์มอยู่ต่อหลังดาวน์โหลด (ไม่มี result card เพราะไม่มี documentId) */
|
||||||
|
const draftSubmitting = ref(false)
|
||||||
|
const draftSuccess = ref<string | null>(null)
|
||||||
|
const draftError = ref<string | null>(null)
|
||||||
|
|
||||||
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
|
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|
@ -77,6 +83,8 @@ async function load() {
|
||||||
async function submit() {
|
async function submit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
downloadError.value = null
|
downloadError.value = null
|
||||||
|
draftSuccess.value = null
|
||||||
|
draftError.value = null
|
||||||
issues.value = Object.fromEntries(
|
issues.value = Object.fromEntries(
|
||||||
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
|
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
|
||||||
)
|
)
|
||||||
|
|
@ -97,11 +105,40 @@ async function submit() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอไฟล์กลับมาดาวน์โหลดทันที
|
||||||
|
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
||||||
|
async function submitDraft() {
|
||||||
|
draftSuccess.value = null
|
||||||
|
draftError.value = null
|
||||||
|
submitError.value = null
|
||||||
|
issues.value = Object.fromEntries(
|
||||||
|
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
|
||||||
|
)
|
||||||
|
if (Object.keys(issues.value).length > 0) return
|
||||||
|
|
||||||
|
draftSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await downloadFile(`/api/templates/${templateId.value}/generate-draft`, `${detail.value?.name ?? 'เอกสาร'}.docx`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ values: values.value }),
|
||||||
|
})
|
||||||
|
draftSuccess.value =
|
||||||
|
'ดาวน์โหลดไฟล์ร่างแล้ว — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||||
|
} catch (err) {
|
||||||
|
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
||||||
|
} finally {
|
||||||
|
draftSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
|
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
resetOpen()
|
resetOpen()
|
||||||
result.value = null
|
result.value = null
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
|
draftSuccess.value = null
|
||||||
|
draftError.value = null
|
||||||
downloadError.value = null
|
downloadError.value = null
|
||||||
issues.value = {}
|
issues.value = {}
|
||||||
values.value = { ...blankValues(), ...prefill.value }
|
values.value = { ...blankValues(), ...prefill.value }
|
||||||
|
|
@ -254,6 +291,7 @@ onMounted(load)
|
||||||
maxlength="2048"
|
maxlength="2048"
|
||||||
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
|
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
|
||||||
/>
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted">ใช้กับการสร้างเอกสาร (version 0) เท่านั้น — ไฟล์ร่างยังไม่มี version จึงไม่ใช้คำอธิบายนี้</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sortedFields.length === 0" class="empty-state">
|
<div v-if="sortedFields.length === 0" class="empty-state">
|
||||||
|
|
@ -263,15 +301,30 @@ onMounted(load)
|
||||||
|
|
||||||
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
||||||
|
|
||||||
|
<!-- FR-16 — ไฟล์ร่าง: ดาวน์โหลดสำเร็จแล้วฟอร์มอยู่ต่อ ผู้ใช้เปิดไฟล์ใน Word แล้วค่อย Start Versioning -->
|
||||||
|
<AlertBox v-if="draftSuccess" type="success">{{ draftSuccess }}</AlertBox>
|
||||||
|
<AlertBox v-if="draftError" type="error">{{ draftError }}</AlertBox>
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="submitting || draftSubmitting || sortedFields.length === 0 || !detail.isActive"
|
||||||
|
class="btn btn-secondary w-full sm:w-auto"
|
||||||
|
@click="submitDraft"
|
||||||
|
>
|
||||||
|
{{ draftSubmitting ? 'กำลังสร้างไฟล์ร่าง…' : 'สร้างไฟล์ร่าง (ยังไม่ version)' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
|
:disabled="submitting || draftSubmitting || sortedFields.length === 0 || !detail.isActive"
|
||||||
class="btn btn-primary w-full sm:w-auto"
|
class="btn btn-primary w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
|
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
117
web/test/useApi.spec.ts
Normal file
117
web/test/useApi.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, useApi } from '../app/composables/useApi'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ
|
||||||
|
* POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const getAccessToken = vi.fn()
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
|
||||||
|
/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */
|
||||||
|
function fakeResponse(options: {
|
||||||
|
ok?: boolean
|
||||||
|
status?: number
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string
|
||||||
|
}): Response {
|
||||||
|
const lower = Object.fromEntries(
|
||||||
|
Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
ok: options.ok ?? true,
|
||||||
|
status: options.status ?? 200,
|
||||||
|
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
|
||||||
|
blob: async () => new Blob([options.body ?? 'docx-bytes']),
|
||||||
|
text: async () => options.body ?? '',
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
let clicked: HTMLAnchorElement | null = null
|
||||||
|
const originalCreateObjectURL = URL.createObjectURL
|
||||||
|
const originalRevokeObjectURL = URL.revokeObjectURL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } }))
|
||||||
|
vi.stubGlobal('useAuth', () => ({ getAccessToken }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
getAccessToken.mockReset().mockResolvedValue('token-1')
|
||||||
|
fetchMock.mockReset()
|
||||||
|
clicked = null
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) {
|
||||||
|
clicked = this
|
||||||
|
})
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:mock')
|
||||||
|
URL.revokeObjectURL = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
URL.createObjectURL = originalCreateObjectURL
|
||||||
|
URL.revokeObjectURL = originalRevokeObjectURL
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => {
|
||||||
|
it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => {
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
fakeResponse({
|
||||||
|
headers: {
|
||||||
|
// .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย
|
||||||
|
'Content-Disposition':
|
||||||
|
"attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
|
||||||
|
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
|
||||||
|
})
|
||||||
|
expect(clicked?.download).toBe('ไฟล์ร่าง.docx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => {
|
||||||
|
fetchMock.mockResolvedValue(fakeResponse({}))
|
||||||
|
|
||||||
|
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
|
||||||
|
|
||||||
|
expect(clicked?.download).toBe('fallback.docx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries once with a refreshed token on 401 before saving', async () => {
|
||||||
|
getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1'))
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 }))
|
||||||
|
.mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } }))
|
||||||
|
|
||||||
|
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2')
|
||||||
|
expect(clicked?.download).toBe('draft.docx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws ApiError with the backend message when the response is not ok', async () => {
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
constructor: ApiError,
|
||||||
|
status: 400,
|
||||||
|
message: 'กรอก required fields ให้ครบ: employee_name',
|
||||||
|
})
|
||||||
|
expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
|
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
|
||||||
|
|
@ -9,4 +10,11 @@ export default defineConfig({
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
include: ['test/**/*.spec.ts'],
|
include: ['test/**/*.spec.ts'],
|
||||||
},
|
},
|
||||||
|
// alias ~/ ชี้ srcDir = app/ ตาม Nuxt — จำเป็นตอน spec import ไฟล์ที่ลาก useAuth
|
||||||
|
// (useAuth อ้าง ~/utils/oidc — เดิมไม่มี spec ไหนแตะ useApi จึงไม่เคยโดน)
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': fileURLToPath(new URL('./app', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue