feat(documents): allow archiving inbox documents
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 5m56s

This commit is contained in:
2026-10-02 17:12:05 +02:00
parent 09a002d600
commit 1885bfc521
6 changed files with 243 additions and 13 deletions

View File

@@ -184,6 +184,29 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
if (!file) throw scannerError(404, "Dokument nicht gefunden.") if (!file) throw scannerError(404, "Dokument nicht gefunden.")
return currentAssignments(db, tenantId, file) return currentAssignments(db, tenantId, file)
} }
const archiveDocument = async (tenantId: number, userId: string, fileId: string) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
// Use the same lock order as filing so both actions cannot succeed concurrently.
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
const [file] = await tx.select({ archived: files.archived }).from(files)
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId))).for("update")
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
if (!file.archived) {
await tx.update(files).set({ archived: true, updatedAt: new Date(), updatedBy: userId })
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
}
// Keep the receipt for scanner retries; invalidate any analysis already in flight.
await tx.update(scannerInboxDocuments).set({ suggestionClaim: null,
suggestionStatus: sql`CASE WHEN ${scannerInboxDocuments.suggestionStatus} = 'processing' THEN 'pending' ELSE ${scannerInboxDocuments.suggestionStatus} END` })
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
return { id: fileId, archived: true }
})
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => { const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants) const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share") .where(eq(tenants.id, tenantId)).for("share")
@@ -277,7 +300,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
} }
} }
return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion, return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion,
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), fileDocument, probe, accept } getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept }
} }
export type ScannerInboxService = ReturnType<typeof createScannerInboxService> export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -41,6 +41,9 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", { server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } }, schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id))) }, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id)))
server.post<{ Params: { id: string } }>("/document-inbox/:id/archive", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async req => service.archiveDocument(req.user.tenant_id!, req.user.user_id, req.params.id))
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string; assignments?: FileAssignments } }>("/document-inbox/:id/file", { server.post<{ Params: { id: string }; Body: { folder: string | null; type: string; assignments?: FileAssignments } }>("/document-inbox/:id/file", {
schema: { schema: {
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },

View File

@@ -527,6 +527,61 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
assert.equal((await fileInto(id)).statusCode, 404) assert.equal((await fileInto(id)).statusCode, 404)
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id]) await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
}) })
await t.test("archiving is tenant scoped, preserves the original and invalidates an ongoing analysis", async () => {
assert.equal((await send("archive.pdf")).statusCode, 201)
const id = (await service.list(1, 50, 0, "archive.pdf")).documents[0].id
const archive = (fileId = id, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${fileId}/archive`, headers })
assert.equal((await archive(id, { "x-test-tenant": "2" })).statusCode, 404)
assert.equal((await archive(randomUUID())).statusCode, 404)
assert.equal((await archive("invalid")).statusCode, 400)
const filedId = (await pool.query("SELECT file_id FROM scanner_inbox_documents WHERE tenant_id=1 AND remote_filename='scan.pdf'")).rows[0].file_id
assert.equal((await archive(filedId)).statusCode, 409)
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await archive()).statusCode, 423)
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
assert.equal((await archive()).statusCode, 403)
await pool.query("DELETE FROM auth_profiles")
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
const countBefore = (await service.openCount(1)).total
const storedBefore = stored.size
// Run this document first, then archive it while its provider response is still pending.
await pool.query("UPDATE scanner_inbox_documents SET received_at=now()-interval '1 day' WHERE file_id=$1", [id])
let finish!: () => void
analysisGate = new Promise(resolve => { finish = resolve })
const started = new Promise<void>(resolve => { analysisStarted = resolve })
const pending = service.processNextSuggestion()
await started
assert.equal(analyses.at(-1).document.name, "archive.pdf")
const result = await archive(id, { "x-test-admin": "false" })
finish()
await pending
analysisGate = undefined
analysisStarted = undefined
assert.equal(result.statusCode, 200, result.body)
assert.deepEqual(result.json(), { id, archived: true })
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
assert.equal(row.archived, true)
assert.equal(row.updated_by, userId)
assert.ok(row.updated_at)
assert.equal(row.path, before.path)
assert.equal(row.folder, before.folder)
assert.equal(row.type, before.type)
assert.deepEqual(stored.get(row.path), pdf)
const receipt = (await pool.query("SELECT filed_at, suggestion_claim, suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [id])).rows[0]
assert.deepEqual(receipt, { filed_at: null, suggestion_claim: null, suggestion_status: "pending" })
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
assert.equal((await service.openCount(1)).total, countBefore - 1)
assert.equal((await suggest(id)).statusCode, 404)
assert.equal((await fileInto(id)).statusCode, 404)
assert.equal((await archive()).statusCode, 200, "repeated requests remain successful")
assert.equal((await pool.query("SELECT updated_at FROM files WHERE id=$1", [id])).rows[0].updated_at.getTime(), row.updated_at.getTime())
assert.equal((await send("archive.pdf")).statusCode, 204)
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
assert.equal(stored.size, storedBefore)
while (await service.processNextSuggestion()) { /* drain the remaining fixtures */ }
assert.equal(analyses.filter(item => item.document.name === "archive.pdf").length, 1)
})
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => { await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1") await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await send("maintenance.pdf")).statusCode, 423) assert.equal((await send("maintenance.pdf")).statusCode, 423)

View File

@@ -12,7 +12,13 @@ Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scan
Nur aktive Ordner, Typen und Zuordnungsziele desselben Mandanten sind zulässig. Mitarbeiter müssen aktiv und interne Benutzer sein. Bei einem Fehler bleibt das Dokument im Eingang; auch die übrigen Änderungen werden nicht teilweise gespeichert. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag oder Analyseauftrag anzulegen. Nur aktive Ordner, Typen und Zuordnungsziele desselben Mandanten sind zulässig. Mitarbeiter müssen aktiv und interne Benutzer sein. Bei einem Fehler bleibt das Dokument im Eingang; auch die übrigen Änderungen werden nicht teilweise gespeichert. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag oder Analyseauftrag anzulegen.
### KI-Vorschläge ## Archivieren
**Archivieren** steht sowohl in jeder Listenzeile als auch im Vorschaufenster zur Verfügung. Nach Bestätigung wird die bestehende Datei als archiviert markiert, ohne sie zu löschen. Ordner und Dokumenttyp müssen dafür nicht ausgewählt sein; ungespeicherte Zuordnungen aus der Vorschau werden nicht übernommen. Das Dokument verschwindet aus der Ablage und der Menüzähler wird sofort aktualisiert. Bei einem Fehler bleibt es sichtbar und der Vorgang kann erneut versucht werden.
Das Original und sein Empfangseintrag bleiben erhalten. Eine identische Wiederholung durch den Scanner aktiviert die Datei nicht erneut. Wartende KI-Aufträge werden für archivierte Dateien übersprungen und Ergebnisse bereits laufender Analysen verworfen. Die Änderung hält Zeitpunkt und Benutzer fest. Bereits unter Dateien abgelegte Dokumente können nicht mehr über die Ablage archiviert werden.
## KI-Vorschläge
Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen. Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen.
@@ -49,6 +55,7 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich. - Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
- `GET /api/document-inbox/count` liefert `{ "total": 3 }` für den aktiven Mandanten. `GET /api/document-inbox/filing-options` liefert verfügbare Ordner, Typen und `assignmentKinds`. `GET /api/document-inbox/assignment-options?kind=project&search=Beispiel` liefert bis zu 100 passende Ziele mit `id` und `name`; `GET /api/document-inbox/:id/assignments` die bereits bestehenden Verknüpfungen des Dokuments. - `GET /api/document-inbox/count` liefert `{ "total": 3 }` für den aktiven Mandanten. `GET /api/document-inbox/filing-options` liefert verfügbare Ordner, Typen und `assignmentKinds`. `GET /api/document-inbox/assignment-options?kind=project&search=Beispiel` liefert bis zu 100 passende Ziele mit `id` und `name`; `GET /api/document-inbox/:id/assignments` die bereits bestehenden Verknüpfungen des Dokuments.
- `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>", "assignments": { "project": 123, "customer": 456 } }` klassifiziert das Original, speichert die optionalen Verknüpfungen und markiert den Eingang atomar als abgelegt. Weggelassene Zuordnungen bleiben erhalten; JSON-`null` entfernt eine vorhandene Zuordnung. Die Schlüssel entsprechen den Dateifeldern; `check` und `authProfile` verwenden UUIDs, die übrigen Bereiche numerische IDs. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer. - `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>", "assignments": { "project": 123, "customer": 456 } }` klassifiziert das Original, speichert die optionalen Verknüpfungen und markiert den Eingang atomar als abgelegt. Weggelassene Zuordnungen bleiben erhalten; JSON-`null` entfernt eine vorhandene Zuordnung. Die Schlüssel entsprechen den Dateifeldern; `check` und `authProfile` verwenden UUIDs, die übrigen Bereiche numerische IDs. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
- `POST /api/document-inbox/:id/archive` archiviert eine Eingangsdatei des aktiven Mandanten ohne weitere Pflichtangaben und antwortet mit `{ "id": "<Datei-UUID>", "archived": true }`. Wiederholte Aufrufe sind erfolgreich, ohne den ursprünglichen Archivierungszeitpunkt zu ändern. Bereits abgelegte Dokumente ergeben `409`, fremde oder nicht im Eingang registrierte Dateien `404` und Mandantenwartung `423`. Archivieren und Ablegen sind gegeneinander abgesichert.
- `GET /api/document-inbox/:id/suggestion` liest den gespeicherten Status `pending`, `processing`, `ready` oder `failed` und liefert bei Erfolg beispielsweise `{ "status": "ready", "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }`. Ohne passende Zuordnung ist `suggestion` gleich `null` und `message` erklärt den Grund. `POST /api/document-inbox/:id/suggestion` fordert eine erneute Vorbereitung an und antwortet mit `202` und demselben Statusformat, ohne auf die Analyse zu warten. Ein bereits wartender oder laufender Auftrag wird nicht dupliziert. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind zugänglich. Diese Anfragen legen nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich. - `GET /api/document-inbox/:id/suggestion` liest den gespeicherten Status `pending`, `processing`, `ready` oder `failed` und liefert bei Erfolg beispielsweise `{ "status": "ready", "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }`. Ohne passende Zuordnung ist `suggestion` gleich `null` und `message` erklärt den Grund. `POST /api/document-inbox/:id/suggestion` fordert eine erneute Vorbereitung an und antwortet mit `202` und demselben Statusformat, ohne auf die Analyse zu warten. Ein bereits wartender oder laufender Auftrag wird nicht dupliziert. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind zugänglich. Diese Anfragen legen nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich.
## Lokaler Funktionstest ## Lokaler Funktionstest

View File

@@ -36,6 +36,12 @@ const previewDocument = ref<InboxDocument | null>(null)
const filingDocument = ref<InboxDocument | null>(null) const filingDocument = ref<InboxDocument | null>(null)
const filingLoading = ref(false) const filingLoading = ref(false)
const filingSaving = ref(false) const filingSaving = ref(false)
const archiveOpen = ref(false)
const archiveTarget = ref<InboxDocument | null>(null)
const archiveSaving = ref(false)
const archiveError = ref("")
let archiveVersion = 0
const documentBusy = computed(() => filingSaving.value || archiveSaving.value)
const filingError = ref("") const filingError = ref("")
const filingFolders = ref<FilingFolder[]>([]) const filingFolders = ref<FilingFolder[]>([])
const filingTypes = ref<FilingType[]>([]) const filingTypes = ref<FilingType[]>([])
@@ -80,7 +86,7 @@ const folderOptions = computed(() => {
return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value
.map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))] .map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))]
}) })
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !documentBusy.value
&& (filingFolder.value === "root" || selectedFolder.value) && (filingFolder.value === "root" || selectedFolder.value)
&& filingTypes.value.some(type => type.id === filingType.value) && filingTypes.value.some(type => type.id === filingType.value)
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype))) && (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
@@ -225,6 +231,7 @@ const removeAssignment = (kind: string) => {
const openPreview = (entry: InboxDocument) => openFiling(entry) const openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => { const openFiling = async (entry: InboxDocument) => {
if (disposed || documentBusy.value) return
suggestionVersion++ suggestionVersion++
clearTimeout(suggestionTimer) clearTimeout(suggestionTimer)
suggestionController?.abort() suggestionController?.abort()
@@ -278,6 +285,44 @@ const openFiling = async (entry: InboxDocument) => {
} }
} }
const removeInboxEntry = (id: string) => {
// Discard older list responses that still contain the completed document.
requestVersion++
clearTimeout(pollTimer)
if (documents.value.some(document => document.id === id)) total.value = Math.max(0, total.value - 1)
documents.value = documents.value.filter(document => document.id !== id)
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
void inboxCount.refresh()
if (previewDocument.value?.id === id) previewOpen.value = false
}
const openArchive = (entry: InboxDocument) => {
if (disposed || documentBusy.value || !auth.activeTenant) return
archiveTarget.value = entry
archiveError.value = ""
archiveOpen.value = true
}
const archiveDocument = async () => {
const entry = archiveTarget.value
if (!entry || !archiveOpen.value || disposed || documentBusy.value || !auth.activeTenant) return
const version = ++archiveVersion
archiveSaving.value = true
archiveError.value = ""
try {
await $api(`/api/document-inbox/${entry.id}/archive`, { method: "POST" })
if (disposed || version !== archiveVersion) return
removeInboxEntry(entry.id)
archiveOpen.value = false
toast.add({ title: "Dokument archiviert", description: `${entry.name} wurde aus der Ablage entfernt.`, color: "success" })
await load(true)
} catch (cause: any) {
if (!disposed && version === archiveVersion) archiveError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht archiviert werden."
} finally {
if (version === archiveVersion) archiveSaving.value = false
}
}
const fileDocument = async () => { const fileDocument = async () => {
if (!canFile.value) return if (!canFile.value) return
const entry = filingDocument.value! const entry = filingDocument.value!
@@ -288,14 +333,7 @@ const fileDocument = async () => {
try { try {
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } }) await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } })
if (disposed || version !== filingVersion) return if (disposed || version !== filingVersion) return
// Cancel pending polling responses before removing the successfully filed entry. removeInboxEntry(entry.id)
requestVersion++
clearTimeout(pollTimer)
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)))
void inboxCount.refresh()
previewOpen.value = false
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" }) toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
await load(true) await load(true)
} catch (cause: any) { } catch (cause: any) {
@@ -429,7 +467,15 @@ watch(previewOpen, open => {
filingLoading.value = false filingLoading.value = false
} }
}) })
watch(archiveOpen, open => {
if (!open) { archiveTarget.value = null; archiveError.value = "" }
})
watch(() => auth.activeTenant, () => { watch(() => auth.activeTenant, () => {
archiveVersion++
archiveOpen.value = false
archiveTarget.value = null
archiveError.value = ""
archiveSaving.value = false
requestVersion++ requestVersion++
setupVersion++ setupVersion++
filingVersion++ filingVersion++
@@ -455,6 +501,7 @@ watch(() => auth.activeTenant, () => {
onMounted(() => { origin.value = window.location.origin; load() }) onMounted(() => { origin.value = window.location.origin; load() })
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true disposed = true
archiveVersion++
requestVersion++ requestVersion++
setupVersion++ setupVersion++
filingVersion++ filingVersion++
@@ -507,7 +554,12 @@ onBeforeUnmount(() => {
</td> </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">{{ 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="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> <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`" :disabled="documentBusy" @click="openFiling(entry)">Ablegen</UButton>
<UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" :disabled="documentBusy" @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)" />
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :aria-label="`${entry.name} archivieren`" :disabled="documentBusy" @click="openArchive(entry)">Archivieren</UButton>
</div></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -522,7 +574,7 @@ onBeforeUnmount(() => {
</div> </div>
</UDashboardPanelContent> </UDashboardPanelContent>
<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' }"> <UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!documentBusy" :close="!documentBusy" :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> <template #body>
<div class="lg:flex lg:h-full lg:min-h-0"> <div class="lg:flex lg:h-full lg:min-h-0">
<div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1"> <div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1">
@@ -597,6 +649,7 @@ onBeforeUnmount(() => {
</template> </template>
<template #footer> <template #footer>
<template v-if="previewDocument"> <template v-if="previewDocument">
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :disabled="documentBusy" @click="openArchive(previewDocument)">Archivieren</UButton>
<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-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 icon="i-heroicons-arrow-down-tray" color="neutral" variant="outline" @click="fileAction(previewDocument, true)">Herunterladen</UButton>
<UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton> <UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton>
@@ -604,6 +657,17 @@ onBeforeUnmount(() => {
</template> </template>
</UModal> </UModal>
<UModal v-model:open="archiveOpen" title="Dokument archivieren" description="Das Dokument verschwindet aus der Ablage. Die Originaldatei bleibt im Archiv erhalten." :dismissible="!archiveSaving" :close="!archiveSaving">
<template #body>
<p class="break-all font-medium">{{ archiveTarget?.name }}</p>
<UAlert v-if="archiveError" class="mt-4" :title="archiveError" color="error" variant="subtle" />
</template>
<template #footer>
<UButton color="neutral" variant="outline" :disabled="archiveSaving" @click="archiveOpen = false">Abbrechen</UButton>
<UButton icon="i-heroicons-archive-box" :loading="archiveSaving" :disabled="!archiveTarget || filingSaving" @click="archiveDocument">Archivieren</UButton>
</template>
</UModal>
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten."> <UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
<template #body> <template #body>
<div class="space-y-5"> <div class="space-y-5">

View File

@@ -167,6 +167,84 @@ test('filing failure preserves the document and selected values for retry', asyn
assert.equal(ui.run('canFile.value'), true) assert.equal(ui.run('canFile.value'), true)
}) })
test('archiving needs no classification, waits for confirmation and updates the list, preview and badge', async t => {
const pending = deferred()
const staleList = deferred()
const requests = []
let listings = 0
const ui = setup((url, options) => {
requests.push({ url, options })
if (url.endsWith('/archive')) return pending.promise
if (url.endsWith('/filing-options')) return Promise.resolve({ folders: [], types: [] })
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'pending', suggestion: null })
if (++listings === 1) return staleList.promise
return Promise.resolve({ documents: [{ id: 'remaining' }], total: 50 })
})
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 51; page.value = 2')
await ui.run('openPreview(documents.value[0])')
await settle()
assert.equal(ui.run('canFile.value'), false)
ui.run('openArchive(documents.value[0])')
assert.equal(requests.some(request => request.url.endsWith('/archive')), false)
const listing = ui.run('load(true)')
const saving = ui.run('archiveDocument()')
await ui.run('archiveDocument()')
await ui.run('fileDocument()')
assert.equal(requests.filter(request => request.url.endsWith('/archive')).length, 1)
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
assert.equal(ui.run('documents.value.length'), 1)
pending.resolve({ id: 'scan', archived: true })
await saving
staleList.resolve({ documents: [{ id: 'scan' }], total: 51 })
await listing
assert.equal(requests.find(request => request.url.endsWith('/archive')).options.method, 'POST')
assert.equal(ui.run('archiveOpen.value'), false)
assert.equal(ui.run('previewOpen.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'remaining')
assert.equal(ui.run('total.value'), 50)
assert.equal(ui.run('page.value'), 1)
assert.equal(ui.countRefreshes(), 1)
})
test('archive cancellation and failure preserve the document and allow a retry from the list', async t => {
let calls = 0
const ui = setup(async () => { calls++; throw { data: { message: 'Archivieren fehlgeschlagen' } } })
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
await settle()
ui.run('archiveOpen.value = false')
await settle()
assert.equal(ui.run('archiveTarget.value'), null)
assert.equal(calls, 0)
ui.run('openArchive(documents.value[0])')
await ui.run('archiveDocument()')
assert.equal(ui.run('archiveError.value'), 'Archivieren fehlgeschlagen')
assert.equal(ui.run('archiveOpen.value'), true)
assert.equal(ui.run('archiveSaving.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'scan')
assert.equal(ui.run('total.value'), 1)
assert.equal(ui.countRefreshes(), 0)
})
test('archive responses from a previous tenant or unmounted page cannot update the current view', async t => {
for (const action of ['tenant', 'unmount']) {
const pending = deferred()
const ui = setup(url => url.endsWith('/archive') ? pending.promise : Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 }))
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
const saving = ui.run('archiveDocument()')
if (action === 'tenant') ui.auth.activeTenant = '2'
else ui.close()
await settle()
pending.resolve({ id: 'scan', archived: true })
await saving
assert.equal(ui.run('documents.value[0].id'), action === 'tenant' ? 'tenant-2' : 'scan')
assert.equal(ui.countRefreshes(), 0)
if (action === 'tenant') assert.equal(ui.run('archiveOpen.value'), false)
}
})
test('tenant switching discards pending folder options and filing responses', async t => { test('tenant switching discards pending folder options and filing responses', async t => {
for (const stage of ['options', 'save']) { for (const stage of ['options', 'save']) {
const pending = deferred() const pending = deferred()