feat(documents): file scanner inbox documents with folder and type
This commit is contained in:
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "scanner_inbox_documents" ADD COLUMN "filed_at" timestamp with time zone;
|
||||||
@@ -498,6 +498,13 @@
|
|||||||
"when": 1790640000000,
|
"when": 1790640000000,
|
||||||
"tag": "0073_scanner_document_inbox",
|
"tag": "0073_scanner_document_inbox",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790640000001,
|
||||||
|
"tag": "0074_scanner_inbox_filing",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const scannerInboxDocuments = pgTable("scanner_inbox_documents", {
|
|||||||
remoteFilename: text("remote_filename").notNull(),
|
remoteFilename: text("remote_filename").notNull(),
|
||||||
checksum: text("checksum").notNull(),
|
checksum: text("checksum").notNull(),
|
||||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
filedAt: timestamp("filed_at", { withTimezone: true }),
|
||||||
}, table => ({
|
}, table => ({
|
||||||
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
||||||
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
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 type { FastifyInstance } from "fastify"
|
||||||
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
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 { sanitizeFilename } from "../utils/filename"
|
||||||
import { s3 } from "../utils/s3"
|
import { s3 } from "../utils/s3"
|
||||||
import { secrets } from "../utils/secrets"
|
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 list = async (tenantId: number, limit: number, offset: number, search: string) => {
|
||||||
const condition = and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId),
|
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)
|
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
|
||||||
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
||||||
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
|
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)
|
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
|
||||||
return { documents, total: total.value }
|
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 accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
|
||||||
const contentType = validateScannerFile(filename, body)
|
const contentType = validateScannerFile(filename, body)
|
||||||
const checksum = digest(body)
|
const checksum = digest(body)
|
||||||
@@ -136,7 +178,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
|||||||
throw error
|
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>
|
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
|
|||||||
}
|
}
|
||||||
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
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 => ({
|
server.get("/document-inbox/scanner", async req => ({
|
||||||
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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);
|
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, locked text);
|
||||||
INSERT INTO tenants VALUES (1, null), (2, null);
|
INSERT INTO tenants VALUES (1, null), (2, null);
|
||||||
CREATE TABLE files (${columns}, CHECK (name <> 'reject.pdf'));
|
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);`)
|
CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
|
||||||
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
||||||
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
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")
|
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||||
assert.ok(entry)
|
|
||||||
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
|
|
||||||
await mkdir(path.join(tempMigrations, "meta"))
|
await mkdir(path.join(tempMigrations, "meta"))
|
||||||
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries: [entry] }))
|
for (const entry of entries) {
|
||||||
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
|
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
|
const db = drizzle(pool) as any
|
||||||
await migrate(db, { migrationsFolder: tempMigrations })
|
await migrate(db, { migrationsFolder: tempMigrations })
|
||||||
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 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)
|
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 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)
|
||||||
@@ -243,6 +325,6 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
|||||||
assert.equal((await send("old.pdf")).statusCode, 401)
|
assert.equal((await send("old.pdf")).statusCode, 401)
|
||||||
await assert.rejects(service.accept(oldIdentity!, "late.pdf", 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.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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
# Epson-Scanner und Dokumentenablage
|
# 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
|
## Einrichtung
|
||||||
|
|
||||||
@@ -15,7 +24,7 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu
|
|||||||
|
|
||||||
## Schnittstelle und Betrieb
|
## 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.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
- 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.
|
- 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
|
## Lokaler Funktionstest
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import dayjs from "dayjs"
|
|||||||
|
|
||||||
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
|
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
|
||||||
type ScannerConnection = { username: string; active: boolean; password?: 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 { $api } = useNuxtApp()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -24,6 +26,37 @@ const password = ref("")
|
|||||||
const confirmReset = ref(false)
|
const confirmReset = ref(false)
|
||||||
const scannerPath = ref("/scanner-inbox/")
|
const scannerPath = ref("/scanner-inbox/")
|
||||||
const origin = ref("")
|
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 isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||||
const scannerUrl = computed(() => {
|
const scannerUrl = computed(() => {
|
||||||
if (!origin.value) return ""
|
if (!origin.value) return ""
|
||||||
@@ -32,6 +65,7 @@ const scannerUrl = computed(() => {
|
|||||||
})
|
})
|
||||||
let requestVersion = 0
|
let requestVersion = 0
|
||||||
let setupVersion = 0
|
let setupVersion = 0
|
||||||
|
let filingVersion = 0
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -51,6 +85,10 @@ const load = async (silent = false) => {
|
|||||||
query: { limit: pageSize, offset: (page.value - 1) * pageSize, search: search.value.trim() },
|
query: { limit: pageSize, offset: (page.value - 1) * pageSize, search: search.value.trim() },
|
||||||
})
|
})
|
||||||
if (disposed || version !== requestVersion) return
|
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
|
documents.value = result.documents
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
error.value = ""
|
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 () => {
|
const openSetup = async () => {
|
||||||
setupOpen.value = true
|
setupOpen.value = true
|
||||||
setupLoading.value = true
|
setupLoading.value = true
|
||||||
@@ -144,9 +231,26 @@ watch(search, () => {
|
|||||||
watch(setupOpen, open => {
|
watch(setupOpen, open => {
|
||||||
if (!open) { setupVersion++; password.value = ""; confirmReset.value = false }
|
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, () => {
|
watch(() => auth.activeTenant, () => {
|
||||||
requestVersion++
|
requestVersion++
|
||||||
setupVersion++
|
setupVersion++
|
||||||
|
filingVersion++
|
||||||
|
filingOpen.value = false
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
clearTimeout(pollTimer)
|
clearTimeout(pollTimer)
|
||||||
documents.value = []
|
documents.value = []
|
||||||
@@ -163,6 +267,7 @@ onBeforeUnmount(() => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
requestVersion++
|
requestVersion++
|
||||||
setupVersion++
|
setupVersion++
|
||||||
|
filingVersion++
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
clearTimeout(pollTimer)
|
clearTimeout(pollTimer)
|
||||||
})
|
})
|
||||||
@@ -179,7 +284,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-highlighted">Eingegangene Dokumente</h1>
|
<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>
|
</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" />
|
<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>
|
</div>
|
||||||
@@ -190,7 +295,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="!documents.length && !error" class="rounded-xl border border-dashed border-default px-6 py-16 text-center">
|
<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" />
|
<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>
|
<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>
|
||||||
<div v-else-if="documents.length" class="overflow-x-auto rounded-xl border border-default">
|
<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="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">{{ 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-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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -218,6 +323,34 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</UDashboardPanelContent>
|
</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.">
|
<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">
|
||||||
|
|||||||
@@ -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.run('documents.value.length'), 0)
|
||||||
assert.equal(ui.timers.size, 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user