feat(documents): suggest inbox folder and type with AI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s

This commit is contained in:
2026-10-01 18:11:41 +02:00
parent 07adca417e
commit 23d49f7186
10 changed files with 597 additions and 109 deletions

View File

@@ -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')
})