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

@ -1,10 +1,11 @@
<script setup lang="ts">
/** 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'
const route = useRoute()
const { apiJson } = useApi()
const { apiJson, downloadFile } = useApi()
const { openingVersionId, downloadError, fallbackUrl, openNotice, openVersion, resetOpen } = useVersionOpen()
const templateId = computed(() => String(route.params.templateId))
@ -25,6 +26,11 @@ const submitting = ref(false)
const submitError = ref<string | 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))
useHead({
@ -77,6 +83,8 @@ async function load() {
async function submit() {
submitError.value = null
downloadError.value = null
draftSuccess.value = null
draftError.value = null
issues.value = Object.fromEntries(
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 ไม่ใช่ฟอร์มว่าง) */
function resetForm() {
resetOpen()
result.value = null
submitError.value = null
draftSuccess.value = null
draftError.value = null
downloadError.value = null
issues.value = {}
values.value = { ...blankValues(), ...prefill.value }
@ -254,6 +291,7 @@ onMounted(load)
maxlength="2048"
placeholder="เช่น ฉบับเสนอลงนามครั้งแรก"
/>
<p class="mt-1 text-xs text-muted">ใช้กับการสร้างเอกสาร (version 0) เท่านั้น — ไฟล์ร่างยังไม่มี version จึงไม่ใช้คำอธิบายนี้</p>
</div>
<div v-if="sortedFields.length === 0" class="empty-state">
@ -263,15 +301,30 @@ onMounted(load)
<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">
<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
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"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 0)' }}
</button>
</div>
<p class="text-xs text-muted">
ไฟล์ร่าง = ได้ .docx ทันทีโดยยังไม่เกิดเอกสารในระบบ — เริ่ม versioning ตอนเปิดใน Word (version 1)
</p>
</form>
</template>
</section>