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:
Suphonchai Phoonsawat 2026-09-10 10:51:13 +07:00
parent 0edb70fd38
commit b3c1c667f7
30 changed files with 879 additions and 93 deletions

View file

@ -40,6 +40,15 @@ describe("readTrackedProperties", () => {
expect(properties.lastVersionId).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)", () => {

View file

@ -19,6 +19,8 @@ export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties {
@ -26,6 +28,8 @@ export interface TrackedDocumentProperties {
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string;
}
/** item ใน Custom XML Part `<VersionHistory>` */
@ -36,7 +40,7 @@ export interface XmlVersionEntry {
user: string;
}
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */
/** อ่าน custom properties ทั้ง 5 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16) */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => {
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 lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value");
documentTrackingId.load("value");
lastVersionId.load("value");
lastVersionTimestamp.load("value");
templateId.load("value");
await context.sync();
const read = (property: Word.CustomProperty): string | undefined =>
@ -61,11 +67,15 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
documentTrackingId: read(documentTrackingId),
lastVersionId: read(lastVersionId),
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> {
const properties = await readTrackedProperties();
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;

View file

@ -141,6 +141,22 @@ describe("startVersioning (FR-1 / AC-5)", () => {
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
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.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 }]);
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
expect(form.get("templateId")).toBeNull();
});
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {

View file

@ -74,7 +74,8 @@ async function uploadCurrentFile(
clientVersionId: string,
fields: Record<string, string>,
message?: string,
onProgress?: (progress: string) => void
onProgress?: (progress: string) => void,
templateId?: string
): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile();
let pdf: Blob | undefined;
@ -94,14 +95,17 @@ async function uploadCurrentFile(
fields,
guessDocumentFileName(),
message,
pdf
pdf,
templateId
);
}
/** 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(
message?: string,
templateId?: string,
onProgress?: (progress: string) => void
): Promise<StartVersioningResult> {
const documentId = generateGuid();
@ -128,9 +132,9 @@ export async function startVersioning(
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload version แรก
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
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 };
}

View file

@ -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).type).toBe("application/pdf");
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");
});

View file

@ -9,7 +9,8 @@ import { generateGuid } from "./metadataService";
*/
/** 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(
documentId: string,
file: Blob,
@ -17,7 +18,8 @@ export async function uploadVersion(
fields?: Record<string, string>,
fileName?: string,
message?: string,
pdf?: Blob
pdf?: Blob,
templateId?: string
): Promise<UploadVersionResult> {
const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`);
@ -30,6 +32,9 @@ export async function uploadVersion(
if (trimmedMessage) {
form.append("message", trimmedMessage);
}
if (templateId) {
form.append("templateId", templateId);
}
return apiSendExpectJson<UploadVersionResult>(
`/api/documents/${documentId}/versions`,
{ method: "POST", body: form },