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
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue