feat(documents): prepare inbox suggestions and add entity assignments
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m58s

This commit is contained in:
2026-10-02 16:09:04 +02:00
parent 64299843e2
commit d7382e7319
15 changed files with 707 additions and 72 deletions

View File

@@ -8,6 +8,7 @@ const props = defineProps({
const route = useRoute()
const auth = useAuthStore()
const { count: inboxCount } = useDocumentInboxCount()
const { has } = usePermission()
const tenantExtraModules = computed(() => {
const modules = auth.activeTenantData?.extraModules
@@ -55,7 +56,8 @@ const links = computed(() => {
featureEnabled("files") ? {
label: "Dokumentenablage",
to: "/organisation/dokumentenablage",
icon: "i-heroicons-inbox-arrow-down"
icon: "i-heroicons-inbox-arrow-down",
badge: inboxCount.value > 0 ? String(inboxCount.value) : undefined,
} : null,
featureEnabled("files") ? {
label: "Dateien",

View File

@@ -0,0 +1,31 @@
import { createSharedComposable } from "@vueuse/core"
export const useDocumentInboxCount = createSharedComposable(() => {
const auth = useAuthStore()
const { $api } = useNuxtApp()
const count = ref(0)
const scope = computed(() => auth.user?.id && auth.activeTenant && !auth.profile?.customer_for_portal && auth.activeTenantData?.features?.files !== false
? `${auth.user.id}:${auth.activeTenant}` : "")
let version = 0
let disposed = false
let controller: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const refresh = async () => {
const request = ++version
clearTimeout(timer)
controller?.abort()
if (!scope.value || disposed) { count.value = 0; return }
controller = new AbortController()
try {
const result = await $api<{ total: number }>("/api/document-inbox/count", { signal: controller.signal })
if (!disposed && request === version) count.value = Math.max(0, Number(result.total) || 0)
} catch {
if (request === version) count.value = 0
} finally {
if (!disposed && request === version && scope.value) timer = setTimeout(refresh, 10000)
}
}
watch(scope, () => { count.value = 0; void refresh() }, { immediate: true, flush: "sync" })
onScopeDispose(() => { disposed = true; version++; controller?.abort(); clearTimeout(timer) })
return { count, refresh }
})

View File

@@ -1,18 +1,21 @@
<script setup lang="ts">
import dayjs from "dayjs"
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string; suggestionStatus?: string }
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 }
type SuggestionResult = { status: "pending" | "processing" | "ready" | "failed"; suggestion: FilingSuggestion | null; message?: string }
type AssignmentKind = { value: string; label: string }
type Assignment = { kind: string; id: string; name: string; available?: boolean }
const { $api } = useNuxtApp()
const auth = useAuthStore()
const config = useRuntimeConfig()
const fileActions = useFiles()
const toast = useToast()
const inboxCount = useDocumentInboxCount()
const documents = ref<InboxDocument[]>([])
const total = ref(0)
const page = ref(1)
@@ -43,9 +46,22 @@ const suggestionLoading = ref(false)
const suggestionError = ref("")
const suggestionMessage = ref("")
const selectionTouched = ref(false)
const suggestionCache = new Map<string, SuggestionResult>()
const suggestionStatus = ref("")
let suggestionTimer: ReturnType<typeof setTimeout> | undefined
let suggestionController: AbortController | undefined
let suggestionVersion = 0
const assignmentKinds = ref<AssignmentKind[]>([])
const assignments = ref<Assignment[]>([])
const assignmentChanges = ref<Record<string, string | null>>({})
const assignmentKind = ref("project")
const assignmentSearch = ref("")
const assignmentOptions = ref<{ id: string; name: string }[]>([])
const assignmentId = ref<string | null>(null)
const assignmentLoading = ref(false)
const assignmentError = ref("")
let assignmentVersion = 0
let assignmentTimer: ReturnType<typeof setTimeout> | undefined
let assignmentController: AbortController | undefined
const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value))
const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional))
const folderOptions = computed(() => {
@@ -125,8 +141,14 @@ const applySuggestion = () => {
const loadFilingSuggestion = async (refresh = false) => {
const entry = filingDocument.value
if (!entry || filingLoading.value || filingSaving.value || suggestionLoading.value) return
if (!entry || disposed || filingLoading.value || suggestionLoading.value) return
if (filingSaving.value) {
clearTimeout(suggestionTimer)
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
return
}
const version = ++suggestionVersion
clearTimeout(suggestionTimer)
const filingRequest = filingVersion
suggestionController?.abort()
suggestionController = new AbortController()
@@ -134,18 +156,28 @@ const loadFilingSuggestion = async (refresh = false) => {
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,
const result = await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
method: refresh ? "POST" : "GET", signal: suggestionController.signal,
})
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
suggestionStatus.value = result.status
if (["pending", "processing"].includes(result.status)) {
suggestion.value = null
suggestionMessage.value = result.status === "pending" ? "KI-Vorschlag wird im Hintergrund vorbereitet …" : "Der Dokumentinhalt wird geprüft …"
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
return
}
if (result.status === "failed") {
suggestion.value = null
suggestionError.value = result.message || "Die KI-Vorbereitung ist fehlgeschlagen. Bitte erneut prüfen oder manuell zuordnen."
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) {
@@ -156,15 +188,63 @@ const loadFilingSuggestion = async (refresh = false) => {
}
}
const loadAssignmentOptions = async () => {
if (!previewOpen.value || !assignmentKinds.value.some(kind => kind.value === assignmentKind.value)) return
const version = ++assignmentVersion
assignmentController?.abort()
assignmentController = new AbortController()
assignmentLoading.value = true
assignmentError.value = ""
assignmentId.value = null
assignmentOptions.value = []
try {
const options = await $api<{ id: string; name: string }[]>("/api/document-inbox/assignment-options", {
query: { kind: assignmentKind.value, search: assignmentSearch.value.trim() }, signal: assignmentController.signal,
})
if (!disposed && version === assignmentVersion) assignmentOptions.value = options
} catch {
if (!disposed && version === assignmentVersion) assignmentError.value = "Zuordnungen konnten nicht geladen werden. Bitte erneut versuchen."
} finally {
if (version === assignmentVersion) assignmentLoading.value = false
}
}
const addAssignment = () => {
if (filingSaving.value || assignmentLoading.value) return
const entry = assignmentOptions.value.find(option => option.id === assignmentId.value)
if (!entry) return
assignments.value = [...assignments.value.filter(item => item.kind !== assignmentKind.value), { kind: assignmentKind.value, ...entry }]
assignmentChanges.value[assignmentKind.value] = entry.id
assignmentId.value = null
}
const removeAssignment = (kind: string) => {
if (filingSaving.value) return
assignments.value = assignments.value.filter(item => item.kind !== kind)
assignmentChanges.value[kind] = null
}
const openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => {
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.value = ""
suggestionStatus.value = ""
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
assignments.value = []
assignmentChanges.value = {}
assignmentKinds.value = []
assignmentOptions.value = []
assignmentId.value = null
assignmentLoading.value = false
assignmentKind.value = "project"
assignmentSearch.value = ""
assignmentError.value = ""
selectionTouched.value = false
previewOpen.value = true
previewDocument.value = entry
@@ -178,10 +258,16 @@ const openFiling = async (entry: InboxDocument) => {
filingSaving.value = false
const version = ++filingVersion
try {
const result = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options")
const [result, current] = await Promise.all([
$api<{ folders: FilingFolder[]; types: FilingType[]; assignmentKinds: AssignmentKind[] }>("/api/document-inbox/filing-options"),
$api<Assignment[]>(`/api/document-inbox/${entry.id}/assignments`),
])
if (disposed || version !== filingVersion) return
filingFolders.value = result.folders
filingTypes.value = result.types
assignmentKinds.value = result.assignmentKinds || []
assignments.value = current
void loadAssignmentOptions()
} catch (cause: any) {
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Ordner und Dokumenttypen konnten nicht geladen werden."
} finally {
@@ -200,7 +286,7 @@ const fileDocument = async () => {
filingSaving.value = true
filingError.value = ""
try {
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value } })
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } })
if (disposed || version !== filingVersion) return
// Cancel pending polling responses before removing the successfully filed entry.
requestVersion++
@@ -208,7 +294,7 @@ 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)))
suggestionCache.delete(entry.id)
void inboxCount.refresh()
previewOpen.value = false
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
await load(true)
@@ -304,14 +390,35 @@ watch(filingFolder, () => {
const defaultType = selectedFolder.value?.standardFiletype
if (defaultType) filingType.value = defaultType
}, { flush: "sync" })
watch([assignmentKind, assignmentSearch], ([kind, search], [previousKind]) => {
// SelectMenu clears its search after choosing an item; keep that selection available for adding.
if (kind === previousKind && !search && assignmentId.value) return
assignmentVersion++
assignmentController?.abort()
assignmentId.value = null
assignmentOptions.value = []
clearTimeout(assignmentTimer)
if (previewOpen.value) assignmentTimer = setTimeout(loadAssignmentOptions, 250)
})
watch(previewOpen, open => {
if (!open) {
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.value = ""
suggestionStatus.value = ""
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
assignments.value = []
assignmentChanges.value = {}
assignmentKinds.value = []
assignmentOptions.value = []
assignmentLoading.value = false
assignmentError.value = ""
previewDocument.value = null
filingVersion++
filingDocument.value = null
@@ -328,7 +435,10 @@ watch(() => auth.activeTenant, () => {
filingVersion++
suggestionVersion++
suggestionController?.abort()
suggestionCache.clear()
clearTimeout(suggestionTimer)
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
previewOpen.value = false
previewDocument.value = null
clearTimeout(searchTimer)
@@ -350,7 +460,10 @@ onBeforeUnmount(() => {
filingVersion++
suggestionVersion++
suggestionController?.abort()
suggestionCache.clear()
clearTimeout(suggestionTimer)
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
clearTimeout(searchTimer)
clearTimeout(pollTimer)
})
@@ -388,7 +501,10 @@ onBeforeUnmount(() => {
</thead>
<tbody class="divide-y divide-default">
<tr v-for="entry in documents" :key="entry.id" class="hover:bg-elevated/30">
<td class="max-w-sm px-4 py-4"><button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button></td>
<td class="max-w-sm px-4 py-4">
<button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button>
<UBadge v-if="entry.suggestionStatus" class="mt-2" size="xs" variant="subtle" :color="entry.suggestionStatus === 'failed' ? 'warning' : 'neutral'">{{ entry.suggestionStatus === 'ready' ? 'KI-Prüfung abgeschlossen' : entry.suggestionStatus === 'failed' ? 'KI-Prüfung fehlgeschlagen' : 'KI wird vorbereitet' }}</UBadge>
</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }}</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ formatSize(entry.size) }}</td>
<td class="px-4 py-4"><div class="flex justify-end gap-1"><UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)">Vorschau</UButton><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
@@ -425,9 +541,9 @@ onBeforeUnmount(() => {
<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">
<div v-if="suggestionLoading || (['pending', 'processing'].includes(suggestionStatus) && !suggestionError)" class="space-y-2 text-sm text-muted">
<UProgress />
<p>Der Dokumentinhalt wird geprüft …</p>
<p>{{ suggestionMessage || 'Vorbereiteten KI-Vorschlag laden …' }}</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">
@@ -439,7 +555,7 @@ onBeforeUnmount(() => {
<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>
<p v-else-if="suggestionMessage && !['pending', 'processing'].includes(suggestionStatus)" 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" />
@@ -452,8 +568,29 @@ onBeforeUnmount(() => {
</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" />
<USeparator label="Zuordnungen" />
<div v-if="assignments.length" class="space-y-2">
<div v-for="assignment in assignments" :key="assignment.kind" class="flex items-center justify-between gap-2 rounded-lg border border-default p-2 text-sm">
<div class="min-w-0">
<p class="text-xs text-muted">{{ assignmentKinds.find(kind => kind.value === assignment.kind)?.label }}</p>
<p class="break-words" :class="{ 'text-warning': assignment.available === false }">{{ assignment.name }}</p>
</div>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" :aria-label="`${assignment.name} entfernen`" :disabled="filingSaving" @click="removeAssignment(assignment.kind)" />
</div>
</div>
<UFormField label="Bereich zuordnen">
<USelectMenu v-model="assignmentKind" :items="assignmentKinds" value-key="value" label-key="label" :disabled="filingSaving" class="w-full" />
</UFormField>
<UFormField label="Eintrag auswählen">
<USelectMenu v-model="assignmentId" v-model:search-term="assignmentSearch" :items="assignmentOptions" value-key="id" label-key="name" placeholder="Eintrag suchen …" :search-input="{ placeholder: 'Name suchen …' }" ignore-filter :loading="assignmentLoading" :disabled="filingSaving" class="w-full" />
</UFormField>
<div v-if="assignmentError" class="text-sm text-error" role="status">
{{ assignmentError }}
<UButton size="xs" color="neutral" variant="ghost" @click="loadAssignmentOptions">Erneut versuchen</UButton>
</div>
<UButton icon="i-heroicons-link" variant="soft" :disabled="!assignmentId || assignmentLoading || filingSaving" @click="addAssignment">Zuordnung hinzufügen</UButton>
</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>
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. „Übernehmen und ablegen“ speichert Ordner, Typ und Zuordnungen gemeinsam und entfernt das Dokument aus dem Eingang.</p>
</form>
</aside>
</div>

View File

@@ -0,0 +1,62 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const ts = require('../../backend/node_modules/typescript')
const settle = () => new Promise(setImmediate)
function setup(api) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: null, activeTenantData: {} })
const timers = new Map()
let nextTimer = 0
const scope = vue.effectScope()
const context = vm.createContext({
exports: {}, AbortController,
require: name => name === '@vueuse/core' ? { createSharedComposable: fn => fn } : require(name),
ref: vue.ref, computed: vue.computed, watch: vue.watch, onScopeDispose: vue.onScopeDispose,
useAuthStore: () => auth, useNuxtApp: () => ({ $api: api }),
setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer }, clearTimeout: id => timers.delete(id),
})
const source = fs.readFileSync(`${__dirname}/../composables/useDocumentInboxCount.ts`, 'utf8')
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, context)
const counter = scope.run(() => context.exports.useDocumentInboxCount())
return { auth, counter, timers, close: () => scope.stop() }
}
test('menu count refreshes after filing and cancels its timer when navigation is unmounted', async () => {
let count = 3
const ui = setup(async () => ({ total: count }))
await settle()
assert.equal(ui.counter.count.value, 3)
assert.equal(ui.timers.size, 1)
count = 2
await ui.counter.refresh()
assert.equal(ui.counter.count.value, 2)
assert.equal(ui.timers.size, 1)
ui.close()
assert.equal(ui.timers.size, 0)
})
test('previous tenant responses cannot replace the current badge; portal and logout clear it', async t => {
let finish
let requests = 0
const ui = setup(() => ++requests === 1 ? new Promise(resolve => { finish = resolve }) : Promise.resolve({ total: 2 }))
t.after(ui.close)
ui.auth.activeTenant = '2'
assert.equal(ui.counter.count.value, 0)
await settle()
finish({ total: 999 })
await settle()
assert.equal(ui.counter.count.value, 2)
ui.auth.profile = { customer_for_portal: 15 }
await settle()
assert.equal(ui.counter.count.value, 0)
assert.equal(ui.timers.size, 0)
ui.auth.profile = null
await settle()
assert.equal(ui.counter.count.value, 2)
ui.auth.user = null
await settle()
assert.equal(ui.counter.count.value, 0)
})

View File

@@ -18,13 +18,15 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
let timerId = 0
let mount
let unmount
let countRefreshes = 0
const context = vm.createContext({
require, exports: {}, URL, AbortController,
ref: vue.ref, computed: vue.computed, watch: vue.watch,
useAuthStore: () => auth,
useDocumentInboxCount: () => ({ refresh: async () => { countRefreshes++ } }),
useRuntimeConfig: () => ({ public: { apiBase } }),
useFiles: () => ({}), useToast: () => ({ add() {} }),
useNuxtApp: () => ({ $api: api }),
useNuxtApp: () => ({ $api: (url, options) => /\/assignments$/.test(url) ? Promise.resolve([]) : api(url, options) }),
window: { location: { origin: 'https://fedeo.example' } },
onMounted: handler => { mount = handler },
onBeforeUnmount: handler => { unmount = handler },
@@ -33,7 +35,7 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
})
const source = fs.readFileSync(`${__dirname}/../pages/organisation/dokumentenablage.vue`, 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, context)
return { auth, timers, mount, close: () => unmount(), run: code => vm.runInContext(code, context) }
return { auth, timers, mount, countRefreshes: () => countRefreshes, close: () => unmount(), run: code => vm.runInContext(code, context) }
}
test('scanner setup preserves absolute and relative backend prefixes', async t => {
@@ -193,12 +195,12 @@ test('tenant switching discards pending folder options and filing responses', as
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 => {
test('AI preselection only files the document after confirmation and reads the prepared result without triggering AI', 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('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
if (url.endsWith('/file')) return { id: 'scan' }
return { documents: [], total: 0 }
})
@@ -213,12 +215,14 @@ test('AI preselection only files the document after confirmation and reuses its
await settle()
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
await settle()
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 1)
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 2)
assert.ok(requests.filter(request => request.url.endsWith('/suggestion')).every(request => request.options.method === 'GET'))
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)
assert.equal(ui.countRefreshes(), 1)
})
test('late AI results preserve manual choices, and can still be explicitly applied', async t => {
@@ -227,7 +231,7 @@ test('late AI results preserve manual choices, and can still be explicitly appli
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 })
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
await settle()
assert.equal(ui.run('filingFolder.value'), 'root')
assert.equal(ui.run('filingType.value'), 'other')
@@ -257,10 +261,9 @@ test('closing, changing documents or switching tenant cancels and discards pendi
// 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 })
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
await settle()
assert.equal(ui.run('suggestion.value'), null)
assert.equal(ui.run('suggestionCache.size'), 0)
}
})
@@ -279,3 +282,113 @@ test('AI failures leave manual filing available and optional folder defaults do
await settle()
assert.equal(ui.run('filingType.value'), 'other')
})
test('pending preparation is polled, preserves manual choices and only explicit refresh queues work', async t => {
const requests = []
let state = 'pending'
const ui = setup(async (url, options) => {
if (url.endsWith('/filing-options')) return filingOptions
requests.push(options.method)
return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
})
t.after(ui.close)
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
await settle()
assert.equal(ui.run('suggestionStatus.value'), 'pending')
assert.equal(ui.run('suggestion.value'), null)
assert.equal(ui.timers.size, 1)
ui.run('filingType.value = "other"; selectionTouched.value = true')
state = 'ready'
await ui.run('loadFilingSuggestion()')
assert.equal(ui.run('filingType.value'), 'other')
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
assert.equal(ui.timers.size, 0)
await ui.run('loadFilingSuggestion(true)')
assert.deepEqual(requests, ['GET', 'GET', 'POST'])
})
test('preparation polling resumes when a concurrent manual filing attempt fails', async t => {
const save = deferred()
let state = 'processing'
const ui = setup(async url => {
if (url.endsWith('/filing-options')) return filingOptions
if (url.endsWith('/suggestion')) return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
if (url.endsWith('/file')) { await save.promise; throw { data: { error: 'Bitte erneut versuchen' } } }
})
t.after(ui.close)
const tick = async () => {
assert.equal(ui.timers.size, 1)
const [id, callback] = [...ui.timers][0]
ui.timers.delete(id)
await callback()
}
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
await settle()
ui.run('filingType.value = "other"; selectionTouched.value = true')
const filing = ui.run('fileDocument()')
await tick()
assert.equal(ui.timers.size, 1, 'polling remains scheduled while filing is in progress')
save.resolve()
await filing
assert.equal(ui.run('previewOpen.value'), true)
state = 'ready'
await tick()
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
assert.equal(ui.run('filingType.value'), 'other')
assert.equal(ui.timers.size, 0)
})
test('entity assignments are submitted together with filing and search reset preserves a selected item', async t => {
const requests = []
const ui = setup(async (url, options) => {
requests.push({ url, options })
if (url.endsWith('/filing-options')) return { ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] }
if (url.endsWith('/assignment-options')) return [{ id: '11', name: 'Umbau' }]
if (url.endsWith('/suggestion')) return { status: 'ready', 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()
ui.run('assignmentSearch.value = "Umbau"')
await settle()
await ui.run('loadAssignmentOptions()')
ui.run('assignmentId.value = "11"; assignmentSearch.value = ""')
await settle()
assert.equal(ui.run('assignmentId.value'), '11')
ui.run('addAssignment()')
assert.equal(ui.run('assignments.value[0].name'), 'Umbau')
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
ui.run('assignmentKind.value = "customer"')
await settle()
ui.run('assignmentOptions.value = [{ id: "21", name: "Kunde" }]; assignmentId.value = "21"; assignmentLoading.value = false; addAssignment(); removeAssignment("project")')
await ui.run('fileDocument()')
const saved = requests.find(request => request.url.endsWith('/file'))
assert.equal(saved.options.body.assignments.project, null)
assert.equal(saved.options.body.assignments.customer, '21')
assert.equal(saved.options.body.folder, 'folder')
assert.equal(saved.options.body.type, 'invoice')
assert.equal(ui.countRefreshes(), 1)
})
test('old entity search responses are discarded on category and tenant changes', async t => {
const pending = deferred()
const ui = setup(url => {
if (url.endsWith('/filing-options')) return Promise.resolve({ ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] })
if (url.endsWith('/assignment-options')) return pending.promise
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'ready', suggestion: null })
return Promise.resolve({ documents: [], total: 0 })
})
t.after(ui.close)
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
ui.run('assignmentKind.value = "customer"')
await settle()
ui.auth.activeTenant = '2'
await settle()
pending.resolve([{ id: 'foreign', name: 'Previous tenant project' }])
await settle()
assert.equal(ui.run('assignmentOptions.value.length'), 0)
assert.equal(ui.run('assignments.value.length'), 0)
assert.equal(ui.timers.size, 1, 'only the inbox list polling remains')
})