From 052a3a844699d23e3d10df6d221be1fa199d3060 Mon Sep 17 00:00:00 2001 From: flfeders Date: Tue, 29 Sep 2026 16:48:15 +0200 Subject: [PATCH] feat(documents): file scanner inbox documents with folder and type --- .../migrations/0074_scanner_inbox_filing.sql | 1 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/scanner_inbox.ts | 1 + backend/src/modules/scanner-inbox.service.ts | 48 +++++- backend/src/routes/documentInbox.ts | 10 ++ backend/tests/scannerInbox.test.ts | 94 +++++++++++- docs/scanner-dokumentenablage.md | 14 +- .../pages/organisation/dokumentenablage.vue | 139 +++++++++++++++++- frontend/tests/document-inbox.test.cjs | 100 +++++++++++++ 9 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 backend/db/migrations/0074_scanner_inbox_filing.sql diff --git a/backend/db/migrations/0074_scanner_inbox_filing.sql b/backend/db/migrations/0074_scanner_inbox_filing.sql new file mode 100644 index 00000000..ec0c9147 --- /dev/null +++ b/backend/db/migrations/0074_scanner_inbox_filing.sql @@ -0,0 +1 @@ +ALTER TABLE "scanner_inbox_documents" ADD COLUMN "filed_at" timestamp with time zone; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index ea8ac0fe..d479c6cd 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -498,6 +498,13 @@ "when": 1790640000000, "tag": "0073_scanner_document_inbox", "breakpoints": true + }, + { + "idx": 71, + "version": "7", + "when": 1790640000001, + "tag": "0074_scanner_inbox_filing", + "breakpoints": true } ] } diff --git a/backend/db/schema/scanner_inbox.ts b/backend/db/schema/scanner_inbox.ts index f7bededa..b048e4a2 100644 --- a/backend/db/schema/scanner_inbox.ts +++ b/backend/db/schema/scanner_inbox.ts @@ -17,6 +17,7 @@ export const scannerInboxDocuments = pgTable("scanner_inbox_documents", { remoteFilename: text("remote_filename").notNull(), checksum: text("checksum").notNull(), receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(), + filedAt: timestamp("filed_at", { withTimezone: true }), }, table => ({ filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename), receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt), diff --git a/backend/src/modules/scanner-inbox.service.ts b/backend/src/modules/scanner-inbox.service.ts index 566a1a29..9daf85d1 100644 --- a/backend/src/modules/scanner-inbox.service.ts +++ b/backend/src/modules/scanner-inbox.service.ts @@ -1,8 +1,8 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto" -import { and, count, desc, eq, ilike, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm" import type { FastifyInstance } from "fastify" import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" -import { files, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema" +import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema" import { sanitizeFilename } from "../utils/filename" import { s3 } from "../utils/s3" import { secrets } from "../utils/secrets" @@ -84,6 +84,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc } const list = async (tenantId: number, limit: number, offset: number, search: string) => { const condition = and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), + isNull(scannerInboxDocuments.filedAt), eq(files.archived, false), search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined) const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt }) @@ -93,6 +94,47 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc .innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition) return { documents, total: total.value } } + const filingOptions = async (tenantId: number) => { + const [availableFolders, types] = await Promise.all([ + db.select({ id: folders.id, name: folders.name, parent: folders.parent, + standardFiletype: folders.standardFiletype, standardFiletypeIsOptional: folders.standardFiletypeIsOptional }) + .from(folders).where(and(eq(folders.tenant, tenantId), eq(folders.archived, false))).orderBy(asc(folders.name)), + db.select({ id: filetags.id, name: filetags.name }).from(filetags) + .where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)), + ]) + return { folders: availableFolders, types } + } + const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: 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.") + 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({ id: files.id }).from(files) + .where(and(eq(files.id, fileId), eq(files.tenant, tenantId), eq(files.archived, false))).for("update") + if (!file) throw scannerError(404, "Dokument nicht gefunden oder archiviert.") + if (folderId) { + const [folder] = await tx.select({ standardFiletype: folders.standardFiletype, optional: folders.standardFiletypeIsOptional }) + .from(folders).where(and(eq(folders.id, folderId), eq(folders.tenant, tenantId), eq(folders.archived, false))).for("share") + if (!folder) throw scannerError(400, "Der gewählte Ordner ist nicht verfügbar.") + if (folder.standardFiletype && !folder.optional && folder.standardFiletype !== typeId) { + throw scannerError(400, "Dieser Ordner gibt einen festen Dokumenttyp vor.") + } + } + const [type] = await tx.select({ id: filetags.id }).from(filetags) + .where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share") + if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.") + const now = new Date() + await tx.update(files).set({ folder: folderId, type: typeId, updatedAt: now, updatedBy: userId }) + .where(and(eq(files.id, fileId), eq(files.tenant, tenantId))) + // Keep the receipt so a scanner retry cannot recreate a document after filing. + await tx.update(scannerInboxDocuments).set({ filedAt: now }) + .where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId))) + return { id: fileId, folder: folderId, type: typeId } + }) const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => { const contentType = validateScannerFile(filename, body) const checksum = digest(body) @@ -136,7 +178,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc throw error } } - return { connection, credentials, disable, authenticate, find, list, accept } + return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, accept } } export type ScannerInboxService = ReturnType diff --git a/backend/src/routes/documentInbox.ts b/backend/src/routes/documentInbox.ts index ac6d79e2..265cebb2 100644 --- a/backend/src/routes/documentInbox.ts +++ b/backend/src/routes/documentInbox.ts @@ -24,6 +24,16 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio } return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "") }) + server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!)) + server.post<{ Params: { id: string }; Body: { folder: string | null; type: string } }>("/document-inbox/:id/file", { + schema: { + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: { + folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] }, + type: { type: "string", format: "uuid" }, + } }, + }, + }, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type)) server.get("/document-inbox/scanner", async req => ({ connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT, })) diff --git a/backend/tests/scannerInbox.test.ts b/backend/tests/scannerInbox.test.ts index e745ed69..4a6254b8 100644 --- a/backend/tests/scannerInbox.test.ts +++ b/backend/tests/scannerInbox.test.ts @@ -132,15 +132,20 @@ test("scanner inbox integration: migration, persistence, isolation, retries and await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, locked text); INSERT INTO tenants VALUES (1, null), (2, null); CREATE TABLE files (${columns}, CHECK (name <> 'reject.pdf')); + CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false); + CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid, + "standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false); CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`) const migrationRoot = path.resolve(__dirname, "../db/migrations") const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8")) - const entry = journal.entries.find((item: any) => item.tag === "0073_scanner_document_inbox") - assert.ok(entry) - assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when)) + const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag)) await mkdir(path.join(tempMigrations, "meta")) - await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries: [entry] })) - await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql"))) + for (const entry of entries) { + assert.ok(entry) + assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when)) + await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql"))) + } + await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries })) const db = drizzle(pool) as any await migrate(db, { migrationsFolder: tempMigrations }) await migrate(db, { migrationsFolder: tempMigrations }) @@ -219,6 +224,83 @@ test("scanner inbox integration: migration, persistence, isolation, retries and assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('failed.pdf', 'reject.pdf')")).rows[0].count, "0") assert.equal((await send("failed.pdf")).statusCode, 201) }) + const targetFolder = randomUUID() + const targetType = randomUUID() + const alternativeType = randomUUID() + const foreignFolder = randomUUID() + const foreignType = randomUUID() + const archivedFolder = randomUUID() + const archivedType = randomUUID() + await pool.query(`INSERT INTO filetags (id, tenant, name, archived) VALUES + ($1, 1, 'Rechnung', false), ($2, 1, 'Sonstiges', false), ($3, 2, 'Fremder Typ', false), ($4, 1, 'Archiviert', true)`, + [targetType, alternativeType, foreignType, archivedType]) + await pool.query(`INSERT INTO folders (id, tenant, name, "standardFiletype", "standardFiletypeIsOptional", archived) VALUES + ($1, 1, 'Rechnungen', $2, false, false), ($3, 2, 'Fremder Ordner', null, true, false), ($4, 1, 'Archiviert', null, true, true)`, + [targetFolder, targetType, foreignFolder, archivedFolder]) + const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) => + app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers }) + await t.test("filing options and destinations exclude other tenants and archived entries", async () => { + const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" }) + assert.equal(options.statusCode, 200) + assert.deepEqual(options.json().folders.map((item: any) => item.id), [targetFolder]) + assert.equal(options.json().folders[0].standardFiletypeIsOptional, false) + assert.deepEqual(options.json().types.map((item: any) => item.id).sort(), [targetType, alternativeType].sort()) + const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id + for (const [folder, type] of [[foreignFolder, targetType], [archivedFolder, targetType], [targetFolder, foreignType], + [null, archivedType], [targetFolder, alternativeType], [randomUUID(), targetType], [null, randomUUID()]]) { + assert.equal((await fileInto(id, folder, type!)).statusCode, 400) + } + assert.equal((await fileInto(id, null, targetType, { "x-test-tenant": "2" })).statusCode, 404) + assert.equal((await fileInto(randomUUID())).statusCode, 404) + for (const payload of [{ folder: null }, { type: targetType }, { folder: "invalid", type: targetType }, { folder: null, type: null }]) { + assert.equal((await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload })).statusCode, 400) + } + await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1") + assert.equal((await fileInto(id)).statusCode, 423) + await pool.query("UPDATE tenants SET locked=null WHERE id=1") + await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId]) + assert.equal((await fileInto(id)).statusCode, 403) + assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403) + await pool.query("DELETE FROM auth_profiles") + assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1) + const row = (await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0] + assert.deepEqual(row, { folder: null, type: null }) + }) + await t.test("filing updates the original file once, keeps its content and cannot be undone by scanner retries", async () => { + const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id + const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0] + const countBefore = stored.size + const responses = await Promise.all([fileInto(id, targetFolder, targetType, { "x-test-admin": "false" }), fileInto(id)]) + assert.deepEqual(responses.map(response => response.statusCode).sort(), [200, 409]) + const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0] + assert.equal(row.folder, targetFolder) + assert.equal(row.type, targetType) + assert.equal(row.updated_by, userId) + assert.ok(row.updated_at) + assert.equal(row.path, before.path) + assert.equal(row.name, before.name) + assert.deepEqual(stored.get(row.path), pdf) + assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0) + assert.equal((await send("scan.pdf")).statusCode, 204) + assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0) + assert.equal((await service.list(2, 50, 0, "scan.pdf")).total, 1) + assert.equal(stored.size, countBefore) + assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200) + const rootId = (await service.list(1, 50, 0, "parallel.pdf")).documents[0].id + assert.equal((await fileInto(rootId, null, alternativeType)).statusCode, 200) + assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [rootId])).rows[0], { folder: null, type: alternativeType }) + }) + await t.test("a failure marking the receipt rolls back file classification", async () => { + const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id + await pool.query("ALTER TABLE scanner_inbox_documents ADD CONSTRAINT simulate_filing_failure CHECK (remote_filename <> 'failed.pdf' OR filed_at IS NULL)") + assert.equal((await fileInto(id)).statusCode, 500) + assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null }) + assert.equal((await service.list(1, 50, 0, "failed.pdf")).total, 1) + await pool.query("ALTER TABLE scanner_inbox_documents DROP CONSTRAINT simulate_filing_failure") + await pool.query("UPDATE files SET archived=true WHERE id=$1", [id]) + assert.equal((await fileInto(id)).statusCode, 404) + await pool.query("UPDATE files SET archived=false WHERE id=$1", [id]) + }) 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") assert.equal((await send("maintenance.pdf")).statusCode, 423) @@ -243,6 +325,6 @@ test("scanner inbox integration: migration, persistence, isolation, retries and assert.equal((await send("old.pdf")).statusCode, 401) await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 }) assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201) - assert.ok((await service.list(1, 50, 0, "")).total >= 4) + assert.equal((await service.list(1, 50, 0, "")).total, 2) }) }) diff --git a/docs/scanner-dokumentenablage.md b/docs/scanner-dokumentenablage.md index 09399cf0..8916bb63 100644 --- a/docs/scanner-dokumentenablage.md +++ b/docs/scanner-dokumentenablage.md @@ -1,6 +1,15 @@ # Epson-Scanner und Dokumentenablage -Unter **Organisation → Dokumentenablage** erscheinen eingegangene Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Öffnen und Herunterladen sind verfügbar. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Es gibt keine Seitentrennung, OCR, Klassifizierung oder Rechnungsverarbeitung. +Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Öffnen und Herunterladen sind verfügbar. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Es gibt keine automatische Seitentrennung, OCR, Klassifizierung oder Rechnungsverarbeitung. + +## In Dateien ablegen + +1. Beim Dokument **Ablegen** wählen und bei Bedarf das Original öffnen. +2. Einen vorhandenen FEDEO-Dateiordner oder **Dateien (Hauptebene)** auswählen. Unterordner erscheinen mit ihrem vollständigen Pfad. +3. Einen **Dokumenttyp** aus den vorhandenen Dateitypen wählen. Neue Typen lassen sich unter **Stammdaten → Dateitypen** anlegen. Der Standardtyp eines Ordners wird vorausgewählt; ein vorgeschriebener Typ ist nicht änderbar. +4. **In Dateien ablegen** speichert Ordner und Typ am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner verfügbar. Es wird keine Kopie erzeugt und keine weitere Verarbeitung ausgelöst. + +Nur aktive Ordner und Typen desselben Mandanten sind zulässig. Bei einem Fehler bleibt das Dokument im Eingang. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag anzulegen. ## Einrichtung @@ -15,7 +24,7 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu ## Schnittstelle und Betrieb -- Datenbankmigration `0073_scanner_document_inbox` wird über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. +- Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. - Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert. - Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/`. Der Empfangsordner existiert bereits; Unterordner, Lesen, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis. - `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen. @@ -23,6 +32,7 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu - Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; leerer Upload/ungültiger Pfad: `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden. - Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy. - 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/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "", "type": "" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. 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. ## Lokaler Funktionstest diff --git a/frontend/pages/organisation/dokumentenablage.vue b/frontend/pages/organisation/dokumentenablage.vue index fe57cc41..a6e7c0fd 100644 --- a/frontend/pages/organisation/dokumentenablage.vue +++ b/frontend/pages/organisation/dokumentenablage.vue @@ -3,6 +3,8 @@ import dayjs from "dayjs" type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: 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 } const { $api } = useNuxtApp() const auth = useAuthStore() @@ -24,6 +26,37 @@ const password = ref("") const confirmReset = ref(false) const scannerPath = ref("/scanner-inbox/") const origin = ref("") +const filingOpen = ref(false) +const filingDocument = ref(null) +const filingLoading = ref(false) +const filingSaving = ref(false) +const filingError = ref("") +const filingFolders = ref([]) +const filingTypes = ref([]) +const filingFolder = ref("root") +const filingType = ref("") +const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value)) +const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional)) +const folderOptions = computed(() => { + const byId = new Map(filingFolders.value.map(folder => [folder.id, folder])) + const path = (folder: FilingFolder) => { + const parts = [folder.name] + const visited = new Set([folder.id]) + let parent = folder.parent ? byId.get(folder.parent) : undefined + while (parent && !visited.has(parent.id)) { + parts.unshift(parent.name) + visited.add(parent.id) + parent = parent.parent ? byId.get(parent.parent) : undefined + } + return parts.join(" / ") + } + 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"))] +}) +const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value + && (filingFolder.value === "root" || selectedFolder.value) + && filingTypes.value.some(type => type.id === filingType.value) + && (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype))) const isAdmin = computed(() => Boolean(auth.user?.is_admin)) const scannerUrl = computed(() => { if (!origin.value) return "" @@ -32,6 +65,7 @@ const scannerUrl = computed(() => { }) let requestVersion = 0 let setupVersion = 0 +let filingVersion = 0 let searchTimer: ReturnType | undefined let pollTimer: ReturnType | undefined let disposed = false @@ -51,6 +85,10 @@ const load = async (silent = false) => { query: { limit: pageSize, offset: (page.value - 1) * pageSize, search: search.value.trim() }, }) if (disposed || version !== requestVersion) return + if (page.value > 1 && (page.value - 1) * pageSize >= result.total) { + page.value = Math.max(1, Math.ceil(result.total / pageSize)) + return load() + } documents.value = result.documents total.value = result.total error.value = "" @@ -63,6 +101,55 @@ const load = async (silent = false) => { } } +const openFiling = async (entry: InboxDocument) => { + filingOpen.value = true + filingDocument.value = entry + filingFolder.value = "root" + filingType.value = "" + filingFolders.value = [] + filingTypes.value = [] + filingError.value = "" + filingLoading.value = true + filingSaving.value = false + const version = ++filingVersion + try { + const result = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options") + if (disposed || version !== filingVersion) return + filingFolders.value = result.folders + filingTypes.value = result.types + } 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 + } +} + +const fileDocument = async () => { + if (!canFile.value) return + const entry = filingDocument.value! + const version = filingVersion + const folder = filingFolder.value === "root" ? null : filingFolder.value + filingSaving.value = true + filingError.value = "" + try { + await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value } }) + if (disposed || version !== filingVersion) return + // Cancel pending polling responses before removing the successfully filed entry. + 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))) + filingOpen.value = false + toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" }) + await load(true) + } catch (cause: any) { + if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht abgelegt werden." + } finally { + if (version === filingVersion) filingSaving.value = false + } +} + const openSetup = async () => { setupOpen.value = true setupLoading.value = true @@ -144,9 +231,26 @@ watch(search, () => { watch(setupOpen, open => { if (!open) { setupVersion++; password.value = ""; confirmReset.value = false } }) +watch(filingFolder, () => { + const defaultType = selectedFolder.value?.standardFiletype + if (defaultType) filingType.value = defaultType +}) +watch(filingOpen, open => { + if (!open) { + filingVersion++ + filingDocument.value = null + filingFolders.value = [] + filingTypes.value = [] + filingError.value = "" + filingSaving.value = false + filingLoading.value = false + } +}) watch(() => auth.activeTenant, () => { requestVersion++ setupVersion++ + filingVersion++ + filingOpen.value = false clearTimeout(searchTimer) clearTimeout(pollTimer) documents.value = [] @@ -163,6 +267,7 @@ onBeforeUnmount(() => { disposed = true requestVersion++ setupVersion++ + filingVersion++ clearTimeout(searchTimer) clearTimeout(pollTimer) }) @@ -179,7 +284,7 @@ onBeforeUnmount(() => {

Eingegangene Dokumente

-

Hier landen die Dokumente deines Scanners. Jede übertragene Datei erscheint einzeln.

+

Prüfe eingegangene Scans und lege sie mit Ordner und Dokumenttyp unter Dateien ab.

@@ -190,7 +295,7 @@ onBeforeUnmount(() => {
-

{{ search ? 'Keine passenden Dokumente' : 'Noch keine Dokumente eingegangen' }}

+

{{ search ? 'Keine passenden Dokumente' : 'Keine offenen Dokumente' }}

{{ search ? 'Versuche einen anderen Dateinamen.' : 'Sobald dein Scanner eine Datei überträgt, erscheint sie hier automatisch.' }}

@@ -203,7 +308,7 @@ onBeforeUnmount(() => { {{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }} {{ formatSize(entry.size) }} -
+
Ablegen
@@ -218,6 +323,34 @@ onBeforeUnmount(() => {
+ + + +