dev #232

Merged
flfeders merged 13 commits from dev into main 2026-09-30 15:43:46 +00:00
9 changed files with 400 additions and 14 deletions
Showing only changes of commit 052a3a8446 - Show all commits

View File

@@ -0,0 +1 @@
ALTER TABLE "scanner_inbox_documents" ADD COLUMN "filed_at" timestamp with time zone;

View File

@@ -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
}
]
}

View File

@@ -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),

View File

@@ -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<typeof createScannerInboxService>

View File

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

View File

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

View File

@@ -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/<Dateiname>`. 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": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` 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

View File

@@ -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<InboxDocument | null>(null)
const filingLoading = ref(false)
const filingSaving = ref(false)
const filingError = ref("")
const filingFolders = ref<FilingFolder[]>([])
const filingTypes = ref<FilingType[]>([])
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<typeof setTimeout> | undefined
let pollTimer: ReturnType<typeof setTimeout> | 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(() => {
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-highlighted">Eingegangene Dokumente</h1>
<p class="mt-1 text-sm text-muted">Hier landen die Dokumente deines Scanners. Jede übertragene Datei erscheint einzeln.</p>
<p class="mt-1 text-sm text-muted">Prüfe eingegangene Scans und lege sie mit Ordner und Dokumenttyp unter Dateien ab.</p>
</div>
<UInput v-model="search" aria-label="Dokumente nach Dateiname suchen" placeholder="Dateiname suchen …" icon="i-heroicons-magnifying-glass" :maxlength="200" class="w-full sm:w-72" />
</div>
@@ -190,7 +295,7 @@ onBeforeUnmount(() => {
</div>
<div v-else-if="!documents.length && !error" class="rounded-xl border border-dashed border-default px-6 py-16 text-center">
<UIcon name="i-heroicons-inbox-arrow-down" class="mx-auto h-10 w-10 text-muted" />
<h2 class="mt-3 font-semibold">{{ search ? 'Keine passenden Dokumente' : 'Noch keine Dokumente eingegangen' }}</h2>
<h2 class="mt-3 font-semibold">{{ search ? 'Keine passenden Dokumente' : 'Keine offenen Dokumente' }}</h2>
<p class="mt-1 text-sm text-muted">{{ search ? 'Versuche einen anderen Dateinamen.' : 'Sobald dein Scanner eine Datei überträgt, erscheint sie hier automatisch.' }}</p>
</div>
<div v-else-if="documents.length" class="overflow-x-auto rounded-xl border border-default">
@@ -203,7 +308,7 @@ onBeforeUnmount(() => {
<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" @click="fileAction(entry, false)"><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="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-arrow-top-right-on-square" color="neutral" variant="ghost" :aria-label="`${entry.name} öffnen`" @click="fileAction(entry, false)" /><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`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" :aria-label="`${entry.name} öffnen`" @click="fileAction(entry, false)" /><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
</tr>
</tbody>
</table>
@@ -218,6 +323,34 @@ onBeforeUnmount(() => {
</div>
</UDashboardPanelContent>
<UModal v-model:open="filingOpen" title="In Dateien ablegen" description="Wähle den Zielordner und klassifiziere das Dokument." :dismissible="!filingSaving" :close="!filingSaving">
<template #body>
<form class="space-y-5" @submit.prevent="fileDocument">
<div v-if="filingDocument" class="flex items-center justify-between gap-3 rounded-lg bg-elevated/50 p-3">
<span class="break-all text-sm font-medium">{{ filingDocument.name }}</span>
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" aria-label="Dokument öffnen" @click="fileAction(filingDocument, false)" />
</div>
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
<USkeleton v-if="filingLoading" class="h-32 w-full" />
<template v-else>
<UFormField label="Zielordner" required>
<USelectMenu v-model="filingFolder" :items="folderOptions" value-key="id" label-key="name" :search-input="{ placeholder: 'Ordner suchen …' }" :disabled="filingSaving" class="w-full" />
</UFormField>
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : 'Die Dokumenttypen entsprechen den Dateitypen in FEDEO.'">
<USelectMenu v-model="filingType" :items="filingTypes" value-key="id" label-key="name" placeholder="Dokumenttyp wählen …" :search-input="{ placeholder: 'Dokumenttyp suchen …' }" :disabled="filingSaving || fixedType" class="w-full" />
</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" />
</template>
<p class="text-sm text-muted">Nach dem Ablegen findest du das Dokument unter Dateien. Es verschwindet aus diesem Eingang.</p>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" :disabled="filingSaving" @click="filingOpen = false">Abbrechen</UButton>
<UButton type="submit" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">In Dateien ablegen</UButton>
</div>
</form>
</template>
</UModal>
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
<template #body>
<div class="space-y-5">

View File

@@ -90,3 +90,103 @@ test('unmounting cancels polling and ignores in-flight responses', async () => {
assert.equal(ui.run('documents.value.length'), 0)
assert.equal(ui.timers.size, 0)
})
const filingOptions = {
folders: [
{ id: 'parent', name: 'Buchhaltung', parent: null, standardFiletype: null, standardFiletypeIsOptional: true },
{ id: 'folder', name: 'Rechnungen', parent: 'parent', standardFiletype: 'invoice', standardFiletypeIsOptional: false },
],
types: [{ id: 'invoice', name: 'Rechnung' }, { id: 'other', name: 'Sonstiges' }],
}
test('filing shows folder paths, requires a type and honours mandatory folder defaults', async t => {
const ui = setup(async () => filingOptions)
t.after(ui.close)
await ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
assert.equal(ui.run('canFile.value'), false)
assert.equal(ui.run('folderOptions.value.find(folder => folder.id === "folder").name'), 'Buchhaltung / Rechnungen')
ui.run('filingFolder.value = "folder"')
await settle()
assert.equal(ui.run('filingType.value'), 'invoice')
assert.equal(ui.run('fixedType.value'), true)
assert.equal(ui.run('canFile.value'), true)
ui.run('filingType.value = "other"')
assert.equal(ui.run('canFile.value'), false)
ui.run('filingFolder.value = "root"')
await settle()
assert.equal(ui.run('fixedType.value'), false)
assert.equal(ui.run('canFile.value'), true)
})
test('successful filing sends folder and type, removes the entry and returns from an empty last page', async t => {
const pendingSave = deferred()
const requests = []
const ui = setup((url, options) => {
requests.push({ url, options })
if (url.endsWith('/filing-options')) return Promise.resolve(filingOptions)
if (url.endsWith('/file')) return pendingSave.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('openFiling(documents.value[0])')
ui.run('filingType.value = "invoice"')
const saving = ui.run('fileDocument()')
await ui.run('fileDocument()')
assert.equal(requests.filter(item => item.url.endsWith('/file')).length, 1)
assert.equal(ui.run('documents.value.length'), 1, 'keeps the original until saving succeeds')
pendingSave.resolve({ id: 'scan' })
await saving
const post = requests.find(item => item.url.endsWith('/file'))
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('page.value'), 1)
assert.equal(requests.at(-1).options.query.offset, 0)
assert.equal(ui.run('documents.value[0].id'), 'remaining')
})
test('filing failure preserves the document and selected values for retry', async t => {
const ui = setup(async url => {
if (url.endsWith('/file')) throw { data: { message: 'Ordner nicht verfügbar' } }
return filingOptions
})
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1')
await ui.run('openFiling(documents.value[0])')
ui.run('filingFolder.value = "folder"')
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('filingType.value'), 'invoice')
assert.equal(ui.run('documents.value[0].id'), 'scan')
assert.equal(ui.run('canFile.value'), true)
})
test('tenant switching discards pending folder options and filing responses', async t => {
for (const stage of ['options', 'save']) {
const pending = deferred()
const ui = setup(url => {
if (url.endsWith('/filing-options')) return stage === 'options' ? pending.promise : Promise.resolve(filingOptions)
if (url.endsWith('/file')) return pending.promise
return Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 })
})
t.after(ui.close)
let operation = ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
if (stage === 'save') {
await operation
ui.run('filingType.value = "invoice"')
operation = ui.run('fileDocument()')
}
ui.auth.activeTenant = '2'
await settle()
pending.resolve(stage === 'options' ? filingOptions : { id: 'scan' })
await operation
assert.equal(ui.run('filingOpen.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')
}
})