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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue