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

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