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 },

View file

@ -76,6 +76,8 @@ const App: React.FC = () => {
const [username, setUsername] = 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 [historyLoading, setHistoryLoading] = React.useState(false);
const [historyError, setHistoryError] = React.useState<string | null>(null);
@ -101,6 +103,7 @@ const App: React.FC = () => {
if (error instanceof AuthRequiredError) {
setPhase("signed-out");
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
return true;
@ -125,7 +128,9 @@ const App: React.FC = () => {
[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 () => {
setPhase("checking");
setBootError(null);
@ -138,19 +143,17 @@ const App: React.FC = () => {
authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null);
const tracked = await metadataService.isTrackedDocument();
if (!tracked) {
setPhase("untracked");
return;
}
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) {
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked
// ไม่เคย track / track แต่ไม่มี id (ไฟล์เสียหาย) — ไฟล์ร่างจาก template อยู่กลุ่มนี้ (FR-16)
setDocumentTemplateId(properties.templateId ?? null);
setPhase("untracked");
return;
}
setDocumentId(id);
setDocumentTemplateId(null);
setPhase("tracking");
await refreshHistory(id);
} catch (error) {
@ -207,20 +210,23 @@ const App: React.FC = () => {
authService.signOut();
setUsername(null);
setDocumentId(null);
setDocumentTemplateId(null);
setVersions([]);
setLoginError(null);
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) => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
setFlowWarning(null);
try {
const result = await startVersioning(message, setProgressMessage);
const result = await startVersioning(message, documentTemplateId ?? undefined, setProgressMessage);
setDocumentId(result.documentId);
setDocumentTemplateId(null);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
if (!result.hasPdf)
@ -361,6 +367,12 @@ const App: React.FC = () => {
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</Text>
{documentTemplateId ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
ไฟล์นี้เป็นไฟล์ร่างจาก template — กดเริ่มติดตามแล้วระบบจะผูกเอกสารกับ template ต้นทาง
และ sync ค่า fields ให้อัตโนมัติ
</Text>
) : null}
<Button
appearance="primary"
icon={<DocumentArrowUp24Regular />}
@ -454,7 +466,11 @@ const App: React.FC = () => {
open={startConfirmOpen}
busy={starting}
title="เริ่มติดตามเอกสารนี้?"
description="ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
description={
documentTemplateId
? "ระบบจะ stamp metadata ลงไฟล์ บันทึกไฟล์ปัจจุบันเป็น version 1 และผูกเอกสารกับ template ต้นทาง (ไฟล์ร่าง)"
: "ระบบจะ stamp metadata ลงไฟล์และบันทึกไฟล์ปัจจุบันเป็น version 1 ของเอกสารนี้"
}
confirmLabel="เริ่มติดตาม (version 1)"
onConfirm={(message) => void handleStartVersioning(message)}
onDismiss={() => setStartConfirmOpen(false)}