feat(documents): suggest inbox folder and type with AI
This commit is contained in:
@@ -5,6 +5,8 @@ type InboxDocument = { id: string; name: string; mimeType: string; size: number;
|
||||
type ScannerConnection = { username: string; active: boolean; password?: string }
|
||||
type FilingFolder = { id: string; name: string; parent: string | null; standardFiletype: string | null; standardFiletypeIsOptional: boolean }
|
||||
type FilingType = { id: string; name: string }
|
||||
type FilingSuggestion = { folderId: string | null; typeId: string; confidence: "high" | "medium"; reason: string }
|
||||
type SuggestionResult = { suggestion: FilingSuggestion | null; message?: string }
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
@@ -28,7 +30,6 @@ const scannerPath = ref("/scanner-inbox/")
|
||||
const origin = ref("")
|
||||
const previewOpen = ref(false)
|
||||
const previewDocument = ref<InboxDocument | null>(null)
|
||||
const filingOpen = ref(false)
|
||||
const filingDocument = ref<InboxDocument | null>(null)
|
||||
const filingLoading = ref(false)
|
||||
const filingSaving = ref(false)
|
||||
@@ -37,6 +38,14 @@ const filingFolders = ref<FilingFolder[]>([])
|
||||
const filingTypes = ref<FilingType[]>([])
|
||||
const filingFolder = ref("root")
|
||||
const filingType = ref("")
|
||||
const suggestion = ref<FilingSuggestion | null>(null)
|
||||
const suggestionLoading = ref(false)
|
||||
const suggestionError = ref("")
|
||||
const suggestionMessage = ref("")
|
||||
const selectionTouched = ref(false)
|
||||
const suggestionCache = new Map<string, SuggestionResult>()
|
||||
let suggestionController: AbortController | undefined
|
||||
let suggestionVersion = 0
|
||||
const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value))
|
||||
const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional))
|
||||
const folderOptions = computed(() => {
|
||||
@@ -59,6 +68,10 @@ const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.va
|
||||
&& (filingFolder.value === "root" || selectedFolder.value)
|
||||
&& filingTypes.value.some(type => type.id === filingType.value)
|
||||
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
|
||||
const suggestionSelected = computed(() => Boolean(suggestion.value
|
||||
&& (suggestion.value.folderId || "root") === filingFolder.value && suggestion.value.typeId === filingType.value))
|
||||
const suggestionFolderName = computed(() => folderOptions.value.find(folder => folder.id === (suggestion.value?.folderId || "root"))?.name)
|
||||
const suggestionTypeName = computed(() => filingTypes.value.find(type => type.id === suggestion.value?.typeId)?.name)
|
||||
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||
const scannerUrl = computed(() => {
|
||||
if (!origin.value) return ""
|
||||
@@ -103,20 +116,58 @@ const load = async (silent = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = (entry: InboxDocument) => {
|
||||
previewDocument.value = entry
|
||||
previewOpen.value = true
|
||||
const applySuggestion = () => {
|
||||
if (!suggestion.value || filingSaving.value) return
|
||||
filingFolder.value = suggestion.value.folderId || "root"
|
||||
filingType.value = suggestion.value.typeId
|
||||
selectionTouched.value = false
|
||||
}
|
||||
|
||||
const filePreviewDocument = () => {
|
||||
const entry = previewDocument.value
|
||||
if (!entry) return
|
||||
previewOpen.value = false
|
||||
openFiling(entry)
|
||||
const loadFilingSuggestion = async (refresh = false) => {
|
||||
const entry = filingDocument.value
|
||||
if (!entry || filingLoading.value || filingSaving.value || suggestionLoading.value) return
|
||||
const version = ++suggestionVersion
|
||||
const filingRequest = filingVersion
|
||||
suggestionController?.abort()
|
||||
suggestionController = new AbortController()
|
||||
suggestionLoading.value = true
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
try {
|
||||
const result = (!refresh && suggestionCache.get(entry.id)) || await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
|
||||
method: "POST", signal: suggestionController.signal,
|
||||
})
|
||||
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
|
||||
const proposed = result.suggestion
|
||||
const folder = filingFolders.value.find(folder => folder.id === proposed?.folderId)
|
||||
const valid = proposed && filingTypes.value.some(type => type.id === proposed.typeId)
|
||||
&& (proposed.folderId === null || (folder && (!folder.standardFiletype || folder.standardFiletypeIsOptional || folder.standardFiletype === proposed.typeId)))
|
||||
suggestion.value = valid ? proposed : null
|
||||
suggestionMessage.value = valid ? "" : result.message || "Keine passende Zuordnung gefunden. Du kannst Ordner und Typ selbst auswählen."
|
||||
if (suggestionCache.size >= 50) suggestionCache.delete(suggestionCache.keys().next().value!)
|
||||
suggestionCache.set(entry.id, { suggestion: suggestion.value, message: suggestionMessage.value })
|
||||
// A delayed AI response must not replace choices the user has already made.
|
||||
if (!selectionTouched.value) applySuggestion()
|
||||
} catch (cause: any) {
|
||||
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
|
||||
suggestionError.value = cause?.data?.message || cause?.data?.error || "Der KI-Vorschlag konnte nicht geladen werden. Du kannst die Zuordnung manuell vornehmen."
|
||||
} finally {
|
||||
if (version === suggestionVersion) suggestionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = (entry: InboxDocument) => openFiling(entry)
|
||||
|
||||
const openFiling = async (entry: InboxDocument) => {
|
||||
filingOpen.value = true
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestion.value = null
|
||||
suggestionLoading.value = false
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
selectionTouched.value = false
|
||||
previewOpen.value = true
|
||||
previewDocument.value = entry
|
||||
filingDocument.value = entry
|
||||
filingFolder.value = "root"
|
||||
filingType.value = ""
|
||||
@@ -134,7 +185,10 @@ const openFiling = async (entry: InboxDocument) => {
|
||||
} catch (cause: any) {
|
||||
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Ordner und Dokumenttypen konnten nicht geladen werden."
|
||||
} finally {
|
||||
if (version === filingVersion) filingLoading.value = false
|
||||
if (version === filingVersion) {
|
||||
filingLoading.value = false
|
||||
if (!filingError.value && !disposed) void loadFilingSuggestion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +208,8 @@ const fileDocument = async () => {
|
||||
documents.value = documents.value.filter(document => document.id !== entry.id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
filingOpen.value = false
|
||||
suggestionCache.delete(entry.id)
|
||||
previewOpen.value = false
|
||||
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
|
||||
await load(true)
|
||||
} catch (cause: any) {
|
||||
@@ -245,15 +300,19 @@ watch(search, () => {
|
||||
watch(setupOpen, open => {
|
||||
if (!open) { setupVersion++; password.value = ""; confirmReset.value = false }
|
||||
})
|
||||
watch(previewOpen, open => {
|
||||
if (!open) previewDocument.value = null
|
||||
})
|
||||
watch(filingFolder, () => {
|
||||
const defaultType = selectedFolder.value?.standardFiletype
|
||||
if (defaultType) filingType.value = defaultType
|
||||
})
|
||||
watch(filingOpen, open => {
|
||||
}, { flush: "sync" })
|
||||
watch(previewOpen, open => {
|
||||
if (!open) {
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestion.value = null
|
||||
suggestionLoading.value = false
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
previewDocument.value = null
|
||||
filingVersion++
|
||||
filingDocument.value = null
|
||||
filingFolders.value = []
|
||||
@@ -267,9 +326,11 @@ watch(() => auth.activeTenant, () => {
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestionCache.clear()
|
||||
previewOpen.value = false
|
||||
previewDocument.value = null
|
||||
filingOpen.value = false
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
documents.value = []
|
||||
@@ -287,6 +348,9 @@ onBeforeUnmount(() => {
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestionCache.clear()
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
@@ -342,53 +406,67 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Vorschau des eingegangenen Dokuments" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-6xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
|
||||
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!filingSaving" :close="!filingSaving" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
|
||||
<template #body>
|
||||
<AuthenticatedFilePreview
|
||||
v-if="previewOpen && previewDocument"
|
||||
:key="previewDocument.id"
|
||||
:file-id="previewDocument.id"
|
||||
:path="previewDocument.name"
|
||||
interactive
|
||||
/>
|
||||
<div class="lg:flex lg:h-full lg:min-h-0">
|
||||
<div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1">
|
||||
<AuthenticatedFilePreview
|
||||
v-if="previewOpen && previewDocument"
|
||||
:key="previewDocument.id"
|
||||
:file-id="previewDocument.id"
|
||||
:path="previewDocument.name"
|
||||
interactive
|
||||
/>
|
||||
</div>
|
||||
<aside class="border-t border-default p-5 lg:w-88 lg:shrink-0 lg:overflow-y-auto lg:border-t-0 lg:border-l">
|
||||
<form id="inbox-preview-filing" class="space-y-5" @submit.prevent="fileDocument">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-heroicons-sparkles" /> KI-Zuordnung</h2>
|
||||
<UButton size="xs" color="neutral" variant="ghost" :disabled="filingLoading || filingSaving || suggestionLoading" @click="loadFilingSuggestion(true)">Erneut prüfen</UButton>
|
||||
</div>
|
||||
<div aria-live="polite" class="space-y-3">
|
||||
<div v-if="suggestionLoading" class="space-y-2 text-sm text-muted">
|
||||
<UProgress />
|
||||
<p>Der Dokumentinhalt wird geprüft …</p>
|
||||
</div>
|
||||
<UAlert v-if="suggestionError" :title="suggestionError" color="warning" variant="subtle" />
|
||||
<div v-else-if="suggestion" class="space-y-2 rounded-lg bg-elevated/50 p-3 text-sm">
|
||||
<UBadge :color="suggestion.confidence === 'high' ? 'success' : 'warning'" variant="subtle">{{ suggestion.confidence === 'high' ? 'Passender Vorschlag' : 'Bitte Zuordnung prüfen' }}</UBadge>
|
||||
<dl class="space-y-1">
|
||||
<div><dt class="inline text-muted">Ordner: </dt><dd class="inline break-words">{{ suggestionFolderName }}</dd></div>
|
||||
<div><dt class="inline text-muted">Typ: </dt><dd class="inline">{{ suggestionTypeName }}</dd></div>
|
||||
</dl>
|
||||
<p class="text-muted">{{ suggestion.reason }}</p>
|
||||
<UButton v-if="!suggestionSelected" size="sm" variant="soft" :disabled="filingSaving" @click="applySuggestion">KI-Vorschlag einsetzen</UButton>
|
||||
</div>
|
||||
<p v-else-if="suggestionMessage" class="text-sm text-muted">{{ suggestionMessage }}</p>
|
||||
</div>
|
||||
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
|
||||
<USkeleton v-if="filingLoading" class="h-32 w-full" />
|
||||
<template v-else>
|
||||
<UFormField label="Zielordner" required>
|
||||
<USelectMenu v-model="filingFolder" :items="folderOptions" value-key="id" label-key="name" :search-input="{ placeholder: 'Ordner suchen …' }" :disabled="filingSaving" class="w-full" @update:model-value="selectionTouched = true" />
|
||||
</UFormField>
|
||||
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : undefined">
|
||||
<USelectMenu v-model="filingType" :items="filingTypes" value-key="id" label-key="name" placeholder="Dokumenttyp wählen …" :search-input="{ placeholder: 'Dokumenttyp suchen …' }" :disabled="filingSaving || fixedType" class="w-full" @update:model-value="selectionTouched = true" />
|
||||
</UFormField>
|
||||
<UAlert v-if="!filingTypes.length && !filingError" title="Noch keine Dokumenttypen vorhanden" description="Lege zuerst unter Stammdaten → Dateitypen einen Typ an." color="warning" variant="subtle" />
|
||||
<UAlert v-else-if="fixedType && !filingTypes.some(type => type.id === filingType)" title="Der vorgegebene Dokumenttyp ist nicht verfügbar." description="Bitte die Ordnereinstellungen korrigieren oder einen anderen Zielordner wählen." color="warning" variant="subtle" />
|
||||
</template>
|
||||
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. Erst mit „Übernehmen und ablegen“ wird das Dokument unter Dateien gespeichert und aus dem Eingang entfernt.</p>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="previewDocument">
|
||||
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" @click="fileAction(previewDocument, false)">In neuem Tab öffnen</UButton>
|
||||
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="outline" @click="fileAction(previewDocument, true)">Herunterladen</UButton>
|
||||
<UButton v-if="!filingOpen" icon="i-heroicons-folder-arrow-down" @click="filePreviewDocument">In Dateien ablegen</UButton>
|
||||
<UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton>
|
||||
</template>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="filingOpen" title="In Dateien ablegen" description="Wähle den Zielordner und klassifiziere das Dokument." :dismissible="!filingSaving" :close="!filingSaving">
|
||||
<template #body>
|
||||
<form class="space-y-5" @submit.prevent="fileDocument">
|
||||
<div v-if="filingDocument" class="flex items-center justify-between gap-3 rounded-lg bg-elevated/50 p-3">
|
||||
<span class="break-all text-sm font-medium">{{ filingDocument.name }}</span>
|
||||
<UButton icon="i-heroicons-eye" color="neutral" variant="ghost" aria-label="Dokumentvorschau öffnen" @click="openPreview(filingDocument)" />
|
||||
</div>
|
||||
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
|
||||
<USkeleton v-if="filingLoading" class="h-32 w-full" />
|
||||
<template v-else>
|
||||
<UFormField label="Zielordner" required>
|
||||
<USelectMenu v-model="filingFolder" :items="folderOptions" value-key="id" label-key="name" :search-input="{ placeholder: 'Ordner suchen …' }" :disabled="filingSaving" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : 'Die Dokumenttypen entsprechen den Dateitypen in FEDEO.'">
|
||||
<USelectMenu v-model="filingType" :items="filingTypes" value-key="id" label-key="name" placeholder="Dokumenttyp wählen …" :search-input="{ placeholder: 'Dokumenttyp suchen …' }" :disabled="filingSaving || fixedType" class="w-full" />
|
||||
</UFormField>
|
||||
<UAlert v-if="!filingTypes.length && !filingError" title="Noch keine Dokumenttypen vorhanden" description="Lege zuerst unter Stammdaten → Dateitypen einen Typ an." color="warning" variant="subtle" />
|
||||
<UAlert v-else-if="fixedType && !filingTypes.some(type => type.id === filingType)" title="Der vorgegebene Dokumenttyp ist nicht verfügbar." description="Bitte die Ordnereinstellungen korrigieren oder einen anderen Zielordner wählen." color="warning" variant="subtle" />
|
||||
</template>
|
||||
<p class="text-sm text-muted">Nach dem Ablegen findest du das Dokument unter Dateien. Es verschwindet aus diesem Eingang.</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" :disabled="filingSaving" @click="filingOpen = false">Abbrechen</UButton>
|
||||
<UButton type="submit" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">In Dateien ablegen</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
|
||||
<template #body>
|
||||
<div class="space-y-5">
|
||||
|
||||
@@ -19,7 +19,7 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
let mount
|
||||
let unmount
|
||||
const context = vm.createContext({
|
||||
require, exports: {}, URL,
|
||||
require, exports: {}, URL, AbortController,
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
useAuthStore: () => auth,
|
||||
useRuntimeConfig: () => ({ public: { apiBase } }),
|
||||
@@ -141,7 +141,7 @@ test('successful filing sends folder and type, removes the entry and returns fro
|
||||
assert.equal(post.url, '/api/document-inbox/scan/file')
|
||||
assert.equal(post.options.body.folder, null)
|
||||
assert.equal(post.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.run('page.value'), 1)
|
||||
assert.equal(requests.at(-1).options.query.offset, 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'remaining')
|
||||
@@ -159,7 +159,7 @@ test('filing failure preserves the document and selected values for retry', asyn
|
||||
await settle()
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(ui.run('filingError.value'), 'Ordner nicht verfügbar')
|
||||
assert.equal(ui.run('filingOpen.value'), true)
|
||||
assert.equal(ui.run('previewOpen.value'), true)
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('documents.value[0].id'), 'scan')
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
@@ -184,9 +184,98 @@ test('tenant switching discards pending folder options and filing responses', as
|
||||
await settle()
|
||||
pending.resolve(stage === 'options' ? filingOptions : { id: 'scan' })
|
||||
await operation
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.run('filingFolders.value.length'), 0)
|
||||
assert.equal(ui.run('filingTypes.value.length'), 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'tenant-2')
|
||||
}
|
||||
})
|
||||
|
||||
const aiSuggestion = { folderId: 'folder', typeId: 'invoice', confidence: 'high', reason: 'Rechnung erkannt.' }
|
||||
|
||||
test('AI preselection only files the document after confirmation and reuses its preview result', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(ui.run('filingFolder.value'), 'folder')
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
|
||||
ui.run('previewOpen.value = false')
|
||||
await settle()
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 1)
|
||||
await ui.run('fileDocument()')
|
||||
const save = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(save.options.body.folder, 'folder')
|
||||
assert.equal(save.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
})
|
||||
|
||||
test('late AI results preserve manual choices, and can still be explicitly applied', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => url.endsWith('/suggestion') ? pending.promise : Promise.resolve(filingOptions))
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('filingFolder.value'), 'root')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
ui.run('applySuggestion()')
|
||||
assert.equal(ui.run('filingFolder.value'), 'folder')
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
})
|
||||
|
||||
test('closing, changing documents or switching tenant cancels and discards pending AI responses', async t => {
|
||||
for (const action of ['close', 'document', 'tenant', 'unmount']) {
|
||||
const pending = deferred()
|
||||
const signals = []
|
||||
const ui = setup((url, options) => {
|
||||
if (url.endsWith('/suggestion')) { signals.push(options.signal); return pending.promise }
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve(filingOptions)
|
||||
return Promise.resolve({ documents: [], total: 0 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
if (action === 'close') ui.run('previewOpen.value = false')
|
||||
if (action === 'tenant') ui.auth.activeTenant = '2'
|
||||
if (action === 'document') await ui.run('openPreview({ id: "next", name: "next.pdf" })')
|
||||
if (action === 'unmount') ui.close()
|
||||
await settle()
|
||||
assert.equal(signals[0].aborted, true)
|
||||
// Keep the replacement document's request separate from the stale first result.
|
||||
if (action === 'document') ui.run('previewOpen.value = false')
|
||||
await settle()
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.run('suggestionCache.size'), 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('AI failures leave manual filing available and optional folder defaults do not overwrite the AI type', async t => {
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/suggestion')) throw { data: { message: 'KI nicht verfügbar' } }
|
||||
return filingOptions
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestionError.value'), 'KI nicht verfügbar')
|
||||
ui.run('filingType.value = "other"')
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
ui.run('filingFolders.value[1].standardFiletypeIsOptional = true; suggestion.value = { folderId: "folder", typeId: "other" }; applySuggestion()')
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user