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
|
|
@ -72,9 +72,10 @@ export function useApi() {
|
|||
/**
|
||||
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
|
||||
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
|
||||
* init ใช้ override method/body ได้ — ไฟล์ร่าง (FR-16) เป็น POST + JSON body ที่คืนไฟล์
|
||||
*/
|
||||
async function downloadFile(path: string, fallbackName: string): Promise<void> {
|
||||
const res = await request(path)
|
||||
async function downloadFile(path: string, fallbackName: string, init: RequestInit = {}): Promise<void> {
|
||||
const res = await request(path, init)
|
||||
if (!res.ok) throw await toApiError(res)
|
||||
|
||||
const blob = await res.blob()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
117
web/test/useApi.spec.ts
Normal file
117
web/test/useApi.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, useApi } from '../app/composables/useApi'
|
||||
|
||||
/**
|
||||
* useApi — fetch wrapper ของ web portal: ไฟล์ร่าง (FR-16) ใช้ downloadFile แบบ
|
||||
* POST + JSON body ที่คืนไฟล์ (ชื่อไทยอ่านจาก Content-Disposition filename* ที่ CORS expose ไว้)
|
||||
*/
|
||||
|
||||
const getAccessToken = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
/** Response ปลอม — ไม่พึ่ง global Response ของ environment (happy-dom) */
|
||||
function fakeResponse(options: {
|
||||
ok?: boolean
|
||||
status?: number
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
}): Response {
|
||||
const lower = Object.fromEntries(
|
||||
Object.entries(options.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
|
||||
)
|
||||
return {
|
||||
ok: options.ok ?? true,
|
||||
status: options.status ?? 200,
|
||||
headers: { get: (name: string) => lower[name.toLowerCase()] ?? null },
|
||||
blob: async () => new Blob([options.body ?? 'docx-bytes']),
|
||||
text: async () => options.body ?? '',
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
let clicked: HTMLAnchorElement | null = null
|
||||
const originalCreateObjectURL = URL.createObjectURL
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({ public: { apiBaseUrl: 'https://api.test/' } }))
|
||||
vi.stubGlobal('useAuth', () => ({ getAccessToken }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
getAccessToken.mockReset().mockResolvedValue('token-1')
|
||||
fetchMock.mockReset()
|
||||
clicked = null
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function click(this: HTMLAnchorElement) {
|
||||
clicked = this
|
||||
})
|
||||
URL.createObjectURL = vi.fn(() => 'blob:mock')
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
URL.createObjectURL = originalCreateObjectURL
|
||||
URL.revokeObjectURL = originalRevokeObjectURL
|
||||
})
|
||||
|
||||
describe('downloadFile (FR-16 ไฟล์ร่าง — POST + JSON body คืนไฟล์)', () => {
|
||||
it('forwards method/body to fetch with Bearer and saves the Thai filename from Content-Disposition', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
fakeResponse({
|
||||
headers: {
|
||||
// .NET ใส่ทั้ง filename และ filename* (RFC 5987) สำหรับชื่อไทย
|
||||
'Content-Disposition':
|
||||
"attachment; filename=\"draft.docx\"; filename*=UTF-8''%E0%B9%84%E0%B8%9F%E0%B8%A5%E0%B9%8C%E0%B8%A3%E0%B9%88%E0%B8%B2%E0%B8%87.docx",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.test/api/templates/abc/generate-draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
|
||||
body: JSON.stringify({ values: { employee_name: 'สมหญิง' } }),
|
||||
})
|
||||
expect(clicked?.download).toBe('ไฟล์ร่าง.docx')
|
||||
})
|
||||
|
||||
it('falls back to the given name when Content-Disposition is missing (CORS ไม่ expose)', async () => {
|
||||
fetchMock.mockResolvedValue(fakeResponse({}))
|
||||
|
||||
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
|
||||
|
||||
expect(clicked?.download).toBe('fallback.docx')
|
||||
})
|
||||
|
||||
it('retries once with a refreshed token on 401 before saving', async () => {
|
||||
getAccessToken.mockImplementation(async (forceRefresh?: boolean) => (forceRefresh ? 'token-2' : 'token-1'))
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(fakeResponse({ ok: false, status: 401 }))
|
||||
.mockResolvedValueOnce(fakeResponse({ headers: { 'Content-Disposition': 'attachment; filename="draft.docx"' } }))
|
||||
|
||||
await useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-2')
|
||||
expect(clicked?.download).toBe('draft.docx')
|
||||
})
|
||||
|
||||
it('throws ApiError with the backend message when the response is not ok', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
fakeResponse({ ok: false, status: 400, body: 'กรอก required fields ให้ครบ: employee_name' }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
useApi().downloadFile('/api/templates/abc/generate-draft', 'fallback.docx', { method: 'POST' }),
|
||||
).rejects.toMatchObject({
|
||||
constructor: ApiError,
|
||||
status: 400,
|
||||
message: 'กรอก required fields ให้ครบ: employee_name',
|
||||
})
|
||||
expect(clicked).toBeNull() // ไม่มีการดาวน์โหลดเกิดขึ้น
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
|
||||
|
|
@ -9,4 +10,11 @@ export default defineConfig({
|
|||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['test/**/*.spec.ts'],
|
||||
},
|
||||
// alias ~/ ชี้ srcDir = app/ ตาม Nuxt — จำเป็นตอน spec import ไฟล์ที่ลาก useAuth
|
||||
// (useAuth อ้าง ~/utils/oidc — เดิมไม่มี spec ไหนแตะ useApi จึงไม่เคยโดน)
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./app', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue