chore: apply ESLint auto-fix across entire backend
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
Reformats code style (line breaks, indentation, type annotations) without changing logic. Also includes minor feature additions bundled in the same lint run (stats service, user-settings groups, agrarmonitor polling improvements). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,988 @@
|
|||||||
|
# Label-Print-Agent – Architektur & Implementierungsreferenz
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt vollständig das Label-Print-Agent-System aus dem Paperless Manager. Es dient als Grundlage für die Reimplementierung in einem anderen Projekt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Systemübersicht
|
||||||
|
|
||||||
|
Das System ermöglicht es, **Etiketten-Templates** zu definieren (mit Layout, Variablen-Feldern und Abmessungen) und daraus **Druckjobs** zu erzeugen, die von einem oder mehreren **Print Agents** abgeholt und gedruckt werden.
|
||||||
|
|
||||||
|
### Beteiligte Komponenten
|
||||||
|
|
||||||
|
| Komponente | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| **Frontend** | Template-Verwaltung, Job-Erstellung, Label-Vorschau |
|
||||||
|
| **Backend API** | REST-Endpunkte, Job-Queue, Rendering |
|
||||||
|
| **Datenbank** | Persistenz für Templates und Jobs |
|
||||||
|
| **LabelRenderer** | SVG → PNG (300 DPI) |
|
||||||
|
| **Print Agent** | Eigenständiger Client, der Jobs abholt und druckt |
|
||||||
|
|
||||||
|
### Datenfluss (ASCII)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND │
|
||||||
|
│ • Template anlegen (Name, Layout, Felder, Maße) │
|
||||||
|
│ • Vorschau rendern (POST /preview → PNG) │
|
||||||
|
│ • Druckjob erstellen (POST /jobs → { jobId }) │
|
||||||
|
└──────────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND: createJob() │
|
||||||
|
│ 1. Template laden │
|
||||||
|
│ 2. Variablen aufbauen (Felder + Datumssubfelder + {number}) │
|
||||||
|
│ 3. LabelGetUrl aufrufen → {number} reservieren (optional) │
|
||||||
|
│ 4. Job speichern (Status=pending, imageData=null) │
|
||||||
|
│ 5. newJob$.next() → SSE-Notification an alle Agents │
|
||||||
|
└──────────────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┴──────────────────┐
|
||||||
|
│ SSE-Push │ Polling (Fallback)
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRINT AGENT │
|
||||||
|
│ GET /jobs/next?agentId=MEIN-PC │
|
||||||
|
│ → Lock setzen (lockedAt = now) │
|
||||||
|
│ → Lazy Render: Layout → SVG → PNG → in DB speichern │
|
||||||
|
│ → { jobId, labelImageBase64, widthMm, heightMm } │
|
||||||
|
│ │
|
||||||
|
│ Drucken → POST /jobs/:id/printed (Erfolg) │
|
||||||
|
│ → POST /jobs/:id/error (Fehler) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodelle
|
||||||
|
|
||||||
|
### 2.1 LabelTemplate (in PM: Teil des `BarcodeTemplate`)
|
||||||
|
|
||||||
|
Für das neue Projekt empfiehlt sich eine eigenständige `LabelTemplate`-Entity:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface LabelInputField {
|
||||||
|
name: string; // Technischer Feldname (Variablenname)
|
||||||
|
label: string; // Anzeigetext im Formular
|
||||||
|
type: 'text' | 'number' | 'date';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LabelElement =
|
||||||
|
| {
|
||||||
|
type: 'text';
|
||||||
|
content: string; // Inhalt, kann {variablenname} enthalten
|
||||||
|
x: number; // X-Position in mm ab linker Kante
|
||||||
|
y: number; // Y-Position in mm ab oberer Kante
|
||||||
|
fontSize: number; // Schriftgröße in mm
|
||||||
|
bold?: boolean;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
maxWidth?: number; // Maximale Breite in mm (Text wird gestreckt/gestaucht)
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'qr';
|
||||||
|
content: string; // QR-Inhalt, kann {variablenname} enthalten
|
||||||
|
x: number; // X-Position in mm
|
||||||
|
y: number; // Y-Position in mm
|
||||||
|
sizeMm: number; // Quadratische Größe in mm
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'line';
|
||||||
|
x1: number; y1: number; // Startpunkt in mm
|
||||||
|
x2: number; y2: number; // Endpunkt in mm
|
||||||
|
lineWidth?: number; // Linienbreite in mm (default: ~0,085mm = 1px bei 300DPI)
|
||||||
|
};
|
||||||
|
|
||||||
|
// TypeORM Entity (vereinfacht)
|
||||||
|
@Entity('label_templates')
|
||||||
|
export class LabelTemplate {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 57 })
|
||||||
|
widthMm: number; // Etikettenbreite in mm
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 32 })
|
||||||
|
heightMm: number; // Etikettenhöhe in mm
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
inputFields: LabelInputField[] | null; // Formularfelder
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
layout: LabelElement[] | null; // Etikett-Elemente
|
||||||
|
|
||||||
|
@Column({ length: 1000, nullable: true })
|
||||||
|
getUrl: string | null; // URL zur Nummernreservierung (GET, {var} möglich)
|
||||||
|
|
||||||
|
@Column({ length: 1000, nullable: true })
|
||||||
|
printedUrl: string | null; // Callback nach erfolgreichem Druck (POST)
|
||||||
|
|
||||||
|
@Column({ length: 1000, nullable: true })
|
||||||
|
releaseUrl: string | null; // Callback bei Druckfehler (POST)
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 LabelPrintJob
|
||||||
|
|
||||||
|
Die DB-Tabelle fungiert als **persistente Job-Queue**.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Originaldatei: paperless-backend/src/database/entities/label-print-job.entity.ts
|
||||||
|
|
||||||
|
@Entity('label_print_jobs')
|
||||||
|
export class LabelPrintJob {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
Id: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
Status: 'pending' | 'printed' | 'error';
|
||||||
|
|
||||||
|
@Column({ type: 'mediumblob', nullable: true })
|
||||||
|
LabelImageData: Buffer | null; // Lazy-rendered PNG (kann null sein bis Claim)
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
LabelWidthMm: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
LabelHeightMm: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
BarcodeTemplateId: number | null; // Referenz auf Template (ohne FK-Constraint)
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
LabelVariables: Record<string, string> | null; // Aufgelöste Variablen zum Druckzeitpunkt
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
LockedAt: Date | null; // Zeitstempel des Locks
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
LockedByAgent: string | null; // Agenten-ID (z.B. "BUERO-PC")
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
PrintedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
PrintedByAgent: string | null;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
PrinterName: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
ErrorMessage: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
CreatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** `LabelVariables` speichert die bereits aufgelösten Variablen zum Zeitpunkt der Job-Erstellung. Das Template-Layout kann sich danach ändern — der Job verwendet trotzdem die ursprünglichen Werte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API-Endpunkte
|
||||||
|
|
||||||
|
Alle Endpunkte unter `/api/label-print-agent`:
|
||||||
|
|
||||||
|
### Frontend-Endpunkte (erfordern Authentifizierung)
|
||||||
|
|
||||||
|
#### `POST /preview` — Label-Vorschau rendern
|
||||||
|
|
||||||
|
Rendert ein PNG ohne DB-Eintrag. Zum Testen von Layouts.
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: { templateId: number, fieldValues?: Record<string, string> }
|
||||||
|
Response: image/png (Buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /jobs` — Druckjob erstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: { templateId: number, fieldValues?: Record<string, string> }
|
||||||
|
Response: { jobId: string }
|
||||||
|
Status: 201 Created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent-Endpunkte
|
||||||
|
|
||||||
|
#### `GET /events` — SSE-Stream (Server-Sent Events)
|
||||||
|
|
||||||
|
Agent hält diese Verbindung dauerhaft offen. Bei neuem Job erhält er eine Notification.
|
||||||
|
|
||||||
|
```
|
||||||
|
Response: text/event-stream
|
||||||
|
Payload: { type: 'label-job-available' }
|
||||||
|
Header: X-Accel-Buffering: no (wichtig für nginx-Reverse-Proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /jobs/next?agentId=MEIN-PC` — Nächsten Job beanspruchen
|
||||||
|
|
||||||
|
```
|
||||||
|
Response 200: {
|
||||||
|
jobId: string,
|
||||||
|
labelImageBase64: string | null, // PNG als Base64
|
||||||
|
labelImageContentType: 'image/png',
|
||||||
|
labelWidthMm: number,
|
||||||
|
labelHeightMm: number
|
||||||
|
}
|
||||||
|
Response 204: (kein Job verfügbar)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /jobs/:id/image` — Job-Bild separat abrufen
|
||||||
|
|
||||||
|
```
|
||||||
|
Response: image/png (Buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /jobs/:id/printed` — Job als gedruckt markieren
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: { agentId?: string, printerName?: string }
|
||||||
|
Response: { ok: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /jobs/:id/error` — Druckfehler melden
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: { agentId?: string, printerName?: string, errorMessage?: string }
|
||||||
|
Response: { ok: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SVG-Rendering-Pipeline
|
||||||
|
|
||||||
|
**Originaldatei:** `paperless-backend/src/label-print-agent/label-renderer.service.ts`
|
||||||
|
|
||||||
|
### Abhängigkeiten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install qrcode @resvg/resvg-js
|
||||||
|
npm install --save-dev @types/qrcode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vollständige Implementierung
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
import { Resvg } from '@resvg/resvg-js';
|
||||||
|
|
||||||
|
const MM_TO_PX = 300 / 25.4; // 300 DPI
|
||||||
|
|
||||||
|
function mm(v: number): number {
|
||||||
|
return Math.round(v * MM_TO_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen)
|
||||||
|
function applyVars(template: string, vars: Record<string, string>): string {
|
||||||
|
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
||||||
|
const colonIdx = key.indexOf(':');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const varName = key.slice(0, colonIdx);
|
||||||
|
const width = parseInt(key.slice(colonIdx + 1), 10);
|
||||||
|
if (!isNaN(width)) {
|
||||||
|
return (vars[varName] ?? '').padStart(width, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vars[key] ?? `{${key}}`; // Unbekannte Variablen: Platzhalter stehen lassen
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderLabel(
|
||||||
|
layout: LabelElement[],
|
||||||
|
widthMm: number,
|
||||||
|
heightMm: number,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const W = mm(widthMm);
|
||||||
|
const H = mm(heightMm);
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const el of layout) {
|
||||||
|
if (el.type === 'text') {
|
||||||
|
const x = mm(el.x);
|
||||||
|
const fontSize = mm(el.fontSize);
|
||||||
|
const content = escape(applyVars(el.content, variables));
|
||||||
|
const fontWeight = el.bold ? 'bold' : 'normal';
|
||||||
|
const textAnchor =
|
||||||
|
el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
|
||||||
|
const maxWidthAttr = el.maxWidth
|
||||||
|
? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"`
|
||||||
|
: '';
|
||||||
|
// Wichtig: librsvg ignoriert dominant-baseline.
|
||||||
|
// Y-Koordinate ist die Oberkante des Texts → fontSize addieren für Baseline.
|
||||||
|
const yBaseline = mm(el.y) + fontSize;
|
||||||
|
parts.push(
|
||||||
|
`<text x="${x}" y="${yBaseline}" ` +
|
||||||
|
`font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" ` +
|
||||||
|
`font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (el.type === 'qr') {
|
||||||
|
const size = mm(el.sizeMm);
|
||||||
|
const content = applyVars(el.content, variables);
|
||||||
|
const qrBuffer = await QRCode.toBuffer(content, {
|
||||||
|
type: 'png',
|
||||||
|
margin: 0,
|
||||||
|
width: size,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
});
|
||||||
|
const b64 = qrBuffer.toString('base64');
|
||||||
|
parts.push(
|
||||||
|
`<image href="data:image/png;base64,${b64}" ` +
|
||||||
|
`x="${mm(el.x)}" y="${mm(el.y)}" width="${size}" height="${size}"/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
} else if (el.type === 'line') {
|
||||||
|
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
||||||
|
parts.push(
|
||||||
|
`<line x1="${mm(el.x1)}" y1="${mm(el.y1)}" ` +
|
||||||
|
`x2="${mm(el.x2)}" y2="${mm(el.y2)}" ` +
|
||||||
|
`stroke="black" stroke-width="${strokeWidth}"/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
|
||||||
|
` width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`,
|
||||||
|
` <rect width="${W}" height="${H}" fill="white"/>`,
|
||||||
|
` ${parts.join('\n ')}`,
|
||||||
|
`</svg>`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const resvg = new Resvg(svg, {
|
||||||
|
font: {
|
||||||
|
loadSystemFonts: true,
|
||||||
|
fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'],
|
||||||
|
defaultFontFamily: 'Liberation Sans',
|
||||||
|
sansSerifFamily: 'Liberation Sans',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Buffer.from(resvg.render().asPng());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bekannte Eigenheiten
|
||||||
|
|
||||||
|
- **Baseline-Problem:** `librsvg` (verwendet von `@resvg/resvg-js`) ignoriert `dominant-baseline`. Daher muss zur Y-Koordinate die Schriftgröße addiert werden, damit die **Oberkante** des Textes der eingegebenen Y-Position entspricht (s. `yBaseline`-Berechnung).
|
||||||
|
- **Schriften:** Auf Systemen ohne `Liberation Sans` (z.B. Windows) fällt die Bibliothek auf Arial zurück. Für plattformübergreifende Konsistenz können Schriften auch per Buffer eingebettet werden.
|
||||||
|
- **QR-Code:** `errorCorrectionLevel: 'M'` ist ein guter Kompromiss zwischen Fehlertoleranz und Dichte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Job Queue & SSE-Mechanismus
|
||||||
|
|
||||||
|
**Originaldateien:**
|
||||||
|
- `paperless-backend/src/label-print-agent/label-print-agent.service.ts`
|
||||||
|
- `paperless-backend/src/label-print-agent/label-print-agent.controller.ts`
|
||||||
|
|
||||||
|
### Job erstellen
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Originaldatei: label-print-agent.service.ts
|
||||||
|
|
||||||
|
function applyVars(template: string, vars: Record<string, string>): string {
|
||||||
|
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
||||||
|
const colonIdx = key.indexOf(':');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const varName = key.slice(0, colonIdx);
|
||||||
|
const width = parseInt(key.slice(colonIdx + 1), 10);
|
||||||
|
if (!isNaN(width) && varName in vars) {
|
||||||
|
return vars[varName].padStart(width, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vars[key] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJob(templateId: number, fieldValues: Record<string, string>): Promise<LabelPrintJob> {
|
||||||
|
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
||||||
|
if (!template) throw new NotFoundException('Template nicht gefunden');
|
||||||
|
|
||||||
|
const vars: Record<string, string> = { ...fieldValues };
|
||||||
|
|
||||||
|
// Datum-Felder aufsplitten: {datum} → dd.MM.yyyy, {datum.year}, {datum.month}, {datum.day}
|
||||||
|
for (const field of template.LabelInputFields ?? []) {
|
||||||
|
if (field.type === 'date' && fieldValues[field.name]) {
|
||||||
|
const parts = fieldValues[field.name].split('-'); // YYYY-MM-DD
|
||||||
|
if (parts.length === 3) {
|
||||||
|
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
|
vars[`${field.name}.year`] = parts[0];
|
||||||
|
vars[`${field.name}.month`] = parts[1];
|
||||||
|
vars[`${field.name}.day`] = parts[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Externe Nummern-Reservierung (optional)
|
||||||
|
if (template.LabelGetUrl) {
|
||||||
|
const url = applyVars(template.LabelGetUrl, vars);
|
||||||
|
const res = await fetch(url);
|
||||||
|
vars['number'] = (await res.text()).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = this.jobRepo.create({
|
||||||
|
Status: 'pending',
|
||||||
|
LabelImageData: null, // Lazy rendering: erst beim Claim rendern
|
||||||
|
LabelWidthMm: template.LabelWidthMm ?? 57,
|
||||||
|
LabelHeightMm: template.LabelHeightMm ?? 32,
|
||||||
|
BarcodeTemplateId: template.Id,
|
||||||
|
LabelVariables: vars,
|
||||||
|
LockedAt: null,
|
||||||
|
LockedByAgent: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.jobRepo.save(job);
|
||||||
|
this.jobCreated$.next(); // SSE-Notification an alle verbundenen Agents
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job beanspruchen (Optimistic Locking)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Lock-Ablaufzeit: 5 Minuten in der Vergangenheit
|
||||||
|
function lockExpiry(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMinutes(d.getMinutes() - 5);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
|
||||||
|
// Finde den ältesten pending Job ohne Lock oder mit abgelaufenem Lock
|
||||||
|
const candidate = await this.jobRepo.findOne({
|
||||||
|
where: [
|
||||||
|
{ Status: 'pending', LockedAt: IsNull() },
|
||||||
|
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
|
||||||
|
],
|
||||||
|
order: { CreatedAt: 'ASC' }, // FIFO
|
||||||
|
});
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
// Lock setzen (optimistisch, kein DB-Row-Lock)
|
||||||
|
candidate.LockedAt = new Date();
|
||||||
|
candidate.LockedByAgent = agentId;
|
||||||
|
await this.jobRepo.save(candidate);
|
||||||
|
|
||||||
|
// Lazy Render: PNG erst jetzt erzeugen
|
||||||
|
if (!candidate.LabelImageData) {
|
||||||
|
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId } });
|
||||||
|
if (template?.LabelLayout?.length) {
|
||||||
|
candidate.LabelImageData = await this.renderer.render(
|
||||||
|
template.LabelLayout,
|
||||||
|
candidate.LabelWidthMm,
|
||||||
|
candidate.LabelHeightMm,
|
||||||
|
candidate.LabelVariables ?? {},
|
||||||
|
);
|
||||||
|
await this.jobRepo.save(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSE-Stream (NestJS)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Originaldatei: label-print-agent.controller.ts
|
||||||
|
|
||||||
|
private readonly jobCreated$ = new Subject<void>(); // im Service
|
||||||
|
|
||||||
|
// Controller:
|
||||||
|
@Sse('events')
|
||||||
|
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no'); // nginx: kein Buffering
|
||||||
|
return this.service.newJob$.pipe(
|
||||||
|
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSE-Client (Agent-Seite, Plain JavaScript)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const evtSource = new EventSource('http://backend/api/label-print-agent/events', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
evtSource.onmessage = async (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'label-job-available') {
|
||||||
|
await pollAndPrint();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback: alle 30 Sekunden pollen
|
||||||
|
setInterval(pollAndPrint, 30_000);
|
||||||
|
|
||||||
|
async function pollAndPrint() {
|
||||||
|
const res = await fetch(`http://backend/api/label-print-agent/jobs/next?agentId=MEIN-PC`);
|
||||||
|
if (res.status === 204) return; // kein Job
|
||||||
|
|
||||||
|
const job = await res.json();
|
||||||
|
const imageBytes = Buffer.from(job.labelImageBase64, 'base64');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await printToPrinter(imageBytes, job.labelWidthMm, job.labelHeightMm);
|
||||||
|
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/printed`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ agentId: 'MEIN-PC', printerName: 'DYMO 450' }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/error`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ agentId: 'MEIN-PC', errorMessage: String(err) }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Variablen-System
|
||||||
|
|
||||||
|
### Template-Syntax
|
||||||
|
|
||||||
|
| Syntax | Bedeutung | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| `{feldname}` | Wert des Feldes | `{name}` → `Müller GmbH` |
|
||||||
|
| `{feldname:6}` | Zero-padded auf N Stellen | `{nr:6}` → `000042` |
|
||||||
|
| `{datum}` (date-Feld) | Formatiert als `dd.MM.yyyy` | `{datum}` → `15.05.2026` |
|
||||||
|
| `{datum.year}` | Nur das Jahr | `{datum.year}` → `2026` |
|
||||||
|
| `{datum.month}` | Nur den Monat | `{datum.month}` → `05` |
|
||||||
|
| `{datum.day}` | Nur den Tag | `{datum.day}` → `15` |
|
||||||
|
| `{number}` | Reservierte Nummer (via GetUrl) | `{number}` → `1234` |
|
||||||
|
|
||||||
|
### URL-Templates
|
||||||
|
|
||||||
|
Die Felder `LabelGetUrl`, `LabelPrintedUrl`, `LabelReleaseUrl` unterstützen ebenfalls Variablen-Substitution:
|
||||||
|
|
||||||
|
```
|
||||||
|
LabelGetUrl: https://api.example.com/numbers/next?batch={batch}
|
||||||
|
LabelPrintedUrl: https://api.example.com/numbers/{number}/confirm
|
||||||
|
LabelReleaseUrl: https://api.example.com/numbers/{number}/release
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sicherheitscheck:** Nur `http://` und `https://` werden akzeptiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Vollständiger Job-Lebenszyklus
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: pending → (Lock gesetzt) → printed / error
|
||||||
|
|
||||||
|
┌─ createJob() ──────────────────────────────────────────────────┐
|
||||||
|
│ Template laden → Vars aufbauen → GetUrl aufrufen (optional) │
|
||||||
|
│ Job speichern: Status=pending, LockedAt=null, imageData=null │
|
||||||
|
│ newJob$.next() → SSE-Push │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ claimNextJob() ───────────────────────────────────────────────┐
|
||||||
|
│ Kandidat suchen: pending AND (LockedAt IS NULL OR < -5min) │
|
||||||
|
│ Lock setzen: LockedAt=now, LockedByAgent=agentId │
|
||||||
|
│ Lazy Render: Layout + Vars → SVG → PNG → LabelImageData │
|
||||||
|
│ Rückgabe: Job + PNG (base64) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ markPrinted() ────────────────────────────────────────────────┐
|
||||||
|
│ Status = printed, PrintedAt = now, PrintedByAgent, Printer │
|
||||||
|
│ POST LabelPrintedUrl (optional, Variablen aufgelöst) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ markError() ──────────────────────────────────────────────────┐
|
||||||
|
│ Status = error, ErrorMessage │
|
||||||
|
│ POST LabelReleaseUrl (optional) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lock-TTL:** 5 Minuten. Stürzt ein Agent ab, kann ein anderer Agent (oder derselbe nach Neustart) den Job nach 5 Minuten erneut beanspruchen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. NestJS-Modul-Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// label-print-agent.module.ts
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([LabelPrintJob, LabelTemplate]),
|
||||||
|
],
|
||||||
|
controllers: [LabelPrintAgentController],
|
||||||
|
providers: [LabelPrintAgentService, LabelRendererService],
|
||||||
|
})
|
||||||
|
export class LabelPrintAgentModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Empfehlungen für das neue Projekt
|
||||||
|
|
||||||
|
### Architektur übernehmen
|
||||||
|
|
||||||
|
Das Muster **DB als Queue + SSE als Push + Polling als Fallback** ist einfach und robust. Es benötigt keine externe Message-Queue (Redis, RabbitMQ etc.) und funktioniert gut für geringe bis mittlere Job-Volumina.
|
||||||
|
|
||||||
|
### Locking-Strategie
|
||||||
|
|
||||||
|
| Szenario | Empfehlung |
|
||||||
|
|---|---|
|
||||||
|
| Ein Agent | Optimistic Lock (5-Min-TTL) reicht aus |
|
||||||
|
| Mehrere Agents, wenige Jobs | Optimistic Lock reicht aus |
|
||||||
|
| Viele Agents, hoher Durchsatz | DB-Row-Lock (`SELECT ... FOR UPDATE`) oder Redis-Lock |
|
||||||
|
|
||||||
|
### Rendering-Alternativen
|
||||||
|
|
||||||
|
| Umgebung | Alternative |
|
||||||
|
|---|---|
|
||||||
|
| Node.js | `@resvg/resvg-js` + `qrcode` (wie hier) |
|
||||||
|
| Node.js (Docker) | Puppeteer (HTML → PNG/PDF, braucht Chrome) |
|
||||||
|
| Python | `reportlab` oder `cairosvg` |
|
||||||
|
| Kein Node.js | Server-seitiges Rendering via separatem Microservice |
|
||||||
|
|
||||||
|
### SSE-Alternativen
|
||||||
|
|
||||||
|
| Alternative | Wann |
|
||||||
|
|---|---|
|
||||||
|
| SSE (wie hier) | Einfachste Lösung, funktioniert durch nginx |
|
||||||
|
| WebSocket | Wenn bidirektionale Kommunikation nötig ist |
|
||||||
|
| Polling-only | Wenn der Agent kein persistentes HTTP unterstützt |
|
||||||
|
|
||||||
|
### Template-Verwaltung im neuen Projekt
|
||||||
|
|
||||||
|
Im Paperless Manager sind Label-Templates Teil der `BarcodeTemplate`-Entity (historisch gewachsen). Für ein neues Projekt empfiehlt sich eine **eigenständige `LabelTemplate`-Entity** ohne nicht-Label-relevante Felder — das vereinfacht CRUD und API erheblich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quell-Dateien (Paperless Manager)
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `paperless-backend/src/label-print-agent/label-print-agent.service.ts` | Job-Lifecycle, Locking, Callbacks |
|
||||||
|
| `paperless-backend/src/label-print-agent/label-print-agent.controller.ts` | REST-Endpunkte, SSE |
|
||||||
|
| `paperless-backend/src/label-print-agent/label-renderer.service.ts` | SVG→PNG Rendering |
|
||||||
|
| `paperless-backend/src/database/entities/label-print-job.entity.ts` | Job-Entity |
|
||||||
|
| `paperless-backend/src/database/entities/barcode-template.entity.ts` | Template-Entity (inkl. `LabelElement`, `LabelInputField` Typen) |
|
||||||
|
| `paperless-frontend/src/api/labelPrintAgent.ts` | Frontend-API-Client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Frontend-UI: Template-Editor (SettingsPage)
|
||||||
|
|
||||||
|
**Originaldatei:** `paperless-frontend/src/pages/SettingsPage.tsx`
|
||||||
|
|
||||||
|
Der Template-Editor ist ein **Modal-Formular** (720px breit) mit Ant Design. Er enthält zwei eigenständige React-Komponenten.
|
||||||
|
|
||||||
|
### Komponenten-Übersicht
|
||||||
|
|
||||||
|
| Komponente | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `BarcodeTemplatesTab` | Tabelle mit allen Templates + CRUD-Logik |
|
||||||
|
| `LabelElementRow` | Eine Zeile im Layout-Editor (typ-abhängige Felder) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BarcodeTemplatesTab
|
||||||
|
|
||||||
|
Verwaltet den gesamten Template-Lebenszyklus. State:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [data, setData] = useState<BarcodeTemplate[]>([]);
|
||||||
|
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [testPrinting, setTestPrinting] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Formular-Initialwerte** (beim Neu-Anlegen):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
form.setFieldsValue({
|
||||||
|
SplitBefore: false,
|
||||||
|
DateinameTemplate: '',
|
||||||
|
LabelEnabled: false,
|
||||||
|
LabelWidthMm: 57, // Standard: DYMO 57mm
|
||||||
|
LabelHeightMm: 32,
|
||||||
|
LabelInputFields: [],
|
||||||
|
LabelLayout: [],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testetikett-Funktion** (`handleTestLabel`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleTestLabel = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await barcodeTemplatesApi.update(editing.Id, values); // erst speichern
|
||||||
|
|
||||||
|
// Auto-Testwerte für alle Felder generieren
|
||||||
|
const testFieldValues: Record<string, string> = {};
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
for (const f of values.LabelInputFields ?? []) {
|
||||||
|
if (f.type === 'date') testFieldValues[f.name] = today;
|
||||||
|
else if (f.type === 'number') testFieldValues[f.name] = '1';
|
||||||
|
else testFieldValues[f.name] = 'Test';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await labelPrintAgentApi.previewLabel(editing.Id, testFieldValues);
|
||||||
|
setPreviewUrl(url); // öffnet zweites Modal mit PNG
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modal-Footer-Buttons:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Testetikett erstellen] [Abbrechen] [Speichern]
|
||||||
|
↑
|
||||||
|
Nur sichtbar wenn bestehende Vorlage (editing && !isNew)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Formular-Aufbau (innerhalb des Modals)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Name ────────────────────────────────────────────────────────┐
|
||||||
|
│ Input: "z. B. Lager-Etikett" (required) │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Regex ───────────────────────────────────────────────────────┐
|
||||||
|
│ Input mit Regex-Validator │
|
||||||
|
│ Extra: "JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$" │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Regex testen ────────────────────────────────────────────────┐
|
||||||
|
│ Input (live) → Tag: [Treffer] / [Kein Treffer] / [Ungültig] │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Checkbox: "Vor diesem Barcode ein neues Dokument starten" ───┐
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Belegname ───────────────────────────────────────────────────┐
|
||||||
|
│ Input: Platzhalter {barcode}, {datum}, {barcode.gruppe} │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
────────────── Etikett ──────────────
|
||||||
|
|
||||||
|
┌─ Switch: "Etikett-Druck aktivieren" ─────────────────────────┐
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
[Nur sichtbar wenn Switch aktiv]
|
||||||
|
┌─ Breite (mm) │ Höhe (mm) ─────────────────────────────────┐
|
||||||
|
│ InputNumber │ InputNumber (Standard: 57 / 32) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Eingabefelder ─────────────────────────────────────────────┐
|
||||||
|
│ Form.List: je Zeile: │
|
||||||
|
│ [Feldname Input] [Bezeichnung Input] [Typ-Select] [−] │
|
||||||
|
│ Typen: text | number | date │
|
||||||
|
│ [+ Feld hinzufügen] │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ GET-URL ───────────────────────────────────────────────────┐
|
||||||
|
│ Extra zeigt verfügbare Platzhalter live aus Eingabefeldern │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ PRINTED-URL ───────────────────────────────────────────────┐
|
||||||
|
│ Extra: Platzhalter + {number} │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ RELEASE-URL ───────────────────────────────────────────────┐
|
||||||
|
│ Extra: Platzhalter + {number} │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Layout-Elemente ───────────────────────────────────────────┐
|
||||||
|
│ Extra: verfügbare Platzhalter │
|
||||||
|
│ Form.List → je Element: LabelElementRow-Komponente │
|
||||||
|
│ [+ Text] [+ QR-Code] [+ Linie] │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Platzhalter-Chips werden live berechnet:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Aus den aktuellen LabelInputFields dynamisch erzeugt
|
||||||
|
const chips: string[] = [];
|
||||||
|
for (const f of inputFields) {
|
||||||
|
chips.push(`{${f.name}}`);
|
||||||
|
if (f.type === 'date') {
|
||||||
|
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chipsWithNumber = [...chips, '{number}'];
|
||||||
|
// → wird als `extra`-Text bei URL-Feldern und Layout-Elementen angezeigt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LabelElementRow (je Layout-Element eine Card)
|
||||||
|
|
||||||
|
Typ wird per `Form.useWatch` reaktiv ausgelesen → zeigt typ-spezifische Felder:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const type = Form.useWatch(['LabelLayout', listName, 'type'], form);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Typ: Text**
|
||||||
|
|
||||||
|
```
|
||||||
|
[X mm] [Y mm] [Schrift (mm)] [Max. Breite (mm)] [Fett ☐] [Ausrichtung ▼]
|
||||||
|
[Inhalt: Input mit Platzhalter-Hinweis "{nummer} oder {datum}"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Default beim Hinzufügen: `{ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false }`
|
||||||
|
|
||||||
|
**Typ: QR-Code**
|
||||||
|
|
||||||
|
```
|
||||||
|
[X mm] [Y mm] [Größe (mm)] [Inhalt: Input mit "{number}"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: `{ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' }`
|
||||||
|
|
||||||
|
**Typ: Linie**
|
||||||
|
|
||||||
|
```
|
||||||
|
[X1 mm] [Y1 mm] [X2 mm] [Y2 mm] [Stärke (mm, step: 0.1)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: `{ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 }`
|
||||||
|
|
||||||
|
Jede Card hat rechts oben einen Löschen-Button (`DeleteOutlined`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Vorschau-Modal
|
||||||
|
|
||||||
|
Nach „Testetikett erstellen" öffnet sich ein zweites Modal (520px) mit dem gerenderten PNG:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal title="Etikett-Vorschau" width={520}>
|
||||||
|
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<img src={previewUrl} style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `previewUrl` ist ein `URL.createObjectURL(blob)` — muss mit `URL.revokeObjectURL()` beim Schließen freigegeben werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Frontend-UI: Druck-Dialog (InboxPage)
|
||||||
|
|
||||||
|
**Originaldatei:** `paperless-frontend/src/pages/InboxPage.tsx`
|
||||||
|
|
||||||
|
### Einstiegspunkt
|
||||||
|
|
||||||
|
Button in der Toolbar der InboxPage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
|
||||||
|
Etikett drucken
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### openPrintDialog
|
||||||
|
|
||||||
|
Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const openPrintDialog = async () => {
|
||||||
|
const [all, settings] = await Promise.all([
|
||||||
|
barcodeTemplatesApi.list(),
|
||||||
|
userSettingsApi.get(),
|
||||||
|
]);
|
||||||
|
const enabled = all.filter((t) => t.LabelEnabled); // nur aktive Templates
|
||||||
|
setLabelTemplates(enabled);
|
||||||
|
|
||||||
|
// Standard-Template aus User-Settings vorauswählen
|
||||||
|
const defaultId = settings.defaultLabelTemplateId;
|
||||||
|
const defaultTemplate = defaultId != null
|
||||||
|
? (enabled.find((t) => t.Id === defaultId) ?? null)
|
||||||
|
: null;
|
||||||
|
setSelectedTemplate(defaultTemplate);
|
||||||
|
setFieldValues(buildInitialFieldValues(defaultTemplate));
|
||||||
|
setLabelCount(1);
|
||||||
|
setPrintDialogOpen(true);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datum-Vorausfüllung
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildInitialFieldValues(template: BarcodeTemplate | null): Record<string, string> {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const field of template?.LabelInputFields ?? []) {
|
||||||
|
if (field.type === 'date') values[field.name] = today;
|
||||||
|
// text- und number-Felder bleiben leer
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Druck-Modal-Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Eingangsdokumentart ─────────────────────────────────────────┐
|
||||||
|
│ Select → nur Templates mit LabelEnabled=true │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Anzahl Etiketten ────────────────────────────────────────────┐
|
||||||
|
│ InputNumber (min: 1, max: 100, default: 1) │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ [dynamisch: LabelInputFields des gewählten Templates] ────────┐
|
||||||
|
│ date → DatePicker (Format: DD.MM.YYYY, Wert: YYYY-MM-DD) │
|
||||||
|
│ number → InputNumber │
|
||||||
|
│ text → Input │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Footer: [Abbrechen] [Drucken] ← disabled solange kein Template
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch-Druck
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handlePrint = async () => {
|
||||||
|
setPrinting(true);
|
||||||
|
for (let i = 0; i < labelCount; i++) {
|
||||||
|
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
|
||||||
|
// Erzeugt N separate Jobs sequenziell (selbe fieldValues)
|
||||||
|
}
|
||||||
|
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Frontend API-Client
|
||||||
|
|
||||||
|
**Originaldatei:** `paperless-frontend/src/api/labelPrintAgent.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const labelPrintAgentApi = {
|
||||||
|
// Job erstellen (von InboxPage)
|
||||||
|
createJob: (templateId: number, fieldValues: Record<string, string>) =>
|
||||||
|
api.post('/api/label-print-agent/jobs', { templateId, fieldValues }),
|
||||||
|
|
||||||
|
// Vorschau-PNG als Object-URL (von SettingsPage)
|
||||||
|
previewLabel: async (templateId: number, fieldValues: Record<string, string>): Promise<string> => {
|
||||||
|
const res = await api.post('/api/label-print-agent/preview',
|
||||||
|
{ templateId, fieldValues },
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
Generated
+2
-2
@@ -20,7 +20,7 @@
|
|||||||
"@types/form-data": "^2.2.1",
|
"@types/form-data": "^2.2.1",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a30",
|
"agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"basic-ftp": "^5.2.1",
|
"basic-ftp": "^5.2.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -4862,7 +4862,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/agrarmonitor-connector": {
|
"node_modules/agrarmonitor-connector": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a3063144a4f7746c9946d1e4f888f8f0f8b4",
|
"resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#5cb93b3258bd013024013d76ee922e5f8b89244a",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly agrarmonitorService: AgrarmonitorService,
|
private readonly agrarmonitorService: AgrarmonitorService,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>,
|
@InjectRepository(Setting)
|
||||||
|
private readonly settingRepo: Repository<Setting>,
|
||||||
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
||||||
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>,
|
@InjectRepository(CorrespondentSetting)
|
||||||
|
private readonly corrSettingRepo: Repository<CorrespondentSetting>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -59,23 +61,34 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
|
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
|
||||||
async scheduledPolling() {
|
async scheduledPolling() {
|
||||||
if (!process.env['AGRARMONITOR_POLLING_CRON']) return;
|
if (!process.env['AGRARMONITOR_POLLING_CRON']) return;
|
||||||
this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err));
|
this.runPolling().catch((err) =>
|
||||||
|
this.logger.error('Cron-Polling-Fehler:', err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
|
@Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
|
||||||
async scheduledUploadCheck() {
|
async scheduledUploadCheck() {
|
||||||
if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return;
|
if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return;
|
||||||
this.processVerarbeiteteDocuments().catch((err) => this.logger.error('Cron-Upload-Check-Fehler:', err));
|
this.processVerarbeiteteDocuments().catch((err) =>
|
||||||
|
this.logger.error('Cron-Upload-Check-Fehler:', err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }> {
|
async getPollingConfig(): Promise<{
|
||||||
const [fertig, verbucht, hochgeladen, linkField, manuell] = await Promise.all([
|
tagFertig: string;
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
tagVerbucht: string;
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
tagHochgeladen: string;
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
linkField: string;
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
tagManuell: string;
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
}> {
|
||||||
]);
|
const [fertig, verbucht, hochgeladen, linkField, manuell] =
|
||||||
|
await Promise.all([
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
tagFertig: fertig?.Wert ?? '4',
|
tagFertig: fertig?.Wert ?? '4',
|
||||||
tagVerbucht: verbucht?.Wert ?? '9',
|
tagVerbucht: verbucht?.Wert ?? '9',
|
||||||
@@ -91,13 +104,34 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
tagHochgeladen: string,
|
tagHochgeladen: string,
|
||||||
linkField: string,
|
linkField: string,
|
||||||
tagManuell: string,
|
tagManuell: string,
|
||||||
): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }> {
|
): Promise<{
|
||||||
|
tagFertig: string;
|
||||||
|
tagVerbucht: string;
|
||||||
|
tagHochgeladen: string;
|
||||||
|
linkField: string;
|
||||||
|
tagManuell: string;
|
||||||
|
}> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }),
|
this.settingRepo.update(
|
||||||
this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }),
|
{ Tag: 'agrarmonitor_tag_fertig' },
|
||||||
this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }),
|
{ Wert: tagFertig },
|
||||||
this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }),
|
),
|
||||||
this.settingRepo.update({ Tag: 'agrarmonitor_tag_manuell' }, { Wert: tagManuell }),
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_tag_verbucht' },
|
||||||
|
{ Wert: tagVerbucht },
|
||||||
|
),
|
||||||
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_tag_hochgeladen' },
|
||||||
|
{ Wert: tagHochgeladen },
|
||||||
|
),
|
||||||
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_link_field' },
|
||||||
|
{ Wert: linkField },
|
||||||
|
),
|
||||||
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_tag_manuell' },
|
||||||
|
{ Wert: tagManuell },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell };
|
return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell };
|
||||||
}
|
}
|
||||||
@@ -105,11 +139,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
async runPolling(): Promise<PollingResult> {
|
async runPolling(): Promise<PollingResult> {
|
||||||
if (this.pollingRunning) {
|
if (this.pollingRunning) {
|
||||||
this.logger.warn('Polling läuft bereits, überspringe');
|
this.logger.warn('Polling läuft bereits, überspringe');
|
||||||
return { processed: 0, updated: 0, skipped: 0, errors: ['Polling bereits aktiv'] };
|
return {
|
||||||
|
processed: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: ['Polling bereits aktiv'],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
this.pollingRunning = true;
|
this.pollingRunning = true;
|
||||||
|
|
||||||
const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] };
|
const result: PollingResult = {
|
||||||
|
processed: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
this.logger.log('Starte Agrarmonitor-Polling');
|
this.logger.log('Starte Agrarmonitor-Polling');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -126,7 +170,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
return { ...result, errors: [msg] };
|
return { ...result, errors: [msg] };
|
||||||
}
|
}
|
||||||
|
|
||||||
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
|
let amClient: Awaited<
|
||||||
|
ReturnType<typeof this.agrarmonitorService.getClient>
|
||||||
|
>;
|
||||||
try {
|
try {
|
||||||
amClient = await this.agrarmonitorService.getClient();
|
amClient = await this.agrarmonitorService.getClient();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -150,7 +196,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
await this.getOrCreateCorrespondent(customer, Number(customer.id));
|
await this.getOrCreateCorrespondent(customer, Number(customer.id));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
|
this.logger.warn(
|
||||||
|
`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +210,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
const docs: any[] = docsResponse?.results ?? [];
|
const docs: any[] = docsResponse?.results ?? [];
|
||||||
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
|
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
|
||||||
this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`);
|
this.logger.warn(
|
||||||
|
`Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`);
|
this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`);
|
||||||
|
|
||||||
@@ -170,20 +220,25 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
result.processed++;
|
result.processed++;
|
||||||
|
|
||||||
const interneBelegnummer =
|
const interneBelegnummer =
|
||||||
((doc.custom_fields as any[]) ?? []).find(
|
(((doc.custom_fields as any[]) ?? []).find(
|
||||||
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
|
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
|
||||||
)?.value as string ?? '';
|
)?.value as string) ?? '';
|
||||||
|
|
||||||
if (!interneBelegnummer) {
|
if (!interneBelegnummer) {
|
||||||
this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`);
|
this.logger.log(
|
||||||
|
`Dokument ${doc.id as number} hat keine interne Belegnummer`,
|
||||||
|
);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let amResults: Awaited<ReturnType<typeof amClient.eingangsrechnungenLivesearch>>;
|
let amResults: Awaited<
|
||||||
|
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
|
||||||
|
>;
|
||||||
try {
|
try {
|
||||||
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
|
amResults =
|
||||||
|
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as any)?.response?.status;
|
const status = (err as any)?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
@@ -194,14 +249,18 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
|
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amResults.length === 0) {
|
if (amResults.length === 0) {
|
||||||
this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`);
|
this.logger.log(
|
||||||
|
`${interneBelegnummer} nicht in Agrarmonitor gefunden`,
|
||||||
|
);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
@@ -219,9 +278,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
|
|
||||||
if (!amDoc.interneBelegNummer && interneBelegnummer) {
|
if (!amDoc.interneBelegNummer && interneBelegnummer) {
|
||||||
try {
|
try {
|
||||||
await amClient.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer);
|
await amClient.setLieferscheinNummer(
|
||||||
|
amDoc.eingangId,
|
||||||
|
interneBelegnummer,
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.logger.warn(`${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`);
|
this.logger.warn(
|
||||||
|
`${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,17 +297,24 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
const eingangsdatum = new Date(eingangsdatumField.value as string);
|
const eingangsdatum = new Date(eingangsdatumField.value as string);
|
||||||
if (!isNaN(eingangsdatum.getTime())) {
|
if (!isNaN(eingangsdatum.getTime())) {
|
||||||
await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum);
|
await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum);
|
||||||
this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`);
|
this.logger.log(
|
||||||
|
`Eingangsdatum für ${interneBelegnummer} gesetzt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amDoc.buchungsDatum) {
|
if (amDoc.buchungsDatum) {
|
||||||
try {
|
try {
|
||||||
let correspondentId: number | undefined;
|
let correspondentId: number | undefined;
|
||||||
const customer = customers.find((c) => Number(c.id) === amDoc.kundenId);
|
const customer = customers.find(
|
||||||
|
(c) => Number(c.id) === amDoc.kundenId,
|
||||||
|
);
|
||||||
if (customer) {
|
if (customer) {
|
||||||
const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId);
|
const corr = await this.getOrCreateCorrespondent(
|
||||||
|
customer,
|
||||||
|
amDoc.kundenId,
|
||||||
|
);
|
||||||
if (corr) correspondentId = corr.id as number;
|
if (corr) correspondentId = corr.id as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,28 +325,40 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
|
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
|
||||||
|
|
||||||
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
||||||
const newTags = [...new Set(currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]))];
|
const newTags = [
|
||||||
|
...new Set(
|
||||||
|
currentTags
|
||||||
|
.filter((t) => t !== tagFertigId)
|
||||||
|
.concat([tagVerbuchtId]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
const updateData: Record<string, any> = { tags: newTags };
|
const updateData: Record<string, any> = { tags: newTags };
|
||||||
if (correspondentId !== undefined) updateData.correspondent = correspondentId;
|
if (correspondentId !== undefined)
|
||||||
|
updateData.correspondent = correspondentId;
|
||||||
if (ownerId !== undefined) updateData.owner = ownerId;
|
if (ownerId !== undefined) updateData.owner = ownerId;
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(doc.id as number, updateData);
|
await this.paperlessService.updateDocument(
|
||||||
|
doc.id as number,
|
||||||
|
updateData,
|
||||||
|
);
|
||||||
this.logger.log(`Beleg ${interneBelegnummer} gebucht`);
|
this.logger.log(`Beleg ${interneBelegnummer} gebucht`);
|
||||||
result.updated++;
|
result.updated++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = `${interneBelegnummer}: Update-Fehler`;
|
const msg = `${interneBelegnummer}: Update-Fehler`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` +
|
`Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` +
|
||||||
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
|
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.pollingRunning = false;
|
this.pollingRunning = false;
|
||||||
@@ -287,15 +370,30 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
async processVerarbeiteteDocuments(): Promise<PollingResult> {
|
async processVerarbeiteteDocuments(): Promise<PollingResult> {
|
||||||
if (this.uploadCheckRunning) {
|
if (this.uploadCheckRunning) {
|
||||||
this.logger.warn('Upload-Check läuft bereits, überspringe');
|
this.logger.warn('Upload-Check läuft bereits, überspringe');
|
||||||
return { processed: 0, updated: 0, skipped: 0, errors: ['Upload-Check bereits aktiv'] };
|
return {
|
||||||
|
processed: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: ['Upload-Check bereits aktiv'],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
this.uploadCheckRunning = true;
|
this.uploadCheckRunning = true;
|
||||||
|
|
||||||
const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] };
|
const result: PollingResult = {
|
||||||
|
processed: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
this.logger.log('Starte Upload-Check');
|
this.logger.log('Starte Upload-Check');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [hochgeladenSetting, fertigSetting, linkFieldSetting, manuellSetting] = await Promise.all([
|
const [
|
||||||
|
hochgeladenSetting,
|
||||||
|
fertigSetting,
|
||||||
|
linkFieldSetting,
|
||||||
|
manuellSetting,
|
||||||
|
] = await Promise.all([
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||||
@@ -308,11 +406,15 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
|
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
|
||||||
|
|
||||||
if (isNaN(tagHochgeladenId)) {
|
if (isNaN(tagHochgeladenId)) {
|
||||||
this.logger.warn('Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen');
|
this.logger.warn(
|
||||||
|
'Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen',
|
||||||
|
);
|
||||||
return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] };
|
return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
|
let amClient: Awaited<
|
||||||
|
ReturnType<typeof this.agrarmonitorService.getClient>
|
||||||
|
>;
|
||||||
try {
|
try {
|
||||||
amClient = await this.agrarmonitorService.getClient();
|
amClient = await this.agrarmonitorService.getClient();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -329,20 +431,26 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
const docs: any[] = docsResponse?.results ?? [];
|
const docs: any[] = docsResponse?.results ?? [];
|
||||||
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
|
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) {
|
||||||
this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`);
|
this.logger.warn(
|
||||||
|
`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.logger.log(`${docs.length} Dokumente laut Paperless im Dateieingang`);
|
this.logger.log(
|
||||||
|
`${docs.length} Dokumente laut Paperless im Dateieingang`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
result.processed++;
|
result.processed++;
|
||||||
|
|
||||||
const interneBelegnummer =
|
const interneBelegnummer =
|
||||||
((doc.custom_fields as any[]) ?? []).find(
|
(((doc.custom_fields as any[]) ?? []).find(
|
||||||
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
|
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
|
||||||
)?.value as string ?? '';
|
)?.value as string) ?? '';
|
||||||
|
|
||||||
if (!interneBelegnummer) {
|
if (!interneBelegnummer) {
|
||||||
this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`);
|
this.logger.log(
|
||||||
|
`Dokument ${doc.id as number} hat keine interne Belegnummer`,
|
||||||
|
);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
@@ -350,7 +458,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
|
|
||||||
let vorhanden: boolean;
|
let vorhanden: boolean;
|
||||||
try {
|
try {
|
||||||
vorhanden = await amClient.eingangsrechnungVorhanden(interneBelegnummer);
|
vorhanden =
|
||||||
|
await amClient.eingangsrechnungVorhanden(interneBelegnummer);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as any)?.response?.status;
|
const status = (err as any)?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
@@ -361,7 +470,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`;
|
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
@@ -371,7 +482,10 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
// Prüfen ob Beleg noch im Dateieingang von Agrarmonitor liegt
|
// Prüfen ob Beleg noch im Dateieingang von Agrarmonitor liegt
|
||||||
let imDateieingang: boolean;
|
let imDateieingang: boolean;
|
||||||
try {
|
try {
|
||||||
imDateieingang = await amClient.eingangsrechnungImDateieingangVorhanden(interneBelegnummer);
|
imDateieingang =
|
||||||
|
await amClient.eingangsrechnungImDateieingangVorhanden(
|
||||||
|
interneBelegnummer,
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as any)?.response?.status;
|
const status = (err as any)?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
@@ -383,7 +497,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
// Bei Fehler vorsichtig: nicht verschieben
|
// Bei Fehler vorsichtig: nicht verschieben
|
||||||
const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`;
|
const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
@@ -399,9 +515,19 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
|
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
|
||||||
if (!isNaN(tagManuellId)) {
|
if (!isNaN(tagManuellId)) {
|
||||||
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
||||||
const newTags = [...new Set(currentTags.filter(t => t !== tagHochgeladenId).concat([tagManuellId, 19]))];
|
const newTags = [
|
||||||
await this.paperlessService.updateDocument(doc.id as number, { tags: newTags });
|
...new Set(
|
||||||
this.logger.log(`${interneBelegnummer} nicht mehr in Agrarmonitor — als manuell bearbeiten markiert`);
|
currentTags
|
||||||
|
.filter((t) => t !== tagHochgeladenId)
|
||||||
|
.concat([tagManuellId, 19]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await this.paperlessService.updateDocument(doc.id as number, {
|
||||||
|
tags: newTags,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`${interneBelegnummer} nicht mehr in Agrarmonitor — als manuell bearbeiten markiert`,
|
||||||
|
);
|
||||||
result.updated++;
|
result.updated++;
|
||||||
} else {
|
} else {
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
@@ -410,11 +536,16 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`);
|
this.logger.log(
|
||||||
|
`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`,
|
||||||
|
);
|
||||||
|
|
||||||
let amResults: Awaited<ReturnType<typeof amClient.eingangsrechnungenLivesearch>>;
|
let amResults: Awaited<
|
||||||
|
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
|
||||||
|
>;
|
||||||
try {
|
try {
|
||||||
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
|
amResults =
|
||||||
|
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as any)?.response?.status;
|
const status = (err as any)?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
@@ -425,14 +556,18 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
|
const msg = `${interneBelegnummer}: Livesearch-Fehler`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amResults.length > 1) {
|
if (amResults.length > 1) {
|
||||||
this.logger.log(`Dokument ${interneBelegnummer} ist doppelt vorhanden`);
|
this.logger.log(
|
||||||
|
`Dokument ${interneBelegnummer} ist doppelt vorhanden`,
|
||||||
|
);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
@@ -443,29 +578,49 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
// Kundendaten abrufen
|
// Kundendaten abrufen
|
||||||
const customer = await amClient.getCustomerById(amDoc.kundenId);
|
const customer = await amClient.getCustomerById(amDoc.kundenId);
|
||||||
const lieferantennummer = (customer['lieferantennummer'] as string) ?? '';
|
const lieferantennummer =
|
||||||
|
(customer['lieferantennummer'] as string) ?? '';
|
||||||
if (!lieferantennummer) {
|
if (!lieferantennummer) {
|
||||||
this.logger.log(`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`);
|
this.logger.log(
|
||||||
|
`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`,
|
||||||
|
);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await this.delay(500);
|
await this.delay(500);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Korrespondent ermitteln oder anlegen
|
// Korrespondent ermitteln oder anlegen
|
||||||
const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId);
|
const corr = await this.getOrCreateCorrespondent(
|
||||||
|
customer,
|
||||||
|
amDoc.kundenId,
|
||||||
|
);
|
||||||
|
|
||||||
// Owner aus Client-Tabelle
|
// Owner aus Client-Tabelle
|
||||||
let ownerId: number | undefined;
|
let ownerId: number | undefined;
|
||||||
const matchedClient = await this.clientRepo.findOneBy({ AgrarmonitorBetriebId: amDoc.betriebId });
|
const matchedClient = await this.clientRepo.findOneBy({
|
||||||
|
AgrarmonitorBetriebId: amDoc.betriebId,
|
||||||
|
});
|
||||||
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
|
if (matchedClient) ownerId = matchedClient.PaperlessUserId;
|
||||||
|
|
||||||
// Tags: hochgeladen entfernen, fertig hinzufügen
|
// Tags: hochgeladen entfernen, fertig hinzufügen
|
||||||
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
||||||
const newTags = [...new Set(currentTags.filter((t) => t !== tagHochgeladenId).concat([tagFertigId]))];
|
const newTags = [
|
||||||
|
...new Set(
|
||||||
|
currentTags
|
||||||
|
.filter((t) => t !== tagHochgeladenId)
|
||||||
|
.concat([tagFertigId]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// Custom fields aufbauen: bestehende behalten, extern + link setzen
|
// Custom fields aufbauen: bestehende behalten, extern + link setzen
|
||||||
const existingFields: any[] = ((doc.custom_fields as any[]) ?? []).map((f: any) => ({ ...f }));
|
const existingFields: any[] = (
|
||||||
this.setCustomField(existingFields, EXTERN_BELEGNUMMER_FIELD_ID, amDoc.belegNummer);
|
(doc.custom_fields as any[]) ?? []
|
||||||
|
).map((f: any) => ({ ...f }));
|
||||||
|
this.setCustomField(
|
||||||
|
existingFields,
|
||||||
|
EXTERN_BELEGNUMMER_FIELD_ID,
|
||||||
|
amDoc.belegNummer,
|
||||||
|
);
|
||||||
if (!isNaN(linkFieldId)) {
|
if (!isNaN(linkFieldId)) {
|
||||||
this.setCustomField(
|
this.setCustomField(
|
||||||
existingFields,
|
existingFields,
|
||||||
@@ -475,16 +630,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Record<string, any> = {
|
const updateData: Record<string, any> = {
|
||||||
title: (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer,
|
title:
|
||||||
|
(amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer,
|
||||||
document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
|
document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
|
||||||
tags: newTags,
|
tags: newTags,
|
||||||
custom_fields: existingFields,
|
custom_fields: existingFields,
|
||||||
};
|
};
|
||||||
if (amDoc.belegDatum) updateData.created = amDoc.belegDatum.toISOString().slice(0, 10);
|
if (amDoc.belegDatum)
|
||||||
|
updateData.created = amDoc.belegDatum.toISOString().slice(0, 10);
|
||||||
if (corr) updateData.correspondent = corr.id as number;
|
if (corr) updateData.correspondent = corr.id as number;
|
||||||
if (ownerId !== undefined) updateData.owner = ownerId;
|
if (ownerId !== undefined) updateData.owner = ownerId;
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(doc.id as number, updateData);
|
await this.paperlessService.updateDocument(
|
||||||
|
doc.id as number,
|
||||||
|
updateData,
|
||||||
|
);
|
||||||
await this.paperlessService.addNote(
|
await this.paperlessService.addNote(
|
||||||
doc.id as number,
|
doc.id as number,
|
||||||
`Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`,
|
`Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`,
|
||||||
@@ -493,7 +653,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
result.updated++;
|
result.updated++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = `${interneBelegnummer}: Update-Fehler`;
|
const msg = `${interneBelegnummer}: Update-Fehler`;
|
||||||
this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`${msg}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
result.errors.push(msg);
|
result.errors.push(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +664,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` +
|
`Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` +
|
||||||
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
|
`${result.skipped} übersprungen, ${result.errors.length} Fehler`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.uploadCheckRunning = false;
|
this.uploadCheckRunning = false;
|
||||||
@@ -521,16 +683,20 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncCorrespondentIds(): Promise<SyncCorrespondentsResult> {
|
async syncCorrespondentIds(): Promise<SyncCorrespondentsResult> {
|
||||||
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>;
|
let amClient: Awaited<
|
||||||
|
ReturnType<typeof this.agrarmonitorService.getClient>
|
||||||
|
>;
|
||||||
try {
|
try {
|
||||||
amClient = await this.agrarmonitorService.getClient();
|
amClient = await this.agrarmonitorService.getClient();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(`Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`);
|
throw new Error(
|
||||||
|
`Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customers = await amClient.fetchCustomers();
|
const customers = await amClient.fetchCustomers();
|
||||||
const lieferantMap = new Map<string, number>(); // lieferantennummer → AM-ID
|
const lieferantMap = new Map<string, number>(); // lieferantennummer → AM-ID
|
||||||
const kundenMap = new Map<string, number>(); // kundennummer → AM-ID
|
const kundenMap = new Map<string, number>(); // kundennummer → AM-ID
|
||||||
for (const c of customers) {
|
for (const c of customers) {
|
||||||
const liefNr = String(c['lieferantennummer'] ?? '').trim();
|
const liefNr = String(c['lieferantennummer'] ?? '').trim();
|
||||||
if (liefNr) lieferantMap.set(liefNr, Number(c.id));
|
if (liefNr) lieferantMap.set(liefNr, Number(c.id));
|
||||||
@@ -541,14 +707,17 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
const allCorrespondents: any[] = [];
|
const allCorrespondents: any[] = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
while (true) {
|
while (true) {
|
||||||
const resp = await this.paperlessService.getCorrespondents({ page, page_size: 250 });
|
const resp = await this.paperlessService.getCorrespondents({
|
||||||
|
page,
|
||||||
|
page_size: 250,
|
||||||
|
});
|
||||||
allCorrespondents.push(...(resp.results ?? []));
|
allCorrespondents.push(...(resp.results ?? []));
|
||||||
if (!resp.next) break;
|
if (!resp.next) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer
|
const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer
|
||||||
const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer
|
const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
let unmatched = 0;
|
let unmatched = 0;
|
||||||
|
|
||||||
@@ -564,11 +733,19 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
if (liefMatch) amId = lieferantMap.get(liefMatch[1]);
|
if (liefMatch) amId = lieferantMap.get(liefMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amId === undefined) { unmatched++; continue; }
|
if (amId === undefined) {
|
||||||
|
unmatched++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number });
|
let setting = await this.corrSettingRepo.findOneBy({
|
||||||
|
CorrespondentId: corr.id as number,
|
||||||
|
});
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: amId });
|
setting = this.corrSettingRepo.create({
|
||||||
|
CorrespondentId: corr.id as number,
|
||||||
|
AgrarmonitorId: amId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setting.AgrarmonitorId = amId;
|
setting.AgrarmonitorId = amId;
|
||||||
}
|
}
|
||||||
@@ -594,29 +771,47 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
for (const [amId, corrIds] of byAmId) {
|
for (const [amId, corrIds] of byAmId) {
|
||||||
if (corrIds.length <= 1) continue;
|
if (corrIds.length <= 1) continue;
|
||||||
|
|
||||||
const corrs = await Promise.all(corrIds.map(id => this.paperlessService.getCorrespondent(id)));
|
const corrs = await Promise.all(
|
||||||
|
corrIds.map((id) => this.paperlessService.getCorrespondent(id)),
|
||||||
|
);
|
||||||
const uniqueNames = new Set(corrs.map((c: any) => c.name as string));
|
const uniqueNames = new Set(corrs.map((c: any) => c.name as string));
|
||||||
|
|
||||||
if (uniqueNames.size === 1) {
|
if (uniqueNames.size === 1) {
|
||||||
// Gleicher Name — automatisch zusammenführen
|
// Gleicher Name — automatisch zusammenführen
|
||||||
const withoutDocs = corrs.filter((c: any) => Number(c.document_count) === 0);
|
const withoutDocs = corrs.filter(
|
||||||
|
(c: any) => Number(c.document_count) === 0,
|
||||||
|
);
|
||||||
const withDocs = corrs.filter((c: any) => Number(c.document_count) > 0);
|
const withDocs = corrs.filter((c: any) => Number(c.document_count) > 0);
|
||||||
|
|
||||||
if (withoutDocs.length > 0) {
|
if (withoutDocs.length > 0) {
|
||||||
for (const toDelete of withoutDocs) {
|
for (const toDelete of withoutDocs) {
|
||||||
await this.paperlessService.deleteCorrespondent(toDelete.id as number);
|
await this.paperlessService.deleteCorrespondent(
|
||||||
await this.corrSettingRepo.delete({ CorrespondentId: toDelete.id as number });
|
toDelete.id as number,
|
||||||
|
);
|
||||||
|
await this.corrSettingRepo.delete({
|
||||||
|
CorrespondentId: toDelete.id as number,
|
||||||
|
});
|
||||||
autoMerged++;
|
autoMerged++;
|
||||||
this.logger.log(`Duplikat gelöscht (keine Dokumente): ${toDelete.name as string} (ID ${toDelete.id as number})`);
|
this.logger.log(
|
||||||
|
`Duplikat gelöscht (keine Dokumente): ${toDelete.name as string} (ID ${toDelete.id as number})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Alle haben Dokumente — in den mit den meisten Dokumenten zusammenführen
|
// Alle haben Dokumente — in den mit den meisten Dokumenten zusammenführen
|
||||||
const sorted = [...withDocs].sort((a: any, b: any) => Number(b.document_count) - Number(a.document_count));
|
const sorted = [...withDocs].sort(
|
||||||
const keep = sorted[0] as any;
|
(a: any, b: any) =>
|
||||||
|
Number(b.document_count) - Number(a.document_count),
|
||||||
|
);
|
||||||
|
const keep = sorted[0];
|
||||||
for (const toMerge of sorted.slice(1)) {
|
for (const toMerge of sorted.slice(1)) {
|
||||||
await this.mergeCorrespondents(keep.id as number, toMerge.id as number);
|
await this.mergeCorrespondents(
|
||||||
|
keep.id as number,
|
||||||
|
toMerge.id as number,
|
||||||
|
);
|
||||||
autoMerged++;
|
autoMerged++;
|
||||||
this.logger.log(`Duplikat zusammengeführt in ${keep.name as string} (ID ${keep.id as number})`);
|
this.logger.log(
|
||||||
|
`Duplikat zusammengeführt in ${keep.name as string} (ID ${keep.id as number})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -634,12 +829,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer, ` +
|
`Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer, ` +
|
||||||
`${autoMerged} automatisch zusammengeführt, ${conflicts.length} Konflikte`,
|
`${autoMerged} automatisch zusammengeführt, ${conflicts.length} Konflikte`,
|
||||||
);
|
);
|
||||||
return { total: allCorrespondents.length, matched, unmatched, autoMerged, conflicts };
|
return {
|
||||||
|
total: allCorrespondents.length,
|
||||||
|
matched,
|
||||||
|
unmatched,
|
||||||
|
autoMerged,
|
||||||
|
conflicts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async mergeCorrespondents(keepId: number, deleteId: number): Promise<{ mergedDocuments: number }> {
|
async mergeCorrespondents(
|
||||||
|
keepId: number,
|
||||||
|
deleteId: number,
|
||||||
|
): Promise<{ mergedDocuments: number }> {
|
||||||
let mergedDocuments = 0;
|
let mergedDocuments = 0;
|
||||||
let page = 1;
|
let page = 1;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -651,7 +855,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
const docs: any[] = resp?.results ?? [];
|
const docs: any[] = resp?.results ?? [];
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
await this.paperlessService.updateDocument(doc.id as number, { correspondent: keepId });
|
await this.paperlessService.updateDocument(doc.id as number, {
|
||||||
|
correspondent: keepId,
|
||||||
|
});
|
||||||
mergedDocuments++;
|
mergedDocuments++;
|
||||||
}
|
}
|
||||||
if (!resp?.next) break;
|
if (!resp?.next) break;
|
||||||
@@ -659,14 +865,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
await this.paperlessService.deleteCorrespondent(deleteId);
|
await this.paperlessService.deleteCorrespondent(deleteId);
|
||||||
await this.corrSettingRepo.delete({ CorrespondentId: deleteId });
|
await this.corrSettingRepo.delete({ CorrespondentId: deleteId });
|
||||||
this.logger.log(`Korrespondent ${deleteId} → ${keepId} zusammengeführt (${mergedDocuments} Dokumente)`);
|
this.logger.log(
|
||||||
|
`Korrespondent ${deleteId} → ${keepId} zusammengeführt (${mergedDocuments} Dokumente)`,
|
||||||
|
);
|
||||||
return { mergedDocuments };
|
return { mergedDocuments };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateCorrespondent(customer: Record<string, unknown>, kundenId?: number): Promise<any> {
|
private async getOrCreateCorrespondent(
|
||||||
|
customer: Record<string, unknown>,
|
||||||
|
kundenId?: number,
|
||||||
|
): Promise<any> {
|
||||||
// Direkter Lookup über gespeicherte Agrarmonitor-ID
|
// Direkter Lookup über gespeicherte Agrarmonitor-ID
|
||||||
if (kundenId !== undefined) {
|
if (kundenId !== undefined) {
|
||||||
const setting = await this.corrSettingRepo.findOneBy({ AgrarmonitorId: kundenId });
|
const setting = await this.corrSettingRepo.findOneBy({
|
||||||
|
AgrarmonitorId: kundenId,
|
||||||
|
});
|
||||||
if (setting) {
|
if (setting) {
|
||||||
return { id: setting.CorrespondentId };
|
return { id: setting.CorrespondentId };
|
||||||
}
|
}
|
||||||
@@ -688,9 +901,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
|
|
||||||
// Link für künftige Läufe speichern
|
// Link für künftige Läufe speichern
|
||||||
if (corr && kundenId !== undefined) {
|
if (corr && kundenId !== undefined) {
|
||||||
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number });
|
let setting = await this.corrSettingRepo.findOneBy({
|
||||||
|
CorrespondentId: corr.id as number,
|
||||||
|
});
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: kundenId });
|
setting = this.corrSettingRepo.create({
|
||||||
|
CorrespondentId: corr.id as number,
|
||||||
|
AgrarmonitorId: kundenId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setting.AgrarmonitorId = kundenId;
|
setting.AgrarmonitorId = kundenId;
|
||||||
}
|
}
|
||||||
@@ -700,11 +918,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
return corr;
|
return corr;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCustomerName(customer: Record<string, unknown>, nummer: string): string {
|
private buildCustomerName(
|
||||||
|
customer: Record<string, unknown>,
|
||||||
|
nummer: string,
|
||||||
|
): string {
|
||||||
const firma = (customer['firma'] as string) ?? '';
|
const firma = (customer['firma'] as string) ?? '';
|
||||||
const nachname = (customer['nachname'] as string) ?? '';
|
const nachname = (customer['nachname'] as string) ?? '';
|
||||||
const vorname = (customer['vorname'] as string) ?? '';
|
const vorname = (customer['vorname'] as string) ?? '';
|
||||||
const name = firma || (nachname + (vorname ? ', ' + vorname : ''));
|
const name = firma || nachname + (vorname ? ', ' + vorname : '');
|
||||||
return `${name} (${nummer})`;
|
return `${name} (${nummer})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +933,10 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertSetting(tag: string, defaultValue: string): Promise<void> {
|
private async upsertSetting(
|
||||||
|
tag: string,
|
||||||
|
defaultValue: string,
|
||||||
|
): Promise<void> {
|
||||||
const existing = await this.settingRepo.findOneBy({ Tag: tag });
|
const existing = await this.settingRepo.findOneBy({ Tag: tag });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.settingRepo.save(
|
await this.settingRepo.save(
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export class AgrarmonitorController {
|
|||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async registerDevice(@Body() body: { pcName: string; agrarmonitorId: string }) {
|
async registerDevice(
|
||||||
|
@Body() body: { pcName: string; agrarmonitorId: string },
|
||||||
|
) {
|
||||||
return this.service.registerDevice(body.pcName, body.agrarmonitorId);
|
return this.service.registerDevice(body.pcName, body.agrarmonitorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +34,23 @@ export class AgrarmonitorController {
|
|||||||
|
|
||||||
@Put('polling-config')
|
@Put('polling-config')
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }) {
|
async updatePollingConfig(
|
||||||
return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField, body.tagManuell ?? '');
|
@Body()
|
||||||
|
body: {
|
||||||
|
tagFertig: string;
|
||||||
|
tagVerbucht: string;
|
||||||
|
tagHochgeladen: string;
|
||||||
|
linkField: string;
|
||||||
|
tagManuell: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.pollingService.updatePollingConfig(
|
||||||
|
body.tagFertig,
|
||||||
|
body.tagVerbucht,
|
||||||
|
body.tagHochgeladen,
|
||||||
|
body.linkField,
|
||||||
|
body.tagManuell ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('run-polling')
|
@Post('run-polling')
|
||||||
@@ -60,7 +77,9 @@ export class AgrarmonitorController {
|
|||||||
@Post('merge-correspondents')
|
@Post('merge-correspondents')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async mergeCorrespondents(@Body() body: { keepId: number; deleteId: number }) {
|
async mergeCorrespondents(
|
||||||
|
@Body() body: { keepId: number; deleteId: number },
|
||||||
|
) {
|
||||||
return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId);
|
return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,38 @@ export class AgrarmonitorService {
|
|||||||
async getClient(): Promise<AgrarmonitorConnectorResult> {
|
async getClient(): Promise<AgrarmonitorConnectorResult> {
|
||||||
if (this.client) return this.client;
|
if (this.client) return this.client;
|
||||||
|
|
||||||
const username = this.configService.get<string>('AGRARMONITOR_USERNAME', '');
|
const username = this.configService.get<string>(
|
||||||
const password = this.configService.get<string>('AGRARMONITOR_PASSWORD', '');
|
'AGRARMONITOR_USERNAME',
|
||||||
const baseUrl = this.configService.get<string>('AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de');
|
'',
|
||||||
const apiBaseUrl = this.configService.get<string>('AGRARMONITOR_API_BASE_URL', 'https://api.agrarmonitor.de');
|
);
|
||||||
|
const password = this.configService.get<string>(
|
||||||
|
'AGRARMONITOR_PASSWORD',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const baseUrl = this.configService.get<string>(
|
||||||
|
'AGRARMONITOR_BASE_URL',
|
||||||
|
'https://admin7.agrarmonitor.de',
|
||||||
|
);
|
||||||
|
const apiBaseUrl = this.configService.get<string>(
|
||||||
|
'AGRARMONITOR_API_BASE_URL',
|
||||||
|
'https://api.agrarmonitor.de',
|
||||||
|
);
|
||||||
const apiToken = this.configService.get<string>('AGRARMONITOR_API_TOKEN');
|
const apiToken = this.configService.get<string>('AGRARMONITOR_API_TOKEN');
|
||||||
const cookiePath = this.configService.get<string>('AGRARMONITOR_COOKIE_PATH', './data/agrarmonitor-cookies.json');
|
const cookiePath = this.configService.get<string>(
|
||||||
const encryptionKey = this.configService.get<string>('AGRARMONITOR_ENCRYPTION_KEY');
|
'AGRARMONITOR_COOKIE_PATH',
|
||||||
|
'./data/agrarmonitor-cookies.json',
|
||||||
|
);
|
||||||
|
const encryptionKey = this.configService.get<string>(
|
||||||
|
'AGRARMONITOR_ENCRYPTION_KEY',
|
||||||
|
);
|
||||||
|
|
||||||
const encryptor = encryptionKey ? new AesGcmCookieEncryptor(encryptionKey) : undefined;
|
const encryptor = encryptionKey
|
||||||
const cookieStore = new FileCookieStore(cookiePath, { encryptor, logger: this.logger });
|
? new AesGcmCookieEncryptor(encryptionKey)
|
||||||
|
: undefined;
|
||||||
|
const cookieStore = new FileCookieStore(cookiePath, {
|
||||||
|
encryptor,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
|
||||||
this.client = await createAgrarmonitorClient({
|
this.client = await createAgrarmonitorClient({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -84,7 +106,10 @@ export class AgrarmonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerDevice(pcName: string, agrarmonitorId: string): Promise<AgrarmonitorRegisterResultDto> {
|
async registerDevice(
|
||||||
|
pcName: string,
|
||||||
|
agrarmonitorId: string,
|
||||||
|
): Promise<AgrarmonitorRegisterResultDto> {
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
const result = await client.registerDevice({ agrarmonitorId, pcName });
|
const result = await client.registerDevice({ agrarmonitorId, pcName });
|
||||||
return { success: result.success, message: result.message };
|
return { success: result.success, message: result.message };
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import * as path from 'path';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: [
|
envFilePath: [
|
||||||
path.resolve(__dirname, '../../.env'), // Root .env (zentral)
|
path.resolve(__dirname, '../../.env'), // Root .env (zentral)
|
||||||
path.resolve(__dirname, '../.env'), // Lokale .env (Fallback)
|
path.resolve(__dirname, '../.env'), // Lokale .env (Fallback)
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -33,8 +39,8 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
|
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
|
||||||
`headers: ${JSON.stringify(Object.keys(request.headers))} | ` +
|
`headers: ${JSON.stringify(Object.keys(request.headers))} | ` +
|
||||||
`key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`,
|
`key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -44,11 +50,15 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const keyEntry = await this.apiKeysService.validateKey(apiKey as string);
|
const keyEntry = await this.apiKeysService.validateKey(apiKey as string);
|
||||||
this.logger.log(`[${method} ${url}] accepted – key "${keyEntry.name}" (id=${keyEntry.id})`);
|
this.logger.log(
|
||||||
|
`[${method} ${url}] accepted – key "${keyEntry.name}" (id=${keyEntry.id})`,
|
||||||
|
);
|
||||||
request.apiKeyMetadata = { id: keyEntry.id, name: keyEntry.name };
|
request.apiKeyMetadata = { id: keyEntry.id, name: keyEntry.name };
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`[${method} ${url}] rejected – validation failed: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`[${method} ${url}] rejected – validation failed: ${err.message}`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException(err.message || 'Invalid API Key');
|
throw new UnauthorizedException(err.message || 'Invalid API Key');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,27 @@ export class ApiKeysService {
|
|||||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createApiKey(name: string, expiresDays?: number): Promise<{ plainKey: string; entity: ApiKey }> {
|
async createApiKey(
|
||||||
|
name: string,
|
||||||
|
expiresDays?: number,
|
||||||
|
): Promise<{ plainKey: string; entity: ApiKey }> {
|
||||||
const prefix = 'pm_';
|
const prefix = 'pm_';
|
||||||
const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex
|
const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex
|
||||||
const plainKey = `${prefix}${randomPart}`;
|
const plainKey = `${prefix}${randomPart}`;
|
||||||
|
|
||||||
const keyHash = this.hashKey(plainKey);
|
const keyHash = this.hashKey(plainKey);
|
||||||
|
|
||||||
const apiKey = this.apiKeyRepo.create({
|
const apiKey = this.apiKeyRepo.create({
|
||||||
name,
|
name,
|
||||||
keyPrefix: prefix,
|
keyPrefix: prefix,
|
||||||
keyHash,
|
keyHash,
|
||||||
expiresAt: expiresDays ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null,
|
expiresAt: expiresDays
|
||||||
|
? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedKey = await this.apiKeyRepo.save(apiKey);
|
const savedKey = await this.apiKeyRepo.save(apiKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plainKey,
|
plainKey,
|
||||||
entity: savedKey,
|
entity: savedKey,
|
||||||
@@ -37,7 +42,7 @@ export class ApiKeysService {
|
|||||||
|
|
||||||
async validateKey(plainKey: string): Promise<ApiKey> {
|
async validateKey(plainKey: string): Promise<ApiKey> {
|
||||||
const keyHash = this.hashKey(plainKey);
|
const keyHash = this.hashKey(plainKey);
|
||||||
|
|
||||||
const apiKey = await this.apiKeyRepo.findOne({
|
const apiKey = await this.apiKeyRepo.findOne({
|
||||||
where: { keyHash },
|
where: { keyHash },
|
||||||
});
|
});
|
||||||
@@ -52,7 +57,11 @@ export class ApiKeysService {
|
|||||||
|
|
||||||
// Update last used timestamp (async, don't wait for it to return response faster)
|
// Update last used timestamp (async, don't wait for it to return response faster)
|
||||||
apiKey.lastUsedAt = new Date();
|
apiKey.lastUsedAt = new Date();
|
||||||
this.apiKeyRepo.save(apiKey).catch(err => this.logger.error('Fehler beim Aktualisieren von lastUsedAt', err));
|
this.apiKeyRepo
|
||||||
|
.save(apiKey)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.error('Fehler beim Aktualisieren von lastUsedAt', err),
|
||||||
|
);
|
||||||
|
|
||||||
return apiKey;
|
return apiKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ import { PermissionsGuard } from './permissions.guard';
|
|||||||
useClass: PermissionsGuard,
|
useClass: PermissionsGuard,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [PassportModule, ApiKeysService, ApiKeyGuard, JwtAuthGuard, JwtOrApiKeyGuard, PermissionsGuard, TypeOrmModule],
|
exports: [
|
||||||
|
PassportModule,
|
||||||
|
ApiKeysService,
|
||||||
|
ApiKeyGuard,
|
||||||
|
JwtAuthGuard,
|
||||||
|
JwtOrApiKeyGuard,
|
||||||
|
PermissionsGuard,
|
||||||
|
TypeOrmModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
import { ApiKeyGuard } from './api-key.guard';
|
import { ApiKeyGuard } from './api-key.guard';
|
||||||
@@ -28,7 +33,9 @@ export class JwtOrApiKeyGuard implements CanActivate {
|
|||||||
// Try JWT first
|
// Try JWT first
|
||||||
try {
|
try {
|
||||||
const result = this.jwtGuard.canActivate(context);
|
const result = this.jwtGuard.canActivate(context);
|
||||||
const jwtOk = isObservable(result) ? await lastValueFrom(result) : await result;
|
const jwtOk = isObservable(result)
|
||||||
|
? await lastValueFrom(result)
|
||||||
|
: await result;
|
||||||
if (jwtOk) {
|
if (jwtOk) {
|
||||||
this.logger.log(`${tag} authenticated via JWT`);
|
this.logger.log(`${tag} authenticated via JWT`);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: any): { userId: string; email: string; name: string; preferredUsername: string | null; groups: string[]; permissions: any[] } {
|
validate(payload: any): {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
preferredUsername: string | null;
|
||||||
|
groups: string[];
|
||||||
|
permissions: any[];
|
||||||
|
} {
|
||||||
const groups = payload.groups || [];
|
const groups = payload.groups || [];
|
||||||
return {
|
return {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ import { SetMetadata } from '@nestjs/common';
|
|||||||
import { Permission } from './permissions.enum';
|
import { Permission } from './permissions.enum';
|
||||||
|
|
||||||
export const PERMISSIONS_KEY = 'permissions';
|
export const PERMISSIONS_KEY = 'permissions';
|
||||||
export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||||
|
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export const Permission = {
|
|||||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = typeof Permission[keyof typeof Permission];
|
export type Permission = (typeof Permission)[keyof typeof Permission];
|
||||||
|
|
||||||
export function mapGroupsToPermissions(groups: string[] | undefined | null): Permission[] {
|
export function mapGroupsToPermissions(
|
||||||
|
groups: string[] | undefined | null,
|
||||||
|
): Permission[] {
|
||||||
const permissions = new Set<Permission>();
|
const permissions = new Set<Permission>();
|
||||||
|
|
||||||
if (!groups || !Array.isArray(groups)) {
|
if (!groups || !Array.isArray(groups)) {
|
||||||
@@ -28,7 +30,8 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
|||||||
return Array.from(permissions);
|
return Array.from(permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groups.includes('PM_Belege')) permissions.add(Permission.PROCESS_MANUALLY);
|
if (groups.includes('PM_Belege'))
|
||||||
|
permissions.add(Permission.PROCESS_MANUALLY);
|
||||||
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
||||||
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
||||||
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
||||||
|
|||||||
@@ -8,32 +8,34 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(PERMISSIONS_KEY, [
|
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
|
||||||
context.getHandler(),
|
PERMISSIONS_KEY,
|
||||||
context.getClass(),
|
[context.getHandler(), context.getClass()],
|
||||||
]);
|
);
|
||||||
|
|
||||||
if (!requiredPermissions) {
|
if (!requiredPermissions) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const { user } = request;
|
const { user } = request;
|
||||||
|
|
||||||
if (request.apiKeyMetadata) {
|
if (request.apiKeyMetadata) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.permissions) {
|
if (!user || !user.permissions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermissions = user.permissions as Permission[];
|
const userPermissions = user.permissions as Permission[];
|
||||||
|
|
||||||
if (userPermissions.includes(Permission.MANAGE_ALL)) {
|
if (userPermissions.includes(Permission.MANAGE_ALL)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return requiredPermissions.some((permission) => userPermissions.includes(permission));
|
return requiredPermissions.some((permission) =>
|
||||||
|
userPermissions.includes(permission),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import {
|
|||||||
BarcodeTemplate,
|
BarcodeTemplate,
|
||||||
type BarcodeActionType,
|
type BarcodeActionType,
|
||||||
} from '../database/entities/barcode-template.entity';
|
} from '../database/entities/barcode-template.entity';
|
||||||
import { InboxDocument, type StoredQrCode } from '../database/entities/inbox-document.entity';
|
import {
|
||||||
|
InboxDocument,
|
||||||
|
type StoredQrCode,
|
||||||
|
} from '../database/entities/inbox-document.entity';
|
||||||
import { PageCacheService } from './page-cache.service';
|
import { PageCacheService } from './page-cache.service';
|
||||||
import { applyTemplate, buildVariables } from '../inbox-postprocessor/variable-resolver';
|
import {
|
||||||
|
applyTemplate,
|
||||||
|
buildVariables,
|
||||||
|
} from '../inbox-postprocessor/variable-resolver';
|
||||||
|
|
||||||
export interface MatchedBarcode {
|
export interface MatchedBarcode {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -51,7 +57,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
try {
|
try {
|
||||||
rows = await this.templateRepo.find();
|
rows = await this.templateRepo.find();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Template-Migration: Query fehlgeschlagen: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Template-Migration: Query fehlgeschlagen: ${err.message}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let migrated = 0;
|
let migrated = 0;
|
||||||
@@ -59,13 +67,17 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
const actions = (tpl.Actions ?? []) as string[];
|
const actions = (tpl.Actions ?? []) as string[];
|
||||||
if (actions.includes('SPLIT_BEFORE')) {
|
if (actions.includes('SPLIT_BEFORE')) {
|
||||||
tpl.SplitBefore = true;
|
tpl.SplitBefore = true;
|
||||||
tpl.Actions = actions.filter((a) => a !== 'SPLIT_BEFORE') as BarcodeActionType[];
|
tpl.Actions = actions.filter(
|
||||||
|
(a) => a !== 'SPLIT_BEFORE',
|
||||||
|
) as BarcodeActionType[];
|
||||||
await this.templateRepo.save(tpl);
|
await this.templateRepo.save(tpl);
|
||||||
migrated += 1;
|
migrated += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (migrated > 0) {
|
if (migrated > 0) {
|
||||||
this.logger.log(`Template-Migration: ${migrated} Vorlage(n) auf SplitBefore-Flag umgestellt`);
|
this.logger.log(
|
||||||
|
`Template-Migration: ${migrated} Vorlage(n) auf SplitBefore-Flag umgestellt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +95,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
try {
|
try {
|
||||||
await this.documentRepo.save(doc);
|
await this.documentRepo.save(doc);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Scan-Ergebnis konnte nicht gespeichert werden (${doc.Id}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Scan-Ergebnis konnte nicht gespeichert werden (${doc.Id}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.matchTemplates(qrCodes);
|
return this.matchTemplates(qrCodes);
|
||||||
@@ -105,7 +119,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
return this.matchTemplates(doc.QrCodes ?? []);
|
return this.matchTemplates(doc.QrCodes ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async matchTemplates(qrCodes: StoredQrCode[]): Promise<MatchedBarcode[]> {
|
private async matchTemplates(
|
||||||
|
qrCodes: StoredQrCode[],
|
||||||
|
): Promise<MatchedBarcode[]> {
|
||||||
if (qrCodes.length === 0) return [];
|
if (qrCodes.length === 0) return [];
|
||||||
const templates = await this.getTemplates();
|
const templates = await this.getTemplates();
|
||||||
return qrCodes.map((qr) => {
|
return qrCodes.map((qr) => {
|
||||||
@@ -118,7 +134,11 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
dateinameTemplate: tpl?.DateinameTemplate
|
dateinameTemplate: tpl?.DateinameTemplate
|
||||||
? applyTemplate(
|
? applyTemplate(
|
||||||
tpl.DateinameTemplate,
|
tpl.DateinameTemplate,
|
||||||
buildVariables({ doc: {} as InboxDocument, template: tpl, matchingQrValue: qr.value }),
|
buildVariables({
|
||||||
|
doc: {} as InboxDocument,
|
||||||
|
template: tpl,
|
||||||
|
matchingQrValue: qr.value,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
splitBefore: tpl?.SplitBefore ?? false,
|
splitBefore: tpl?.SplitBefore ?? false,
|
||||||
@@ -127,7 +147,10 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private firstMatch(value: string, templates: BarcodeTemplate[]): BarcodeTemplate | null {
|
private firstMatch(
|
||||||
|
value: string,
|
||||||
|
templates: BarcodeTemplate[],
|
||||||
|
): BarcodeTemplate | null {
|
||||||
for (const tpl of templates) {
|
for (const tpl of templates) {
|
||||||
try {
|
try {
|
||||||
const re = new RegExp(tpl.Regex);
|
const re = new RegExp(tpl.Regex);
|
||||||
@@ -141,7 +164,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
private async getTemplates(): Promise<BarcodeTemplate[]> {
|
private async getTemplates(): Promise<BarcodeTemplate[]> {
|
||||||
if (!this.templatesCache) {
|
if (!this.templatesCache) {
|
||||||
this.templatesCache = await this.templateRepo.find({ order: { Id: 'ASC' } });
|
this.templatesCache = await this.templateRepo.find({
|
||||||
|
order: { Id: 'ASC' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return this.templatesCache;
|
return this.templatesCache;
|
||||||
}
|
}
|
||||||
@@ -168,7 +193,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`QR-Scan fehlgeschlagen (${pdfPath}, Seite ${i + 1}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`QR-Scan fehlgeschlagen (${pdfPath}, Seite ${i + 1}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +233,22 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
const { width: imgW, height: imgH } = await image.metadata();
|
const { width: imgW, height: imgH } = await image.metadata();
|
||||||
if (!imgW || !imgH) return { found: [] };
|
if (!imgW || !imgH) return { found: [] };
|
||||||
|
|
||||||
const left = Math.round(Math.max(0, x * imgW));
|
const left = Math.round(Math.max(0, x * imgW));
|
||||||
const top = Math.round(Math.max(0, y * imgH));
|
const top = Math.round(Math.max(0, y * imgH));
|
||||||
const width = Math.round(Math.min(imgW - left, w * imgW));
|
const width = Math.round(Math.min(imgW - left, w * imgW));
|
||||||
const height = Math.round(Math.min(imgH - top, h * imgH));
|
const height = Math.round(Math.min(imgH - top, h * imgH));
|
||||||
if (width <= 0 || height <= 0) return { found: [] };
|
if (width <= 0 || height <= 0) return { found: [] };
|
||||||
|
|
||||||
const cropped = await image.extract({ left, top, width, height }).png().toBuffer();
|
const cropped = await image
|
||||||
|
.extract({ left, top, width, height })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
const qrResults = await this.qrCodeService.extractFromImage(cropped);
|
const qrResults = await this.qrCodeService.extractFromImage(cropped);
|
||||||
if (qrResults.length === 0) return { found: [] };
|
if (qrResults.length === 0) return { found: [] };
|
||||||
|
|
||||||
const existingKeys = new Set((doc.QrCodes ?? []).map((qr) => `${qr.page}:${qr.value}`));
|
const existingKeys = new Set(
|
||||||
|
(doc.QrCodes ?? []).map((qr) => `${qr.page}:${qr.value}`),
|
||||||
|
);
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
@@ -254,7 +286,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
if (docs.length === 0) return { scanned: 0, failed: 0 };
|
if (docs.length === 0) return { scanned: 0, failed: 0 };
|
||||||
|
|
||||||
this.logger.log(`Rescan: starte Neuerfassung für ${docs.length} Inbox-Dokument(e)`);
|
this.logger.log(
|
||||||
|
`Rescan: starte Neuerfassung für ${docs.length} Inbox-Dokument(e)`,
|
||||||
|
);
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
@@ -277,7 +311,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
|
|||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.log(`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`);
|
this.logger.log(
|
||||||
|
`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`,
|
||||||
|
);
|
||||||
return { scanned, failed };
|
return { scanned, failed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { BarcodeTemplate, type BarcodeActionType, type LabelInputField, type LabelElement } from '../database/entities/barcode-template.entity';
|
import {
|
||||||
|
BarcodeTemplate,
|
||||||
|
type BarcodeActionType,
|
||||||
|
type LabelInputField,
|
||||||
|
type LabelElement,
|
||||||
|
} from '../database/entities/barcode-template.entity';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
import { BarcodeScannerService } from './barcode-scanner.service';
|
import { BarcodeScannerService } from './barcode-scanner.service';
|
||||||
|
|
||||||
const VALID_ACTIONS: BarcodeActionType[] = ['SEND_TO_PAPERLESS', 'SEND_BY_EMAIL'];
|
const VALID_ACTIONS: BarcodeActionType[] = [
|
||||||
|
'SEND_TO_PAPERLESS',
|
||||||
|
'SEND_BY_EMAIL',
|
||||||
|
];
|
||||||
|
|
||||||
interface UpsertDto {
|
interface UpsertDto {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
@@ -55,8 +63,11 @@ function validate(dto: UpsertDto, partial = false): void {
|
|||||||
if (dto.SplitBefore !== undefined && typeof dto.SplitBefore !== 'boolean') {
|
if (dto.SplitBefore !== undefined && typeof dto.SplitBefore !== 'boolean') {
|
||||||
throw new BadRequestException('SplitBefore muss ein Boolean sein');
|
throw new BadRequestException('SplitBefore muss ein Boolean sein');
|
||||||
}
|
}
|
||||||
if (dto.DateinameTemplate !== undefined && dto.DateinameTemplate !== null &&
|
if (
|
||||||
typeof dto.DateinameTemplate !== 'string') {
|
dto.DateinameTemplate !== undefined &&
|
||||||
|
dto.DateinameTemplate !== null &&
|
||||||
|
typeof dto.DateinameTemplate !== 'string'
|
||||||
|
) {
|
||||||
throw new BadRequestException('DateinameTemplate muss ein String sein');
|
throw new BadRequestException('DateinameTemplate muss ein String sein');
|
||||||
}
|
}
|
||||||
if (!partial || dto.Actions !== undefined) {
|
if (!partial || dto.Actions !== undefined) {
|
||||||
@@ -83,7 +94,9 @@ export class BarcodeTemplatesController {
|
|||||||
|
|
||||||
private triggerRescan(): void {
|
private triggerRescan(): void {
|
||||||
this.scanner.rescanAll().catch((err) => {
|
this.scanner.rescanAll().catch((err) => {
|
||||||
this.logger.error(`Rescan nach Template-Änderung fehlgeschlagen: ${err.message}`);
|
this.logger.error(
|
||||||
|
`Rescan nach Template-Änderung fehlgeschlagen: ${err.message}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,21 +138,31 @@ export class BarcodeTemplatesController {
|
|||||||
const existing = await this.repo.findOneBy({ Id: id });
|
const existing = await this.repo.findOneBy({ Id: id });
|
||||||
if (!existing) throw new NotFoundException('Vorlage nicht gefunden');
|
if (!existing) throw new NotFoundException('Vorlage nicht gefunden');
|
||||||
|
|
||||||
const regexChanged = dto.Regex !== undefined && dto.Regex !== existing.Regex;
|
const regexChanged =
|
||||||
|
dto.Regex !== undefined && dto.Regex !== existing.Regex;
|
||||||
|
|
||||||
if (dto.Name !== undefined) existing.Name = dto.Name.trim();
|
if (dto.Name !== undefined) existing.Name = dto.Name.trim();
|
||||||
if (dto.Regex !== undefined) existing.Regex = dto.Regex;
|
if (dto.Regex !== undefined) existing.Regex = dto.Regex;
|
||||||
if (dto.SplitBefore !== undefined) existing.SplitBefore = dto.SplitBefore;
|
if (dto.SplitBefore !== undefined) existing.SplitBefore = dto.SplitBefore;
|
||||||
if (dto.DateinameTemplate !== undefined) existing.DateinameTemplate = dto.DateinameTemplate ?? null;
|
if (dto.DateinameTemplate !== undefined)
|
||||||
|
existing.DateinameTemplate = dto.DateinameTemplate ?? null;
|
||||||
if (dto.Actions !== undefined) existing.Actions = dto.Actions;
|
if (dto.Actions !== undefined) existing.Actions = dto.Actions;
|
||||||
if (dto.LabelEnabled !== undefined) existing.LabelEnabled = dto.LabelEnabled;
|
if (dto.LabelEnabled !== undefined)
|
||||||
if (dto.LabelWidthMm !== undefined) existing.LabelWidthMm = dto.LabelWidthMm ?? null;
|
existing.LabelEnabled = dto.LabelEnabled;
|
||||||
if (dto.LabelHeightMm !== undefined) existing.LabelHeightMm = dto.LabelHeightMm ?? null;
|
if (dto.LabelWidthMm !== undefined)
|
||||||
if (dto.LabelInputFields !== undefined) existing.LabelInputFields = dto.LabelInputFields ?? null;
|
existing.LabelWidthMm = dto.LabelWidthMm ?? null;
|
||||||
if (dto.LabelGetUrl !== undefined) existing.LabelGetUrl = dto.LabelGetUrl ?? null;
|
if (dto.LabelHeightMm !== undefined)
|
||||||
if (dto.LabelPrintedUrl !== undefined) existing.LabelPrintedUrl = dto.LabelPrintedUrl ?? null;
|
existing.LabelHeightMm = dto.LabelHeightMm ?? null;
|
||||||
if (dto.LabelReleaseUrl !== undefined) existing.LabelReleaseUrl = dto.LabelReleaseUrl ?? null;
|
if (dto.LabelInputFields !== undefined)
|
||||||
if (dto.LabelLayout !== undefined) existing.LabelLayout = dto.LabelLayout ?? null;
|
existing.LabelInputFields = dto.LabelInputFields ?? null;
|
||||||
|
if (dto.LabelGetUrl !== undefined)
|
||||||
|
existing.LabelGetUrl = dto.LabelGetUrl ?? null;
|
||||||
|
if (dto.LabelPrintedUrl !== undefined)
|
||||||
|
existing.LabelPrintedUrl = dto.LabelPrintedUrl ?? null;
|
||||||
|
if (dto.LabelReleaseUrl !== undefined)
|
||||||
|
existing.LabelReleaseUrl = dto.LabelReleaseUrl ?? null;
|
||||||
|
if (dto.LabelLayout !== undefined)
|
||||||
|
existing.LabelLayout = dto.LabelLayout ?? null;
|
||||||
|
|
||||||
const saved = await this.repo.save(existing);
|
const saved = await this.repo.save(existing);
|
||||||
this.scanner.invalidateTemplates();
|
this.scanner.invalidateTemplates();
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export class PageCacheService {
|
|||||||
private readonly inboxRoot: string;
|
private readonly inboxRoot: string;
|
||||||
|
|
||||||
constructor(configService: ConfigService) {
|
constructor(configService: ConfigService) {
|
||||||
this.inboxRoot = configService.get<string>('INBOX_DATA_DIR', '/mnt/data/inbox');
|
this.inboxRoot = configService.get<string>(
|
||||||
|
'INBOX_DATA_DIR',
|
||||||
|
'/mnt/data/inbox',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
documentDir(documentId: string): string {
|
documentDir(documentId: string): string {
|
||||||
@@ -47,7 +50,10 @@ export class PageCacheService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.copyFile(src, previewDest);
|
await fs.copyFile(src, previewDest);
|
||||||
await sharp(src).resize({ width: THUMBNAIL_WIDTH }).png().toFile(thumbDest);
|
await sharp(src)
|
||||||
|
.resize({ width: THUMBNAIL_WIDTH })
|
||||||
|
.png()
|
||||||
|
.toFile(thumbDest);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`,
|
`Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`,
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ export class DailyDigestController {
|
|||||||
async sendNow(@Request() req: any) {
|
async sendNow(@Request() req: any) {
|
||||||
const { userId, email, preferredUsername, groups } = req.user;
|
const { userId, email, preferredUsername, groups } = req.user;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername, groups);
|
await this.dailyDigestService.sendDigestForUser(
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
preferredUsername,
|
||||||
|
groups,
|
||||||
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
|
|||||||
@@ -17,16 +17,56 @@ interface DigestTile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DIGEST_TILES: DigestTile[] = [
|
const DIGEST_TILES: DigestTile[] = [
|
||||||
{ key: 'inbox', title: 'Eingangsbox', description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', icon: '📥', accent: '#1677ff', accentSoft: '#e6f0ff', permission: Permission.VIEW_SCANNER },
|
{
|
||||||
{ key: 'posteingang', title: 'Posteingang', description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', icon: '📄', accent: '#13c2c2', accentSoft: '#e6fffb', permission: Permission.VIEW_INBOX },
|
key: 'inbox',
|
||||||
{ key: 'manuell', title: 'Manuell bearbeiten', description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', icon: '✏️', accent: '#fa8c16', accentSoft: '#fff7e6', permission: Permission.PROCESS_MANUALLY },
|
title: 'Eingangsbox',
|
||||||
{ key: 'mailpostfach', title: 'Mailpostfach', description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', icon: '📬', accent: '#722ed1', accentSoft: '#f9f0ff', permission: Permission.VIEW_MAIL },
|
description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.',
|
||||||
{ key: 'agrarmonitor', title: 'In Agrarmonitor', description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', icon: '🌱', accent: '#52c41a', accentSoft: '#f6ffed', permission: Permission.PROCESS_MANUALLY },
|
icon: '📥',
|
||||||
|
accent: '#1677ff',
|
||||||
|
accentSoft: '#e6f0ff',
|
||||||
|
permission: Permission.VIEW_SCANNER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'posteingang',
|
||||||
|
title: 'Posteingang',
|
||||||
|
description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.',
|
||||||
|
icon: '📄',
|
||||||
|
accent: '#13c2c2',
|
||||||
|
accentSoft: '#e6fffb',
|
||||||
|
permission: Permission.VIEW_INBOX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manuell',
|
||||||
|
title: 'Manuell bearbeiten',
|
||||||
|
description: 'Dokumente mit fehlender Erkennung manuell ergänzen.',
|
||||||
|
icon: '✏️',
|
||||||
|
accent: '#fa8c16',
|
||||||
|
accentSoft: '#fff7e6',
|
||||||
|
permission: Permission.PROCESS_MANUALLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mailpostfach',
|
||||||
|
title: 'Mailpostfach',
|
||||||
|
description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.',
|
||||||
|
icon: '📬',
|
||||||
|
accent: '#722ed1',
|
||||||
|
accentSoft: '#f9f0ff',
|
||||||
|
permission: Permission.VIEW_MAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'agrarmonitor',
|
||||||
|
title: 'In Agrarmonitor',
|
||||||
|
description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.',
|
||||||
|
icon: '🌱',
|
||||||
|
accent: '#52c41a',
|
||||||
|
accentSoft: '#f6ffed',
|
||||||
|
permission: Permission.PROCESS_MANUALLY,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] {
|
function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] {
|
||||||
const permissions = mapGroupsToPermissions(groups ?? []);
|
const permissions = mapGroupsToPermissions(groups ?? []);
|
||||||
return DIGEST_TILES.filter(t => permissions.includes(t.permission));
|
return DIGEST_TILES.filter((t) => permissions.includes(t.permission));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -41,19 +81,42 @@ export class DailyDigestService {
|
|||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.appUrl = this.configService.get<string>('APP_URL', '').replace(/\/+$/, '');
|
this.appUrl = this.configService
|
||||||
this.agrarmonitorBaseUrl = this.configService.get<string>('AGRARMONITOR_BASE_URL', '').replace(/\/+$/, '');
|
.get<string>('APP_URL', '')
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
this.agrarmonitorBaseUrl = this.configService
|
||||||
|
.get<string>('AGRARMONITOR_BASE_URL', '')
|
||||||
|
.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendDigestForUser(userId: string, email: string, preferredUsername?: string, groups?: string[]) {
|
async sendDigestForUser(
|
||||||
|
userId: string,
|
||||||
|
email: string,
|
||||||
|
preferredUsername?: string,
|
||||||
|
groups?: string[],
|
||||||
|
) {
|
||||||
const visibleTiles = getVisibleTiles(groups);
|
const visibleTiles = getVisibleTiles(groups);
|
||||||
if (visibleTiles.length === 0) {
|
if (visibleTiles.length === 0) {
|
||||||
this.logger.warn(`Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`);
|
this.logger.warn(
|
||||||
|
`Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
const today = new Date().toLocaleDateString('de-DE', {
|
||||||
const counts = await this.statsService.getDashboardCounts(preferredUsername);
|
weekday: 'long',
|
||||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles);
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
const counts =
|
||||||
|
await this.statsService.getDashboardCounts(preferredUsername);
|
||||||
|
const html = buildDigestHtml(
|
||||||
|
counts,
|
||||||
|
today,
|
||||||
|
this.appUrl,
|
||||||
|
this.agrarmonitorBaseUrl,
|
||||||
|
visibleTiles,
|
||||||
|
);
|
||||||
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
||||||
await this.mailService.sendMail({
|
await this.mailService.sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
@@ -61,30 +124,50 @@ export class DailyDigestService {
|
|||||||
body: plainText,
|
body: plainText,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
this.logger.log(`Manueller Digest gesendet an ${email} (userId: ${userId})`);
|
this.logger.log(
|
||||||
|
`Manueller Digest gesendet an ${email} (userId: ${userId})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *', { timeZone: 'Europe/Berlin' })
|
@Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *', {
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
})
|
||||||
async sendDailyDigests() {
|
async sendDailyDigests() {
|
||||||
this.logger.log('Starte täglichen E-Mail-Digest...');
|
this.logger.log('Starte täglichen E-Mail-Digest...');
|
||||||
|
|
||||||
const subscribers = await this.userSettingsService.findAllDigestSubscribers();
|
const subscribers =
|
||||||
|
await this.userSettingsService.findAllDigestSubscribers();
|
||||||
if (subscribers.length === 0) {
|
if (subscribers.length === 0) {
|
||||||
this.logger.log('Keine Abonnenten für den täglichen Digest.');
|
this.logger.log('Keine Abonnenten für den täglichen Digest.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
const today = new Date().toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
for (const sub of subscribers) {
|
for (const sub of subscribers) {
|
||||||
try {
|
try {
|
||||||
const visibleTiles = getVisibleTiles(sub.UserGroups);
|
const visibleTiles = getVisibleTiles(sub.UserGroups);
|
||||||
if (visibleTiles.length === 0) {
|
if (visibleTiles.length === 0) {
|
||||||
this.logger.warn(`Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`);
|
this.logger.warn(
|
||||||
|
`Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined);
|
const counts = await this.statsService.getDashboardCounts(
|
||||||
const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles);
|
sub.UserPreferredUsername ?? undefined,
|
||||||
|
);
|
||||||
|
const html = buildDigestHtml(
|
||||||
|
counts,
|
||||||
|
today,
|
||||||
|
this.appUrl,
|
||||||
|
this.agrarmonitorBaseUrl,
|
||||||
|
visibleTiles,
|
||||||
|
);
|
||||||
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
const plainText = buildDigestPlainText(counts, today, visibleTiles);
|
||||||
await this.mailService.sendMail({
|
await this.mailService.sendMail({
|
||||||
to: sub.UserEmail!,
|
to: sub.UserEmail!,
|
||||||
@@ -94,41 +177,60 @@ export class DailyDigestService {
|
|||||||
});
|
});
|
||||||
this.logger.log(`Digest gesendet an ${sub.UserEmail}`);
|
this.logger.log(`Digest gesendet an ${sub.UserEmail}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`);
|
this.logger.error(
|
||||||
|
`Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tileUrl(tile: DigestTile, appUrl: string, agrarmonitorBaseUrl: string): string {
|
function tileUrl(
|
||||||
if (tile.key === 'agrarmonitor') return agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : '';
|
tile: DigestTile,
|
||||||
|
appUrl: string,
|
||||||
|
agrarmonitorBaseUrl: string,
|
||||||
|
): string {
|
||||||
|
if (tile.key === 'agrarmonitor')
|
||||||
|
return agrarmonitorBaseUrl
|
||||||
|
? `${agrarmonitorBaseUrl}/dateien/eingang#dateien`
|
||||||
|
: '';
|
||||||
return appUrl ? `${appUrl}/${tile.key === 'inbox' ? 'inbox' : tile.key}` : '';
|
return appUrl ? `${appUrl}/${tile.key === 'inbox' ? 'inbox' : tile.key}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string, visibleTiles: DigestTile[]): string {
|
function buildDigestHtml(
|
||||||
const tiles = visibleTiles.map(t => ({
|
counts: DashboardCounts,
|
||||||
|
today: string,
|
||||||
|
appUrl: string,
|
||||||
|
agrarmonitorBaseUrl: string,
|
||||||
|
visibleTiles: DigestTile[],
|
||||||
|
): string {
|
||||||
|
const tiles = visibleTiles.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
url: tileUrl(t, appUrl, agrarmonitorBaseUrl),
|
url: tileUrl(t, appUrl, agrarmonitorBaseUrl),
|
||||||
count: counts[t.key],
|
count: counts[t.key],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalOpen = tiles.reduce((sum, t) => sum + t.count, 0);
|
const totalOpen = tiles.reduce((sum, t) => sum + t.count, 0);
|
||||||
const summaryText = totalOpen > 0
|
const summaryText =
|
||||||
? `Sie haben <strong>${totalOpen} offene Vorgänge</strong> in Ihren Bereichen.`
|
totalOpen > 0
|
||||||
: 'Alle Bereiche sind auf dem aktuellen Stand. ✓';
|
? `Sie haben <strong>${totalOpen} offene Vorgänge</strong> in Ihren Bereichen.`
|
||||||
|
: 'Alle Bereiche sind auf dem aktuellen Stand. ✓';
|
||||||
|
|
||||||
const cards = tiles.map(t => {
|
const cards = tiles
|
||||||
const badge = t.count > 0
|
.map((t) => {
|
||||||
? `<td align="right" valign="top"><span style="display:inline-block;background:${t.accent};color:#ffffff;font-family:sans-serif;font-size:12px;font-weight:700;padding:2px 9px;border-radius:10px;">${t.count}</span></td>`
|
const badge =
|
||||||
: '<td></td>';
|
t.count > 0
|
||||||
const footerCount = t.count > 0
|
? `<td align="right" valign="top"><span style="display:inline-block;background:${t.accent};color:#ffffff;font-family:sans-serif;font-size:12px;font-weight:700;padding:2px 9px;border-radius:10px;">${t.count}</span></td>`
|
||||||
? `<span style="font-family:sans-serif;font-size:13px;color:${t.accent};font-weight:600;">${t.count} offen</span>`
|
: '<td></td>';
|
||||||
: `<span style="font-family:sans-serif;font-size:13px;color:#9ca3af;">Keine offenen Vorgänge</span>`;
|
const footerCount =
|
||||||
const openLink = t.url
|
t.count > 0
|
||||||
? `<a href="${t.url}" style="font-family:sans-serif;font-size:13px;color:${t.accent};text-decoration:none;font-weight:500;">Öffnen ›</a>`
|
? `<span style="font-family:sans-serif;font-size:13px;color:${t.accent};font-weight:600;">${t.count} offen</span>`
|
||||||
: '';
|
: `<span style="font-family:sans-serif;font-size:13px;color:#9ca3af;">Keine offenen Vorgänge</span>`;
|
||||||
|
const openLink = t.url
|
||||||
|
? `<a href="${t.url}" style="font-family:sans-serif;font-size:13px;color:${t.accent};text-decoration:none;font-weight:500;">Öffnen ›</a>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr><td style="padding:0 0 16px 0;">
|
<tr><td style="padding:0 0 16px 0;">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #e5e7eb;border-top:3px solid ${t.accent};border-radius:8px;">
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #e5e7eb;border-top:3px solid ${t.accent};border-radius:8px;">
|
||||||
<tr><td style="padding:20px 20px 16px;">
|
<tr><td style="padding:20px 20px 16px;">
|
||||||
@@ -153,7 +255,8 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string,
|
|||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
}).join('');
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -197,8 +300,14 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string,
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDigestPlainText(counts: DashboardCounts, today: string, visibleTiles: DigestTile[]): string {
|
function buildDigestPlainText(
|
||||||
const lines = visibleTiles.map(t => ` ${t.title.padEnd(22)} ${counts[t.key]}`).join('\n');
|
counts: DashboardCounts,
|
||||||
|
today: string,
|
||||||
|
visibleTiles: DigestTile[],
|
||||||
|
): string {
|
||||||
|
const lines = visibleTiles
|
||||||
|
.map((t) => ` ${t.title.padEnd(22)} ${counts[t.key]}`)
|
||||||
|
.join('\n');
|
||||||
return `Paperless Manager – Tagesübersicht ${today}
|
return `Paperless Manager – Tagesübersicht ${today}
|
||||||
|
|
||||||
Offene Vorgänge:
|
Offene Vorgänge:
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity('api_keys')
|
@Entity('api_keys')
|
||||||
export class ApiKey {
|
export class ApiKey {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToOne, JoinColumn, Index } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
import { Email } from './email.entity';
|
import { Email } from './email.entity';
|
||||||
import { Content } from './content.entity';
|
import { Content } from './content.entity';
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,25 @@ export interface LabelInputField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LabelElement =
|
export type LabelElement =
|
||||||
| { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number }
|
| {
|
||||||
| { type: 'qr'; content: string; x: number; y: number; sizeMm: number }
|
type: 'text';
|
||||||
| { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number };
|
content: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fontSize: number;
|
||||||
|
bold?: boolean;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
| { type: 'qr'; content: string; x: number; y: number; sizeMm: number }
|
||||||
|
| {
|
||||||
|
type: 'line';
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
lineWidth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Entity('barcode_templates')
|
@Entity('barcode_templates')
|
||||||
export class BarcodeTemplate {
|
export class BarcodeTemplate {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn, Index } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
import { Attachment } from './attachment.entity';
|
import { Attachment } from './attachment.entity';
|
||||||
|
|
||||||
@Entity('Contents')
|
@Entity('Contents')
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column, Index, OneToMany } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
import { Attachment } from './attachment.entity';
|
import { Attachment } from './attachment.entity';
|
||||||
|
|
||||||
@Entity('Emails')
|
@Entity('Emails')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
export interface FilterCondition {
|
export interface FilterCondition {
|
||||||
field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_<id>' | 'title' | 'archive_serial_number'
|
field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_<id>' | 'title' | 'archive_serial_number'
|
||||||
operator: string; // 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'is_set' | 'is_not_set'
|
operator: string; // 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'is_set' | 'is_not_set'
|
||||||
value: any;
|
value: any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export class UserClient {
|
|||||||
@Column({ type: 'int' })
|
@Column({ type: 'int' })
|
||||||
ClientId!: number;
|
ClientId!: number;
|
||||||
|
|
||||||
@Column({ type: 'enum', enum: ['viewer', 'editor', 'admin'], default: 'editor' })
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['viewer', 'editor', 'admin'],
|
||||||
|
default: 'editor',
|
||||||
|
})
|
||||||
Role!: 'viewer' | 'editor' | 'admin';
|
Role!: 'viewer' | 'editor' | 'admin';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export class EmailDownloadController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async backfillThumbnails() {
|
async backfillThumbnails() {
|
||||||
this.logger.log('Manueller Backfill für Thumbnails wurde ausgelöst.');
|
this.logger.log('Manueller Backfill für Thumbnails wurde ausgelöst.');
|
||||||
const result = await this.emailDownloadService.backfillThumbnailsForNewEmails();
|
const result =
|
||||||
|
await this.emailDownloadService.backfillThumbnailsForNewEmails();
|
||||||
return { message: 'Backfill abgeschlossen.', result };
|
return { message: 'Backfill abgeschlossen.', result };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ import { EmailModule } from '../email/email.module';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Email, Attachment, Content]),
|
TypeOrmModule.forFeature([Email, Attachment, Content]),
|
||||||
PreprocessingModule,
|
PreprocessingModule,
|
||||||
EmailModule
|
EmailModule,
|
||||||
],
|
],
|
||||||
controllers: [EmailDownloadController],
|
controllers: [EmailDownloadController],
|
||||||
providers: [EmailDownloadService],
|
providers: [EmailDownloadService],
|
||||||
exports: [EmailDownloadService],
|
exports: [EmailDownloadService],
|
||||||
})
|
})
|
||||||
export class EmailDownloadModule {}
|
export class EmailDownloadModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ImapFlow, type FetchMessageObject } from 'imapflow';
|
import { ImapFlow, type FetchMessageObject } from 'imapflow';
|
||||||
import { simpleParser, type AddressObject, type Attachment as MailAttachment } from 'mailparser';
|
import {
|
||||||
|
simpleParser,
|
||||||
|
type AddressObject,
|
||||||
|
type Attachment as MailAttachment,
|
||||||
|
} from 'mailparser';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -26,8 +30,10 @@ export class EmailDownloadService {
|
|||||||
private readonly pdfService: PdfService,
|
private readonly pdfService: PdfService,
|
||||||
private readonly pageCache: EmailPageCacheService,
|
private readonly pageCache: EmailPageCacheService,
|
||||||
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
||||||
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>,
|
@InjectRepository(Attachment)
|
||||||
@InjectRepository(Content) private readonly contentRepo: Repository<Content>,
|
private readonly attachmentRepo: Repository<Attachment>,
|
||||||
|
@InjectRepository(Content)
|
||||||
|
private readonly contentRepo: Repository<Content>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
@@ -40,7 +46,10 @@ export class EmailDownloadService {
|
|||||||
try {
|
try {
|
||||||
await this.fetchAndStore();
|
await this.fetchAndStore();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Fehler im E-Mail-Download-Job: ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`Fehler im E-Mail-Download-Job: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
}
|
}
|
||||||
@@ -49,12 +58,15 @@ export class EmailDownloadService {
|
|||||||
private async fetchAndStore(): Promise<void> {
|
private async fetchAndStore(): Promise<void> {
|
||||||
const host = this.configService.get<string>('IMAP_HOST');
|
const host = this.configService.get<string>('IMAP_HOST');
|
||||||
const port = this.configService.get<number>('IMAP_PORT', 993);
|
const port = this.configService.get<number>('IMAP_PORT', 993);
|
||||||
const secure = this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true';
|
const secure =
|
||||||
|
this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true';
|
||||||
const user = this.configService.get<string>('IMAP_USERNAME');
|
const user = this.configService.get<string>('IMAP_USERNAME');
|
||||||
const pass = this.configService.get<string>('IMAP_PASSWORD');
|
const pass = this.configService.get<string>('IMAP_PASSWORD');
|
||||||
|
|
||||||
if (!host || !user || !pass) {
|
if (!host || !user || !pass) {
|
||||||
this.logger.warn('IMAP-Konfiguration unvollständig – Job wird übersprungen.');
|
this.logger.warn(
|
||||||
|
'IMAP-Konfiguration unvollständig – Job wird übersprungen.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,35 +86,47 @@ export class EmailDownloadService {
|
|||||||
const lock = await client.getMailboxLock('INBOX');
|
const lock = await client.getMailboxLock('INBOX');
|
||||||
try {
|
try {
|
||||||
const status = await client.status('INBOX', { messages: true });
|
const status = await client.status('INBOX', { messages: true });
|
||||||
this.logger.log(`Posteingang geöffnet. Anzahl der Nachrichten: ${status.messages ?? 0}`);
|
this.logger.log(
|
||||||
|
`Posteingang geöffnet. Anzahl der Nachrichten: ${status.messages ?? 0}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!status.messages || status.messages === 0) {
|
if (!status.messages || status.messages === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iter = client.fetch(
|
const iter = client.fetch('1:*', {
|
||||||
'1:*',
|
envelope: true,
|
||||||
{ envelope: true, uid: true, source: true },
|
uid: true,
|
||||||
);
|
source: true,
|
||||||
|
});
|
||||||
|
|
||||||
for await (const msg of iter) {
|
for await (const msg of iter) {
|
||||||
const messageId = msg.envelope?.messageId;
|
const messageId = msg.envelope?.messageId;
|
||||||
if (!messageId) continue;
|
if (!messageId) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await this.emailRepo.findOne({ where: { MessageId: messageId } });
|
const existing = await this.emailRepo.findOne({
|
||||||
|
where: { MessageId: messageId },
|
||||||
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logger.debug(`E-Mail mit MessageId ${messageId} bereits vorhanden.`);
|
this.logger.debug(
|
||||||
|
`E-Mail mit MessageId ${messageId} bereits vorhanden.`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.processMessage(msg);
|
await this.processMessage(msg);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Fehler beim Abrufen der Nachricht ${messageId}: ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`Fehler beim Abrufen der Nachricht ${messageId}: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log('Alle neuen E-Mails und deren Anhänge wurden in der Datenbank gespeichert.');
|
this.logger.log(
|
||||||
|
'Alle neuen E-Mails und deren Anhänge wurden in der Datenbank gespeichert.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
lock.release();
|
lock.release();
|
||||||
await client.logout().catch(() => undefined);
|
await client.logout().catch(() => undefined);
|
||||||
@@ -119,12 +143,18 @@ export class EmailDownloadService {
|
|||||||
email.MessageId = messageId;
|
email.MessageId = messageId;
|
||||||
email.SenderAddress = formatAddress(parsed.from);
|
email.SenderAddress = formatAddress(parsed.from);
|
||||||
email.RecipientAddress = formatAddress(parsed.to);
|
email.RecipientAddress = formatAddress(parsed.to);
|
||||||
email.Subject = (msg.envelope?.subject ?? parsed.subject ?? '').slice(0, 500);
|
email.Subject = (msg.envelope?.subject ?? parsed.subject ?? '').slice(
|
||||||
|
0,
|
||||||
|
500,
|
||||||
|
);
|
||||||
email.Date = msg.envelope?.date ?? parsed.date ?? new Date();
|
email.Date = msg.envelope?.date ?? parsed.date ?? new Date();
|
||||||
email.Body = parsed.html || parsed.text || '';
|
email.Body = parsed.html || parsed.text || '';
|
||||||
email.Status = 0;
|
email.Status = 0;
|
||||||
|
|
||||||
const attachmentsToPersist: Array<{ attachment: Attachment; buffer: Buffer }> = [];
|
const attachmentsToPersist: Array<{
|
||||||
|
attachment: Attachment;
|
||||||
|
buffer: Buffer;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const att of parsed.attachments) {
|
for (const att of parsed.attachments) {
|
||||||
const entry = await this.buildAttachment(att);
|
const entry = await this.buildAttachment(att);
|
||||||
@@ -132,9 +162,13 @@ export class EmailDownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Double-Check: nochmal gegen DB prüfen (Race-Condition-Schutz wie in C#)
|
// Double-Check: nochmal gegen DB prüfen (Race-Condition-Schutz wie in C#)
|
||||||
const existing2 = await this.emailRepo.findOne({ where: { MessageId: messageId } });
|
const existing2 = await this.emailRepo.findOne({
|
||||||
|
where: { MessageId: messageId },
|
||||||
|
});
|
||||||
if (existing2) {
|
if (existing2) {
|
||||||
this.logger.debug(`E-Mail mit MessageId ${messageId} nach dem Download bereits vorhanden.`);
|
this.logger.debug(
|
||||||
|
`E-Mail mit MessageId ${messageId} nach dem Download bereits vorhanden.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,55 +186,79 @@ export class EmailDownloadService {
|
|||||||
|
|
||||||
// Generate PDF thumbnails if it's a PDF
|
// Generate PDF thumbnails if it's a PDF
|
||||||
if (savedAttachment.ContentType === 'application/pdf') {
|
if (savedAttachment.ContentType === 'application/pdf') {
|
||||||
await this.generateThumbnailsForAttachment(savedAttachment, buffer);
|
await this.generateThumbnailsForAttachment(savedAttachment, buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Neue E-Mail mit MessageId ${messageId} hinzugefügt (${attachmentsToPersist.length} Anhänge).`);
|
this.logger.debug(
|
||||||
|
`Neue E-Mail mit MessageId ${messageId} hinzugefügt (${attachmentsToPersist.length} Anhänge).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async generateThumbnailsForAttachment(attachment: Attachment, buffer: Buffer): Promise<void> {
|
public async generateThumbnailsForAttachment(
|
||||||
try {
|
attachment: Attachment,
|
||||||
const tempPdfPath = path.join(os.tmpdir(), `email-att-${attachment.Id}.pdf`);
|
buffer: Buffer,
|
||||||
await fs.writeFile(tempPdfPath, buffer);
|
): Promise<void> {
|
||||||
|
try {
|
||||||
const images = await this.pdfService.pdfToImages(tempPdfPath, 400);
|
const tempPdfPath = path.join(
|
||||||
await this.pageCache.generate(attachment.Id, images);
|
os.tmpdir(),
|
||||||
|
`email-att-${attachment.Id}.pdf`,
|
||||||
attachment.PageCount = images.length;
|
);
|
||||||
await this.attachmentRepo.save(attachment);
|
await fs.writeFile(tempPdfPath, buffer);
|
||||||
|
|
||||||
await this.pdfService.cleanup(images);
|
const images = await this.pdfService.pdfToImages(tempPdfPath, 400);
|
||||||
await fs.unlink(tempPdfPath).catch(() => {});
|
await this.pageCache.generate(attachment.Id, images);
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.warn(`Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`);
|
attachment.PageCount = images.length;
|
||||||
}
|
await this.attachmentRepo.save(attachment);
|
||||||
|
|
||||||
|
await this.pdfService.cleanup(images);
|
||||||
|
await fs.unlink(tempPdfPath).catch(() => {});
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async backfillThumbnailsForNewEmails(): Promise<{ processed: number; failed: number }> {
|
public async backfillThumbnailsForNewEmails(): Promise<{
|
||||||
|
processed: number;
|
||||||
|
failed: number;
|
||||||
|
}> {
|
||||||
const emails = await this.emailRepo.find({
|
const emails = await this.emailRepo.find({
|
||||||
where: { Status: 0 },
|
where: { Status: 0 },
|
||||||
relations: ['Attachments', 'Attachments.Content']
|
relations: ['Attachments', 'Attachments.Content'],
|
||||||
});
|
});
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
for (const attachment of email.Attachments) {
|
for (const attachment of email.Attachments) {
|
||||||
if (attachment.ContentType === 'application/pdf' && attachment.PageCount === 0 && attachment.Content?.Content1) {
|
if (
|
||||||
this.logger.log(`Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`);
|
attachment.ContentType === 'application/pdf' &&
|
||||||
try {
|
attachment.PageCount === 0 &&
|
||||||
await this.generateThumbnailsForAttachment(attachment, attachment.Content.Content1);
|
attachment.Content?.Content1
|
||||||
processed++;
|
) {
|
||||||
} catch (err) {
|
this.logger.log(
|
||||||
failed++;
|
`Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`,
|
||||||
}
|
);
|
||||||
|
try {
|
||||||
|
await this.generateThumbnailsForAttachment(
|
||||||
|
attachment,
|
||||||
|
attachment.Content.Content1,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Backfill abgeschlossen: ${processed} erfolgreich, ${failed} fehlgeschlagen.`);
|
this.logger.log(
|
||||||
|
`Backfill abgeschlossen: ${processed} erfolgreich, ${failed} fehlgeschlagen.`,
|
||||||
|
);
|
||||||
return { processed, failed };
|
return { processed, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,14 +292,19 @@ export class EmailDownloadService {
|
|||||||
attachment.IsEmbedded = isEmbedded;
|
attachment.IsEmbedded = isEmbedded;
|
||||||
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
||||||
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
||||||
attachment.Erechnung = contentType.toLowerCase() === 'application/pdf' ? isERechnung(buffer) : false;
|
attachment.Erechnung =
|
||||||
|
contentType.toLowerCase() === 'application/pdf'
|
||||||
|
? isERechnung(buffer)
|
||||||
|
: false;
|
||||||
attachment.ParentId = null;
|
attachment.ParentId = null;
|
||||||
|
|
||||||
return { attachment, buffer };
|
return { attachment, buffer };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(addr: AddressObject | AddressObject[] | undefined): string {
|
function formatAddress(
|
||||||
|
addr: AddressObject | AddressObject[] | undefined,
|
||||||
|
): string {
|
||||||
if (!addr) return '';
|
if (!addr) return '';
|
||||||
const first = Array.isArray(addr) ? addr[0] : addr;
|
const first = Array.isArray(addr) ? addr[0] : addr;
|
||||||
return (first?.text ?? '').slice(0, 255);
|
return (first?.text ?? '').slice(0, 255);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
const KNOWN_NAMES = [
|
const KNOWN_NAMES = ['factur-x.xml', 'zugferd-invoice.xml', 'xrechnung.xml'];
|
||||||
'factur-x.xml',
|
|
||||||
'zugferd-invoice.xml',
|
|
||||||
'xrechnung.xml',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isERechnung(pdfBuffer: Buffer): boolean {
|
export function isERechnung(pdfBuffer: Buffer): boolean {
|
||||||
const asText = pdfBuffer.toString('latin1').toLowerCase();
|
const asText = pdfBuffer.toString('latin1').toLowerCase();
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { Controller, Get, Post, Body, Param, Query, Res, HttpException, HttpStatus, Logger, Delete } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
Delete,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { EmailImportService } from './email-import.service';
|
import { EmailImportService } from './email-import.service';
|
||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
@@ -23,11 +35,19 @@ export class EmailImportController {
|
|||||||
|
|
||||||
@Post('mappings')
|
@Post('mappings')
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async addMapping(@Body() body: { emailAddress: string; paperlessCorrespondentId: number }) {
|
async addMapping(
|
||||||
|
@Body() body: { emailAddress: string; paperlessCorrespondentId: number },
|
||||||
|
) {
|
||||||
if (!body.emailAddress || !body.paperlessCorrespondentId) {
|
if (!body.emailAddress || !body.paperlessCorrespondentId) {
|
||||||
throw new HttpException('Missing emailAddress or paperlessCorrespondentId', HttpStatus.BAD_REQUEST);
|
throw new HttpException(
|
||||||
|
'Missing emailAddress or paperlessCorrespondentId',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.importService.addMapping(body.emailAddress, body.paperlessCorrespondentId);
|
return this.importService.addMapping(
|
||||||
|
body.emailAddress,
|
||||||
|
body.paperlessCorrespondentId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('mappings/:id')
|
@Delete('mappings/:id')
|
||||||
@@ -54,7 +74,11 @@ export class EmailImportController {
|
|||||||
@Get('belegnummer')
|
@Get('belegnummer')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
async getBelegnummer(@Query('date') date: string) {
|
async getBelegnummer(@Query('date') date: string) {
|
||||||
if (!date) throw new HttpException('Date query parameter required', HttpStatus.BAD_REQUEST);
|
if (!date)
|
||||||
|
throw new HttpException(
|
||||||
|
'Date query parameter required',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
const nummer = await this.importService.getBelegnummer(date);
|
const nummer = await this.importService.getBelegnummer(date);
|
||||||
return { nummer };
|
return { nummer };
|
||||||
}
|
}
|
||||||
@@ -62,7 +86,11 @@ export class EmailImportController {
|
|||||||
@Post('belegnummer/release')
|
@Post('belegnummer/release')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
async releaseBelegnummer(@Body() body: { date: string; number: string }) {
|
async releaseBelegnummer(@Body() body: { date: string; number: string }) {
|
||||||
if (!body.date || !body.number) throw new HttpException('Date and number required', HttpStatus.BAD_REQUEST);
|
if (!body.date || !body.number)
|
||||||
|
throw new HttpException(
|
||||||
|
'Date and number required',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
await this.importService.releaseBelegnummer(body.date, body.number);
|
await this.importService.releaseBelegnummer(body.date, body.number);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -74,7 +102,10 @@ export class EmailImportController {
|
|||||||
@Param('attachmentId') attachmentId: number,
|
@Param('attachmentId') attachmentId: number,
|
||||||
@Body() body: { pages: { start: number; end: number } },
|
@Body() body: { pages: { start: number; end: number } },
|
||||||
) {
|
) {
|
||||||
const isDuplicate = await this.importService.checkSplitChecksum(attachmentId, body.pages);
|
const isDuplicate = await this.importService.checkSplitChecksum(
|
||||||
|
attachmentId,
|
||||||
|
body.pages,
|
||||||
|
);
|
||||||
return { isDuplicate };
|
return { isDuplicate };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +118,18 @@ export class EmailImportController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const pdfBuffer = await this.importService.generatePrintPdf(attachmentId, barcodeData);
|
const pdfBuffer = await this.importService.generatePrintPdf(
|
||||||
this.logger.log(`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`);
|
attachmentId,
|
||||||
|
barcodeData,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`,
|
||||||
|
);
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `inline; filename="preview-${attachmentId}.pdf"`);
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="preview-${attachmentId}.pdf"`,
|
||||||
|
);
|
||||||
res.send(pdfBuffer);
|
res.send(pdfBuffer);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Error generating print preview: ${err.message}`);
|
this.logger.error(`Error generating print preview: ${err.message}`);
|
||||||
@@ -134,7 +173,9 @@ export class EmailImportController {
|
|||||||
@Get('jobs/:jobId/status')
|
@Get('jobs/:jobId/status')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
getJobStatus(@Param('jobId') jobId: string) {
|
getJobStatus(@Param('jobId') jobId: string) {
|
||||||
return this.importService.getJobStatus(jobId) ?? { message: '', done: false };
|
return (
|
||||||
|
this.importService.getJobStatus(jobId) ?? { message: '', done: false }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Final Import ---
|
// --- Final Import ---
|
||||||
|
|||||||
@@ -21,9 +21,16 @@ import * as crypto from 'crypto';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailImportService {
|
export class EmailImportService {
|
||||||
private readonly logger = new Logger(EmailImportService.name);
|
private readonly logger = new Logger(EmailImportService.name);
|
||||||
private readonly importJobs = new Map<string, { message: string; done: boolean }>();
|
private readonly importJobs = new Map<
|
||||||
|
string,
|
||||||
|
{ message: string; done: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
private setJobStatus(jobId: string | undefined, message: string, done = false): void {
|
private setJobStatus(
|
||||||
|
jobId: string | undefined,
|
||||||
|
message: string,
|
||||||
|
done = false,
|
||||||
|
): void {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
this.importJobs.set(jobId, { message, done });
|
this.importJobs.set(jobId, { message, done });
|
||||||
}
|
}
|
||||||
@@ -35,9 +42,12 @@ export class EmailImportService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
||||||
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>,
|
@InjectRepository(Attachment)
|
||||||
@InjectRepository(Content) private readonly contentRepo: Repository<Content>,
|
private readonly attachmentRepo: Repository<Attachment>,
|
||||||
@InjectRepository(CorrespondentEmailMapping) private readonly mappingRepo: Repository<CorrespondentEmailMapping>,
|
@InjectRepository(Content)
|
||||||
|
private readonly contentRepo: Repository<Content>,
|
||||||
|
@InjectRepository(CorrespondentEmailMapping)
|
||||||
|
private readonly mappingRepo: Repository<CorrespondentEmailMapping>,
|
||||||
@InjectRepository(Task) private readonly taskRepo: Repository<Task>,
|
@InjectRepository(Task) private readonly taskRepo: Repository<Task>,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
private readonly pdfService: PdfService,
|
private readonly pdfService: PdfService,
|
||||||
@@ -53,8 +63,13 @@ export class EmailImportService {
|
|||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1);
|
const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1);
|
||||||
if (!hasPreview && attachment.Content?.Content1) {
|
if (!hasPreview && attachment.Content?.Content1) {
|
||||||
this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`);
|
this.logger.log(
|
||||||
const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`);
|
`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`,
|
||||||
|
);
|
||||||
|
const tempPdfPath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`email-att-gen-${attachment.Id}.pdf`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(tempPdfPath, attachment.Content.Content1);
|
await fs.writeFile(tempPdfPath, attachment.Content.Content1);
|
||||||
|
|
||||||
@@ -66,7 +81,9 @@ export class EmailImportService {
|
|||||||
|
|
||||||
await this.pdfService.cleanup(images);
|
await this.pdfService.cleanup(images);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(tempPdfPath).catch(() => {});
|
await fs.unlink(tempPdfPath).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -80,9 +97,14 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addMapping(emailAddress: string, paperlessCorrespondentId: number) {
|
async addMapping(emailAddress: string, paperlessCorrespondentId: number) {
|
||||||
let mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } });
|
let mapping = await this.mappingRepo.findOne({
|
||||||
|
where: { EmailAddress: emailAddress },
|
||||||
|
});
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
mapping = this.mappingRepo.create({ EmailAddress: emailAddress, PaperlessCorrespondentId: paperlessCorrespondentId });
|
mapping = this.mappingRepo.create({
|
||||||
|
EmailAddress: emailAddress,
|
||||||
|
PaperlessCorrespondentId: paperlessCorrespondentId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
mapping.PaperlessCorrespondentId = paperlessCorrespondentId;
|
mapping.PaperlessCorrespondentId = paperlessCorrespondentId;
|
||||||
}
|
}
|
||||||
@@ -94,114 +116,177 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCorrespondentByEmail(emailAddress: string): Promise<number | null> {
|
async getCorrespondentByEmail(emailAddress: string): Promise<number | null> {
|
||||||
const mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } });
|
const mapping = await this.mappingRepo.findOne({
|
||||||
|
where: { EmailAddress: emailAddress },
|
||||||
|
});
|
||||||
return mapping ? mapping.PaperlessCorrespondentId : null;
|
return mapping ? mapping.PaperlessCorrespondentId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Belegnummern API ---
|
// --- Belegnummern API ---
|
||||||
private buildUrl(urlTemplate: string, dateStr: string): string {
|
private buildUrl(urlTemplate: string, dateStr: string): string {
|
||||||
const dateObj = new Date(dateStr);
|
const dateObj = new Date(dateStr);
|
||||||
const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj).getFullYear().toString();
|
const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj)
|
||||||
|
.getFullYear()
|
||||||
|
.toString();
|
||||||
return urlTemplate.replace('{Jahr}', year);
|
return urlTemplate.replace('{Jahr}', year);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBelegnummer(emailDate: string): Promise<string> {
|
async getBelegnummer(emailDate: string): Promise<string> {
|
||||||
const urlTemplate = this.configService.get<string>('BELEGNUMMER_GET_URL');
|
const urlTemplate = this.configService.get<string>('BELEGNUMMER_GET_URL');
|
||||||
if (!urlTemplate) throw new HttpException('BELEGNUMMER_GET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR);
|
if (!urlTemplate)
|
||||||
|
throw new HttpException(
|
||||||
|
'BELEGNUMMER_GET_URL not configured',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
const url = this.buildUrl(urlTemplate, emailDate);
|
const url = this.buildUrl(urlTemplate, emailDate);
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Fetching Belegnummer from ${url}`);
|
this.logger.debug(`Fetching Belegnummer from ${url}`);
|
||||||
const response = await axios.get(url);
|
const response = await axios.get(url);
|
||||||
|
|
||||||
// If the response is an object, try to extract 'nummer' or 'number'
|
// If the response is an object, try to extract 'nummer' or 'number'
|
||||||
let result = response.data;
|
let result = response.data;
|
||||||
if (result && typeof result === 'object') {
|
if (result && typeof result === 'object') {
|
||||||
result = result.nummer || result.number || result.data?.nummer || JSON.stringify(result);
|
result =
|
||||||
|
result.nummer ||
|
||||||
|
result.number ||
|
||||||
|
result.data?.nummer ||
|
||||||
|
JSON.stringify(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Received Belegnummer: ${result}`);
|
this.logger.debug(`Received Belegnummer: ${result}`);
|
||||||
return String(result);
|
return String(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status || 'UNKNOWN';
|
const status = error.response?.status || 'UNKNOWN';
|
||||||
const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message;
|
const detail = error.response?.data
|
||||||
this.logger.error(`Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`);
|
? JSON.stringify(error.response.data)
|
||||||
throw new HttpException(`Fehler beim Abrufen der Belegnummer: ${detail}`, HttpStatus.BAD_GATEWAY);
|
: error.message;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`,
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
`Fehler beim Abrufen der Belegnummer: ${detail}`,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseBelegnummer(emailDate: string, number: string): Promise<void> {
|
async releaseBelegnummer(emailDate: string, number: string): Promise<void> {
|
||||||
const urlTemplate = this.configService.get<string>('BELEGNUMMER_RELEASE_URL');
|
const urlTemplate = this.configService.get<string>(
|
||||||
|
'BELEGNUMMER_RELEASE_URL',
|
||||||
|
);
|
||||||
if (!urlTemplate) {
|
if (!urlTemplate) {
|
||||||
this.logger.warn('BELEGNUMMER_RELEASE_URL not configured, skipping release.');
|
this.logger.warn(
|
||||||
|
'BELEGNUMMER_RELEASE_URL not configured, skipping release.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanNumber = number.replace(/^0+/, '') || '0';
|
const cleanNumber = number.replace(/^0+/, '') || '0';
|
||||||
let url = this.buildUrl(urlTemplate, emailDate);
|
let url = this.buildUrl(urlTemplate, emailDate);
|
||||||
url = url.replace('{Nummer}', cleanNumber);
|
url = url.replace('{Nummer}', cleanNumber);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Releasing Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`);
|
this.logger.log(
|
||||||
|
`Releasing Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`,
|
||||||
|
);
|
||||||
await axios.get(url);
|
await axios.get(url);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to release Belegnummer at ${url}: ${error.message}`);
|
this.logger.error(
|
||||||
|
`Failed to release Belegnummer at ${url}: ${error.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setBelegnummer(emailDate: string, number: string): Promise<void> {
|
async setBelegnummer(emailDate: string, number: string): Promise<void> {
|
||||||
const urlTemplate = this.configService.get<string>('BELEGNUMMER_SET_URL');
|
const urlTemplate = this.configService.get<string>('BELEGNUMMER_SET_URL');
|
||||||
if (!urlTemplate) throw new HttpException('BELEGNUMMER_SET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR);
|
if (!urlTemplate)
|
||||||
|
throw new HttpException(
|
||||||
|
'BELEGNUMMER_SET_URL not configured',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
const cleanNumber = number.replace(/^0+/, '') || '0';
|
const cleanNumber = number.replace(/^0+/, '') || '0';
|
||||||
let url = this.buildUrl(urlTemplate, emailDate);
|
let url = this.buildUrl(urlTemplate, emailDate);
|
||||||
url = url.replace('{Nummer}', cleanNumber);
|
url = url.replace('{Nummer}', cleanNumber);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Setting Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`);
|
this.logger.log(
|
||||||
|
`Setting Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`,
|
||||||
|
);
|
||||||
await axios.get(url);
|
await axios.get(url);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to set Belegnummer at ${url}: ${error.message}`);
|
this.logger.error(
|
||||||
throw new HttpException('Fehler beim Setzen der Belegnummer', HttpStatus.BAD_GATEWAY);
|
`Failed to set Belegnummer at ${url}: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
'Fehler beim Setzen der Belegnummer',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Checksum Check for Split Documents ---
|
// --- Checksum Check for Split Documents ---
|
||||||
async checkSplitChecksum(attachmentId: number, pages: { start: number; end: number }): Promise<boolean> {
|
async checkSplitChecksum(
|
||||||
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } });
|
attachmentId: number,
|
||||||
|
pages: { start: number; end: number },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const content = await this.contentRepo.findOne({
|
||||||
|
where: { AttachmentEntityId: attachmentId },
|
||||||
|
});
|
||||||
if (!content) return false;
|
if (!content) return false;
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(content.Content1, { ignoreEncryption: true });
|
const pdfDoc = await PDFDocument.load(content.Content1, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
});
|
||||||
const total = pdfDoc.getPageCount();
|
const total = pdfDoc.getPageCount();
|
||||||
const startIdx = Math.max(1, pages.start) - 1;
|
const startIdx = Math.max(1, pages.start) - 1;
|
||||||
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
||||||
|
|
||||||
const sliced = await PDFDocument.create();
|
const sliced = await PDFDocument.create();
|
||||||
const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i);
|
const indices = Array.from(
|
||||||
|
{ length: endIdx - startIdx + 1 },
|
||||||
|
(_, i) => startIdx + i,
|
||||||
|
);
|
||||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||||
copied.forEach(p => sliced.addPage(p));
|
copied.forEach((p) => sliced.addPage(p));
|
||||||
|
|
||||||
const checksum = crypto.createHash('md5').update(Buffer.from(await sliced.save())).digest('hex');
|
const checksum = crypto
|
||||||
|
.createHash('md5')
|
||||||
|
.update(Buffer.from(await sliced.save()))
|
||||||
|
.digest('hex');
|
||||||
return this.paperlessService.checksumExists(checksum);
|
return this.paperlessService.checksumExists(checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Print Preview ---
|
// --- Print Preview ---
|
||||||
async generatePrintPdf(attachmentId: number, barcodeData: any): Promise<Buffer> {
|
async generatePrintPdf(
|
||||||
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } });
|
attachmentId: number,
|
||||||
if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
|
barcodeData: any,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const content = await this.contentRepo.findOne({
|
||||||
|
where: { AttachmentEntityId: attachmentId },
|
||||||
|
});
|
||||||
|
if (!content)
|
||||||
|
throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND);
|
||||||
|
|
||||||
let pdfBytes: Buffer = content.Content1;
|
let pdfBytes: Buffer = content.Content1;
|
||||||
|
|
||||||
const pages: { start: number; end: number } | undefined = barcodeData._pages;
|
const pages: { start: number; end: number } | undefined =
|
||||||
|
barcodeData._pages;
|
||||||
if (pages) {
|
if (pages) {
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
});
|
||||||
const total = pdfDoc.getPageCount();
|
const total = pdfDoc.getPageCount();
|
||||||
const startIdx = Math.max(1, pages.start) - 1;
|
const startIdx = Math.max(1, pages.start) - 1;
|
||||||
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
|
||||||
const sliced = await PDFDocument.create();
|
const sliced = await PDFDocument.create();
|
||||||
const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i);
|
const indices = Array.from(
|
||||||
|
{ length: endIdx - startIdx + 1 },
|
||||||
|
(_, i) => startIdx + i,
|
||||||
|
);
|
||||||
const copied = await sliced.copyPages(pdfDoc, indices);
|
const copied = await sliced.copyPages(pdfDoc, indices);
|
||||||
copied.forEach(p => sliced.addPage(p));
|
copied.forEach((p) => sliced.addPage(p));
|
||||||
pdfBytes = Buffer.from(await sliced.save());
|
pdfBytes = Buffer.from(await sliced.save());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,18 +295,24 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise<Buffer> {
|
async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise<Buffer> {
|
||||||
this.logger.debug(`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`);
|
this.logger.debug(
|
||||||
|
`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`,
|
||||||
|
);
|
||||||
|
|
||||||
let currentPdfBytes = pdfBytes;
|
let currentPdfBytes = pdfBytes;
|
||||||
const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`);
|
const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`);
|
||||||
await fs.writeFile(tempInputPath, pdfBytes);
|
await fs.writeFile(tempInputPath, pdfBytes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First try to load to check encryption
|
// First try to load to check encryption
|
||||||
let pdfDoc = await PDFDocument.load(currentPdfBytes, { ignoreEncryption: true });
|
let pdfDoc = await PDFDocument.load(currentPdfBytes, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (pdfDoc.isEncrypted) {
|
if (pdfDoc.isEncrypted) {
|
||||||
this.logger.log('PDF ist verschlüsselt, versuche Bereinigung via Ghostscript...');
|
this.logger.log(
|
||||||
|
'PDF ist verschlüsselt, versuche Bereinigung via Ghostscript...',
|
||||||
|
);
|
||||||
const sanitizedPath = await this.pdfService.sanitizePdf(tempInputPath);
|
const sanitizedPath = await this.pdfService.sanitizePdf(tempInputPath);
|
||||||
currentPdfBytes = await fs.readFile(sanitizedPath);
|
currentPdfBytes = await fs.readFile(sanitizedPath);
|
||||||
await fs.unlink(sanitizedPath).catch(() => {});
|
await fs.unlink(sanitizedPath).catch(() => {});
|
||||||
@@ -230,107 +321,128 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
this.logger.debug(`applyBarcodeToPdf: Pages = ${pages.length}, Encrypted = ${pdfDoc.isEncrypted}`);
|
this.logger.debug(
|
||||||
|
`applyBarcodeToPdf: Pages = ${pages.length}, Encrypted = ${pdfDoc.isEncrypted}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden');
|
this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden');
|
||||||
return Buffer.from(await pdfDoc.save());
|
return Buffer.from(await pdfDoc.save());
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstPage = pages[0];
|
const firstPage = pages[0];
|
||||||
|
|
||||||
const { x, y, nummer, datum, jahr } = barcodeData;
|
const { x, y, nummer, datum, jahr } = barcodeData;
|
||||||
|
|
||||||
// Parse date
|
|
||||||
const d = new Date(datum);
|
|
||||||
const yyyy = (isNaN(d.getTime()) ? new Date() : d).getFullYear().toString();
|
|
||||||
const mm = String((isNaN(d.getTime()) ? new Date() : d).getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String((isNaN(d.getTime()) ? new Date() : d).getDate()).padStart(2, '0');
|
|
||||||
|
|
||||||
const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd
|
|
||||||
const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`;
|
|
||||||
const printDateStr = `${dd}.${mm}.${yyyy}`;
|
|
||||||
|
|
||||||
// Dimensions: 57x32 mm
|
// Parse date
|
||||||
const PT_PER_MM = 2.83465;
|
const d = new Date(datum);
|
||||||
const boxW = 57 * PT_PER_MM;
|
const yyyy = (isNaN(d.getTime()) ? new Date() : d)
|
||||||
const boxH = 32 * PT_PER_MM;
|
.getFullYear()
|
||||||
|
.toString();
|
||||||
// A4 dimensions: 210x297 mm
|
const mm = String(
|
||||||
const PAGE_H_PT = 297 * PT_PER_MM;
|
(isNaN(d.getTime()) ? new Date() : d).getMonth() + 1,
|
||||||
|
).padStart(2, '0');
|
||||||
// Convert mm to points (Y is from bottom in pdf-lib)
|
const dd = String(
|
||||||
const startX = Number(x) * PT_PER_MM;
|
(isNaN(d.getTime()) ? new Date() : d).getDate(),
|
||||||
const startY = PAGE_H_PT - (Number(y) * PT_PER_MM) - boxH;
|
).padStart(2, '0');
|
||||||
|
|
||||||
// 1. Draw Background Box (White with Black border)
|
const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd
|
||||||
firstPage.drawRectangle({
|
const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`;
|
||||||
x: startX,
|
const printDateStr = `${dd}.${mm}.${yyyy}`;
|
||||||
y: startY,
|
|
||||||
width: boxW,
|
|
||||||
height: boxH,
|
|
||||||
color: rgb(1, 1, 1),
|
|
||||||
borderColor: rgb(0, 0, 0),
|
|
||||||
borderWidth: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Draw QR Code
|
// Dimensions: 57x32 mm
|
||||||
const qrBuffer = await QRCode.toBuffer(qrContent, {
|
const PT_PER_MM = 2.83465;
|
||||||
errorCorrectionLevel: 'H',
|
const boxW = 57 * PT_PER_MM;
|
||||||
margin: 0,
|
const boxH = 32 * PT_PER_MM;
|
||||||
width: 300,
|
|
||||||
color: { dark: '#000000', light: '#FFFFFF' }
|
|
||||||
});
|
|
||||||
const qrImage = await pdfDoc.embedPng(qrBuffer);
|
|
||||||
|
|
||||||
// QR Code size: 27x27 mm (10% smaller than 30x30)
|
|
||||||
const qrSize = 27 * PT_PER_MM;
|
|
||||||
const padding = (32 - 27) / 2; // Center vertically in 32mm box
|
|
||||||
const qrX = startX + (padding * PT_PER_MM);
|
|
||||||
const qrY = startY + (padding * PT_PER_MM);
|
|
||||||
|
|
||||||
firstPage.drawImage(qrImage, {
|
|
||||||
x: qrX,
|
|
||||||
y: qrY,
|
|
||||||
width: qrSize,
|
|
||||||
height: qrSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Draw Texts
|
// A4 dimensions: 210x297 mm
|
||||||
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
const PAGE_H_PT = 297 * PT_PER_MM;
|
||||||
|
|
||||||
// Helper to draw centered text in a specific area
|
// Convert mm to points (Y is from bottom in pdf-lib)
|
||||||
const drawCenteredInArea = (text: string, relX: number, relY: number, areaW: number, areaH: number, fontSize: number) => {
|
const startX = Number(x) * PT_PER_MM;
|
||||||
const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize);
|
const startY = PAGE_H_PT - Number(y) * PT_PER_MM - boxH;
|
||||||
const absX = startX + (relX * PT_PER_MM) + (areaW * PT_PER_MM / 2) - (textWidth / 2);
|
|
||||||
const absY = (startY + boxH) - (relY * PT_PER_MM) - (areaH * PT_PER_MM / 2) - (fontSize / 2.5);
|
// 1. Draw Background Box (White with Black border)
|
||||||
|
firstPage.drawRectangle({
|
||||||
firstPage.drawText(text, {
|
x: startX,
|
||||||
x: absX,
|
y: startY,
|
||||||
y: absY,
|
width: boxW,
|
||||||
size: fontSize,
|
height: boxH,
|
||||||
font: helveticaBold,
|
color: rgb(1, 1, 1),
|
||||||
color: rgb(0, 0, 0),
|
borderColor: rgb(0, 0, 0),
|
||||||
|
borderWidth: 1,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const isNeu = barcodeData.isNeu === true;
|
// 2. Draw QR Code
|
||||||
|
const qrBuffer = await QRCode.toBuffer(qrContent, {
|
||||||
|
errorCorrectionLevel: 'H',
|
||||||
|
margin: 0,
|
||||||
|
width: 300,
|
||||||
|
color: { dark: '#000000', light: '#FFFFFF' },
|
||||||
|
});
|
||||||
|
const qrImage = await pdfDoc.embedPng(qrBuffer);
|
||||||
|
|
||||||
// Text Area X: +33.3mm, Width: 21mm
|
// QR Code size: 27x27 mm (10% smaller than 30x30)
|
||||||
// Year: Y + 3mm, Height: 7.5mm
|
const qrSize = 27 * PT_PER_MM;
|
||||||
drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12);
|
const padding = (32 - 27) / 2; // Center vertically in 32mm box
|
||||||
|
const qrX = startX + padding * PT_PER_MM;
|
||||||
|
const qrY = startY + padding * PT_PER_MM;
|
||||||
|
|
||||||
// Number: Y + 10.5mm, Height: 7.5mm
|
firstPage.drawImage(qrImage, {
|
||||||
const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0');
|
x: qrX,
|
||||||
drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12);
|
y: qrY,
|
||||||
|
width: qrSize,
|
||||||
|
height: qrSize,
|
||||||
|
});
|
||||||
|
|
||||||
// "Eingegangen": Y + 19mm, Height: 4mm, Size 8
|
// 3. Draw Texts
|
||||||
drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8);
|
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
|
||||||
// Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8
|
// Helper to draw centered text in a specific area
|
||||||
drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8);
|
const drawCenteredInArea = (
|
||||||
|
text: string,
|
||||||
|
relX: number,
|
||||||
|
relY: number,
|
||||||
|
areaW: number,
|
||||||
|
areaH: number,
|
||||||
|
fontSize: number,
|
||||||
|
) => {
|
||||||
|
const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize);
|
||||||
|
const absX =
|
||||||
|
startX + relX * PT_PER_MM + (areaW * PT_PER_MM) / 2 - textWidth / 2;
|
||||||
|
const absY =
|
||||||
|
startY +
|
||||||
|
boxH -
|
||||||
|
relY * PT_PER_MM -
|
||||||
|
(areaH * PT_PER_MM) / 2 -
|
||||||
|
fontSize / 2.5;
|
||||||
|
|
||||||
return Buffer.from(await pdfDoc.save());
|
firstPage.drawText(text, {
|
||||||
|
x: absX,
|
||||||
|
y: absY,
|
||||||
|
size: fontSize,
|
||||||
|
font: helveticaBold,
|
||||||
|
color: rgb(0, 0, 0),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNeu = barcodeData.isNeu === true;
|
||||||
|
|
||||||
|
// Text Area X: +33.3mm, Width: 21mm
|
||||||
|
// Year: Y + 3mm, Height: 7.5mm
|
||||||
|
drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12);
|
||||||
|
|
||||||
|
// Number: Y + 10.5mm, Height: 7.5mm
|
||||||
|
const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0');
|
||||||
|
drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12);
|
||||||
|
|
||||||
|
// "Eingegangen": Y + 19mm, Height: 4mm, Size 8
|
||||||
|
drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8);
|
||||||
|
|
||||||
|
// Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8
|
||||||
|
drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8);
|
||||||
|
|
||||||
|
return Buffer.from(await pdfDoc.save());
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(tempInputPath).catch(() => {});
|
await fs.unlink(tempInputPath).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -345,12 +457,20 @@ export class EmailImportService {
|
|||||||
paperlessCorrespondentId?: number | null;
|
paperlessCorrespondentId?: number | null;
|
||||||
parentDocumentId?: number | null;
|
parentDocumentId?: number | null;
|
||||||
splitRanges?: { start: number; end: number }[];
|
splitRanges?: { start: number; end: number }[];
|
||||||
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
|
barcode?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
nummer: string;
|
||||||
|
datum: string;
|
||||||
|
jahr: string;
|
||||||
|
};
|
||||||
belegnummer?: string;
|
belegnummer?: string;
|
||||||
}[];
|
}[];
|
||||||
emailDate: string;
|
emailDate: string;
|
||||||
}): Promise<{ success: boolean; results: any[] }> {
|
}): Promise<{ success: boolean; results: any[] }> {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-'));
|
const tempDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), 'paperless-mail-import-'),
|
||||||
|
);
|
||||||
const results = [];
|
const results = [];
|
||||||
this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...');
|
this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...');
|
||||||
|
|
||||||
@@ -358,10 +478,14 @@ export class EmailImportService {
|
|||||||
for (const att of data.attachments) {
|
for (const att of data.attachments) {
|
||||||
if (att.type === 'IGNORE') continue;
|
if (att.type === 'IGNORE') continue;
|
||||||
|
|
||||||
const attachmentEntity = await this.attachmentRepo.findOne({ where: { Id: att.attachmentId } });
|
const attachmentEntity = await this.attachmentRepo.findOne({
|
||||||
|
where: { Id: att.attachmentId },
|
||||||
|
});
|
||||||
if (!attachmentEntity) continue;
|
if (!attachmentEntity) continue;
|
||||||
|
|
||||||
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: att.attachmentId } });
|
const content = await this.contentRepo.findOne({
|
||||||
|
where: { AttachmentEntityId: att.attachmentId },
|
||||||
|
});
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
const originalPdfBytes = content.Content1;
|
const originalPdfBytes = content.Content1;
|
||||||
@@ -375,26 +499,36 @@ export class EmailImportService {
|
|||||||
|
|
||||||
if (att.splitRanges && att.splitRanges.length > 0) {
|
if (att.splitRanges && att.splitRanges.length > 0) {
|
||||||
// SPLIT PDF
|
// SPLIT PDF
|
||||||
const pdfDoc = await PDFDocument.load(originalPdfBytes, { ignoreEncryption: true });
|
const pdfDoc = await PDFDocument.load(originalPdfBytes, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
});
|
||||||
const totalPages = pdfDoc.getPageCount();
|
const totalPages = pdfDoc.getPageCount();
|
||||||
|
|
||||||
for (const range of att.splitRanges) {
|
for (const range of att.splitRanges) {
|
||||||
const start = Math.max(1, range.start);
|
const start = Math.max(1, range.start);
|
||||||
const end = Math.min(range.end, totalPages);
|
const end = Math.min(range.end, totalPages);
|
||||||
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
this.logger.warn(`Ungültiger Bereich für Splitting: ${start}-${end} (Seiten gesamt: ${totalPages})`);
|
this.logger.warn(
|
||||||
|
`Ungültiger Bereich für Splitting: ${start}-${end} (Seiten gesamt: ${totalPages})`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPdf = await PDFDocument.create();
|
const newPdf = await PDFDocument.create();
|
||||||
// Pages are 0-indexed in pdf-lib
|
// Pages are 0-indexed in pdf-lib
|
||||||
const pageIndices = Array.from({ length: end - start + 1 }, (_, i) => start - 1 + i);
|
const pageIndices = Array.from(
|
||||||
|
{ length: end - start + 1 },
|
||||||
|
(_, i) => start - 1 + i,
|
||||||
|
);
|
||||||
const copiedPages = await newPdf.copyPages(pdfDoc, pageIndices);
|
const copiedPages = await newPdf.copyPages(pdfDoc, pageIndices);
|
||||||
copiedPages.forEach((p) => newPdf.addPage(p));
|
copiedPages.forEach((p) => newPdf.addPage(p));
|
||||||
|
|
||||||
const splitPdfBytes = await newPdf.save();
|
const splitPdfBytes = await newPdf.save();
|
||||||
const tempFilePath = path.join(tempDir, `${baseFilename}_${start}-${end}.pdf`);
|
const tempFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`${baseFilename}_${start}-${end}.pdf`,
|
||||||
|
);
|
||||||
await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes));
|
await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes));
|
||||||
|
|
||||||
uploadPromises.push({
|
uploadPromises.push({
|
||||||
@@ -423,20 +557,28 @@ export class EmailImportService {
|
|||||||
for (const uploadItem of uploadPromises) {
|
for (const uploadItem of uploadPromises) {
|
||||||
const options: any = {
|
const options: any = {
|
||||||
filename: uploadItem.filename,
|
filename: uploadItem.filename,
|
||||||
title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename,
|
title: att.belegnummer
|
||||||
|
? `Beleg ${att.belegnummer}`
|
||||||
|
: uploadItem.filename,
|
||||||
created: createdDate,
|
created: createdDate,
|
||||||
owner: null,
|
owner: null,
|
||||||
};
|
};
|
||||||
if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId;
|
if (att.paperlessCorrespondentId)
|
||||||
|
options.correspondent = att.paperlessCorrespondentId;
|
||||||
|
|
||||||
this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`);
|
this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`);
|
||||||
const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options);
|
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
||||||
|
uploadItem.path,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
// Create background task for enrichment (same logic as Inbox)
|
// Create background task for enrichment (same logic as Inbox)
|
||||||
const backgroundTask = this.taskRepo.create({
|
const backgroundTask = this.taskRepo.create({
|
||||||
TaskId: paperlessTaskId,
|
TaskId: paperlessTaskId,
|
||||||
InterneBelegnummer: att.belegnummer || '',
|
InterneBelegnummer: att.belegnummer || '',
|
||||||
Eingangsdatum: att.barcode?.datum ? new Date(att.barcode.datum) : createdDate,
|
Eingangsdatum: att.barcode?.datum
|
||||||
|
? new Date(att.barcode.datum)
|
||||||
|
: createdDate,
|
||||||
Belegdatum: createdDate,
|
Belegdatum: createdDate,
|
||||||
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
||||||
BetriebID: null, // Owner
|
BetriebID: null, // Owner
|
||||||
@@ -450,17 +592,25 @@ export class EmailImportService {
|
|||||||
// Still poll for Doc ID so we can return it to the frontend for immediate preview
|
// Still poll for Doc ID so we can return it to the frontend for immediate preview
|
||||||
let docId = null;
|
let docId = null;
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
this.setJobStatus(data.jobId, `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`);
|
this.setJobStatus(
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
data.jobId,
|
||||||
try {
|
`Warte auf Paperless-Verarbeitung... (${i + 1}/30)`,
|
||||||
const taskStatus = await this.paperlessService.getTask(paperlessTaskId);
|
);
|
||||||
// Paperless returns { results: [ ... ] } for filtered tasks
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
const statusObj = taskStatus.results ? taskStatus.results[0] : (Array.isArray(taskStatus) ? taskStatus[0] : taskStatus);
|
try {
|
||||||
if (statusObj && statusObj.related_document) {
|
const taskStatus =
|
||||||
docId = statusObj.related_document;
|
await this.paperlessService.getTask(paperlessTaskId);
|
||||||
break;
|
// Paperless returns { results: [ ... ] } for filtered tasks
|
||||||
}
|
const statusObj = taskStatus.results
|
||||||
} catch(e) {}
|
? taskStatus.results[0]
|
||||||
|
: Array.isArray(taskStatus)
|
||||||
|
? taskStatus[0]
|
||||||
|
: taskStatus;
|
||||||
|
if (statusObj && statusObj.related_document) {
|
||||||
|
docId = statusObj.related_document;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (docId) {
|
if (docId) {
|
||||||
@@ -478,7 +628,9 @@ export class EmailImportService {
|
|||||||
|
|
||||||
// Confirm Belegnummer if used
|
// Confirm Belegnummer if used
|
||||||
if (att.belegnummer && att.barcode?.nummer) {
|
if (att.belegnummer && att.barcode?.nummer) {
|
||||||
await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch(e => this.logger.warn(`Failed to set Belegnummer: ${e.message}`));
|
await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch(
|
||||||
|
(e) => this.logger.warn(`Failed to set Belegnummer: ${e.message}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({ attachmentId: att.attachmentId, paperlessIds });
|
results.push({ attachmentId: att.attachmentId, paperlessIds });
|
||||||
@@ -486,12 +638,14 @@ export class EmailImportService {
|
|||||||
|
|
||||||
// Mark Email as processed (Status = 1)
|
// Mark Email as processed (Status = 1)
|
||||||
if (data.attachments.length > 0) {
|
if (data.attachments.length > 0) {
|
||||||
const firstAtt = await this.attachmentRepo.findOne({
|
const firstAtt = await this.attachmentRepo.findOne({
|
||||||
where: { Id: data.attachments[0].attachmentId }
|
where: { Id: data.attachments[0].attachmentId },
|
||||||
});
|
});
|
||||||
if (firstAtt) {
|
if (firstAtt) {
|
||||||
await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 });
|
await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 });
|
||||||
this.logger.log(`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`);
|
this.logger.log(
|
||||||
|
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +654,8 @@ export class EmailImportService {
|
|||||||
} finally {
|
} finally {
|
||||||
// Clean up temp dir and job status
|
// Clean up temp dir and job status
|
||||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
if (data.jobId) setTimeout(() => this.importJobs.delete(data.jobId!), 5000);
|
if (data.jobId)
|
||||||
|
setTimeout(() => this.importJobs.delete(data.jobId!), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export class EmailPageCacheService {
|
|||||||
private readonly mailsRoot: string;
|
private readonly mailsRoot: string;
|
||||||
|
|
||||||
constructor(configService: ConfigService) {
|
constructor(configService: ConfigService) {
|
||||||
this.mailsRoot = configService.get<string>('MAILS_DATA_DIR', '/mnt/data/mails');
|
this.mailsRoot = configService.get<string>(
|
||||||
|
'MAILS_DATA_DIR',
|
||||||
|
'/mnt/data/mails',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
attachmentDir(attachmentId: number | string): string {
|
attachmentDir(attachmentId: number | string): string {
|
||||||
@@ -20,14 +23,23 @@ export class EmailPageCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previewPath(attachmentId: number | string, page: number): string {
|
previewPath(attachmentId: number | string, page: number): string {
|
||||||
return path.join(this.attachmentDir(attachmentId), `page-${page}.preview.png`);
|
return path.join(
|
||||||
|
this.attachmentDir(attachmentId),
|
||||||
|
`page-${page}.preview.png`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailPath(attachmentId: number | string, page: number): string {
|
thumbnailPath(attachmentId: number | string, page: number): string {
|
||||||
return path.join(this.attachmentDir(attachmentId), `page-${page}.thumb.png`);
|
return path.join(
|
||||||
|
this.attachmentDir(attachmentId),
|
||||||
|
`page-${page}.thumb.png`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generate(attachmentId: number | string, renderedImages: string[]): Promise<void> {
|
async generate(
|
||||||
|
attachmentId: number | string,
|
||||||
|
renderedImages: string[],
|
||||||
|
): Promise<void> {
|
||||||
const dir = this.attachmentDir(attachmentId);
|
const dir = this.attachmentDir(attachmentId);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
@@ -39,14 +51,22 @@ export class EmailPageCacheService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.copyFile(src, previewDest);
|
await fs.copyFile(src, previewDest);
|
||||||
await sharp(src).resize({ width: THUMBNAIL_WIDTH }).png().toFile(thumbDest);
|
await sharp(src)
|
||||||
|
.resize({ width: THUMBNAIL_WIDTH })
|
||||||
|
.png()
|
||||||
|
.toFile(thumbDest);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`E-Mail Page Cache fehlgeschlagen (Attachment ${attachmentId} Seite ${page}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`E-Mail Page Cache fehlgeschlagen (Attachment ${attachmentId} Seite ${page}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasPreview(attachmentId: number | string, page: number): Promise<boolean> {
|
async hasPreview(
|
||||||
|
attachmentId: number | string,
|
||||||
|
page: number,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fs.access(this.previewPath(attachmentId, page));
|
await fs.access(this.previewPath(attachmentId, page));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,8 +4,26 @@ import { EmailController } from './email.controller';
|
|||||||
import { Email } from '../database/entities/email.entity';
|
import { Email } from '../database/entities/email.entity';
|
||||||
|
|
||||||
const mockEmails: Partial<Email>[] = [
|
const mockEmails: Partial<Email>[] = [
|
||||||
{ Id: 1, MessageId: 'msg-1', SenderAddress: 'a@test.de', RecipientAddress: 'b@test.de', Subject: 'Test 1', Date: new Date(), Body: 'body', Status: 0 },
|
{
|
||||||
{ Id: 2, MessageId: 'msg-2', SenderAddress: 'c@test.de', RecipientAddress: 'd@test.de', Subject: 'Test 2', Date: new Date(), Body: 'body2', Status: 1 },
|
Id: 1,
|
||||||
|
MessageId: 'msg-1',
|
||||||
|
SenderAddress: 'a@test.de',
|
||||||
|
RecipientAddress: 'b@test.de',
|
||||||
|
Subject: 'Test 1',
|
||||||
|
Date: new Date(),
|
||||||
|
Body: 'body',
|
||||||
|
Status: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
MessageId: 'msg-2',
|
||||||
|
SenderAddress: 'c@test.de',
|
||||||
|
RecipientAddress: 'd@test.de',
|
||||||
|
Subject: 'Test 2',
|
||||||
|
Date: new Date(),
|
||||||
|
Body: 'body2',
|
||||||
|
Status: 1,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockQueryBuilder = {
|
const mockQueryBuilder = {
|
||||||
@@ -44,7 +62,9 @@ describe('EmailController', () => {
|
|||||||
|
|
||||||
it('getEmails filters by status', async () => {
|
it('getEmails filters by status', async () => {
|
||||||
await controller.getEmails('1');
|
await controller.getEmails('1');
|
||||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', { status: 1 });
|
expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', {
|
||||||
|
status: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getEmail returns single item', async () => {
|
it('getEmail returns single item', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Res, Logger, NotFoundException, Patch, Body } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
@@ -15,8 +26,10 @@ export class EmailController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
|
||||||
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>,
|
@InjectRepository(Attachment)
|
||||||
@InjectRepository(Content) private readonly contentRepo: Repository<Content>,
|
private readonly attachmentRepo: Repository<Attachment>,
|
||||||
|
@InjectRepository(Content)
|
||||||
|
private readonly contentRepo: Repository<Content>,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -26,7 +39,8 @@ export class EmailController {
|
|||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
const qb = this.emailRepo.createQueryBuilder('e')
|
const qb = this.emailRepo
|
||||||
|
.createQueryBuilder('e')
|
||||||
.leftJoinAndSelect('e.Attachments', 'a')
|
.leftJoinAndSelect('e.Attachments', 'a')
|
||||||
.orderBy('e.Date', 'DESC')
|
.orderBy('e.Date', 'DESC')
|
||||||
.take(parseInt(limit ?? '50', 10));
|
.take(parseInt(limit ?? '50', 10));
|
||||||
@@ -66,21 +80,28 @@ export class EmailController {
|
|||||||
const attachment = await this.attachmentRepo.findOne({ where: { Id: id } });
|
const attachment = await this.attachmentRepo.findOne({ where: { Id: id } });
|
||||||
if (!attachment) throw new NotFoundException('Anhang nicht gefunden');
|
if (!attachment) throw new NotFoundException('Anhang nicht gefunden');
|
||||||
|
|
||||||
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: id } });
|
const content = await this.contentRepo.findOne({
|
||||||
|
where: { AttachmentEntityId: id },
|
||||||
|
});
|
||||||
if (!content) throw new NotFoundException('Inhalt nicht gefunden');
|
if (!content) throw new NotFoundException('Inhalt nicht gefunden');
|
||||||
|
|
||||||
res.setHeader('Content-Type', attachment.ContentType || 'application/octet-stream');
|
res.setHeader(
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(attachment.FileName)}"`);
|
'Content-Type',
|
||||||
|
attachment.ContentType || 'application/octet-stream',
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${encodeURIComponent(attachment.FileName)}"`,
|
||||||
|
);
|
||||||
res.send(content.Content1);
|
res.send(content.Content1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@RequirePermissions(Permission.MANAGE_ALL)
|
@RequirePermissions(Permission.MANAGE_ALL)
|
||||||
async updateStatus(
|
async updateStatus(@Param('id') id: string, @Body('status') status: number) {
|
||||||
@Param('id') id: string,
|
const email = await this.emailRepo.findOneOrFail({
|
||||||
@Body('status') status: number,
|
where: { Id: parseInt(id, 10) },
|
||||||
) {
|
});
|
||||||
const email = await this.emailRepo.findOneOrFail({ where: { Id: parseInt(id, 10) } });
|
|
||||||
email.Status = status;
|
email.Status = status;
|
||||||
await this.emailRepo.save(email);
|
await this.emailRepo.save(email);
|
||||||
this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`);
|
this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`);
|
||||||
@@ -91,10 +112,14 @@ export class EmailController {
|
|||||||
@RequirePermissions(Permission.MANAGE_ALL)
|
@RequirePermissions(Permission.MANAGE_ALL)
|
||||||
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {
|
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {
|
||||||
const { includeProcessed = false } = body;
|
const { includeProcessed = false } = body;
|
||||||
this.logger.log(`Starte manuelle Prüfung der E-Mail-Anhänge in Paperless... (includeProcessed=${includeProcessed})`);
|
this.logger.log(
|
||||||
|
`Starte manuelle Prüfung der E-Mail-Anhänge in Paperless... (includeProcessed=${includeProcessed})`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const whereCondition = includeProcessed ? [{ Status: 0 }, { Status: 1 }] : { Status: 0 };
|
const whereCondition = includeProcessed
|
||||||
|
? [{ Status: 0 }, { Status: 1 }]
|
||||||
|
: { Status: 0 };
|
||||||
const emails = await this.emailRepo.find({
|
const emails = await this.emailRepo.find({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
relations: ['Attachments'],
|
relations: ['Attachments'],
|
||||||
@@ -116,25 +141,43 @@ export class EmailController {
|
|||||||
|
|
||||||
for (const attachment of email.Attachments) {
|
for (const attachment of email.Attachments) {
|
||||||
// Prüfe nur PDFs mit Checksumme
|
// Prüfe nur PDFs mit Checksumme
|
||||||
if (attachment.ContentType === 'application/pdf' && attachment.Checksum) {
|
if (
|
||||||
this.logger.debug(`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`);
|
attachment.ContentType === 'application/pdf' &&
|
||||||
|
attachment.Checksum
|
||||||
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const docId = await this.paperlessService.getDocumentIdByChecksum(attachment.Checksum);
|
const docId = await this.paperlessService.getDocumentIdByChecksum(
|
||||||
|
attachment.Checksum,
|
||||||
|
);
|
||||||
if (docId !== null) {
|
if (docId !== null) {
|
||||||
this.logger.log(`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`);
|
this.logger.log(
|
||||||
|
`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`,
|
||||||
|
);
|
||||||
hasMatch = true;
|
hasMatch = true;
|
||||||
|
|
||||||
// PaperlessDocumentId hinterlegen, falls noch nicht vorhanden
|
// PaperlessDocumentId hinterlegen, falls noch nicht vorhanden
|
||||||
const existingIds: Record<string, number> = attachment.PaperlessDocumentIds ?? {};
|
const existingIds: Record<string, number> =
|
||||||
|
attachment.PaperlessDocumentIds ?? {};
|
||||||
if (!existingIds['full']) {
|
if (!existingIds['full']) {
|
||||||
attachment.PaperlessDocumentIds = { ...existingIds, full: docId };
|
attachment.PaperlessDocumentIds = {
|
||||||
|
...existingIds,
|
||||||
|
full: docId,
|
||||||
|
};
|
||||||
await this.attachmentRepo.save(attachment);
|
await this.attachmentRepo.save(attachment);
|
||||||
idsUpdated++;
|
idsUpdated++;
|
||||||
this.logger.log(`Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`);
|
this.logger.log(
|
||||||
|
`Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Fehler bei Checksummen-Prüfung für Attachment ${attachment.Id}: ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`Fehler bei Checksummen-Prüfung für Attachment ${attachment.Id}: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,18 +187,27 @@ export class EmailController {
|
|||||||
email.Status = 1;
|
email.Status = 1;
|
||||||
await this.emailRepo.save(email);
|
await this.emailRepo.save(email);
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
this.logger.log(`E-Mail ${email.Id} auf Status 1 (Verarbeitet) gesetzt.`);
|
this.logger.log(
|
||||||
|
`E-Mail ${email.Id} auf Status 1 (Verarbeitet) gesetzt.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((index + 1) % 10 === 0) {
|
if ((index + 1) % 10 === 0) {
|
||||||
this.logger.log(`Zwischenstand: ${index + 1} von ${emails.length} E-Mails geprüft.`);
|
this.logger.log(
|
||||||
|
`Zwischenstand: ${index + 1} von ${emails.length} E-Mails geprüft.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`);
|
this.logger.log(
|
||||||
|
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
|
||||||
|
);
|
||||||
return { updatedCount, idsUpdated };
|
return { updatedCount, idsUpdated };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Kritischer Fehler bei checkAttachments: ${error.message}`, error.stack);
|
this.logger.error(
|
||||||
|
`Kritischer Fehler bei checkAttachments: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Email, Attachment, Content, CorrespondentEmailMapping, Task]),
|
TypeOrmModule.forFeature([
|
||||||
|
Email,
|
||||||
|
Attachment,
|
||||||
|
Content,
|
||||||
|
CorrespondentEmailMapping,
|
||||||
|
Task,
|
||||||
|
]),
|
||||||
PaperlessModule,
|
PaperlessModule,
|
||||||
PreprocessingModule,
|
PreprocessingModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ export class FreigabeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('documents/:id/freigabe')
|
@Put('documents/:id/freigabe')
|
||||||
setFreigabe(
|
setFreigabe(@Param('id') id: string, @Body('value') value: string | null) {
|
||||||
@Param('id') id: string,
|
|
||||||
@Body('value') value: string | null,
|
|
||||||
) {
|
|
||||||
return this.freigabeService.setFreigabe(parseInt(id, 10), value ?? null);
|
return this.freigabeService.setFreigabe(parseInt(id, 10), value ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { FreigabeController } from './freigabe.controller';
|
|||||||
import { FreigabeService } from './freigabe.service';
|
import { FreigabeService } from './freigabe.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule],
|
||||||
TypeOrmModule.forFeature([DocumentType]),
|
|
||||||
PaperlessModule,
|
|
||||||
],
|
|
||||||
controllers: [FreigabeController],
|
controllers: [FreigabeController],
|
||||||
providers: [FreigabeService],
|
providers: [FreigabeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export class FreigabeService {
|
|||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getFreigabeDocuments(page: number, pageSize: number, nurNichtFreigegeben: boolean) {
|
async getFreigabeDocuments(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
nurNichtFreigegeben: boolean,
|
||||||
|
) {
|
||||||
const docTypes = await this.documentTypeRepo.find({
|
const docTypes = await this.documentTypeRepo.find({
|
||||||
where: { FreigabeErforderlich: true as any },
|
where: { FreigabeErforderlich: true as any },
|
||||||
});
|
});
|
||||||
@@ -47,7 +51,9 @@ export class FreigabeService {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Fallback: Paperless unterstützt den custom_fields-Filter möglicherweise nicht
|
// Fallback: Paperless unterstützt den custom_fields-Filter möglicherweise nicht
|
||||||
// In diesem Fall alle Belege laden und client-seitig filtern
|
// In diesem Fall alle Belege laden und client-seitig filtern
|
||||||
this.logger.warn('custom_fields Filter nicht unterstützt, lade alle Belege und filtere lokal');
|
this.logger.warn(
|
||||||
|
'custom_fields Filter nicht unterstützt, lade alle Belege und filtere lokal',
|
||||||
|
);
|
||||||
const fallbackParams: Record<string, any> = {
|
const fallbackParams: Record<string, any> = {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 9999,
|
page_size: 9999,
|
||||||
@@ -60,8 +66,15 @@ export class FreigabeService {
|
|||||||
|
|
||||||
if (nurNichtFreigegeben) {
|
if (nurNichtFreigegeben) {
|
||||||
const filtered = results.filter((doc: any) => {
|
const filtered = results.filter((doc: any) => {
|
||||||
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === FREIGABE_FIELD_ID);
|
const cf = (doc.custom_fields ?? []).find(
|
||||||
return !cf || cf.value === null || cf.value === '' || cf.value === undefined;
|
(f: any) => f.field === FREIGABE_FIELD_ID,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
!cf ||
|
||||||
|
cf.value === null ||
|
||||||
|
cf.value === '' ||
|
||||||
|
cf.value === undefined
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
return {
|
return {
|
||||||
@@ -82,20 +95,24 @@ export class FreigabeService {
|
|||||||
const doc = await this.paperlessService.getDocument(documentId);
|
const doc = await this.paperlessService.getDocument(documentId);
|
||||||
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
||||||
|
|
||||||
const existing = customFields.find((f: any) => f.field === FREIGABE_FIELD_ID);
|
const existing = customFields.find(
|
||||||
|
(f: any) => f.field === FREIGABE_FIELD_ID,
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.value = value;
|
existing.value = value;
|
||||||
} else if (value !== null && value !== '') {
|
} else if (value !== null && value !== '') {
|
||||||
customFields.push({ field: FREIGABE_FIELD_ID, value });
|
customFields.push({ field: FREIGABE_FIELD_ID, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(documentId, { custom_fields: customFields });
|
await this.paperlessService.updateDocument(documentId, {
|
||||||
|
custom_fields: customFields,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFreigabeOptions(): Promise<{ id: string; label: string }[]> {
|
async getFreigabeOptions(): Promise<{ id: string; label: string }[]> {
|
||||||
const fields = await this.paperlessService.getCustomFields();
|
const fields = await this.paperlessService.getCustomFields();
|
||||||
const field = (fields as any[]).find((f: any) => f.id === FREIGABE_FIELD_ID);
|
const field = fields.find((f: any) => f.id === FREIGABE_FIELD_ID);
|
||||||
if (!field) return [];
|
if (!field) return [];
|
||||||
|
|
||||||
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function applyEditsToTemp(
|
|||||||
if (!Number.isInteger(pageNum)) continue;
|
if (!Number.isInteger(pageNum)) continue;
|
||||||
const idx = pageNum - 1;
|
const idx = pageNum - 1;
|
||||||
if (idx < 0 || idx >= pdf.getPageCount()) continue;
|
if (idx < 0 || idx >= pdf.getPageCount()) continue;
|
||||||
const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360;
|
const normalized = (((Math.round(rot / 90) * 90) % 360) + 360) % 360;
|
||||||
if (normalized === 0) continue;
|
if (normalized === 0) continue;
|
||||||
pdf.getPage(idx).setRotation(degrees(normalized));
|
pdf.getPage(idx).setRotation(degrees(normalized));
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export async function buildSegmentBuffer(
|
|||||||
copied.forEach((page, i) => {
|
copied.forEach((page, i) => {
|
||||||
const rot = rotations[String(segmentPages[i])];
|
const rot = rotations[String(segmentPages[i])];
|
||||||
if (rot !== undefined) {
|
if (rot !== undefined) {
|
||||||
const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360;
|
const normalized = (((Math.round(rot / 90) * 90) % 360) + 360) % 360;
|
||||||
if (normalized !== 0) page.setRotation(degrees(normalized));
|
if (normalized !== 0) page.setRotation(degrees(normalized));
|
||||||
}
|
}
|
||||||
outPdf.addPage(page);
|
outPdf.addPage(page);
|
||||||
@@ -91,7 +91,7 @@ export async function extractSectionToTemp(
|
|||||||
const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
|
const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
|
||||||
const outPdf = await PDFDocument.create();
|
const outPdf = await PDFDocument.create();
|
||||||
const copied = await outPdf.copyPages(srcPdf, pageIndices);
|
const copied = await outPdf.copyPages(srcPdf, pageIndices);
|
||||||
copied.forEach(p => outPdf.addPage(p));
|
copied.forEach((p) => outPdf.addPage(p));
|
||||||
const out = await outPdf.save();
|
const out = await outPdf.save();
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-'));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-'));
|
||||||
const tmpPath = path.join(tmpDir, 'section.pdf');
|
const tmpPath = path.join(tmpDir, 'section.pdf');
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { PostprocessingModule } from '../postprocessing/postprocessing.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([InboxPostprocessingAction, InboxDocument, BarcodeTemplate, Task]),
|
TypeOrmModule.forFeature([
|
||||||
|
InboxPostprocessingAction,
|
||||||
|
InboxDocument,
|
||||||
|
BarcodeTemplate,
|
||||||
|
Task,
|
||||||
|
]),
|
||||||
BarcodeModule,
|
BarcodeModule,
|
||||||
PaperlessModule,
|
PaperlessModule,
|
||||||
PostprocessingModule,
|
PostprocessingModule,
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { Repository } from 'typeorm';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import {
|
import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity';
|
||||||
InboxPostprocessingAction,
|
|
||||||
} from '../database/entities/inbox-postprocessing-action.entity';
|
|
||||||
import { InboxDocument } from '../database/entities/inbox-document.entity';
|
import { InboxDocument } from '../database/entities/inbox-document.entity';
|
||||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
@@ -14,7 +12,11 @@ import { PageCacheService } from '../barcode/page-cache.service';
|
|||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
import { MailService } from '../postprocessing/mail.service';
|
import { MailService } from '../postprocessing/mail.service';
|
||||||
import { ExportService } from '../postprocessing/export.service';
|
import { ExportService } from '../postprocessing/export.service';
|
||||||
import { applyEditsToTemp, cleanupTemp, extractSectionToTemp } from './edit-applier';
|
import {
|
||||||
|
applyEditsToTemp,
|
||||||
|
cleanupTemp,
|
||||||
|
extractSectionToTemp,
|
||||||
|
} from './edit-applier';
|
||||||
import { applyTemplate, buildVariables } from './variable-resolver';
|
import { applyTemplate, buildVariables } from './variable-resolver';
|
||||||
|
|
||||||
function parseFlexDate(s: string): Date | null {
|
function parseFlexDate(s: string): Date | null {
|
||||||
@@ -27,7 +29,9 @@ function parseFlexDate(s: string): Date | null {
|
|||||||
// German: DD.MM.YYYY
|
// German: DD.MM.YYYY
|
||||||
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const d = new Date(`${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`);
|
const d = new Date(
|
||||||
|
`${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`,
|
||||||
|
);
|
||||||
return isNaN(d.getTime()) ? null : d;
|
return isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -96,21 +100,26 @@ export class InboxPostprocessorService {
|
|||||||
const templates = await this.templateRepo.find({ order: { Id: 'ASC' } });
|
const templates = await this.templateRepo.find({ order: { Id: 'ASC' } });
|
||||||
const matchedTemplateIds = [
|
const matchedTemplateIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
doc.QrCodes
|
doc.QrCodes.map((qr) => {
|
||||||
.map((qr) => {
|
const tpl = templates.find((t) => {
|
||||||
const tpl = templates.find((t) => {
|
try {
|
||||||
try { return new RegExp(t.Regex).test(qr.value); }
|
return new RegExp(t.Regex).test(qr.value);
|
||||||
catch { return false; }
|
} catch {
|
||||||
});
|
return false;
|
||||||
return tpl?.Id ?? null;
|
}
|
||||||
})
|
});
|
||||||
.filter((id): id is number => id !== null),
|
return tpl?.Id ?? null;
|
||||||
|
}).filter((id): id is number => id !== null),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (matchedTemplateIds.length === 0) return { results: [], totalSections: 0 };
|
if (matchedTemplateIds.length === 0)
|
||||||
|
return { results: [], totalSections: 0 };
|
||||||
|
|
||||||
const actions = await this.actionRepo.find({
|
const actions = await this.actionRepo.find({
|
||||||
where: matchedTemplateIds.map((tid) => ({ BarcodeTemplateId: tid, IsActive: true })),
|
where: matchedTemplateIds.map((tid) => ({
|
||||||
|
BarcodeTemplateId: tid,
|
||||||
|
IsActive: true,
|
||||||
|
})),
|
||||||
order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' },
|
order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,18 +149,31 @@ export class InboxPostprocessorService {
|
|||||||
}
|
}
|
||||||
// Mapping: Original-Seite → 0-basierter Index in processedPdf
|
// Mapping: Original-Seite → 0-basierter Index in processedPdf
|
||||||
const processedPageIndex = new Map<number, number>();
|
const processedPageIndex = new Map<number, number>();
|
||||||
survivingOriginalPages.forEach((origPage, idx) => processedPageIndex.set(origPage, idx));
|
survivingOriginalPages.forEach((origPage, idx) =>
|
||||||
|
processedPageIndex.set(origPage, idx),
|
||||||
|
);
|
||||||
|
|
||||||
// QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern)
|
// QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern)
|
||||||
const qrsWithIdx = doc.QrCodes
|
const qrsWithIdx = doc.QrCodes.filter((qr) =>
|
||||||
.filter(qr => processedPageIndex.has(qr.page))
|
processedPageIndex.has(qr.page),
|
||||||
.map(qr => ({ page: qr.page, value: qr.value, processedIdx: processedPageIndex.get(qr.page)! }))
|
)
|
||||||
|
.map((qr) => ({
|
||||||
|
page: qr.page,
|
||||||
|
value: qr.value,
|
||||||
|
processedIdx: processedPageIndex.get(qr.page)!,
|
||||||
|
}))
|
||||||
.sort((a, b) => a.processedIdx - b.processedIdx);
|
.sort((a, b) => a.processedIdx - b.processedIdx);
|
||||||
|
|
||||||
// Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen
|
// Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen
|
||||||
const splitPoints: number[] = [];
|
const splitPoints: number[] = [];
|
||||||
for (const qr of qrsWithIdx) {
|
for (const qr of qrsWithIdx) {
|
||||||
const tplMatch = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
const tplMatch = templates.find((t) => {
|
||||||
|
try {
|
||||||
|
return new RegExp(t.Regex).test(qr.value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx);
|
if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx);
|
||||||
}
|
}
|
||||||
const processedPageCount = survivingOriginalPages.length;
|
const processedPageCount = survivingOriginalPages.length;
|
||||||
@@ -160,7 +182,13 @@ export class InboxPostprocessorService {
|
|||||||
let totalSections = 0;
|
let totalSections = 0;
|
||||||
for (let i = 0; i < qrsWithIdx.length; i++) {
|
for (let i = 0; i < qrsWithIdx.length; i++) {
|
||||||
const qr = qrsWithIdx[i];
|
const qr = qrsWithIdx[i];
|
||||||
const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
const tpl = templates.find((t) => {
|
||||||
|
try {
|
||||||
|
return new RegExp(t.Regex).test(qr.value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!tpl) continue;
|
if (!tpl) continue;
|
||||||
if (i > 0 && !tpl.SplitBefore) continue;
|
if (i > 0 && !tpl.SplitBefore) continue;
|
||||||
totalSections++;
|
totalSections++;
|
||||||
@@ -177,7 +205,13 @@ export class InboxPostprocessorService {
|
|||||||
if (processedSection && processOnlyOne) break;
|
if (processedSection && processOnlyOne) break;
|
||||||
const qr = qrsWithIdx[i];
|
const qr = qrsWithIdx[i];
|
||||||
|
|
||||||
const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } });
|
const tpl = templates.find((t) => {
|
||||||
|
try {
|
||||||
|
return new RegExp(t.Regex).test(qr.value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!tpl) continue;
|
if (!tpl) continue;
|
||||||
if (i > 0 && !tpl.SplitBefore) continue;
|
if (i > 0 && !tpl.SplitBefore) continue;
|
||||||
|
|
||||||
@@ -191,14 +225,27 @@ export class InboxPostprocessorService {
|
|||||||
const tplActions = actionsByTemplate.get(tpl.Id);
|
const tplActions = actionsByTemplate.get(tpl.Id);
|
||||||
if (!tplActions || tplActions.length === 0) continue;
|
if (!tplActions || tplActions.length === 0) continue;
|
||||||
|
|
||||||
const variables = buildVariables({ doc, template: tpl, matchingQrValue: qr.value });
|
const variables = buildVariables({
|
||||||
|
doc,
|
||||||
|
template: tpl,
|
||||||
|
matchingQrValue: qr.value,
|
||||||
|
});
|
||||||
|
|
||||||
// Abschnitt aus der verarbeiteten PDF extrahieren
|
// Abschnitt aus der verarbeiteten PDF extrahieren
|
||||||
const startIdx = qr.processedIdx;
|
const startIdx = qr.processedIdx;
|
||||||
const nextSplitIdx = splitPoints.find(sp => sp > startIdx);
|
const nextSplitIdx = splitPoints.find((sp) => sp > startIdx);
|
||||||
const endIdx = nextSplitIdx !== undefined ? nextSplitIdx - 1 : processedPageCount - 1;
|
const endIdx =
|
||||||
const pageIndices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i);
|
nextSplitIdx !== undefined
|
||||||
const sectionPdfPath = await extractSectionToTemp(processedPdfPath, pageIndices);
|
? nextSplitIdx - 1
|
||||||
|
: processedPageCount - 1;
|
||||||
|
const pageIndices = Array.from(
|
||||||
|
{ length: endIdx - startIdx + 1 },
|
||||||
|
(_, i) => startIdx + i,
|
||||||
|
);
|
||||||
|
const sectionPdfPath = await extractSectionToTemp(
|
||||||
|
processedPdfPath,
|
||||||
|
pageIndices,
|
||||||
|
);
|
||||||
|
|
||||||
const defaultFilenameBase = tpl.DateinameTemplate
|
const defaultFilenameBase = tpl.DateinameTemplate
|
||||||
? applyTemplate(tpl.DateinameTemplate, variables)
|
? applyTemplate(tpl.DateinameTemplate, variables)
|
||||||
@@ -209,7 +256,13 @@ export class InboxPostprocessorService {
|
|||||||
if (abortProcessing) break;
|
if (abortProcessing) break;
|
||||||
if (action.ActionType === 'PAPERLESS') {
|
if (action.ActionType === 'PAPERLESS') {
|
||||||
try {
|
try {
|
||||||
const res = await this.runPaperless(action.Content ?? {}, sectionPdfPath, variables, defaultFilenameBase, replaceDuplicate);
|
const res = await this.runPaperless(
|
||||||
|
action.Content ?? {},
|
||||||
|
sectionPdfPath,
|
||||||
|
variables,
|
||||||
|
defaultFilenameBase,
|
||||||
|
replaceDuplicate,
|
||||||
|
);
|
||||||
results.push({
|
results.push({
|
||||||
sectionIndex: currentSectionIndex,
|
sectionIndex: currentSectionIndex,
|
||||||
actionId: action.Id,
|
actionId: action.Id,
|
||||||
@@ -227,17 +280,40 @@ export class InboxPostprocessorService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
`Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
||||||
);
|
);
|
||||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message });
|
results.push({
|
||||||
|
sectionIndex: currentSectionIndex,
|
||||||
|
actionId: action.Id,
|
||||||
|
actionType: action.ActionType,
|
||||||
|
ok: false,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await this.runAction(action, sectionPdfPath, doc, variables, defaultFilenameBase);
|
await this.runAction(
|
||||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: true });
|
action,
|
||||||
|
sectionPdfPath,
|
||||||
|
doc,
|
||||||
|
variables,
|
||||||
|
defaultFilenameBase,
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
sectionIndex: currentSectionIndex,
|
||||||
|
actionId: action.Id,
|
||||||
|
actionType: action.ActionType,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
`Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`,
|
||||||
);
|
);
|
||||||
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message });
|
results.push({
|
||||||
|
sectionIndex: currentSectionIndex,
|
||||||
|
actionId: action.Id,
|
||||||
|
actionType: action.ActionType,
|
||||||
|
ok: false,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +341,13 @@ export class InboxPostprocessorService {
|
|||||||
|
|
||||||
switch (action.ActionType) {
|
switch (action.ActionType) {
|
||||||
case 'MAIL':
|
case 'MAIL':
|
||||||
return this.runMail(content, pdfPath, doc, variables, defaultFilenameBase);
|
return this.runMail(
|
||||||
|
content,
|
||||||
|
pdfPath,
|
||||||
|
doc,
|
||||||
|
variables,
|
||||||
|
defaultFilenameBase,
|
||||||
|
);
|
||||||
case 'EXPORT':
|
case 'EXPORT':
|
||||||
return this.runExport(content, pdfPath, variables, defaultFilenameBase);
|
return this.runExport(content, pdfPath, variables, defaultFilenameBase);
|
||||||
default:
|
default:
|
||||||
@@ -326,9 +408,13 @@ export class InboxPostprocessorService {
|
|||||||
): Promise<PaperlessRunResult> {
|
): Promise<PaperlessRunResult> {
|
||||||
// 1. Interne Belegnummer auflösen (Pflicht)
|
// 1. Interne Belegnummer auflösen (Pflicht)
|
||||||
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
const intNrTpl = String(content.interneBelegnummer ?? '').trim();
|
||||||
if (!intNrTpl) throw new Error('Interne Belegnummer ist in der Aktion nicht konfiguriert');
|
if (!intNrTpl)
|
||||||
|
throw new Error(
|
||||||
|
'Interne Belegnummer ist in der Aktion nicht konfiguriert',
|
||||||
|
);
|
||||||
const interneBelegnummer = applyTemplate(intNrTpl, variables).trim();
|
const interneBelegnummer = applyTemplate(intNrTpl, variables).trim();
|
||||||
if (!interneBelegnummer) throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
|
if (!interneBelegnummer)
|
||||||
|
throw new Error('Interne Belegnummer konnte nicht aufgelöst werden');
|
||||||
|
|
||||||
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
|
// 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
|
||||||
const asnTpl = String(content.asn ?? '').trim();
|
const asnTpl = String(content.asn ?? '').trim();
|
||||||
@@ -344,12 +430,18 @@ export class InboxPostprocessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (replaceDuplicate) {
|
if (replaceDuplicate) {
|
||||||
this.logger.log(`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`);
|
this.logger.log(
|
||||||
|
`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 3. Duplikat-Check lokal (tasks-Tabelle)
|
// 3. Duplikat-Check lokal (tasks-Tabelle)
|
||||||
const existingTask = await this.taskRepo.findOneBy({ InterneBelegnummer: interneBelegnummer });
|
const existingTask = await this.taskRepo.findOneBy({
|
||||||
|
InterneBelegnummer: interneBelegnummer,
|
||||||
|
});
|
||||||
if (existingTask) {
|
if (existingTask) {
|
||||||
this.logger.warn(`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`);
|
this.logger.warn(
|
||||||
|
`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
skipped: true,
|
skipped: true,
|
||||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
||||||
@@ -358,9 +450,14 @@ export class InboxPostprocessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
|
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer)
|
||||||
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(7, interneBelegnummer);
|
const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(
|
||||||
|
7,
|
||||||
|
interneBelegnummer,
|
||||||
|
);
|
||||||
if (cf7DocId !== null) {
|
if (cf7DocId !== null) {
|
||||||
this.logger.warn(`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`);
|
this.logger.warn(
|
||||||
|
`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
skipped: true,
|
skipped: true,
|
||||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
|
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`,
|
||||||
@@ -370,9 +467,12 @@ export class InboxPostprocessorService {
|
|||||||
|
|
||||||
// 5. Duplikat-Check Paperless API (archive_serial_number)
|
// 5. Duplikat-Check Paperless API (archive_serial_number)
|
||||||
if (archiveSerialNumber !== undefined) {
|
if (archiveSerialNumber !== undefined) {
|
||||||
const asnDocId = await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
|
const asnDocId =
|
||||||
|
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
|
||||||
if (asnDocId !== null) {
|
if (asnDocId !== null) {
|
||||||
this.logger.warn(`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`);
|
this.logger.warn(
|
||||||
|
`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
skipped: true,
|
skipped: true,
|
||||||
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
|
message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`,
|
||||||
@@ -384,10 +484,14 @@ export class InboxPostprocessorService {
|
|||||||
// 6. Checksum berechnen und prüfen
|
// 6. Checksum berechnen und prüfen
|
||||||
const buffer = await fs.readFile(pdfPath);
|
const buffer = await fs.readFile(pdfPath);
|
||||||
const checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
const checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
||||||
const checksumExists = await this.paperlessService.checksumExists(checksum);
|
const checksumExists =
|
||||||
|
await this.paperlessService.checksumExists(checksum);
|
||||||
if (checksumExists) {
|
if (checksumExists) {
|
||||||
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
this.logger.warn(`Duplikat (Checksum): ${checksum}`);
|
||||||
return { skipped: true, message: 'Duplikat (Checksum-Übereinstimmung)' };
|
return {
|
||||||
|
skipped: true,
|
||||||
|
message: 'Duplikat (Checksum-Übereinstimmung)',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,22 +503,30 @@ export class InboxPostprocessorService {
|
|||||||
: defaultFilenameBase || undefined;
|
: defaultFilenameBase || undefined;
|
||||||
|
|
||||||
const tags = Array.isArray(content.tags)
|
const tags = Array.isArray(content.tags)
|
||||||
? content.tags.map((t: any) => Number(t)).filter((n: number) => Number.isFinite(n))
|
? content.tags
|
||||||
|
.map((t: any) => Number(t))
|
||||||
|
.filter((n: number) => Number.isFinite(n))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const documentType = content.documentType ? Number(content.documentType) : undefined;
|
const documentType = content.documentType
|
||||||
const correspondent = content.correspondent ? Number(content.correspondent) : undefined;
|
? Number(content.documentType)
|
||||||
const owner = content.owner !== undefined && content.owner !== null && content.owner !== ''
|
|
||||||
? Number(content.owner)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const correspondent = content.correspondent
|
||||||
|
? Number(content.correspondent)
|
||||||
|
: undefined;
|
||||||
|
const owner =
|
||||||
|
content.owner !== undefined &&
|
||||||
|
content.owner !== null &&
|
||||||
|
content.owner !== ''
|
||||||
|
? Number(content.owner)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const rawCustomFields: Record<string, string> | null =
|
const rawCustomFields: Record<string, string> | null =
|
||||||
content.customFields && typeof content.customFields === 'object'
|
content.customFields && typeof content.customFields === 'object'
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(content.customFields as Record<string, any>).map(([k, v]) => [
|
Object.entries(content.customFields as Record<string, any>).map(
|
||||||
k,
|
([k, v]) => [k, applyTemplate(String(v ?? ''), variables)],
|
||||||
applyTemplate(String(v ?? ''), variables),
|
),
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -422,7 +534,9 @@ export class InboxPostprocessorService {
|
|||||||
const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim();
|
const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim();
|
||||||
let eingangsdatum: Date;
|
let eingangsdatum: Date;
|
||||||
if (eingangsdatumTpl) {
|
if (eingangsdatumTpl) {
|
||||||
eingangsdatum = parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ?? new Date();
|
eingangsdatum =
|
||||||
|
parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ??
|
||||||
|
new Date();
|
||||||
} else if (rawCustomFields?.['9']) {
|
} else if (rawCustomFields?.['9']) {
|
||||||
const parsed = parseFlexDate(rawCustomFields['9']);
|
const parsed = parseFlexDate(rawCustomFields['9']);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -441,10 +555,12 @@ export class InboxPostprocessorService {
|
|||||||
// - User-konfigurierte Felder aus rawCustomFields
|
// - User-konfigurierte Felder aus rawCustomFields
|
||||||
const uploadCustomFields: Record<string, string> = {};
|
const uploadCustomFields: Record<string, string> = {};
|
||||||
if (rawCustomFields) {
|
if (rawCustomFields) {
|
||||||
for (const [k, v] of Object.entries(rawCustomFields)) uploadCustomFields[k] = v;
|
for (const [k, v] of Object.entries(rawCustomFields))
|
||||||
|
uploadCustomFields[k] = v;
|
||||||
}
|
}
|
||||||
uploadCustomFields['7'] = interneBelegnummer;
|
uploadCustomFields['7'] = interneBelegnummer;
|
||||||
uploadCustomFields['9'] = `${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`;
|
uploadCustomFields['9'] =
|
||||||
|
`${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// 6. Upload
|
// 6. Upload
|
||||||
const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, {
|
const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, {
|
||||||
@@ -469,9 +585,10 @@ export class InboxPostprocessorService {
|
|||||||
Tags: tags && tags.length > 0 ? tags.join(',') : null,
|
Tags: tags && tags.length > 0 ? tags.join(',') : null,
|
||||||
BetriebID: owner ?? null,
|
BetriebID: owner ?? null,
|
||||||
externeBelegnummer: null,
|
externeBelegnummer: null,
|
||||||
CustomFieldsJson: rawCustomFields && Object.keys(rawCustomFields).length > 0
|
CustomFieldsJson:
|
||||||
? JSON.stringify(rawCustomFields)
|
rawCustomFields && Object.keys(rawCustomFields).length > 0
|
||||||
: null,
|
? JSON.stringify(rawCustomFields)
|
||||||
|
: null,
|
||||||
Asn: asn || null,
|
Asn: asn || null,
|
||||||
Lieferant: null,
|
Lieferant: null,
|
||||||
EinkaufID: null,
|
EinkaufID: null,
|
||||||
@@ -482,7 +599,9 @@ export class InboxPostprocessorService {
|
|||||||
});
|
});
|
||||||
await this.taskRepo.save(task);
|
await this.taskRepo.save(task);
|
||||||
|
|
||||||
this.logger.log(`Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`);
|
this.logger.log(
|
||||||
|
`Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`,
|
||||||
|
);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export function buildVariables(ctx: ResolverContext): Record<string, string> {
|
|||||||
* Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template.
|
* Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template.
|
||||||
* Unbekannte Platzhalter bleiben unverändert.
|
* Unbekannte Platzhalter bleiben unverändert.
|
||||||
*/
|
*/
|
||||||
export function applyTemplate(template: string, vars: Record<string, string>): string {
|
export function applyTemplate(
|
||||||
|
template: string,
|
||||||
|
vars: Record<string, string>,
|
||||||
|
): string {
|
||||||
if (!template) return template;
|
if (!template) return template;
|
||||||
return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => {
|
return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => {
|
||||||
return name in vars ? vars[name] : full;
|
return name in vars ? vars[name] : full;
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ export class ClientsController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
||||||
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>,
|
@InjectRepository(UserClient)
|
||||||
|
private readonly userClientRepo: Repository<UserClient>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getMyClients(@Request() req: any) {
|
async getMyClients(@Request() req: any) {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const mappings = await this.userClientRepo.find({ where: { UserId: userId } });
|
const mappings = await this.userClientRepo.find({
|
||||||
|
where: { UserId: userId },
|
||||||
|
});
|
||||||
const clientIds = mappings.map((m) => m.ClientId);
|
const clientIds = mappings.map((m) => m.ClientId);
|
||||||
|
|
||||||
if (clientIds.length === 0) {
|
if (clientIds.length === 0) {
|
||||||
|
|||||||
@@ -28,17 +28,24 @@ export class InboxMigrationService implements OnApplicationBootstrap {
|
|||||||
@InjectRepository(InboxDocument)
|
@InjectRepository(InboxDocument)
|
||||||
private readonly documentRepo: Repository<InboxDocument>,
|
private readonly documentRepo: Repository<InboxDocument>,
|
||||||
) {
|
) {
|
||||||
this.legacyRoot = this.configService.get<string>('SCANS_DATA_DIR', '/mnt/data/scans');
|
this.legacyRoot = this.configService.get<string>(
|
||||||
|
'SCANS_DATA_DIR',
|
||||||
|
'/mnt/data/scans',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
let subdirs: string[];
|
let subdirs: string[];
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(this.legacyRoot, { withFileTypes: true });
|
const entries = await fs.readdir(this.legacyRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code !== 'ENOENT') {
|
if (err.code !== 'ENOENT') {
|
||||||
this.logger.warn(`Migration: ${this.legacyRoot} nicht lesbar: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Migration: ${this.legacyRoot} nicht lesbar: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -61,7 +68,9 @@ export class InboxMigrationService implements OnApplicationBootstrap {
|
|||||||
await this.migrateFile(src, subdir, name);
|
await this.migrateFile(src, subdir, name);
|
||||||
migrated += 1;
|
migrated += 1;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Migration fehlgeschlagen (${src}): ${err.message}`);
|
this.logger.error(
|
||||||
|
`Migration fehlgeschlagen (${src}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +84,11 @@ export class InboxMigrationService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async migrateFile(src: string, subdir: string, name: string): Promise<void> {
|
private async migrateFile(
|
||||||
|
src: string,
|
||||||
|
subdir: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const source: InboxSource = subdir === 'all' ? 'all' : 'user';
|
const source: InboxSource = subdir === 'all' ? 'all' : 'user';
|
||||||
const owner = source === 'all' ? null : subdir;
|
const owner = source === 'all' ? null : subdir;
|
||||||
@@ -115,7 +128,9 @@ export class InboxMigrationService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadLegacyQrCodes(oldFilePath: string): Promise<StoredQrCode[]> {
|
private async loadLegacyQrCodes(
|
||||||
|
oldFilePath: string,
|
||||||
|
): Promise<StoredQrCode[]> {
|
||||||
try {
|
try {
|
||||||
const rows = await this.dataSource.query<LegacyScanRow[]>(
|
const rows = await this.dataSource.query<LegacyScanRow[]>(
|
||||||
'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1',
|
'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1',
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export class InboxController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Request() req: any) {
|
async list(@Request() req: any) {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
return this.inboxService.listFiles(preferredUsername);
|
return this.inboxService.listFiles(preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +50,18 @@ export class InboxController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
const { doc, pdfPath } = await this.inboxService.resolveDocument(id, preferredUsername);
|
req.user?.preferredUsername ?? null;
|
||||||
|
const { doc, pdfPath } = await this.inboxService.resolveDocument(
|
||||||
|
id,
|
||||||
|
preferredUsername,
|
||||||
|
);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${doc.OriginalName}"`);
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`inline; filename="${doc.OriginalName}"`,
|
||||||
|
);
|
||||||
return new StreamableFile(createReadStream(pdfPath));
|
return new StreamableFile(createReadStream(pdfPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +72,14 @@ export class InboxController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
const filePath = await this.inboxService.resolvePageImage(id, page, 'thumbnail', preferredUsername);
|
req.user?.preferredUsername ?? null;
|
||||||
|
const filePath = await this.inboxService.resolvePageImage(
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
'thumbnail',
|
||||||
|
preferredUsername,
|
||||||
|
);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'image/png');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
@@ -75,7 +89,8 @@ export class InboxController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async remove(@Param('id') id: string, @Request() req: any): Promise<void> {
|
async remove(@Param('id') id: string, @Request() req: any): Promise<void> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
await this.inboxService.deleteDocument(id, preferredUsername);
|
await this.inboxService.deleteDocument(id, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +101,8 @@ export class InboxController {
|
|||||||
@Param('page', ParseIntPipe) page: number,
|
@Param('page', ParseIntPipe) page: number,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
await this.inboxService.deletePage(id, page, preferredUsername);
|
await this.inboxService.deletePage(id, page, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,14 +113,19 @@ export class InboxController {
|
|||||||
@Param('page', ParseIntPipe) page: number,
|
@Param('page', ParseIntPipe) page: number,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
await this.inboxService.toggleManualSplit(id, page, preferredUsername);
|
await this.inboxService.toggleManualSplit(id, page, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reset-edits')
|
@Post(':id/reset-edits')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async resetEdits(@Param('id') id: string, @Request() req: any): Promise<void> {
|
async resetEdits(
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<void> {
|
||||||
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
await this.inboxService.resetEdits(id, preferredUsername);
|
await this.inboxService.resetEdits(id, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +133,15 @@ export class InboxController {
|
|||||||
async postprocess(
|
async postprocess(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
|
@Body()
|
||||||
|
body: {
|
||||||
|
sectionOffset?: number;
|
||||||
|
processOnlyOne?: boolean;
|
||||||
|
replaceDuplicate?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
const { results, totalSections } = await this.postprocessor.runForDocument(
|
const { results, totalSections } = await this.postprocessor.runForDocument(
|
||||||
id,
|
id,
|
||||||
preferredUsername,
|
preferredUsername,
|
||||||
@@ -137,8 +164,14 @@ export class InboxController {
|
|||||||
if (!Number.isFinite(rotation)) {
|
if (!Number.isFinite(rotation)) {
|
||||||
throw new BadRequestException('rotation muss eine Zahl sein');
|
throw new BadRequestException('rotation muss eine Zahl sein');
|
||||||
}
|
}
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
await this.inboxService.setPageRotation(id, page, rotation, preferredUsername);
|
req.user?.preferredUsername ?? null;
|
||||||
|
await this.inboxService.setPageRotation(
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
rotation,
|
||||||
|
preferredUsername,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/pages/:page/preview')
|
@Get(':id/pages/:page/preview')
|
||||||
@@ -148,8 +181,14 @@ export class InboxController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
const filePath = await this.inboxService.resolvePageImage(id, page, 'preview', preferredUsername);
|
req.user?.preferredUsername ?? null;
|
||||||
|
const filePath = await this.inboxService.resolvePageImage(
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
'preview',
|
||||||
|
preferredUsername,
|
||||||
|
);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'image/png');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
@@ -163,8 +202,17 @@ export class InboxController {
|
|||||||
@Body() body: { x: number; y: number; w: number; h: number },
|
@Body() body: { x: number; y: number; w: number; h: number },
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<{ found: string[] }> {
|
): Promise<{ found: string[] }> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
return this.inboxService.scanRegion(id, page, body.x, body.y, body.w, body.h, preferredUsername);
|
req.user?.preferredUsername ?? null;
|
||||||
|
return this.inboxService.scanRegion(
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
body.x,
|
||||||
|
body.y,
|
||||||
|
body.w,
|
||||||
|
body.h,
|
||||||
|
preferredUsername,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/source')
|
@Post(':id/source')
|
||||||
@@ -174,7 +222,8 @@ export class InboxController {
|
|||||||
@Body() body: { source: any },
|
@Body() body: { source: any },
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
|
req.user?.preferredUsername ?? null;
|
||||||
await this.inboxService.updateSource(id, body.source, preferredUsername);
|
await this.inboxService.updateSource(id, body.source, preferredUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +234,19 @@ export class InboxController {
|
|||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(id, preferredUsername, body.pages ?? []);
|
req.user?.preferredUsername ?? null;
|
||||||
|
const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(
|
||||||
|
id,
|
||||||
|
preferredUsername,
|
||||||
|
body.pages ?? [],
|
||||||
|
);
|
||||||
const { Readable } = await import('stream');
|
const { Readable } = await import('stream');
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
);
|
||||||
return new StreamableFile(Readable.from(buffer));
|
return new StreamableFile(Readable.from(buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +254,8 @@ export class InboxController {
|
|||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async sendEmail(
|
async sendEmail(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: {
|
@Body()
|
||||||
|
body: {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -207,10 +265,12 @@ export class InboxController {
|
|||||||
},
|
},
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
|
const preferredUsername: string | null =
|
||||||
const smtpOverride = body.sender === 'user'
|
req.user?.preferredUsername ?? null;
|
||||||
? await this.userSettingsService.getSmtpConfig(req.user.userId)
|
const smtpOverride =
|
||||||
: null;
|
body.sender === 'user'
|
||||||
|
? await this.userSettingsService.getSmtpConfig(req.user.userId)
|
||||||
|
: null;
|
||||||
await this.inboxService.sendAsEmail(id, preferredUsername, {
|
await this.inboxService.sendAsEmail(id, preferredUsername, {
|
||||||
...body,
|
...body,
|
||||||
smtpOverride: smtpOverride ?? undefined,
|
smtpOverride: smtpOverride ?? undefined,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { BarcodeScannerService, type MatchedBarcode } from '../barcode/barcode-scanner.service';
|
import {
|
||||||
|
BarcodeScannerService,
|
||||||
|
type MatchedBarcode,
|
||||||
|
} from '../barcode/barcode-scanner.service';
|
||||||
import { PageCacheService } from '../barcode/page-cache.service';
|
import { PageCacheService } from '../barcode/page-cache.service';
|
||||||
import {
|
import {
|
||||||
InboxDocument,
|
InboxDocument,
|
||||||
@@ -48,7 +51,14 @@ export class InboxService {
|
|||||||
|
|
||||||
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
|
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
|
||||||
const where = preferredUsername
|
const where = preferredUsername
|
||||||
? [{ Source: 'all' as InboxSource, IsScanned: true }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername, IsScanned: true }]
|
? [
|
||||||
|
{ Source: 'all' as InboxSource, IsScanned: true },
|
||||||
|
{
|
||||||
|
Source: 'user' as InboxSource,
|
||||||
|
OwnerUsername: preferredUsername,
|
||||||
|
IsScanned: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
: [{ Source: 'all' as InboxSource, IsScanned: true }];
|
: [{ Source: 'all' as InboxSource, IsScanned: true }];
|
||||||
|
|
||||||
const docs = await this.documentRepo.find({
|
const docs = await this.documentRepo.find({
|
||||||
@@ -64,7 +74,9 @@ export class InboxService {
|
|||||||
source: doc.Source,
|
source: doc.Source,
|
||||||
pageCount: doc.PageCount,
|
pageCount: doc.PageCount,
|
||||||
deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b),
|
deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b),
|
||||||
manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort((a, b) => a - b),
|
manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
),
|
||||||
rotations: { ...(doc.Rotations ?? {}) },
|
rotations: { ...(doc.Rotations ?? {}) },
|
||||||
barcodes: await this.barcodeScanner.getMatched(doc),
|
barcodes: await this.barcodeScanner.getMatched(doc),
|
||||||
createdAt: doc.CreatedAt.toISOString(),
|
createdAt: doc.CreatedAt.toISOString(),
|
||||||
@@ -73,7 +85,10 @@ export class InboxService {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveDocument(id: string, preferredUsername: string | null): Promise<ResolvedDocument> {
|
async resolveDocument(
|
||||||
|
id: string,
|
||||||
|
preferredUsername: string | null,
|
||||||
|
): Promise<ResolvedDocument> {
|
||||||
const doc = await this.documentRepo.findOne({ where: { Id: id } });
|
const doc = await this.documentRepo.findOne({ where: { Id: id } });
|
||||||
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
|
||||||
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
|
||||||
@@ -85,7 +100,9 @@ export class InboxService {
|
|||||||
const stat = await fs.stat(pdfPath);
|
const stat = await fs.stat(pdfPath);
|
||||||
if (!stat.isFile()) throw new Error('not a file');
|
if (!stat.isFile()) throw new Error('not a file');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`,
|
||||||
|
);
|
||||||
throw new NotFoundException('Dokument nicht gefunden');
|
throw new NotFoundException('Dokument nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +152,7 @@ export class InboxService {
|
|||||||
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
|
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
|
||||||
throw new NotFoundException('Seite nicht gefunden');
|
throw new NotFoundException('Seite nicht gefunden');
|
||||||
}
|
}
|
||||||
const normalized = ((Math.round(rotation / 90) * 90) % 360 + 360) % 360;
|
const normalized = (((Math.round(rotation / 90) * 90) % 360) + 360) % 360;
|
||||||
const next: Record<string, number> = { ...(doc.Rotations ?? {}) };
|
const next: Record<string, number> = { ...(doc.Rotations ?? {}) };
|
||||||
if (normalized === 0) {
|
if (normalized === 0) {
|
||||||
delete next[String(page)];
|
delete next[String(page)];
|
||||||
@@ -149,7 +166,10 @@ export class InboxService {
|
|||||||
/**
|
/**
|
||||||
* Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück.
|
* Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück.
|
||||||
*/
|
*/
|
||||||
async resetEdits(id: string, preferredUsername: string | null): Promise<void> {
|
async resetEdits(
|
||||||
|
id: string,
|
||||||
|
preferredUsername: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const { doc } = await this.resolveDocument(id, preferredUsername);
|
const { doc } = await this.resolveDocument(id, preferredUsername);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (doc.DeletedPages && doc.DeletedPages.length > 0) {
|
if (doc.DeletedPages && doc.DeletedPages.length > 0) {
|
||||||
@@ -170,7 +190,11 @@ export class InboxService {
|
|||||||
/**
|
/**
|
||||||
* Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite.
|
* Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite.
|
||||||
*/
|
*/
|
||||||
async toggleManualSplit(id: string, page: number, preferredUsername: string | null): Promise<void> {
|
async toggleManualSplit(
|
||||||
|
id: string,
|
||||||
|
page: number,
|
||||||
|
preferredUsername: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const { doc } = await this.resolveDocument(id, preferredUsername);
|
const { doc } = await this.resolveDocument(id, preferredUsername);
|
||||||
if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) {
|
if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) {
|
||||||
throw new BadRequestException('Ungültige Seitennummer für Trennung');
|
throw new BadRequestException('Ungültige Seitennummer für Trennung');
|
||||||
@@ -185,14 +209,19 @@ export class InboxService {
|
|||||||
await this.documentRepo.save(doc);
|
await this.documentRepo.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDocument(id: string, preferredUsername: string | null): Promise<void> {
|
async deleteDocument(
|
||||||
|
id: string,
|
||||||
|
preferredUsername: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const { doc } = await this.resolveDocument(id, preferredUsername);
|
const { doc } = await this.resolveDocument(id, preferredUsername);
|
||||||
const dir = this.pageCache.documentDir(doc.Id);
|
const dir = this.pageCache.documentDir(doc.Id);
|
||||||
await this.documentRepo.delete(doc.Id);
|
await this.documentRepo.delete(doc.Id);
|
||||||
try {
|
try {
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +260,9 @@ export class InboxService {
|
|||||||
doc.OwnerUsername = null;
|
doc.OwnerUsername = null;
|
||||||
} else {
|
} else {
|
||||||
if (!preferredUsername) {
|
if (!preferredUsername) {
|
||||||
throw new BadRequestException('Benutzername erforderlich für persönlichen Scan');
|
throw new BadRequestException(
|
||||||
|
'Benutzername erforderlich für persönlichen Scan',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
doc.Source = 'user';
|
doc.Source = 'user';
|
||||||
doc.OwnerUsername = preferredUsername;
|
doc.OwnerUsername = preferredUsername;
|
||||||
@@ -260,7 +291,9 @@ export class InboxService {
|
|||||||
): Promise<{ buffer: Buffer; filename: string }> {
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
||||||
const deleted = new Set(doc.DeletedPages ?? []);
|
const deleted = new Set(doc.DeletedPages ?? []);
|
||||||
const safePages = pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p));
|
const safePages = pages.filter(
|
||||||
|
(p) => p >= 1 && p <= doc.PageCount && !deleted.has(p),
|
||||||
|
);
|
||||||
const buffer = await buildSegmentBuffer(doc, pdfPath, safePages);
|
const buffer = await buildSegmentBuffer(doc, pdfPath, safePages);
|
||||||
return { buffer, filename: doc.OriginalName };
|
return { buffer, filename: doc.OriginalName };
|
||||||
}
|
}
|
||||||
@@ -274,7 +307,14 @@ export class InboxService {
|
|||||||
body: string;
|
body: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
segments: { pages: number[]; filename: string }[];
|
segments: { pages: number[]; filename: string }[];
|
||||||
smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string };
|
smtpOverride?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
|
||||||
@@ -282,9 +322,13 @@ export class InboxService {
|
|||||||
|
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
opts.segments.map(async (seg) => {
|
opts.segments.map(async (seg) => {
|
||||||
const safePages = seg.pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p));
|
const safePages = seg.pages.filter(
|
||||||
|
(p) => p >= 1 && p <= doc.PageCount && !deleted.has(p),
|
||||||
|
);
|
||||||
const content = await buildSegmentBuffer(doc, pdfPath, safePages);
|
const content = await buildSegmentBuffer(doc, pdfPath, safePages);
|
||||||
const filename = seg.filename.endsWith('.pdf') ? seg.filename : `${seg.filename}.pdf`;
|
const filename = seg.filename.endsWith('.pdf')
|
||||||
|
? seg.filename
|
||||||
|
: `${seg.filename}.pdf`;
|
||||||
return { filename, content };
|
return { filename, content };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ export class KontonummernController {
|
|||||||
return this.kontonummernService.create(dto.correspondentId, dto.nummer);
|
return this.kontonummernService.create(dto.correspondentId, dto.nummer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export class LabelPrintAgentController {
|
|||||||
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const buf = await this.service.renderPreview(body.templateId, body.fieldValues ?? {});
|
const buf = await this.service.renderPreview(
|
||||||
|
body.templateId,
|
||||||
|
body.fieldValues ?? {},
|
||||||
|
);
|
||||||
const { Readable } = await import('stream');
|
const { Readable } = await import('stream');
|
||||||
res.setHeader('Content-Type', 'image/png');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
return new StreamableFile(Readable.from(buf));
|
return new StreamableFile(Readable.from(buf));
|
||||||
@@ -45,16 +48,21 @@ export class LabelPrintAgentController {
|
|||||||
async createJob(
|
async createJob(
|
||||||
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const job = await this.service.createJob(body.templateId, body.fieldValues ?? {});
|
const job = await this.service.createJob(
|
||||||
|
body.templateId,
|
||||||
|
body.fieldValues ?? {},
|
||||||
|
);
|
||||||
return { jobId: String(job.Id) };
|
return { jobId: String(job.Id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent: SSE-Stream für neue Druckaufträge
|
// Agent: SSE-Stream für neue Druckaufträge
|
||||||
@Sse('events')
|
@Sse('events')
|
||||||
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
|
sseEvents(
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Observable<MessageEvent> {
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
return this.service.newJob$.pipe(
|
return this.service.newJob$.pipe(
|
||||||
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
|
map(() => ({ data: { type: 'label-job-available' } }) as MessageEvent),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +76,9 @@ export class LabelPrintAgentController {
|
|||||||
}
|
}
|
||||||
res.status(HttpStatus.OK).json({
|
res.status(HttpStatus.OK).json({
|
||||||
jobId: String(job.Id),
|
jobId: String(job.Id),
|
||||||
labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null,
|
labelImageBase64: job.LabelImageData
|
||||||
|
? job.LabelImageData.toString('base64')
|
||||||
|
: null,
|
||||||
labelImageContentType: 'image/png',
|
labelImageContentType: 'image/png',
|
||||||
labelWidthMm: job.LabelWidthMm,
|
labelWidthMm: job.LabelWidthMm,
|
||||||
labelHeightMm: job.LabelHeightMm,
|
labelHeightMm: job.LabelHeightMm,
|
||||||
@@ -95,7 +105,11 @@ export class LabelPrintAgentController {
|
|||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() body: { agentId?: string; printerName?: string },
|
@Body() body: { agentId?: string; printerName?: string },
|
||||||
) {
|
) {
|
||||||
await this.service.markPrinted(id, body.agentId ?? 'unknown', body.printerName ?? '');
|
await this.service.markPrinted(
|
||||||
|
id,
|
||||||
|
body.agentId ?? 'unknown',
|
||||||
|
body.printerName ?? '',
|
||||||
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +118,15 @@ export class LabelPrintAgentController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async markError(
|
async markError(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() body: { agentId?: string; printerName?: string; errorMessage?: string },
|
@Body()
|
||||||
|
body: { agentId?: string; printerName?: string; errorMessage?: string },
|
||||||
) {
|
) {
|
||||||
await this.service.markError(id, body.agentId ?? 'unknown', body.printerName ?? '', body.errorMessage ?? '');
|
await this.service.markError(
|
||||||
|
id,
|
||||||
|
body.agentId ?? 'unknown',
|
||||||
|
body.printerName ?? '',
|
||||||
|
body.errorMessage ?? '',
|
||||||
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, LessThan, IsNull } from 'typeorm';
|
import { Repository, LessThan, IsNull } from 'typeorm';
|
||||||
import { Subject, Observable } from 'rxjs';
|
import { Subject, Observable } from 'rxjs';
|
||||||
@@ -56,9 +61,14 @@ export class LabelPrintAgentService {
|
|||||||
templateId: number,
|
templateId: number,
|
||||||
fieldValues: Record<string, string>,
|
fieldValues: Record<string, string>,
|
||||||
): Promise<LabelPrintJob> {
|
): Promise<LabelPrintJob> {
|
||||||
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
const template = await this.templateRepo.findOne({
|
||||||
|
where: { Id: templateId },
|
||||||
|
});
|
||||||
if (!template) throw new NotFoundException('Template nicht gefunden');
|
if (!template) throw new NotFoundException('Template nicht gefunden');
|
||||||
if (!template.LabelEnabled) throw new BadRequestException('Etikett-Druck für dieses Template nicht aktiviert');
|
if (!template.LabelEnabled)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Etikett-Druck für dieses Template nicht aktiviert',
|
||||||
|
);
|
||||||
|
|
||||||
// Variablen aufbauen
|
// Variablen aufbauen
|
||||||
const vars: Record<string, string> = { ...fieldValues };
|
const vars: Record<string, string> = { ...fieldValues };
|
||||||
@@ -127,7 +137,9 @@ export class LabelPrintAgentService {
|
|||||||
|
|
||||||
// Lazy render
|
// Lazy render
|
||||||
if (!candidate.LabelImageData) {
|
if (!candidate.LabelImageData) {
|
||||||
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId ?? undefined } });
|
const template = await this.templateRepo.findOne({
|
||||||
|
where: { Id: candidate.BarcodeTemplateId ?? undefined },
|
||||||
|
});
|
||||||
if (template?.LabelLayout?.length) {
|
if (template?.LabelLayout?.length) {
|
||||||
try {
|
try {
|
||||||
candidate.LabelImageData = await this.renderer.render(
|
candidate.LabelImageData = await this.renderer.render(
|
||||||
@@ -138,7 +150,9 @@ export class LabelPrintAgentService {
|
|||||||
);
|
);
|
||||||
await this.jobRepo.save(candidate);
|
await this.jobRepo.save(candidate);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`);
|
this.logger.error(
|
||||||
|
`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +165,11 @@ export class LabelPrintAgentService {
|
|||||||
return job?.LabelImageData ?? null;
|
return job?.LabelImageData ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markPrinted(jobId: number, agentId: string, printerName: string): Promise<void> {
|
async markPrinted(
|
||||||
|
jobId: number,
|
||||||
|
agentId: string,
|
||||||
|
printerName: string,
|
||||||
|
): Promise<void> {
|
||||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||||
|
|
||||||
@@ -164,7 +182,12 @@ export class LabelPrintAgentService {
|
|||||||
await this.callUrl('PRINTED', job);
|
await this.callUrl('PRINTED', job);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise<void> {
|
async markError(
|
||||||
|
jobId: number,
|
||||||
|
agentId: string,
|
||||||
|
printerName: string,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<void> {
|
||||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||||
|
|
||||||
@@ -177,10 +200,16 @@ export class LabelPrintAgentService {
|
|||||||
await this.callUrl('RELEASE', job);
|
await this.callUrl('RELEASE', job);
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderPreview(templateId: number, fieldValues: Record<string, string>): Promise<Buffer> {
|
async renderPreview(
|
||||||
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
templateId: number,
|
||||||
|
fieldValues: Record<string, string>,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const template = await this.templateRepo.findOne({
|
||||||
|
where: { Id: templateId },
|
||||||
|
});
|
||||||
if (!template) throw new NotFoundException('Template nicht gefunden');
|
if (!template) throw new NotFoundException('Template nicht gefunden');
|
||||||
if (!template.LabelLayout?.length) throw new BadRequestException('Kein Layout definiert');
|
if (!template.LabelLayout?.length)
|
||||||
|
throw new BadRequestException('Kein Layout definiert');
|
||||||
|
|
||||||
const vars: Record<string, string> = { ...fieldValues };
|
const vars: Record<string, string> = { ...fieldValues };
|
||||||
for (const field of template.LabelInputFields ?? []) {
|
for (const field of template.LabelInputFields ?? []) {
|
||||||
@@ -204,18 +233,26 @@ export class LabelPrintAgentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise<void> {
|
private async callUrl(
|
||||||
|
type: 'PRINTED' | 'RELEASE',
|
||||||
|
job: LabelPrintJob,
|
||||||
|
): Promise<void> {
|
||||||
const template = job.BarcodeTemplateId
|
const template = job.BarcodeTemplateId
|
||||||
? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } })
|
? await this.templateRepo.findOne({
|
||||||
|
where: { Id: job.BarcodeTemplateId },
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
|
|
||||||
const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
const urlTemplate =
|
||||||
|
type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
||||||
if (!urlTemplate) return;
|
if (!urlTemplate) return;
|
||||||
|
|
||||||
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
|
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
|
||||||
if (!isSafeUrl(url)) {
|
if (!isSafeUrl(url)) {
|
||||||
this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`);
|
this.logger.warn(
|
||||||
|
`${type}-URL übersprungen (ungültiges Protokoll): ${url}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ function mm(v: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escape(s: string): string {
|
function escape(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyVars(template: string, vars: Record<string, string>): string {
|
function applyVars(template: string, vars: Record<string, string>): string {
|
||||||
@@ -49,8 +53,15 @@ export class LabelRendererService {
|
|||||||
const fontSize = mm(el.fontSize);
|
const fontSize = mm(el.fontSize);
|
||||||
const content = escape(applyVars(el.content, variables));
|
const content = escape(applyVars(el.content, variables));
|
||||||
const fontWeight = el.bold ? 'bold' : 'normal';
|
const fontWeight = el.bold ? 'bold' : 'normal';
|
||||||
const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
|
const textAnchor =
|
||||||
const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : '';
|
el.align === 'center'
|
||||||
|
? 'middle'
|
||||||
|
: el.align === 'right'
|
||||||
|
? 'end'
|
||||||
|
: 'start';
|
||||||
|
const maxWidthAttr = el.maxWidth
|
||||||
|
? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"`
|
||||||
|
: '';
|
||||||
// librsvg (used by sharp) ignores dominant-baseline; add fontSize to y so that
|
// librsvg (used by sharp) ignores dominant-baseline; add fontSize to y so that
|
||||||
// the stored coordinate is the top of the text, not the baseline.
|
// the stored coordinate is the top of the text, not the baseline.
|
||||||
const yBaseline = y + fontSize;
|
const yBaseline = y + fontSize;
|
||||||
@@ -70,9 +81,13 @@ export class LabelRendererService {
|
|||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
});
|
});
|
||||||
const b64 = qrBuffer.toString('base64');
|
const b64 = qrBuffer.toString('base64');
|
||||||
parts.push(`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`);
|
parts.push(
|
||||||
|
`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`,
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`);
|
this.logger.warn(
|
||||||
|
`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (el.type === 'line') {
|
} else if (el.type === 'line') {
|
||||||
const x1 = mm(el.x1);
|
const x1 = mm(el.x1);
|
||||||
@@ -80,7 +95,9 @@ export class LabelRendererService {
|
|||||||
const x2 = mm(el.x2);
|
const x2 = mm(el.x2);
|
||||||
const y2 = mm(el.y2);
|
const y2 = mm(el.y2);
|
||||||
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
||||||
parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`);
|
parts.push(
|
||||||
|
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ async function bootstrap(): Promise<void> {
|
|||||||
const port = process.env.PORT ?? 3100;
|
const port = process.env.PORT ?? 3100;
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env.CORS_ORIGIN ?? (process.env.NODE_ENV === 'production' ? false : '*'),
|
origin:
|
||||||
|
process.env.CORS_ORIGIN ??
|
||||||
|
(process.env.NODE_ENV === 'production' ? false : '*'),
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,32 +16,48 @@ export class PaperlessProcessorService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
private readonly postprocessingService: PostprocessingService,
|
private readonly postprocessingService: PostprocessingService,
|
||||||
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>,
|
@InjectRepository(DocumentType)
|
||||||
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>,
|
private readonly docTypeRepo: Repository<DocumentType>,
|
||||||
|
@InjectRepository(DocumentField)
|
||||||
|
private readonly docFieldRepo: Repository<DocumentField>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
|
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
|
||||||
async processDocuments() {
|
async processDocuments() {
|
||||||
try {
|
try {
|
||||||
const response = await this.paperlessService.getDocuments({ tags__id__all: 16, page_size: 9999 });
|
const response = await this.paperlessService.getDocuments({
|
||||||
const documents: any[] = Array.isArray(response) ? response : (response?.results ?? []);
|
tags__id__all: 16,
|
||||||
|
page_size: 9999,
|
||||||
|
});
|
||||||
|
const documents: any[] = Array.isArray(response)
|
||||||
|
? response
|
||||||
|
: (response?.results ?? []);
|
||||||
if (documents.length === 0) return;
|
if (documents.length === 0) return;
|
||||||
|
|
||||||
const customFields = await this.paperlessService.getCustomFields();
|
const customFields = await this.paperlessService.getCustomFields();
|
||||||
const validFieldIds = new Set(customFields.map((f: any) => f.id));
|
const validFieldIds = new Set(customFields.map((f: any) => f.id));
|
||||||
|
|
||||||
this.logger.log(`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`);
|
this.logger.log(
|
||||||
|
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
try {
|
try {
|
||||||
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
|
const updatedDoc = await this.processSingleDocument(
|
||||||
// Postprocessing nach dem Speichern evaluieren
|
doc,
|
||||||
await this.postprocessingService.evaluate(updatedDoc || doc);
|
validFieldIds,
|
||||||
|
);
|
||||||
|
// Postprocessing nach dem Speichern evaluieren
|
||||||
|
await this.postprocessingService.evaluate(updatedDoc || doc);
|
||||||
} catch (innerErr: any) {
|
} catch (innerErr: any) {
|
||||||
this.logger.error(`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`);
|
this.logger.error(
|
||||||
if (innerErr.response?.data) {
|
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
|
||||||
this.logger.error(`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`);
|
);
|
||||||
}
|
if (innerErr.response?.data) {
|
||||||
|
this.logger.error(
|
||||||
|
`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -49,17 +65,24 @@ export class PaperlessProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processSingleDocument(doc: any, validFieldIds: Set<number>): Promise<any> {
|
private async processSingleDocument(
|
||||||
|
doc: any,
|
||||||
|
validFieldIds: Set<number>,
|
||||||
|
): Promise<any> {
|
||||||
this.logger.log(`Verarbeite Dokument ID: ${doc.id}`);
|
this.logger.log(`Verarbeite Dokument ID: ${doc.id}`);
|
||||||
|
|
||||||
if (!doc.document_type) {
|
if (!doc.document_type) {
|
||||||
this.logger.warn(`Dokument ${doc.id} hat keinen Dokumenten-Typen – setze Tag 17.`);
|
this.logger.warn(
|
||||||
|
`Dokument ${doc.id} hat keinen Dokumenten-Typen – setze Tag 17.`,
|
||||||
|
);
|
||||||
const tagsSet = new Set<number>(doc.tags || []);
|
const tagsSet = new Set<number>(doc.tags || []);
|
||||||
tagsSet.add(17);
|
tagsSet.add(17);
|
||||||
if (!tagsSet.has(1)) {
|
if (!tagsSet.has(1)) {
|
||||||
tagsSet.add(6);
|
tagsSet.add(6);
|
||||||
}
|
}
|
||||||
const updated = await this.paperlessService.updateDocument(doc.id, { tags: Array.from(tagsSet) });
|
const updated = await this.paperlessService.updateDocument(doc.id, {
|
||||||
|
tags: Array.from(tagsSet),
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +91,9 @@ export class PaperlessProcessorService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!docTypeConfig) {
|
if (!docTypeConfig) {
|
||||||
this.logger.warn(`Konfiguration für DocumentType ${doc.document_type} nicht in der Datenbank gefunden.`);
|
this.logger.warn(
|
||||||
|
`Konfiguration für DocumentType ${doc.document_type} nicht in der Datenbank gefunden.`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +102,13 @@ export class PaperlessProcessorService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (fieldsConfig.length === 0) {
|
if (fieldsConfig.length === 0) {
|
||||||
this.logger.log(`Dokument ${doc.id} (Typ ${doc.document_type}) hat keine Dokument-Felder in der DB konfiguriert.`);
|
this.logger.log(
|
||||||
|
`Dokument ${doc.id} (Typ ${doc.document_type}) hat keine Dokument-Felder in der DB konfiguriert.`,
|
||||||
|
);
|
||||||
const newTagsNoFields = Array.from(new Set([...(doc.tags || []), 17]));
|
const newTagsNoFields = Array.from(new Set([...(doc.tags || []), 17]));
|
||||||
const updated = await this.paperlessService.updateDocument(doc.id, { tags: newTagsNoFields });
|
const updated = await this.paperlessService.updateDocument(doc.id, {
|
||||||
|
tags: newTagsNoFields,
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +120,19 @@ export class PaperlessProcessorService {
|
|||||||
if (fieldConf.Type === 4) {
|
if (fieldConf.Type === 4) {
|
||||||
const customFieldId = fieldConf.TypeIndex;
|
const customFieldId = fieldConf.TypeIndex;
|
||||||
if (!customFieldId) continue;
|
if (!customFieldId) continue;
|
||||||
|
|
||||||
if (!validFieldIds.has(customFieldId)) {
|
if (!validFieldIds.has(customFieldId)) {
|
||||||
this.logger.warn(`Überspringe ungültiges Custom Field (TypeIndex: ${customFieldId}) für Dokument ${doc.id} - in Paperless nicht vorhanden.`);
|
this.logger.warn(
|
||||||
|
`Überspringe ungültiges Custom Field (TypeIndex: ${customFieldId}) für Dokument ${doc.id} - in Paperless nicht vorhanden.`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingField = newCustomFields.find(f => f.field === customFieldId);
|
const existingField = newCustomFields.find(
|
||||||
|
(f) => f.field === customFieldId,
|
||||||
|
);
|
||||||
let isFilled = false;
|
let isFilled = false;
|
||||||
|
|
||||||
if (existingField) {
|
if (existingField) {
|
||||||
isFilled = existingField.value !== null && existingField.value !== '';
|
isFilled = existingField.value !== null && existingField.value !== '';
|
||||||
} else {
|
} else {
|
||||||
@@ -114,13 +147,16 @@ export class PaperlessProcessorService {
|
|||||||
let isFilled = false;
|
let isFilled = false;
|
||||||
switch (fieldConf.Type) {
|
switch (fieldConf.Type) {
|
||||||
case 1:
|
case 1:
|
||||||
isFilled = doc.correspondent !== null && doc.correspondent !== undefined;
|
isFilled =
|
||||||
|
doc.correspondent !== null && doc.correspondent !== undefined;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
isFilled = !!doc.created || !!doc.created_date;
|
isFilled = !!doc.created || !!doc.created_date;
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
isFilled = doc.archive_serial_number !== null && doc.archive_serial_number !== undefined;
|
isFilled =
|
||||||
|
doc.archive_serial_number !== null &&
|
||||||
|
doc.archive_serial_number !== undefined;
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
isFilled = !!doc.title;
|
isFilled = !!doc.title;
|
||||||
@@ -136,13 +172,13 @@ export class PaperlessProcessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tagsSet = new Set<number>(doc.tags || []);
|
const tagsSet = new Set<number>(doc.tags || []);
|
||||||
|
|
||||||
if (isAllRequiredFilled) {
|
if (isAllRequiredFilled) {
|
||||||
if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady);
|
if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady);
|
||||||
if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady);
|
if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady);
|
||||||
} else {
|
} else {
|
||||||
if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady);
|
if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady);
|
||||||
if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady);
|
if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsSet.add(17);
|
tagsSet.add(17);
|
||||||
@@ -163,7 +199,7 @@ export class PaperlessProcessorService {
|
|||||||
|
|
||||||
while ((match = placeholderRegex.exec(title)) !== null) {
|
while ((match = placeholderRegex.exec(title)) !== null) {
|
||||||
const fieldId = parseInt(match[1], 10);
|
const fieldId = parseInt(match[1], 10);
|
||||||
const cf = newCustomFields.find(f => f.field === fieldId);
|
const cf = newCustomFields.find((f) => f.field === fieldId);
|
||||||
if (!cf || cf.value == null || cf.value === '') {
|
if (!cf || cf.value == null || cf.value === '') {
|
||||||
allFilled = false;
|
allFilled = false;
|
||||||
break;
|
break;
|
||||||
@@ -174,7 +210,10 @@ export class PaperlessProcessorService {
|
|||||||
for (const cf of newCustomFields) {
|
for (const cf of newCustomFields) {
|
||||||
const placeholder = `{{CUSTOM[${cf.field}]}}`;
|
const placeholder = `{{CUSTOM[${cf.field}]}}`;
|
||||||
if (title.includes(placeholder)) {
|
if (title.includes(placeholder)) {
|
||||||
title = title.replaceAll(placeholder, cf.value != null ? String(cf.value) : '');
|
title = title.replaceAll(
|
||||||
|
placeholder,
|
||||||
|
cf.value != null ? String(cf.value) : '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +231,20 @@ export class PaperlessProcessorService {
|
|||||||
|
|
||||||
updatePayload.title = title;
|
updatePayload.title = title;
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`Dokument ${doc.id}: Titel-Template nicht angewendet – nicht alle referenzierten Custom Fields ausgefüllt.`);
|
this.logger.log(
|
||||||
|
`Dokument ${doc.id}: Titel-Template nicht angewendet – nicht alle referenzierten Custom Fields ausgefüllt.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.paperlessService.updateDocument(doc.id, updatePayload);
|
const updated = await this.paperlessService.updateDocument(
|
||||||
|
doc.id,
|
||||||
|
updatePayload,
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Dokument ${doc.id} erfolgreich aktualisiert (Alle Pflichtfelder vorhanden: ${isAllRequiredFilled}).`);
|
this.logger.log(
|
||||||
|
`Dokument ${doc.id} erfolgreich aktualisiert (Alle Pflichtfelder vorhanden: ${isAllRequiredFilled}).`,
|
||||||
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ export class PaperlessTaskProcessorService {
|
|||||||
try {
|
try {
|
||||||
// Fetch tasks that are not finished
|
// Fetch tasks that are not finished
|
||||||
const tasks = await this.taskRepo.find({
|
const tasks = await this.taskRepo.find({
|
||||||
where: [
|
where: [{ Fertig: IsNull() }, { Fertig: 0 }],
|
||||||
{ Fertig: IsNull() },
|
|
||||||
{ Fertig: 0 },
|
|
||||||
],
|
|
||||||
take: 10,
|
take: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,13 +58,17 @@ export class PaperlessTaskProcessorService {
|
|||||||
|
|
||||||
// Fetch task status from Paperless
|
// Fetch task status from Paperless
|
||||||
const paperlessTasks = await this.paperlessService.getTask(t.TaskId);
|
const paperlessTasks = await this.paperlessService.getTask(t.TaskId);
|
||||||
const apiResponseTask = Array.isArray(paperlessTasks) ? paperlessTasks[0] : null;
|
const apiResponseTask = Array.isArray(paperlessTasks)
|
||||||
|
? paperlessTasks[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
if (apiResponseTask) {
|
if (apiResponseTask) {
|
||||||
if (apiResponseTask.status === 'SUCCESS') {
|
if (apiResponseTask.status === 'SUCCESS') {
|
||||||
const dateDone = apiResponseTask.date_done ? new Date(apiResponseTask.date_done) : new Date();
|
const dateDone = apiResponseTask.date_done
|
||||||
|
? new Date(apiResponseTask.date_done)
|
||||||
|
: new Date();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Add 10 seconds buffer as in C#
|
// Add 10 seconds buffer as in C#
|
||||||
if (dateDone.getTime() + 10000 < now.getTime()) {
|
if (dateDone.getTime() + 10000 < now.getTime()) {
|
||||||
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
||||||
@@ -87,38 +88,61 @@ export class PaperlessTaskProcessorService {
|
|||||||
this.logger.log(`${toDelete.length} Tasks gelöscht`);
|
this.logger.log(`${toDelete.length} Tasks gelöscht`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Fehler bei der Task-Verarbeitung: ${error.message}`, error.stack);
|
this.logger.error(
|
||||||
|
`Fehler bei der Task-Verarbeitung: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processSuccessfulTask(t: Task, apiTask: any, parentTask: Task | null) {
|
private async processSuccessfulTask(
|
||||||
|
t: Task,
|
||||||
|
apiTask: any,
|
||||||
|
parentTask: Task | null,
|
||||||
|
) {
|
||||||
const documentId = apiTask.related_document;
|
const documentId = apiTask.related_document;
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`);
|
this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Lade Dokument ${documentId} aus Paperless`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Lade Dokument ${documentId} aus Paperless`,
|
||||||
|
);
|
||||||
const document = await this.paperlessService.getDocument(documentId);
|
const document = await this.paperlessService.getDocument(documentId);
|
||||||
if (!document) {
|
if (!document) {
|
||||||
this.logger.warn(`Dokument mit ID ${documentId} nicht in Paperless gefunden.`);
|
this.logger.warn(
|
||||||
|
`Dokument mit ID ${documentId} nicht in Paperless gefunden.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument geladen: ID=${document.id}, Titel="${document.title}"`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Dokument geladen: ID=${document.id}, Titel="${document.title}"`,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle Duplicate Link
|
// Handle Duplicate Link
|
||||||
if (t.DuplikatZU) {
|
if (t.DuplikatZU) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`);
|
this.logger.log(
|
||||||
const duplikatDoc = await this.paperlessService.getDocument(t.DuplikatZU);
|
`[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`,
|
||||||
|
);
|
||||||
|
const duplikatDoc = await this.paperlessService.getDocument(
|
||||||
|
t.DuplikatZU,
|
||||||
|
);
|
||||||
if (duplikatDoc) {
|
if (duplikatDoc) {
|
||||||
// Update duplikatDoc metadata (Field 8 is for linked documents)
|
// Update duplikatDoc metadata (Field 8 is for linked documents)
|
||||||
let duplikatCustomFields = Array.isArray(duplikatDoc.custom_fields) ? [...duplikatDoc.custom_fields] : [];
|
let duplikatCustomFields = Array.isArray(duplikatDoc.custom_fields)
|
||||||
|
? [...duplikatDoc.custom_fields]
|
||||||
|
: [];
|
||||||
|
|
||||||
// Remove field 4 as in C#
|
// Remove field 4 as in C#
|
||||||
duplikatCustomFields = duplikatCustomFields.filter((f: any) => f.field !== 4);
|
duplikatCustomFields = duplikatCustomFields.filter(
|
||||||
|
(f: any) => f.field !== 4,
|
||||||
|
);
|
||||||
|
|
||||||
const field8 = duplikatCustomFields.find((f: any) => f.field === 8);
|
const field8 = duplikatCustomFields.find((f: any) => f.field === 8);
|
||||||
if (field8) {
|
if (field8) {
|
||||||
@@ -136,13 +160,21 @@ export class PaperlessTaskProcessorService {
|
|||||||
document_type: 11,
|
document_type: 11,
|
||||||
custom_fields: duplikatCustomFields,
|
custom_fields: duplikatCustomFields,
|
||||||
});
|
});
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${duplikatDoc.id} aktualisiert`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${duplikatDoc.id} aktualisiert`,
|
||||||
|
);
|
||||||
|
|
||||||
// Update current document as well
|
// Update current document as well
|
||||||
const currentCustomFields = Array.isArray(document.custom_fields) ? [...document.custom_fields] : [];
|
const currentCustomFields = Array.isArray(document.custom_fields)
|
||||||
const currentField8 = currentCustomFields.find((f: any) => f.field === 8);
|
? [...document.custom_fields]
|
||||||
|
: [];
|
||||||
|
const currentField8 = currentCustomFields.find(
|
||||||
|
(f: any) => f.field === 8,
|
||||||
|
);
|
||||||
if (currentField8) {
|
if (currentField8) {
|
||||||
const values = Array.isArray(currentField8.value) ? currentField8.value : [];
|
const values = Array.isArray(currentField8.value)
|
||||||
|
? currentField8.value
|
||||||
|
: [];
|
||||||
if (!values.includes(duplikatDoc.id)) {
|
if (!values.includes(duplikatDoc.id)) {
|
||||||
values.push(duplikatDoc.id);
|
values.push(duplikatDoc.id);
|
||||||
currentField8.value = values;
|
currentField8.value = values;
|
||||||
@@ -152,70 +184,108 @@ export class PaperlessTaskProcessorService {
|
|||||||
}
|
}
|
||||||
document.custom_fields = currentCustomFields;
|
document.custom_fields = currentCustomFields;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${t.DuplikatZU} nicht gefunden`);
|
this.logger.warn(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${t.DuplikatZU} nicht gefunden`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich Document
|
// Enrich Document
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Reichere Dokument-Metadaten an`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Reichere Dokument-Metadaten an`,
|
||||||
|
);
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
custom_fields: Array.isArray(document.custom_fields) ? [...document.custom_fields] : [],
|
custom_fields: Array.isArray(document.custom_fields)
|
||||||
|
? [...document.custom_fields]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// CustomFieldsJson als Basis zuerst anwenden – dedizierte Felder weiter unten überschreiben diese
|
// CustomFieldsJson als Basis zuerst anwenden – dedizierte Felder weiter unten überschreiben diese
|
||||||
if (t.CustomFieldsJson) {
|
if (t.CustomFieldsJson) {
|
||||||
try {
|
try {
|
||||||
const extra = JSON.parse(t.CustomFieldsJson) as Record<string, string>;
|
const extra = JSON.parse(t.CustomFieldsJson) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
for (const [k, v] of Object.entries(extra)) {
|
for (const [k, v] of Object.entries(extra)) {
|
||||||
const fieldId = parseInt(k, 10);
|
const fieldId = parseInt(k, 10);
|
||||||
if (!Number.isFinite(fieldId)) continue;
|
if (!Number.isFinite(fieldId)) continue;
|
||||||
const idx = updateData.custom_fields.findIndex((f: any) => f.field === fieldId);
|
const idx = updateData.custom_fields.findIndex(
|
||||||
|
(f: any) => f.field === fieldId,
|
||||||
|
);
|
||||||
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
||||||
else updateData.custom_fields.push({ field: fieldId, value: v });
|
else updateData.custom_fields.push({ field: fieldId, value: v });
|
||||||
}
|
}
|
||||||
} catch { /* JSON-Parse-Fehler ignorieren */ }
|
} catch {
|
||||||
|
/* JSON-Parse-Fehler ignorieren */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Asn) {
|
if (t.Asn) {
|
||||||
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
||||||
if (!isNaN(asnNum)) {
|
if (!isNaN(asnNum)) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`,
|
||||||
|
);
|
||||||
updateData.archive_serial_number = asnNum;
|
updateData.archive_serial_number = asnNum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.InterneBelegnummer) {
|
if (t.InterneBelegnummer) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`,
|
||||||
|
);
|
||||||
if (!t.Asn) {
|
if (!t.Asn) {
|
||||||
const asnFromBelegnummer = parseInt(t.InterneBelegnummer.replace(/-/g, ''), 10);
|
const asnFromBelegnummer = parseInt(
|
||||||
|
t.InterneBelegnummer.replace(/-/g, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
if (!isNaN(asnFromBelegnummer)) {
|
if (!isNaN(asnFromBelegnummer)) {
|
||||||
updateData.archive_serial_number = asnFromBelegnummer;
|
updateData.archive_serial_number = asnFromBelegnummer;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`);
|
this.logger.warn(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const existingField7 = updateData.custom_fields.find((f: any) => f.field === 7);
|
const existingField7 = updateData.custom_fields.find(
|
||||||
|
(f: any) => f.field === 7,
|
||||||
|
);
|
||||||
if (existingField7) {
|
if (existingField7) {
|
||||||
existingField7.value = t.InterneBelegnummer;
|
existingField7.value = t.InterneBelegnummer;
|
||||||
} else {
|
} else {
|
||||||
updateData.custom_fields.push({ field: 7, value: t.InterneBelegnummer });
|
updateData.custom_fields.push({
|
||||||
|
field: 7,
|
||||||
|
value: t.InterneBelegnummer,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.externeBelegnummer) {
|
if (t.externeBelegnummer) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`);
|
this.logger.log(
|
||||||
const existingField3 = updateData.custom_fields.find((f: any) => f.field === 3);
|
`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`,
|
||||||
|
);
|
||||||
|
const existingField3 = updateData.custom_fields.find(
|
||||||
|
(f: any) => f.field === 3,
|
||||||
|
);
|
||||||
if (existingField3) {
|
if (existingField3) {
|
||||||
existingField3.value = t.externeBelegnummer;
|
existingField3.value = t.externeBelegnummer;
|
||||||
} else {
|
} else {
|
||||||
updateData.custom_fields.push({ field: 3, value: t.externeBelegnummer });
|
updateData.custom_fields.push({
|
||||||
|
field: 3,
|
||||||
|
value: t.externeBelegnummer,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Eingangsdatum) {
|
if (t.Eingangsdatum) {
|
||||||
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
|
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`);
|
this.logger.log(
|
||||||
const existingField9 = updateData.custom_fields.find((f: any) => f.field === 9);
|
`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`,
|
||||||
|
);
|
||||||
|
const existingField9 = updateData.custom_fields.find(
|
||||||
|
(f: any) => f.field === 9,
|
||||||
|
);
|
||||||
if (existingField9) {
|
if (existingField9) {
|
||||||
existingField9.value = dateValue;
|
existingField9.value = dateValue;
|
||||||
} else {
|
} else {
|
||||||
@@ -224,24 +294,38 @@ export class PaperlessTaskProcessorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (t.DocumentType) {
|
if (t.DocumentType) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`,
|
||||||
|
);
|
||||||
updateData.document_type = t.DocumentType;
|
updateData.document_type = t.DocumentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent Task / Attachment logic
|
// Parent Task / Attachment logic
|
||||||
if (parentTask) {
|
if (parentTask) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`);
|
this.logger.log(
|
||||||
const parentPaperlessTasks = await this.paperlessService.getTask(parentTask.TaskId);
|
`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`,
|
||||||
const apiParentTask = Array.isArray(parentPaperlessTasks) ? parentPaperlessTasks[0] : null;
|
);
|
||||||
|
const parentPaperlessTasks = await this.paperlessService.getTask(
|
||||||
|
parentTask.TaskId,
|
||||||
|
);
|
||||||
|
const apiParentTask = Array.isArray(parentPaperlessTasks)
|
||||||
|
? parentPaperlessTasks[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
if (apiParentTask && apiParentTask.related_document) {
|
if (apiParentTask && apiParentTask.related_document) {
|
||||||
const parentDoc = await this.paperlessService.getDocument(apiParentTask.related_document);
|
const parentDoc = await this.paperlessService.getDocument(
|
||||||
|
apiParentTask.related_document,
|
||||||
|
);
|
||||||
if (parentDoc) {
|
if (parentDoc) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Elterndokument ${parentDoc.id} gefunden, setze Anlage-Typ`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Elterndokument ${parentDoc.id} gefunden, setze Anlage-Typ`,
|
||||||
|
);
|
||||||
updateData.document_type = 5; // Anlage
|
updateData.document_type = 5; // Anlage
|
||||||
updateData.title = `Anlage zu ${parentTask.InterneBelegnummer}`;
|
updateData.title = `Anlage zu ${parentTask.InterneBelegnummer}`;
|
||||||
|
|
||||||
const field8 = updateData.custom_fields.find((f: any) => f.field === 8);
|
const field8 = updateData.custom_fields.find(
|
||||||
|
(f: any) => f.field === 8,
|
||||||
|
);
|
||||||
if (field8) {
|
if (field8) {
|
||||||
const values = Array.isArray(field8.value) ? field8.value : [];
|
const values = Array.isArray(field8.value) ? field8.value : [];
|
||||||
if (!values.includes(parentDoc.id)) {
|
if (!values.includes(parentDoc.id)) {
|
||||||
@@ -249,33 +333,50 @@ export class PaperlessTaskProcessorService {
|
|||||||
field8.value = values;
|
field8.value = values;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateData.custom_fields.push({ field: 8, value: [parentDoc.id] });
|
updateData.custom_fields.push({
|
||||||
|
field: 8,
|
||||||
|
value: [parentDoc.id],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`);
|
this.logger.warn(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ParentTask hat kein related_document`);
|
this.logger.warn(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - ParentTask hat kein related_document`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Belegdatum) {
|
if (t.Belegdatum) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`,
|
||||||
|
);
|
||||||
updateData.created = t.Belegdatum.toISOString();
|
updateData.created = t.Belegdatum.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.BetriebID) {
|
if (t.BetriebID) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`,
|
||||||
|
);
|
||||||
updateData.owner = t.BetriebID;
|
updateData.owner = t.BetriebID;
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
|
||||||
|
);
|
||||||
updateData.owner = null;
|
updateData.owner = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
if (t.Tags) {
|
if (t.Tags) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`);
|
this.logger.log(
|
||||||
const tagIds = t.Tags.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id));
|
`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`,
|
||||||
|
);
|
||||||
|
const tagIds = t.Tags.split(',')
|
||||||
|
.map((id) => parseInt(id.trim(), 10))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
const currentTags = document.tags || [];
|
const currentTags = document.tags || [];
|
||||||
const newTags = Array.from(new Set([...currentTags, ...tagIds]));
|
const newTags = Array.from(new Set([...currentTags, ...tagIds]));
|
||||||
updateData.tags = newTags;
|
updateData.tags = newTags;
|
||||||
@@ -284,46 +385,78 @@ export class PaperlessTaskProcessorService {
|
|||||||
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
||||||
if (t.EinkaufID) {
|
if (t.EinkaufID) {
|
||||||
const link = `https://admin7.agrarmonitor.de/rechnungen/detail/${t.EinkaufID}`;
|
const link = `https://admin7.agrarmonitor.de/rechnungen/detail/${t.EinkaufID}`;
|
||||||
this.logger.log(`Skipping Agrarmonitor details for EinkaufID ${t.EinkaufID}. Link: ${link}`);
|
this.logger.log(
|
||||||
|
`Skipping Agrarmonitor details for EinkaufID ${t.EinkaufID}. Link: ${link}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Lieferant) {
|
if (t.Lieferant) {
|
||||||
this.logger.log(`Skipping Correspondent lookup/creation for Lieferant ID ${t.Lieferant} (Agrarmonitor part deferred).`);
|
this.logger.log(
|
||||||
|
`Skipping Correspondent lookup/creation for Lieferant ID ${t.Lieferant} (Agrarmonitor part deferred).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Document in Paperless
|
// Update Document in Paperless
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere Dokument ${document.id} in Paperless. Payload: ${JSON.stringify(updateData)}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Aktualisiere Dokument ${document.id} in Paperless. Payload: ${JSON.stringify(updateData)}`,
|
||||||
|
);
|
||||||
await this.paperlessService.updateDocument(document.id, updateData);
|
await this.paperlessService.updateDocument(document.id, updateData);
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument ${document.id} erfolgreich aktualisiert`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Dokument ${document.id} erfolgreich aktualisiert`,
|
||||||
|
);
|
||||||
|
|
||||||
// Add Notes
|
// Add Notes
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Füge Notizen hinzu`);
|
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Füge Notizen hinzu`);
|
||||||
await this.paperlessService.addNote(document.id, `Task bearbeitet: ${new Date().toLocaleString('de-DE')}`);
|
await this.paperlessService.addNote(
|
||||||
|
document.id,
|
||||||
|
`Task bearbeitet: ${new Date().toLocaleString('de-DE')}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (t.SourceAttachmentID) {
|
if (t.SourceAttachmentID) {
|
||||||
const attachment = await this.attachmentRepo.findOne({ where: { Id: t.SourceAttachmentID }, relations: ['EmailMessage'] });
|
const attachment = await this.attachmentRepo.findOne({
|
||||||
|
where: { Id: t.SourceAttachmentID },
|
||||||
|
relations: ['EmailMessage'],
|
||||||
|
});
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
const rangePart = t.SourceAttachmentRange && t.SourceAttachmentRange !== 'full'
|
const rangePart =
|
||||||
? ` | Seiten: ${t.SourceAttachmentRange}`
|
t.SourceAttachmentRange && t.SourceAttachmentRange !== 'full'
|
||||||
: '';
|
? ` | Seiten: ${t.SourceAttachmentRange}`
|
||||||
const messageId = attachment.EmailMessage?.MessageId ?? String(attachment.EmailMessageId);
|
: '';
|
||||||
await this.paperlessService.addNote(document.id, `E-Mail-ID: ${messageId} | Datei: ${attachment.FileName}${rangePart}`);
|
const messageId =
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Herkunfts-Notiz hinzugefügt`);
|
attachment.EmailMessage?.MessageId ??
|
||||||
|
String(attachment.EmailMessageId);
|
||||||
|
await this.paperlessService.addNote(
|
||||||
|
document.id,
|
||||||
|
`E-Mail-ID: ${messageId} | Datei: ${attachment.FileName}${rangePart}`,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Herkunfts-Notiz hinzugefügt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.BarcodeJson) {
|
if (t.BarcodeJson) {
|
||||||
await this.paperlessService.addNote(document.id, t.BarcodeJson);
|
await this.paperlessService.addNote(document.id, t.BarcodeJson);
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - BarcodeJson-Notiz hinzugefügt`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - BarcodeJson-Notiz hinzugefügt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync local Documents table
|
// Sync local Documents table
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`);
|
this.logger.log(
|
||||||
const metadata = await this.paperlessService.getDocumentMetadata(document.id);
|
`[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`,
|
||||||
let localDoc = await this.documentRepo.findOne({ where: { documentId: document.id } });
|
);
|
||||||
|
const metadata = await this.paperlessService.getDocumentMetadata(
|
||||||
|
document.id,
|
||||||
|
);
|
||||||
|
let localDoc = await this.documentRepo.findOne({
|
||||||
|
where: { documentId: document.id },
|
||||||
|
});
|
||||||
|
|
||||||
if (!localDoc) {
|
if (!localDoc) {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Erstelle neuen lokalen Dokument-Eintrag`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Erstelle neuen lokalen Dokument-Eintrag`,
|
||||||
|
);
|
||||||
localDoc = this.documentRepo.create({
|
localDoc = this.documentRepo.create({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
checksum: metadata.original_checksum,
|
checksum: metadata.original_checksum,
|
||||||
@@ -331,34 +464,46 @@ export class PaperlessTaskProcessorService {
|
|||||||
});
|
});
|
||||||
await this.documentRepo.save(localDoc);
|
await this.documentRepo.save(localDoc);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere bestehenden lokalen Dokument-Eintrag`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Aktualisiere bestehenden lokalen Dokument-Eintrag`,
|
||||||
|
);
|
||||||
localDoc.checksum = metadata.original_checksum;
|
localDoc.checksum = metadata.original_checksum;
|
||||||
localDoc.filename = metadata.original_filename;
|
localDoc.filename = metadata.original_filename;
|
||||||
await this.documentRepo.save(localDoc);
|
await this.documentRepo.save(localDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Task status
|
// Update Task status
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Task-Status auf Fertig`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} - Setze Task-Status auf Fertig`,
|
||||||
|
);
|
||||||
t.Fertig = 1;
|
t.Fertig = 1;
|
||||||
t.PaperlessDocumentID = document.id;
|
t.PaperlessDocumentID = document.id;
|
||||||
await this.taskRepo.save(t);
|
await this.taskRepo.save(t);
|
||||||
|
|
||||||
// Update source attachment if linked
|
// Update source attachment if linked
|
||||||
if (t.SourceAttachmentID && t.SourceAttachmentRange) {
|
if (t.SourceAttachmentID && t.SourceAttachmentRange) {
|
||||||
const attachment = await this.attachmentRepo.findOne({ where: { Id: t.SourceAttachmentID } });
|
const attachment = await this.attachmentRepo.findOne({
|
||||||
|
where: { Id: t.SourceAttachmentID },
|
||||||
|
});
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
const ids = attachment.PaperlessDocumentIds || {};
|
const ids = attachment.PaperlessDocumentIds || {};
|
||||||
ids[t.SourceAttachmentRange] = document.id;
|
ids[t.SourceAttachmentRange] = document.id;
|
||||||
attachment.PaperlessDocumentIds = ids;
|
attachment.PaperlessDocumentIds = ids;
|
||||||
await this.attachmentRepo.save(attachment);
|
await this.attachmentRepo.save(attachment);
|
||||||
this.logger.log(`[Postprocessing] Anhang ${attachment.Id} mit PaperlessID ${document.id} (${t.SourceAttachmentRange}) aktualisiert`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Anhang ${attachment.Id} mit PaperlessID ${document.id} (${t.SourceAttachmentRange}) aktualisiert`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[Postprocessing] Task ${t.TaskId} erfolgreich abgeschlossen. Dokument ID: ${document.id}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Task ${t.TaskId} erfolgreich abgeschlossen. Dokument ID: ${document.id}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Fehler bei der Verarbeitung von Task ${t.TaskId}: ${error.message}`, error.stack);
|
this.logger.error(
|
||||||
|
`Fehler bei der Verarbeitung von Task ${t.TaskId}: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
Body,
|
||||||
|
Logger,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Res,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
@@ -14,7 +30,6 @@ import { Document } from '../database/entities/document.entity';
|
|||||||
import { DocumentField } from '../database/entities/document-field.entity';
|
import { DocumentField } from '../database/entities/document-field.entity';
|
||||||
import { DocumentType } from '../database/entities/document-type.entity';
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
|
|
||||||
|
|
||||||
@Controller('api/paperless')
|
@Controller('api/paperless')
|
||||||
export class PaperlessController {
|
export class PaperlessController {
|
||||||
private readonly logger = new Logger(PaperlessController.name);
|
private readonly logger = new Logger(PaperlessController.name);
|
||||||
@@ -36,7 +51,7 @@ export class PaperlessController {
|
|||||||
const exists = await this.paperlessService.checksumExists(checksum);
|
const exists = await this.paperlessService.checksumExists(checksum);
|
||||||
return { exists };
|
return { exists };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('documents')
|
@Get('documents')
|
||||||
async getDocuments(
|
async getDocuments(
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
@@ -67,7 +82,7 @@ export class PaperlessController {
|
|||||||
async getTag(@Param('id') id: string) {
|
async getTag(@Param('id') id: string) {
|
||||||
// If the service doesn't have getTag(id), I should add it or just fetch all and find
|
// If the service doesn't have getTag(id), I should add it or just fetch all and find
|
||||||
const tags = await this.paperlessService.getTags();
|
const tags = await this.paperlessService.getTags();
|
||||||
return tags.find(t => t.id === parseInt(id, 10));
|
return tags.find((t) => t.id === parseInt(id, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('document-types')
|
@Get('document-types')
|
||||||
@@ -107,7 +122,11 @@ export class PaperlessController {
|
|||||||
const documents = await this.paperlessService.getInboxDocuments();
|
const documents = await this.paperlessService.getInboxDocuments();
|
||||||
// In old C# logic: only return docs where archive_serial_number is not null
|
// In old C# logic: only return docs where archive_serial_number is not null
|
||||||
return documents
|
return documents
|
||||||
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
|
.filter(
|
||||||
|
(doc: any) =>
|
||||||
|
doc.archive_serial_number !== null &&
|
||||||
|
doc.archive_serial_number !== undefined,
|
||||||
|
)
|
||||||
.map((doc: any) => ({
|
.map((doc: any) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
@@ -127,7 +146,11 @@ export class PaperlessController {
|
|||||||
async getManuellList() {
|
async getManuellList() {
|
||||||
const documents = await this.paperlessService.getManuellDocuments();
|
const documents = await this.paperlessService.getManuellDocuments();
|
||||||
return documents
|
return documents
|
||||||
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
|
.filter(
|
||||||
|
(doc: any) =>
|
||||||
|
doc.archive_serial_number !== null &&
|
||||||
|
doc.archive_serial_number !== undefined,
|
||||||
|
)
|
||||||
.map((doc: any) => ({
|
.map((doc: any) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
@@ -146,16 +169,24 @@ export class PaperlessController {
|
|||||||
@Get('inbox/preview/:id')
|
@Get('inbox/preview/:id')
|
||||||
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
|
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
|
||||||
try {
|
try {
|
||||||
const stream = await this.paperlessService.getDocumentPreviewStream(parseInt(id, 10));
|
const stream = await this.paperlessService.getDocumentPreviewStream(
|
||||||
|
parseInt(id, 10),
|
||||||
|
);
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
'Content-Disposition': `inline; filename="${id}preview.png"`,
|
'Content-Disposition': `inline; filename="${id}preview.png"`,
|
||||||
});
|
});
|
||||||
stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); });
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent)
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).end();
|
||||||
|
else res.end();
|
||||||
|
});
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching preview');
|
res
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.send('Error fetching preview');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,12 +195,18 @@ export class PaperlessController {
|
|||||||
@Get('inbox/pdf/:id')
|
@Get('inbox/pdf/:id')
|
||||||
async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
|
async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
|
||||||
try {
|
try {
|
||||||
const stream = await this.paperlessService.getDocumentPdfStream(parseInt(id, 10));
|
const stream = await this.paperlessService.getDocumentPdfStream(
|
||||||
|
parseInt(id, 10),
|
||||||
|
);
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
'Content-Disposition': `inline; filename="${id}.pdf"`,
|
'Content-Disposition': `inline; filename="${id}.pdf"`,
|
||||||
});
|
});
|
||||||
stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); });
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent)
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).end();
|
||||||
|
else res.end();
|
||||||
|
});
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -179,25 +216,28 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('requirements/:id')
|
@Get('requirements/:id')
|
||||||
async getRequirements(@Param('id') id: string, @Query('Posteingang') posteingang: string) {
|
async getRequirements(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('Posteingang') posteingang: string,
|
||||||
|
) {
|
||||||
const documentTypeId = parseInt(id, 10);
|
const documentTypeId = parseInt(id, 10);
|
||||||
const isPosteingang = posteingang === '1';
|
const isPosteingang = posteingang === '1';
|
||||||
|
|
||||||
const requirements = await this.documentFieldRepo.find({
|
const requirements = await this.documentFieldRepo.find({
|
||||||
where: { DocumentType: documentTypeId },
|
where: { DocumentType: documentTypeId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom fields fetching inside here could be slow, but this is the simplest translation of the old API
|
// Custom fields fetching inside here could be slow, but this is the simplest translation of the old API
|
||||||
// Actually, getting all CFs doesn't take too long in Paperless API.
|
// Actually, getting all CFs doesn't take too long in Paperless API.
|
||||||
const customFields = await this.paperlessService.getCustomFields();
|
const customFields = await this.paperlessService.getCustomFields();
|
||||||
|
|
||||||
const retVal: any[] = [];
|
const retVal: any[] = [];
|
||||||
|
|
||||||
for (const req of requirements) {
|
for (const req of requirements) {
|
||||||
if (isPosteingang && !req.VisiblePosteingang) {
|
if (isPosteingang && !req.VisiblePosteingang) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmp: any = {
|
const tmp: any = {
|
||||||
id: req.Id,
|
id: req.Id,
|
||||||
feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''),
|
feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''),
|
||||||
@@ -214,7 +254,7 @@ export class PaperlessController {
|
|||||||
if (cf) {
|
if (cf) {
|
||||||
tmp.feldName = cf.name;
|
tmp.feldName = cf.name;
|
||||||
tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type;
|
tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type;
|
||||||
|
|
||||||
if (cf.extra_data && cf.extra_data.select_options) {
|
if (cf.extra_data && cf.extra_data.select_options) {
|
||||||
tmp.fieldOptions = cf.extra_data.select_options
|
tmp.fieldOptions = cf.extra_data.select_options
|
||||||
.filter((o: any) => o !== null)
|
.filter((o: any) => o !== null)
|
||||||
@@ -227,9 +267,14 @@ export class PaperlessController {
|
|||||||
} else if (req.Type === 1) {
|
} else if (req.Type === 1) {
|
||||||
tmp.feldName = 'Absender';
|
tmp.feldName = 'Absender';
|
||||||
tmp.feldTyp = 'select';
|
tmp.feldTyp = 'select';
|
||||||
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
|
const response = await this.paperlessService.getCorrespondents({
|
||||||
|
page_size: 9999,
|
||||||
|
});
|
||||||
const correspondents = response.results;
|
const correspondents = response.results;
|
||||||
tmp.fieldOptions = correspondents.map((c: any) => ({ id: c.id.toString(), label: c.name }));
|
tmp.fieldOptions = correspondents.map((c: any) => ({
|
||||||
|
id: c.id.toString(),
|
||||||
|
label: c.name,
|
||||||
|
}));
|
||||||
} else if (req.Type === 2) {
|
} else if (req.Type === 2) {
|
||||||
tmp.feldName = 'Belegdatum';
|
tmp.feldName = 'Belegdatum';
|
||||||
tmp.feldTyp = 'date';
|
tmp.feldTyp = 'date';
|
||||||
@@ -246,7 +291,7 @@ export class PaperlessController {
|
|||||||
|
|
||||||
retVal.push(tmp);
|
retVal.push(tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return retVal;
|
return retVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,34 +310,49 @@ export class PaperlessController {
|
|||||||
if (body.date) {
|
if (body.date) {
|
||||||
let docDate = new Date(body.date);
|
let docDate = new Date(body.date);
|
||||||
if (docDate.getHours() > 22) {
|
if (docDate.getHours() > 22) {
|
||||||
docDate = new Date(docDate.getTime() + 24 * 60 * 60 * 1000 - docDate.getHours() * 60 * 60 * 1000);
|
docDate = new Date(
|
||||||
|
docDate.getTime() +
|
||||||
|
24 * 60 * 60 * 1000 -
|
||||||
|
docDate.getHours() * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
oldDocument.created_date = docDate.toISOString().split('T')[0];
|
oldDocument.created_date = docDate.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfDefinitions = await this.paperlessService.getCustomFields();
|
const cfDefinitions = await this.paperlessService.getCustomFields();
|
||||||
|
|
||||||
// update custom fields
|
// update custom fields
|
||||||
if (body.customFields) {
|
if (body.customFields) {
|
||||||
for (const [key, value] of Object.entries(body.customFields)) {
|
for (const [key, value] of Object.entries(body.customFields)) {
|
||||||
const fieldId = parseInt(key, 10);
|
const fieldId = parseInt(key, 10);
|
||||||
const cfDef = cfDefinitions.find((c: any) => c.id === fieldId);
|
const cfDef = cfDefinitions.find((c: any) => c.id === fieldId);
|
||||||
|
|
||||||
let processedValue = value;
|
let processedValue = value;
|
||||||
if (cfDef?.data_type === 'documentlink' && value !== null && value !== '' && !Array.isArray(value)) {
|
if (
|
||||||
|
cfDef?.data_type === 'documentlink' &&
|
||||||
|
value !== null &&
|
||||||
|
value !== '' &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
processedValue = [value];
|
processedValue = [value];
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingFieldIndex = oldDocument.custom_fields.findIndex((f: any) => f.field === fieldId);
|
const existingFieldIndex = oldDocument.custom_fields.findIndex(
|
||||||
|
(f: any) => f.field === fieldId,
|
||||||
|
);
|
||||||
|
|
||||||
if (existingFieldIndex !== -1) {
|
if (existingFieldIndex !== -1) {
|
||||||
if (processedValue === null || processedValue === '') {
|
if (processedValue === null || processedValue === '') {
|
||||||
oldDocument.custom_fields.splice(existingFieldIndex, 1);
|
oldDocument.custom_fields.splice(existingFieldIndex, 1);
|
||||||
} else {
|
} else {
|
||||||
oldDocument.custom_fields[existingFieldIndex].value = processedValue;
|
oldDocument.custom_fields[existingFieldIndex].value =
|
||||||
|
processedValue;
|
||||||
}
|
}
|
||||||
} else if (processedValue !== null && processedValue !== '') {
|
} else if (processedValue !== null && processedValue !== '') {
|
||||||
oldDocument.custom_fields.push({ field: fieldId, value: processedValue });
|
oldDocument.custom_fields.push({
|
||||||
|
field: fieldId,
|
||||||
|
value: processedValue,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,7 +361,7 @@ export class PaperlessController {
|
|||||||
const reqs = await this.documentFieldRepo.find({
|
const reqs = await this.documentFieldRepo.find({
|
||||||
where: { DocumentType: oldDocument.document_type },
|
where: { DocumentType: oldDocument.document_type },
|
||||||
});
|
});
|
||||||
|
|
||||||
let isReady = true;
|
let isReady = true;
|
||||||
let isReadyPosteingang = true;
|
let isReadyPosteingang = true;
|
||||||
|
|
||||||
@@ -309,12 +369,19 @@ export class PaperlessController {
|
|||||||
let isFieldValid = false;
|
let isFieldValid = false;
|
||||||
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
|
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
|
||||||
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
|
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
|
||||||
if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null;
|
if (req.Type === 3)
|
||||||
if (req.Type === 4) isFieldValid = !!oldDocument.custom_fields.find((cf: any) => cf.field === req.TypeIndex && cf.value !== null && cf.value !== '');
|
isFieldValid = oldDocument.archive_serial_number !== null;
|
||||||
if (req.Type === 5) isFieldValid = oldDocument.title !== null && oldDocument.title !== '';
|
if (req.Type === 4)
|
||||||
|
isFieldValid = !!oldDocument.custom_fields.find(
|
||||||
|
(cf: any) =>
|
||||||
|
cf.field === req.TypeIndex && cf.value !== null && cf.value !== '',
|
||||||
|
);
|
||||||
|
if (req.Type === 5)
|
||||||
|
isFieldValid = oldDocument.title !== null && oldDocument.title !== '';
|
||||||
|
|
||||||
if (req.IsRequired && !isFieldValid) isReady = false;
|
if (req.IsRequired && !isFieldValid) isReady = false;
|
||||||
if (req.IsRequiredPosteingang && !isFieldValid) isReadyPosteingang = false;
|
if (req.IsRequiredPosteingang && !isFieldValid)
|
||||||
|
isReadyPosteingang = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docType = await this.documentTypeRepo.findOne({
|
const docType = await this.documentTypeRepo.findOne({
|
||||||
@@ -325,7 +392,10 @@ export class PaperlessController {
|
|||||||
|
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
|
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
|
||||||
if (docType?.TagNotReady) oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagNotReady);
|
if (docType?.TagNotReady)
|
||||||
|
oldDocument.tags = oldDocument.tags.filter(
|
||||||
|
(t: number) => t !== docType.TagNotReady,
|
||||||
|
);
|
||||||
if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) {
|
if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) {
|
||||||
oldDocument.tags.push(docType.TagReady);
|
oldDocument.tags.push(docType.TagReady);
|
||||||
}
|
}
|
||||||
@@ -335,17 +405,24 @@ export class PaperlessController {
|
|||||||
for (const cf of oldDocument.custom_fields) {
|
for (const cf of oldDocument.custom_fields) {
|
||||||
const placeholder = `{{CUSTOM[${cf.field}]}}`;
|
const placeholder = `{{CUSTOM[${cf.field}]}}`;
|
||||||
if (titleTemplate.includes(placeholder)) {
|
if (titleTemplate.includes(placeholder)) {
|
||||||
titleTemplate = titleTemplate.replace(placeholder, cf.value?.toString() ?? '');
|
titleTemplate = titleTemplate.replace(
|
||||||
|
placeholder,
|
||||||
|
cf.value?.toString() ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
titleTemplate = titleTemplate.replace('{{DATE}}', oldDocument.created_date);
|
titleTemplate = titleTemplate.replace(
|
||||||
|
'{{DATE}}',
|
||||||
|
oldDocument.created_date,
|
||||||
|
);
|
||||||
oldDocument.title = titleTemplate;
|
oldDocument.title = titleTemplate;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (docType?.TagNotReady) {
|
if (docType?.TagNotReady) {
|
||||||
if (isReadyPosteingang) {
|
if (isReadyPosteingang) {
|
||||||
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
|
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
|
||||||
if (!oldDocument.tags.includes(docType.TagNotReady)) oldDocument.tags.push(docType.TagNotReady);
|
if (!oldDocument.tags.includes(docType.TagNotReady))
|
||||||
|
oldDocument.tags.push(docType.TagNotReady);
|
||||||
} else {
|
} else {
|
||||||
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
|
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
|
||||||
}
|
}
|
||||||
@@ -353,7 +430,9 @@ export class PaperlessController {
|
|||||||
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
|
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
|
||||||
}
|
}
|
||||||
if (docType?.TagReady) {
|
if (docType?.TagReady) {
|
||||||
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagReady);
|
oldDocument.tags = oldDocument.tags.filter(
|
||||||
|
(t: number) => t !== docType.TagReady,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,16 +470,21 @@ export class PaperlessController {
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body() dto: UploadExternalDto,
|
@Body() dto: UploadExternalDto,
|
||||||
) {
|
) {
|
||||||
this.logger.log(`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`);
|
this.logger.log(
|
||||||
|
`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 0. Check if ASN already exists
|
// 0. Check if ASN already exists
|
||||||
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
||||||
|
|
||||||
// 1. Forward to Paperless
|
// 1. Forward to Paperless
|
||||||
const paperlessTaskId = await this.paperlessService.uploadDocument(file.path, {
|
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
||||||
title: `Beleg ${dto.interneBelegnummer}`,
|
file.path,
|
||||||
});
|
{
|
||||||
|
title: `Beleg ${dto.interneBelegnummer}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Create local Task
|
// 2. Create local Task
|
||||||
const task = this.taskRepo.create({
|
const task = this.taskRepo.create({
|
||||||
@@ -422,10 +506,15 @@ export class PaperlessController {
|
|||||||
|
|
||||||
await this.taskRepo.save(task);
|
await this.taskRepo.save(task);
|
||||||
|
|
||||||
this.logger.log(`Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`);
|
this.logger.log(
|
||||||
|
`Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`,
|
||||||
|
);
|
||||||
return task.TaskId;
|
return task.TaskId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`, err.stack);
|
this.logger.error(
|
||||||
|
`Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`,
|
`Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`,
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -14,12 +14,22 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([DocumentType, DocumentField, Task, Document, Attachment]),
|
TypeOrmModule.forFeature([
|
||||||
|
DocumentType,
|
||||||
|
DocumentField,
|
||||||
|
Task,
|
||||||
|
Document,
|
||||||
|
Attachment,
|
||||||
|
]),
|
||||||
forwardRef(() => PostprocessingModule),
|
forwardRef(() => PostprocessingModule),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [PaperlessController],
|
controllers: [PaperlessController],
|
||||||
providers: [PaperlessService, PaperlessProcessorService, PaperlessTaskProcessorService],
|
providers: [
|
||||||
|
PaperlessService,
|
||||||
|
PaperlessProcessorService,
|
||||||
|
PaperlessTaskProcessorService,
|
||||||
|
],
|
||||||
exports: [PaperlessService],
|
exports: [PaperlessService],
|
||||||
})
|
})
|
||||||
export class PaperlessModule {}
|
export class PaperlessModule {}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export class PaperlessService {
|
|||||||
private readonly client: AxiosInstance;
|
private readonly client: AxiosInstance;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const baseURL = this.configService.get<string>('PAPERLESS_URL', 'http://localhost:8000');
|
const baseURL = this.configService.get<string>(
|
||||||
|
'PAPERLESS_URL',
|
||||||
|
'http://localhost:8000',
|
||||||
|
);
|
||||||
const token = this.configService.get<string>('PAPERLESS_TOKEN', '');
|
const token = this.configService.get<string>('PAPERLESS_TOKEN', '');
|
||||||
|
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
@@ -49,16 +52,22 @@ export class PaperlessService {
|
|||||||
|
|
||||||
if (options?.title) form.append('title', options.title);
|
if (options?.title) form.append('title', options.title);
|
||||||
if (options?.created) form.append('created', options.created);
|
if (options?.created) form.append('created', options.created);
|
||||||
if (options?.documentType) form.append('document_type', String(options.documentType));
|
if (options?.documentType)
|
||||||
if (options?.correspondent) form.append('correspondent', String(options.correspondent));
|
form.append('document_type', String(options.documentType));
|
||||||
if (options?.storagePath) form.append('storage_path', String(options.storagePath));
|
if (options?.correspondent)
|
||||||
|
form.append('correspondent', String(options.correspondent));
|
||||||
|
if (options?.storagePath)
|
||||||
|
form.append('storage_path', String(options.storagePath));
|
||||||
if (options?.owner !== undefined && options.owner !== null) {
|
if (options?.owner !== undefined && options.owner !== null) {
|
||||||
form.append('owner', String(options.owner));
|
form.append('owner', String(options.owner));
|
||||||
}
|
}
|
||||||
if (options?.tags) {
|
if (options?.tags) {
|
||||||
options.tags.forEach((tag) => form.append('tags', String(tag)));
|
options.tags.forEach((tag) => form.append('tags', String(tag)));
|
||||||
}
|
}
|
||||||
if (options?.archiveSerialNumber !== undefined && !Number.isNaN(options.archiveSerialNumber)) {
|
if (
|
||||||
|
options?.archiveSerialNumber !== undefined &&
|
||||||
|
!Number.isNaN(options.archiveSerialNumber)
|
||||||
|
) {
|
||||||
form.append('archive_serial_number', String(options.archiveSerialNumber));
|
form.append('archive_serial_number', String(options.archiveSerialNumber));
|
||||||
}
|
}
|
||||||
if (options?.customFields && Object.keys(options.customFields).length > 0) {
|
if (options?.customFields && Object.keys(options.customFields).length > 0) {
|
||||||
@@ -92,27 +101,30 @@ export class PaperlessService {
|
|||||||
async getInboxDocuments(): Promise<any[]> {
|
async getInboxDocuments(): Promise<any[]> {
|
||||||
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
params: {
|
params: {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 9999,
|
page_size: 9999,
|
||||||
ordering: '-added',
|
ordering: '-added',
|
||||||
truncate_content: true,
|
truncate_content: true,
|
||||||
tags__id__all: 1
|
tags__id__all: 1,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManuellDocuments(): Promise<any[]> {
|
async getManuellDocuments(): Promise<any[]> {
|
||||||
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
|
const errorTag = this.configService.get<number>(
|
||||||
|
'MANUELL_BEARBEITEN_TAG',
|
||||||
|
6,
|
||||||
|
);
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
params: {
|
params: {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 9999,
|
page_size: 9999,
|
||||||
ordering: '-added',
|
ordering: '-added',
|
||||||
truncate_content: true,
|
truncate_content: true,
|
||||||
tags__id__all: errorTag
|
tags__id__all: errorTag,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
@@ -124,7 +136,9 @@ export class PaperlessService {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const body = err?.response?.data;
|
const body = err?.response?.data;
|
||||||
if (body) {
|
if (body) {
|
||||||
this.logger.error(`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`);
|
this.logger.error(
|
||||||
|
`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -132,14 +146,14 @@ export class PaperlessService {
|
|||||||
|
|
||||||
async getDocumentTypes(): Promise<any[]> {
|
async getDocumentTypes(): Promise<any[]> {
|
||||||
const response = await this.client.get('/document_types/', {
|
const response = await this.client.get('/document_types/', {
|
||||||
params: { page_size: 9999 }
|
params: { page_size: 9999 },
|
||||||
});
|
});
|
||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTags(): Promise<any[]> {
|
async getTags(): Promise<any[]> {
|
||||||
const response = await this.client.get('/tags/', {
|
const response = await this.client.get('/tags/', {
|
||||||
params: { page_size: 9999 }
|
params: { page_size: 9999 },
|
||||||
});
|
});
|
||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
@@ -156,7 +170,7 @@ export class PaperlessService {
|
|||||||
|
|
||||||
async getCustomFields(): Promise<any[]> {
|
async getCustomFields(): Promise<any[]> {
|
||||||
const response = await this.client.get('/custom_fields/', {
|
const response = await this.client.get('/custom_fields/', {
|
||||||
params: { page_size: 9999 }
|
params: { page_size: 9999 },
|
||||||
});
|
});
|
||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
@@ -184,10 +198,14 @@ export class PaperlessService {
|
|||||||
await this.client.delete(`/correspondents/${id}/`);
|
await this.client.delete(`/correspondents/${id}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise<Buffer> {
|
async downloadDocument(
|
||||||
const endpoint = type === 'original'
|
id: number,
|
||||||
? `/documents/${id}/download/`
|
type: 'original' | 'archive' = 'archive',
|
||||||
: `/documents/${id}/download/`;
|
): Promise<Buffer> {
|
||||||
|
const endpoint =
|
||||||
|
type === 'original'
|
||||||
|
? `/documents/${id}/download/`
|
||||||
|
: `/documents/${id}/download/`;
|
||||||
const response = await this.client.get(endpoint, {
|
const response = await this.client.get(endpoint, {
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
params: type === 'original' ? { original: true } : {},
|
params: type === 'original' ? { original: true } : {},
|
||||||
@@ -204,10 +222,14 @@ export class PaperlessService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDocumentPdfStream(id: number, type: 'original' | 'archive' = 'archive'): Promise<any> {
|
async getDocumentPdfStream(
|
||||||
const endpoint = type === 'original'
|
id: number,
|
||||||
? `/documents/${id}/download/`
|
type: 'original' | 'archive' = 'archive',
|
||||||
: `/documents/${id}/download/`;
|
): Promise<any> {
|
||||||
|
const endpoint =
|
||||||
|
type === 'original'
|
||||||
|
? `/documents/${id}/download/`
|
||||||
|
: `/documents/${id}/download/`;
|
||||||
const response = await this.client.get(endpoint, {
|
const response = await this.client.get(endpoint, {
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
params: type === 'original' ? { original: true } : {},
|
params: type === 'original' ? { original: true } : {},
|
||||||
@@ -222,7 +244,9 @@ export class PaperlessService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addNote(id: number, note: string): Promise<any> {
|
async addNote(id: number, note: string): Promise<any> {
|
||||||
const response = await this.client.post(`/documents/${id}/notes/`, { note });
|
const response = await this.client.post(`/documents/${id}/notes/`, {
|
||||||
|
note,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +272,7 @@ export class PaperlessService {
|
|||||||
*/
|
*/
|
||||||
async validateAsnNotExists(interneBelegnummer: string): Promise<void> {
|
async validateAsnNotExists(interneBelegnummer: string): Promise<void> {
|
||||||
if (!interneBelegnummer) return;
|
if (!interneBelegnummer) return;
|
||||||
|
|
||||||
// Logic like in PaperlessTaskProcessorService
|
// Logic like in PaperlessTaskProcessorService
|
||||||
const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10);
|
const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10);
|
||||||
if (isNaN(asnNum)) return;
|
if (isNaN(asnNum)) return;
|
||||||
@@ -271,7 +295,7 @@ export class PaperlessService {
|
|||||||
params: { archive_serial_number: asn, page_size: 5 },
|
params: { archive_serial_number: asn, page_size: 5 },
|
||||||
});
|
});
|
||||||
if ((response.data.count ?? 0) === 0) return null;
|
if ((response.data.count ?? 0) === 0) return null;
|
||||||
const match = (response.data.results as any[] ?? []).find(
|
const match = ((response.data.results as any[]) ?? []).find(
|
||||||
(doc: any) => Number(doc.archive_serial_number) === asn,
|
(doc: any) => Number(doc.archive_serial_number) === asn,
|
||||||
);
|
);
|
||||||
return match ? Number(match.id) : null;
|
return match ? Number(match.id) : null;
|
||||||
@@ -281,7 +305,10 @@ export class PaperlessService {
|
|||||||
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
|
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
|
||||||
* Paperless kann den custom_fields-Filter ignorieren — daher manuell verifizieren.
|
* Paperless kann den custom_fields-Filter ignorieren — daher manuell verifizieren.
|
||||||
*/
|
*/
|
||||||
async findDocumentIdByCustomField(fieldId: number, value: string): Promise<number | null> {
|
async findDocumentIdByCustomField(
|
||||||
|
fieldId: number,
|
||||||
|
value: string,
|
||||||
|
): Promise<number | null> {
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
params: {
|
params: {
|
||||||
[`custom_fields__${fieldId}__value__iexact`]: value,
|
[`custom_fields__${fieldId}__value__iexact`]: value,
|
||||||
@@ -291,9 +318,14 @@ export class PaperlessService {
|
|||||||
});
|
});
|
||||||
if ((response.data.count ?? 0) === 0) return null;
|
if ((response.data.count ?? 0) === 0) return null;
|
||||||
const valueLower = value.toLowerCase();
|
const valueLower = value.toLowerCase();
|
||||||
const match = (response.data.results as any[] ?? []).find((doc: any) =>
|
const match = ((response.data.results as any[]) ?? []).find((doc: any) =>
|
||||||
(Array.isArray(doc.custom_fields) ? doc.custom_fields as any[] : []).some(
|
(Array.isArray(doc.custom_fields)
|
||||||
(cf: any) => cf.field === fieldId && String(cf.value ?? '').toLowerCase() === valueLower,
|
? (doc.custom_fields as any[])
|
||||||
|
: []
|
||||||
|
).some(
|
||||||
|
(cf: any) =>
|
||||||
|
cf.field === fieldId &&
|
||||||
|
String(cf.value ?? '').toLowerCase() === valueLower,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return match ? Number(match.id) : null;
|
return match ? Number(match.id) : null;
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ export class ExportService {
|
|||||||
private readonly logger = new Logger(ExportService.name);
|
private readonly logger = new Logger(ExportService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ExportTarget) private readonly targetRepo: Repository<ExportTarget>,
|
@InjectRepository(ExportTarget)
|
||||||
|
private readonly targetRepo: Repository<ExportTarget>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async exportFile(targetId: number, filename: string, content: Buffer): Promise<void> {
|
async exportFile(
|
||||||
|
targetId: number,
|
||||||
|
filename: string,
|
||||||
|
content: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
||||||
|
|
||||||
if (!target.IsActive) {
|
if (!target.IsActive) {
|
||||||
@@ -32,7 +37,9 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection(targetId: number): Promise<{ success: boolean; message: string }> {
|
async testConnection(
|
||||||
|
targetId: number,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +51,10 @@ export class ExportService {
|
|||||||
await this.testWebDav(target);
|
await this.testWebDav(target);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return { success: false, message: `Unbekanntes Protokoll: ${target.Protocol}` };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Unbekanntes Protokoll: ${target.Protocol}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { success: true, message: 'Verbindung erfolgreich.' };
|
return { success: true, message: 'Verbindung erfolgreich.' };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -52,7 +62,11 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFtp(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
|
private async uploadFtp(
|
||||||
|
target: ExportTarget,
|
||||||
|
filename: string,
|
||||||
|
content: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
const client = new ftp.Client();
|
const client = new ftp.Client();
|
||||||
try {
|
try {
|
||||||
await client.access({
|
await client.access({
|
||||||
@@ -89,7 +103,11 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadWebDav(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
|
private async uploadWebDav(
|
||||||
|
target: ExportTarget,
|
||||||
|
filename: string,
|
||||||
|
content: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
const client = this.createWebDavClient(target);
|
const client = this.createWebDavClient(target);
|
||||||
const remotePath = `${target.RemotePath || '/'}/${filename}`;
|
const remotePath = `${target.RemotePath || '/'}/${filename}`;
|
||||||
await client.putFileContents(remotePath, content);
|
await client.putFileContents(remotePath, content);
|
||||||
|
|||||||
@@ -25,12 +25,24 @@ export class MailService {
|
|||||||
body: string;
|
body: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
attachments?: { filename: string; content: Buffer }[];
|
attachments?: { filename: string; content: Buffer }[];
|
||||||
smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string };
|
smtpOverride?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
let transporter = this.transporter;
|
let transporter = this.transporter;
|
||||||
const globalFromEmail = this.configService.get<string>('SMTP_FROM', 'paperless@localhost');
|
const globalFromEmail = this.configService.get<string>(
|
||||||
|
'SMTP_FROM',
|
||||||
|
'paperless@localhost',
|
||||||
|
);
|
||||||
const globalFromName = this.configService.get<string>('SMTP_FROM_NAME', '');
|
const globalFromName = this.configService.get<string>('SMTP_FROM_NAME', '');
|
||||||
let from = globalFromName ? `"${globalFromName}" <${globalFromEmail}>` : globalFromEmail;
|
let from = globalFromName
|
||||||
|
? `"${globalFromName}" <${globalFromEmail}>`
|
||||||
|
: globalFromEmail;
|
||||||
|
|
||||||
if (options.smtpOverride) {
|
if (options.smtpOverride) {
|
||||||
const o = options.smtpOverride;
|
const o = options.smtpOverride;
|
||||||
@@ -53,7 +65,7 @@ export class MailService {
|
|||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
text: options.body,
|
text: options.body,
|
||||||
html: options.html,
|
html: options.html,
|
||||||
attachments: options.attachments?.map(a => ({
|
attachments: options.attachments?.map((a) => ({
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
content: a.content,
|
content: a.content,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { PaperlessModule } from '../paperless/paperless.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Postprocessing, PostprocessingAction, PostprocessingLog, ExportTarget]),
|
TypeOrmModule.forFeature([
|
||||||
|
Postprocessing,
|
||||||
|
PostprocessingAction,
|
||||||
|
PostprocessingLog,
|
||||||
|
ExportTarget,
|
||||||
|
]),
|
||||||
forwardRef(() => PaperlessModule),
|
forwardRef(() => PaperlessModule),
|
||||||
],
|
],
|
||||||
providers: [PostprocessingService, MailService, ExportService],
|
providers: [PostprocessingService, MailService, ExportService],
|
||||||
|
|||||||
@@ -6,12 +6,39 @@ import { PostprocessingAction } from '../database/entities/postprocessing-action
|
|||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
|
||||||
const mockRules: Partial<Postprocessing>[] = [
|
const mockRules: Partial<Postprocessing>[] = [
|
||||||
{ Id: 1, Name: 'Rule1', DocumentTypeId: 5, CorrespondentId: null, OwnerId: null, TagId: null, Order: 1, IsActive: true, NoFurther: false },
|
{
|
||||||
{ Id: 2, Name: 'StopRule', DocumentTypeId: null, CorrespondentId: null, OwnerId: null, TagId: null, Order: 2, IsActive: true, NoFurther: true },
|
Id: 1,
|
||||||
|
Name: 'Rule1',
|
||||||
|
DocumentTypeId: 5,
|
||||||
|
CorrespondentId: null,
|
||||||
|
OwnerId: null,
|
||||||
|
TagId: null,
|
||||||
|
Order: 1,
|
||||||
|
IsActive: true,
|
||||||
|
NoFurther: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
Name: 'StopRule',
|
||||||
|
DocumentTypeId: null,
|
||||||
|
CorrespondentId: null,
|
||||||
|
OwnerId: null,
|
||||||
|
TagId: null,
|
||||||
|
Order: 2,
|
||||||
|
IsActive: true,
|
||||||
|
NoFurther: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockActions: Partial<PostprocessingAction>[] = [
|
const mockActions: Partial<PostprocessingAction>[] = [
|
||||||
{ Id: 1, PostprocessingId: 1, ActionType: 2, Content: '99', Order: 1, IsActive: true },
|
{
|
||||||
|
Id: 1,
|
||||||
|
PostprocessingId: 1,
|
||||||
|
ActionType: 2,
|
||||||
|
Content: '99',
|
||||||
|
Order: 1,
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('PostprocessingService', () => {
|
describe('PostprocessingService', () => {
|
||||||
@@ -29,7 +56,10 @@ describe('PostprocessingService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
PostprocessingService,
|
PostprocessingService,
|
||||||
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
||||||
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo },
|
{
|
||||||
|
provide: getRepositoryToken(PostprocessingAction),
|
||||||
|
useValue: ppActionRepo,
|
||||||
|
},
|
||||||
{ provide: PaperlessService, useValue: paperlessService },
|
{ provide: PaperlessService, useValue: paperlessService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -58,7 +88,9 @@ describe('PostprocessingService', () => {
|
|||||||
where: { PostprocessingId: 1, IsActive: true },
|
where: { PostprocessingId: 1, IsActive: true },
|
||||||
order: { Order: 'ASC' },
|
order: { Order: 'ASC' },
|
||||||
});
|
});
|
||||||
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { tags: [99] });
|
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, {
|
||||||
|
tags: [99],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('evaluate stops at NoFurther rule', async () => {
|
it('evaluate stops at NoFurther rule', async () => {
|
||||||
@@ -72,7 +104,11 @@ describe('PostprocessingService', () => {
|
|||||||
it('evaluate skips non-matching rules', async () => {
|
it('evaluate skips non-matching rules', async () => {
|
||||||
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
|
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
|
||||||
ppActionRepo.find.mockResolvedValue([]);
|
ppActionRepo.find.mockResolvedValue([]);
|
||||||
await service.evaluate({ documentId: 100, documentTypeId: 999, tagIds: [] });
|
await service.evaluate({
|
||||||
|
documentId: 100,
|
||||||
|
documentTypeId: 999,
|
||||||
|
tagIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Rule1 skipped, Rule2 matched → only 1 action lookup
|
// Rule1 skipped, Rule2 matched → only 1 action lookup
|
||||||
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
|
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Postprocessing, type FilterGroup, type FilterCondition } from '../database/entities/postprocessing.entity';
|
import {
|
||||||
|
Postprocessing,
|
||||||
|
type FilterGroup,
|
||||||
|
type FilterCondition,
|
||||||
|
} from '../database/entities/postprocessing.entity';
|
||||||
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
||||||
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
@@ -20,15 +24,22 @@ export class PostprocessingService {
|
|||||||
private documentTypesCache: { data: any[]; expires: number } | null = null;
|
private documentTypesCache: { data: any[]; expires: number } | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
|
@InjectRepository(Postprocessing)
|
||||||
@InjectRepository(PostprocessingAction) private readonly actionRepo: Repository<PostprocessingAction>,
|
private readonly ppRepo: Repository<Postprocessing>,
|
||||||
@InjectRepository(PostprocessingLog) private readonly logRepo: Repository<PostprocessingLog>,
|
@InjectRepository(PostprocessingAction)
|
||||||
|
private readonly actionRepo: Repository<PostprocessingAction>,
|
||||||
|
@InjectRepository(PostprocessingLog)
|
||||||
|
private readonly logRepo: Repository<PostprocessingLog>,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService,
|
@Inject(forwardRef(() => PaperlessService))
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
) {
|
) {
|
||||||
this.errorTagId = this.configService.get<number>('POSTPROCESSING_ERROR_TAG', 0);
|
this.errorTagId = this.configService.get<number>(
|
||||||
|
'POSTPROCESSING_ERROR_TAG',
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluate(doc: any): Promise<void> {
|
async evaluate(doc: any): Promise<void> {
|
||||||
@@ -40,19 +51,29 @@ export class PostprocessingService {
|
|||||||
// Enrich doc with resolved names (once per evaluation)
|
// Enrich doc with resolved names (once per evaluation)
|
||||||
await this.enrichDocWithNames(doc);
|
await this.enrichDocWithNames(doc);
|
||||||
|
|
||||||
this.logger.log(`[Postprocessing] Dokument ${doc.id} wird gegen ${rules.length} Regel(n) geprüft`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Dokument ${doc.id} wird gegen ${rules.length} Regel(n) geprüft`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (!this.hasConditions(rule.FilterJson)) {
|
if (!this.hasConditions(rule.FilterJson)) {
|
||||||
this.logger.warn(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) hat keine Bedingungen – wird übersprungen.`);
|
this.logger.warn(
|
||||||
|
`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) hat keine Bedingungen – wird übersprungen.`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.debug(`[Postprocessing] Prüfe Regel "${rule.Name}" (ID: ${rule.Id}) für Dokument ${doc.id}`);
|
this.logger.debug(
|
||||||
|
`[Postprocessing] Prüfe Regel "${rule.Name}" (ID: ${rule.Id}) für Dokument ${doc.id}`,
|
||||||
|
);
|
||||||
const matches = this.matchesFilter(rule.FilterJson, doc);
|
const matches = this.matchesFilter(rule.FilterJson, doc);
|
||||||
this.logger.log(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) → ${matches ? 'TRIFFT ZU' : 'trifft nicht zu'}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) → ${matches ? 'TRIFFT ZU' : 'trifft nicht zu'}`,
|
||||||
|
);
|
||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
|
|
||||||
this.logger.log(`[Postprocessing] Regel "${rule.Name}" trifft zu für Dokument ${doc.id}`);
|
this.logger.log(
|
||||||
|
`[Postprocessing] Regel "${rule.Name}" trifft zu für Dokument ${doc.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
const actions = await this.actionRepo.find({
|
const actions = await this.actionRepo.find({
|
||||||
where: { PostprocessingId: rule.Id, IsActive: true },
|
where: { PostprocessingId: rule.Id, IsActive: true },
|
||||||
@@ -63,10 +84,18 @@ export class PostprocessingService {
|
|||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
try {
|
try {
|
||||||
await this.executeAction(action, doc);
|
await this.executeAction(action, doc);
|
||||||
await this.log(rule.Id, action.Id, doc.id, 'success', `Aktion ${action.ActionType} erfolgreich`);
|
await this.log(
|
||||||
|
rule.Id,
|
||||||
|
action.Id,
|
||||||
|
doc.id,
|
||||||
|
'success',
|
||||||
|
`Aktion ${action.ActionType} erfolgreich`,
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
this.logger.error(`Fehler bei Aktion ${action.Id} (Typ ${action.ActionType}) für Dokument ${doc.id}: ${err.message}`);
|
this.logger.error(
|
||||||
|
`Fehler bei Aktion ${action.Id} (Typ ${action.ActionType}) für Dokument ${doc.id}: ${err.message}`,
|
||||||
|
);
|
||||||
await this.log(rule.Id, action.Id, doc.id, 'error', err.message);
|
await this.log(rule.Id, action.Id, doc.id, 'error', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,9 +104,13 @@ export class PostprocessingService {
|
|||||||
try {
|
try {
|
||||||
const currentTags = new Set<number>(doc.tags || []);
|
const currentTags = new Set<number>(doc.tags || []);
|
||||||
currentTags.add(this.errorTagId);
|
currentTags.add(this.errorTagId);
|
||||||
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
|
await this.paperlessService.updateDocument(doc.id, {
|
||||||
|
tags: Array.from(currentTags),
|
||||||
|
});
|
||||||
} catch (tagErr: any) {
|
} catch (tagErr: any) {
|
||||||
this.logger.error(`Konnte Error-Tag ${this.errorTagId} nicht setzen für Dokument ${doc.id}: ${tagErr.message}`);
|
this.logger.error(
|
||||||
|
`Konnte Error-Tag ${this.errorTagId} nicht setzen für Dokument ${doc.id}: ${tagErr.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +125,8 @@ export class PostprocessingService {
|
|||||||
|
|
||||||
private hasConditions(filter: FilterGroup): boolean {
|
private hasConditions(filter: FilterGroup): boolean {
|
||||||
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
||||||
return filter.rules.some(rule => {
|
return filter.rules.some((rule) => {
|
||||||
if ('combinator' in rule) return this.hasConditions(rule as FilterGroup);
|
if ('combinator' in rule) return this.hasConditions(rule);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,11 +134,11 @@ export class PostprocessingService {
|
|||||||
private matchesFilter(filter: FilterGroup, doc: any): boolean {
|
private matchesFilter(filter: FilterGroup, doc: any): boolean {
|
||||||
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
||||||
|
|
||||||
const results = filter.rules.map(rule => {
|
const results = filter.rules.map((rule) => {
|
||||||
if ('combinator' in rule) {
|
if ('combinator' in rule) {
|
||||||
return this.matchesFilter(rule as FilterGroup, doc);
|
return this.matchesFilter(rule, doc);
|
||||||
}
|
}
|
||||||
return this.evaluateCondition(rule as FilterCondition, doc);
|
return this.evaluateCondition(rule, doc);
|
||||||
});
|
});
|
||||||
|
|
||||||
return filter.combinator === 'AND'
|
return filter.combinator === 'AND'
|
||||||
@@ -139,7 +172,9 @@ export class PostprocessingService {
|
|||||||
if (cond.field === 'tag') {
|
if (cond.field === 'tag') {
|
||||||
result = Array.isArray(actual) && actual.includes(Number(expected));
|
result = Array.isArray(actual) && actual.includes(Number(expected));
|
||||||
} else {
|
} else {
|
||||||
result = String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
|
result = String(actual ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(expected ?? '').toLowerCase());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -147,7 +182,9 @@ export class PostprocessingService {
|
|||||||
if (cond.field === 'tag') {
|
if (cond.field === 'tag') {
|
||||||
result = !Array.isArray(actual) || !actual.includes(Number(expected));
|
result = !Array.isArray(actual) || !actual.includes(Number(expected));
|
||||||
} else {
|
} else {
|
||||||
result = !String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
|
result = !String(actual ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(expected ?? '').toLowerCase());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -173,7 +210,7 @@ export class PostprocessingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}`
|
`[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}`,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -196,7 +233,9 @@ export class PostprocessingService {
|
|||||||
// Custom field: "custom_field_<id>"
|
// Custom field: "custom_field_<id>"
|
||||||
if (field.startsWith('custom_field_')) {
|
if (field.startsWith('custom_field_')) {
|
||||||
const fieldId = parseInt(field.replace('custom_field_', ''), 10);
|
const fieldId = parseInt(field.replace('custom_field_', ''), 10);
|
||||||
const cf = (doc.custom_fields || []).find((f: any) => f.field === fieldId);
|
const cf = (doc.custom_fields || []).find(
|
||||||
|
(f: any) => f.field === fieldId,
|
||||||
|
);
|
||||||
return cf?.value ?? null;
|
return cf?.value ?? null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -205,7 +244,10 @@ export class PostprocessingService {
|
|||||||
|
|
||||||
// ── Action Execution ─────────────────────────────────────────────
|
// ── Action Execution ─────────────────────────────────────────────
|
||||||
|
|
||||||
private async executeAction(action: PostprocessingAction, doc: any): Promise<void> {
|
private async executeAction(
|
||||||
|
action: PostprocessingAction,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const content = action.Content;
|
const content = action.Content;
|
||||||
|
|
||||||
switch (action.ActionType) {
|
switch (action.ActionType) {
|
||||||
@@ -233,15 +275,26 @@ export class PostprocessingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getCachedCorrespondents(): Promise<any[]> {
|
private async getCachedCorrespondents(): Promise<any[]> {
|
||||||
if (!this.correspondentsCache || Date.now() > this.correspondentsCache.expires) {
|
if (
|
||||||
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
|
!this.correspondentsCache ||
|
||||||
this.correspondentsCache = { data: response.results, expires: Date.now() + CACHE_TTL_MS };
|
Date.now() > this.correspondentsCache.expires
|
||||||
|
) {
|
||||||
|
const response = await this.paperlessService.getCorrespondents({
|
||||||
|
page_size: 9999,
|
||||||
|
});
|
||||||
|
this.correspondentsCache = {
|
||||||
|
data: response.results,
|
||||||
|
expires: Date.now() + CACHE_TTL_MS,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return this.correspondentsCache.data;
|
return this.correspondentsCache.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCachedDocumentTypes(): Promise<any[]> {
|
private async getCachedDocumentTypes(): Promise<any[]> {
|
||||||
if (!this.documentTypesCache || Date.now() > this.documentTypesCache.expires) {
|
if (
|
||||||
|
!this.documentTypesCache ||
|
||||||
|
Date.now() > this.documentTypesCache.expires
|
||||||
|
) {
|
||||||
const data = await this.paperlessService.getDocumentTypes();
|
const data = await this.paperlessService.getDocumentTypes();
|
||||||
this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS };
|
this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS };
|
||||||
}
|
}
|
||||||
@@ -272,13 +325,18 @@ export class PostprocessingService {
|
|||||||
'{titel}': doc.title ?? '',
|
'{titel}': doc.title ?? '',
|
||||||
'{korrespondent}': String(doc.correspondent ?? ''),
|
'{korrespondent}': String(doc.correspondent ?? ''),
|
||||||
'{absender}': String(doc.correspondent ?? ''),
|
'{absender}': String(doc.correspondent ?? ''),
|
||||||
'{korrespondent_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
|
'{korrespondent_name}':
|
||||||
'{absender_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
|
doc._correspondentName ?? String(doc.correspondent ?? ''),
|
||||||
|
'{absender_name}':
|
||||||
|
doc._correspondentName ?? String(doc.correspondent ?? ''),
|
||||||
'{dokumenttyp}': String(doc.document_type ?? ''),
|
'{dokumenttyp}': String(doc.document_type ?? ''),
|
||||||
'{dokumenttyp_name}': doc._documentTypeName ?? String(doc.document_type ?? ''),
|
'{dokumenttyp_name}':
|
||||||
|
doc._documentTypeName ?? String(doc.document_type ?? ''),
|
||||||
'{besitzer}': String(doc.owner ?? ''),
|
'{besitzer}': String(doc.owner ?? ''),
|
||||||
'{ablagenummer}': String(doc.archive_serial_number ?? ''),
|
'{ablagenummer}': String(doc.archive_serial_number ?? ''),
|
||||||
'{datum}': created ? `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}-${String(created.getDate()).padStart(2, '0')}` : '',
|
'{datum}': created
|
||||||
|
? `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}-${String(created.getDate()).padStart(2, '0')}`
|
||||||
|
: '',
|
||||||
'{jahr}': created ? String(created.getFullYear()) : '',
|
'{jahr}': created ? String(created.getFullYear()) : '',
|
||||||
'{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '',
|
'{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '',
|
||||||
'{tag}': created ? String(created.getDate()).padStart(2, '0') : '',
|
'{tag}': created ? String(created.getDate()).padStart(2, '0') : '',
|
||||||
@@ -289,7 +347,7 @@ export class PostprocessingService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Custom Fields: {custom_field_<id>}
|
// Custom Fields: {custom_field_<id>}
|
||||||
for (const cf of (doc.custom_fields || [])) {
|
for (const cf of doc.custom_fields || []) {
|
||||||
replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? '');
|
replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,16 +368,32 @@ export class PostprocessingService {
|
|||||||
return `${doc.title || `document_${doc.id}`}.pdf`;
|
return `${doc.title || `document_${doc.id}`}.pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleExport(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleExport(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const fileType = content.fileType || 'archive';
|
const fileType = content.fileType || 'archive';
|
||||||
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
|
const buffer = await this.paperlessService.downloadDocument(
|
||||||
|
doc.id,
|
||||||
|
fileType,
|
||||||
|
);
|
||||||
const filename = this.buildFilename(content.filenameTemplate, doc);
|
const filename = this.buildFilename(content.filenameTemplate, doc);
|
||||||
await this.exportService.exportFile(content.exportTargetId, filename, buffer);
|
await this.exportService.exportFile(
|
||||||
|
content.exportTargetId,
|
||||||
|
filename,
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMail(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleMail(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const fileType = content.fileType || 'archive';
|
const fileType = content.fileType || 'archive';
|
||||||
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
|
const buffer = await this.paperlessService.downloadDocument(
|
||||||
|
doc.id,
|
||||||
|
fileType,
|
||||||
|
);
|
||||||
const filename = this.buildFilename(content.filenameTemplate, doc);
|
const filename = this.buildFilename(content.filenameTemplate, doc);
|
||||||
|
|
||||||
const subject = content.subject
|
const subject = content.subject
|
||||||
@@ -337,18 +411,26 @@ export class PostprocessingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleTags(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleTags(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const currentTags = new Set<number>(doc.tags || []);
|
const currentTags = new Set<number>(doc.tags || []);
|
||||||
const addTags: number[] = content.addTags || [];
|
const addTags: number[] = content.addTags || [];
|
||||||
const removeTags: number[] = content.removeTags || [];
|
const removeTags: number[] = content.removeTags || [];
|
||||||
|
|
||||||
addTags.forEach(t => currentTags.add(t));
|
addTags.forEach((t) => currentTags.add(t));
|
||||||
removeTags.forEach(t => currentTags.delete(t));
|
removeTags.forEach((t) => currentTags.delete(t));
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
|
await this.paperlessService.updateDocument(doc.id, {
|
||||||
|
tags: Array.from(currentTags),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCustomField(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleCustomField(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const customFields = [...(doc.custom_fields || [])];
|
const customFields = [...(doc.custom_fields || [])];
|
||||||
const existing = customFields.find((f: any) => f.field === content.fieldId);
|
const existing = customFields.find((f: any) => f.field === content.fieldId);
|
||||||
|
|
||||||
@@ -358,13 +440,23 @@ export class PostprocessingService {
|
|||||||
customFields.push({ field: content.fieldId, value: content.value });
|
customFields.push({ field: content.fieldId, value: content.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(doc.id, { custom_fields: customFields });
|
await this.paperlessService.updateDocument(doc.id, {
|
||||||
|
custom_fields: customFields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleWebhook(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleWebhook(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
const method = (content.method || 'POST').toUpperCase();
|
const method = (content.method || 'POST').toUpperCase();
|
||||||
const headers = content.headers || {};
|
const headers = content.headers || {};
|
||||||
const body = { documentId: doc.id, title: doc.title, tags: doc.tags, ...(content.body || {}) };
|
const body = {
|
||||||
|
documentId: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
tags: doc.tags,
|
||||||
|
...(content.body || {}),
|
||||||
|
};
|
||||||
|
|
||||||
await axios({
|
await axios({
|
||||||
method,
|
method,
|
||||||
@@ -378,7 +470,10 @@ export class PostprocessingService {
|
|||||||
this.logger.log(`Webhook ${method} → ${content.url}`);
|
this.logger.log(`Webhook ${method} → ${content.url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleNote(content: Record<string, any>, doc: any): Promise<void> {
|
private async handleNote(
|
||||||
|
content: Record<string, any>,
|
||||||
|
doc: any,
|
||||||
|
): Promise<void> {
|
||||||
if (!content.note) return;
|
if (!content.note) return;
|
||||||
const resolvedNote = this.resolveTemplate(content.note, doc);
|
const resolvedNote = this.resolveTemplate(content.note, doc);
|
||||||
await this.paperlessService.addNote(doc.id, resolvedNote);
|
await this.paperlessService.addNote(doc.id, resolvedNote);
|
||||||
@@ -386,7 +481,13 @@ export class PostprocessingService {
|
|||||||
|
|
||||||
// ── Logging ──────────────────────────────────────────────────────
|
// ── Logging ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async log(ppId: number, actionId: number | null, docId: number, status: string, message: string): Promise<void> {
|
private async log(
|
||||||
|
ppId: number,
|
||||||
|
actionId: number | null,
|
||||||
|
docId: number,
|
||||||
|
status: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
const entry = this.logRepo.create({
|
const entry = this.logRepo.create({
|
||||||
PostprocessingId: ppId,
|
PostprocessingId: ppId,
|
||||||
ActionId: actionId,
|
ActionId: actionId,
|
||||||
|
|||||||
@@ -51,18 +51,22 @@ export class DocumentPipelineService {
|
|||||||
|
|
||||||
// 2. QR-Code auf erster Seite scannen
|
// 2. QR-Code auf erster Seite scannen
|
||||||
const firstPageBuffer = await fs.readFile(images[0]);
|
const firstPageBuffer = await fs.readFile(images[0]);
|
||||||
const qrResults = await this.qrCodeService.extractFromImage(firstPageBuffer);
|
const qrResults =
|
||||||
|
await this.qrCodeService.extractFromImage(firstPageBuffer);
|
||||||
|
|
||||||
let barcodeData: Record<string, any> | null = null;
|
let barcodeData: Record<string, any> | null = null;
|
||||||
if (qrResults.length > 0) {
|
if (qrResults.length > 0) {
|
||||||
barcodeData = this.qrCodeService.parseBarcode(qrResults[0].data);
|
barcodeData = this.qrCodeService.parseBarcode(qrResults[0].data);
|
||||||
if (barcodeData) {
|
if (barcodeData) {
|
||||||
this.logger.log(`QR-Code erkannt und validiert: ${JSON.stringify(barcodeData)}`);
|
this.logger.log(
|
||||||
|
`QR-Code erkannt und validiert: ${JSON.stringify(barcodeData)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. OCR auf erster Seite
|
// 3. OCR auf erster Seite
|
||||||
const ocrMarkdown = await this.ocrService.extractTextAsMarkdown(firstPageBuffer);
|
const ocrMarkdown =
|
||||||
|
await this.ocrService.extractTextAsMarkdown(firstPageBuffer);
|
||||||
|
|
||||||
// 4. Task in DB erstellen
|
// 4. Task in DB erstellen
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export class OcrService {
|
|||||||
private readonly ollamaModel: string;
|
private readonly ollamaModel: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.ollamaUrl = this.configService.get<string>('OLLAMA_URL', 'http://localhost:11434');
|
this.ollamaUrl = this.configService.get<string>(
|
||||||
|
'OLLAMA_URL',
|
||||||
|
'http://localhost:11434',
|
||||||
|
);
|
||||||
this.ollamaModel = this.configService.get<string>('OLLAMA_MODEL', 'llava');
|
this.ollamaModel = this.configService.get<string>('OLLAMA_MODEL', 'llava');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ Antworte nur mit dem extrahierten Markdown-Text, keine Erklärungen.`;
|
|||||||
);
|
);
|
||||||
|
|
||||||
const markdown = response.data.response?.trim() ?? '';
|
const markdown = response.data.response?.trim() ?? '';
|
||||||
this.logger.log(`OCR abgeschlossen: ${markdown.length} Zeichen extrahiert`);
|
this.logger.log(
|
||||||
|
`OCR abgeschlossen: ${markdown.length} Zeichen extrahiert`,
|
||||||
|
);
|
||||||
return markdown;
|
return markdown;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Ollama OCR fehlgeschlagen: ${error.message}`);
|
this.logger.error(`Ollama OCR fehlgeschlagen: ${error.message}`);
|
||||||
|
|||||||
@@ -60,18 +60,22 @@ export class PdfService {
|
|||||||
|
|
||||||
const entries = await fs.readdir(tmpDir);
|
const entries = await fs.readdir(tmpDir);
|
||||||
const images = entries
|
const images = entries
|
||||||
.filter(f => f.endsWith('.png'))
|
.filter((f) => f.endsWith('.png'))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
|
const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
|
||||||
const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
|
const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
|
||||||
return numA - numB;
|
return numA - numB;
|
||||||
})
|
})
|
||||||
.map(f => path.join(tmpDir, f));
|
.map((f) => path.join(tmpDir, f));
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
this.logger.warn(`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`);
|
this.logger.warn(
|
||||||
|
`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`PDF konvertiert: ${images.length} Seite(n) in ${tmpDir}`);
|
this.logger.debug(
|
||||||
|
`PDF konvertiert: ${images.length} Seite(n) in ${tmpDir}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return images;
|
return images;
|
||||||
@@ -114,7 +118,9 @@ export class PdfService {
|
|||||||
async cleanup(imagePaths: string[]): Promise<void> {
|
async cleanup(imagePaths: string[]): Promise<void> {
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
for (const imgPath of imagePaths) {
|
for (const imgPath of imagePaths) {
|
||||||
try { await fs.unlink(imgPath); } catch {}
|
try {
|
||||||
|
await fs.unlink(imgPath);
|
||||||
|
} catch {}
|
||||||
dirs.add(path.dirname(imgPath));
|
dirs.add(path.dirname(imgPath));
|
||||||
}
|
}
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -31,7 +36,10 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
@InjectRepository(InboxDocument)
|
@InjectRepository(InboxDocument)
|
||||||
private readonly documentRepo: Repository<InboxDocument>,
|
private readonly documentRepo: Repository<InboxDocument>,
|
||||||
) {
|
) {
|
||||||
this.sourceRoot = this.configService.get<string>('SCANNER_WATCH_DIR', '/mnt/scans');
|
this.sourceRoot = this.configService.get<string>(
|
||||||
|
'SCANNER_WATCH_DIR',
|
||||||
|
'/mnt/scans',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
@@ -67,7 +75,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
this.watcher
|
this.watcher
|
||||||
.on('add', (filePath: string) => this.handleNewFile(filePath))
|
.on('add', (filePath: string) => this.handleNewFile(filePath))
|
||||||
.on('error', (error: Error) => this.logger.error(`Watcher Fehler: ${error.message}`));
|
.on('error', (error: Error) =>
|
||||||
|
this.logger.error(`Watcher Fehler: ${error.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log('Scanner-Watcher aktiv');
|
this.logger.log('Scanner-Watcher aktiv');
|
||||||
}
|
}
|
||||||
@@ -82,11 +92,15 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private async initialScan(silent = false): Promise<void> {
|
private async initialScan(silent = false): Promise<void> {
|
||||||
let subdirs: string[];
|
let subdirs: string[];
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(this.sourceRoot, { withFileTypes: true });
|
const entries = await fs.readdir(this.sourceRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
this.logger.warn(`Scanner-Check: Quellverzeichnis nicht lesbar (${this.sourceRoot}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Scanner-Check: Quellverzeichnis nicht lesbar (${this.sourceRoot}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,7 +113,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
files = await fs.readdir(dir);
|
files = await fs.readdir(dir);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
this.logger.warn(`Scanner-Check: ${dir} nicht lesbar: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Scanner-Check: ${dir} nicht lesbar: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -110,7 +126,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (!(await this.isStable(full))) {
|
if (!(await this.isStable(full))) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
this.logger.debug(`Scanner-Check: ${full} noch nicht stabil – Watcher übernimmt`);
|
this.logger.debug(
|
||||||
|
`Scanner-Check: ${full} noch nicht stabil – Watcher übernimmt`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -204,10 +222,14 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
try {
|
try {
|
||||||
await this.barcodeScanner.scanAndMatch(doc);
|
await this.barcodeScanner.scanAndMatch(doc);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Barcode-Scan nach Move fehlgeschlagen (${id}): ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Barcode-Scan nach Move fehlgeschlagen (${id}): ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`);
|
this.logger.error(
|
||||||
|
`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.processing.delete(filePath);
|
this.processing.delete(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,16 @@ describe('SettingsController', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: getRepositoryToken(DocumentType), useValue: docTypeRepo },
|
{ provide: getRepositoryToken(DocumentType), useValue: docTypeRepo },
|
||||||
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
||||||
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo },
|
{
|
||||||
|
provide: getRepositoryToken(PostprocessingAction),
|
||||||
|
useValue: ppActionRepo,
|
||||||
|
},
|
||||||
{ provide: getRepositoryToken(UserClient), useValue: userClientRepo },
|
{ provide: getRepositoryToken(UserClient), useValue: userClientRepo },
|
||||||
{ provide: getRepositoryToken(Client), useValue: makeRepo() },
|
{ provide: getRepositoryToken(Client), useValue: makeRepo() },
|
||||||
{ provide: getRepositoryToken(Setting), useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]) },
|
{
|
||||||
|
provide: getRepositoryToken(Setting),
|
||||||
|
useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -58,7 +64,9 @@ describe('SettingsController', () => {
|
|||||||
|
|
||||||
it('updateDocumentType calls update + findOneByOrFail', async () => {
|
it('updateDocumentType calls update + findOneByOrFail', async () => {
|
||||||
await controller.updateDocumentType('1', { TitelTemplate: 'New' });
|
await controller.updateDocumentType('1', { TitelTemplate: 'New' });
|
||||||
expect(docTypeRepo.update).toHaveBeenCalledWith(1, { TitelTemplate: 'New' });
|
expect(docTypeRepo.update).toHaveBeenCalledWith(1, {
|
||||||
|
TitelTemplate: 'New',
|
||||||
|
});
|
||||||
expect(docTypeRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
|
expect(docTypeRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,7 +78,9 @@ describe('SettingsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('createPostprocessingRule creates and saves', async () => {
|
it('createPostprocessingRule creates and saves', async () => {
|
||||||
const result = await controller.createPostprocessingRule({ Name: 'New' } as any);
|
const result = await controller.createPostprocessingRule({
|
||||||
|
Name: 'New',
|
||||||
|
} as any);
|
||||||
expect(ppRepo.create).toHaveBeenCalledWith({ Name: 'New' });
|
expect(ppRepo.create).toHaveBeenCalledWith({ Name: 'New' });
|
||||||
expect(ppRepo.save).toHaveBeenCalled();
|
expect(ppRepo.save).toHaveBeenCalled();
|
||||||
expect(result).toHaveProperty('Id', 99);
|
expect(result).toHaveProperty('Id', 99);
|
||||||
@@ -89,7 +99,10 @@ describe('SettingsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('createUserClient creates', async () => {
|
it('createUserClient creates', async () => {
|
||||||
const result = await controller.createUserClient({ UserId: 'u2', ClientId: 3 } as any);
|
const result = await controller.createUserClient({
|
||||||
|
UserId: 'u2',
|
||||||
|
ClientId: 3,
|
||||||
|
} as any);
|
||||||
expect(userClientRepo.create).toHaveBeenCalled();
|
expect(userClientRepo.create).toHaveBeenCalled();
|
||||||
expect(result).toHaveProperty('Id', 99);
|
expect(result).toHaveProperty('Id', 99);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Param, Body, Query, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DocumentType } from '../database/entities/document-type.entity';
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
@@ -12,7 +22,10 @@ import { PaperlessService } from '../paperless/paperless.service';
|
|||||||
import { Client } from '../database/entities/client.entity';
|
import { Client } from '../database/entities/client.entity';
|
||||||
import { Setting } from '../database/entities/setting.entity';
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
|
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
|
||||||
import { InboxPostprocessingAction, type InboxActionType } from '../database/entities/inbox-postprocessing-action.entity';
|
import {
|
||||||
|
InboxPostprocessingAction,
|
||||||
|
type InboxActionType,
|
||||||
|
} from '../database/entities/inbox-postprocessing-action.entity';
|
||||||
import { ExportService } from '../postprocessing/export.service';
|
import { ExportService } from '../postprocessing/export.service';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
@@ -23,17 +36,27 @@ export class SettingsController {
|
|||||||
private readonly logger = new Logger(SettingsController.name);
|
private readonly logger = new Logger(SettingsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>,
|
@InjectRepository(DocumentType)
|
||||||
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
|
private readonly docTypeRepo: Repository<DocumentType>,
|
||||||
@InjectRepository(PostprocessingAction) private readonly ppActionRepo: Repository<PostprocessingAction>,
|
@InjectRepository(Postprocessing)
|
||||||
@InjectRepository(PostprocessingLog) private readonly ppLogRepo: Repository<PostprocessingLog>,
|
private readonly ppRepo: Repository<Postprocessing>,
|
||||||
@InjectRepository(ExportTarget) private readonly exportTargetRepo: Repository<ExportTarget>,
|
@InjectRepository(PostprocessingAction)
|
||||||
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>,
|
private readonly ppActionRepo: Repository<PostprocessingAction>,
|
||||||
|
@InjectRepository(PostprocessingLog)
|
||||||
|
private readonly ppLogRepo: Repository<PostprocessingLog>,
|
||||||
|
@InjectRepository(ExportTarget)
|
||||||
|
private readonly exportTargetRepo: Repository<ExportTarget>,
|
||||||
|
@InjectRepository(UserClient)
|
||||||
|
private readonly userClientRepo: Repository<UserClient>,
|
||||||
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
|
||||||
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>,
|
@InjectRepository(Setting)
|
||||||
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>,
|
private readonly settingRepo: Repository<Setting>,
|
||||||
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>,
|
@InjectRepository(DocumentField)
|
||||||
@InjectRepository(InboxPostprocessingAction) private readonly inboxActionRepo: Repository<InboxPostprocessingAction>,
|
private readonly docFieldRepo: Repository<DocumentField>,
|
||||||
|
@InjectRepository(CorrespondentSetting)
|
||||||
|
private readonly corrSettingRepo: Repository<CorrespondentSetting>,
|
||||||
|
@InjectRepository(InboxPostprocessingAction)
|
||||||
|
private readonly inboxActionRepo: Repository<InboxPostprocessingAction>,
|
||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
) {}
|
) {}
|
||||||
@@ -44,7 +67,7 @@ export class SettingsController {
|
|||||||
try {
|
try {
|
||||||
const paperlessTypes = await this.paperlessService.getDocumentTypes();
|
const paperlessTypes = await this.paperlessService.getDocumentTypes();
|
||||||
const existing = await this.docTypeRepo.find();
|
const existing = await this.docTypeRepo.find();
|
||||||
const existingIds = new Set(existing.map(d => d.DocumentTypeId));
|
const existingIds = new Set(existing.map((d) => d.DocumentTypeId));
|
||||||
|
|
||||||
for (const pt of paperlessTypes) {
|
for (const pt of paperlessTypes) {
|
||||||
if (!existingIds.has(pt.id)) {
|
if (!existingIds.has(pt.id)) {
|
||||||
@@ -58,14 +81,20 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Synchronisieren der Dokumenttypen von Paperless', err);
|
this.logger.error(
|
||||||
|
'Fehler beim Synchronisieren der Dokumenttypen von Paperless',
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.docTypeRepo.find({ order: { Id: 'ASC' } });
|
return this.docTypeRepo.find({ order: { Id: 'ASC' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('document-types/:id')
|
@Put('document-types/:id')
|
||||||
async updateDocumentType(@Param('id') id: string, @Body() body: Partial<DocumentType>) {
|
async updateDocumentType(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<DocumentType>,
|
||||||
|
) {
|
||||||
await this.docTypeRepo.update(parseInt(id, 10), body);
|
await this.docTypeRepo.update(parseInt(id, 10), body);
|
||||||
return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
||||||
}
|
}
|
||||||
@@ -80,13 +109,22 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('document-types/:id/fields')
|
@Post('document-types/:id/fields')
|
||||||
async createDocumentField(@Param('id') id: string, @Body() body: Partial<DocumentField>) {
|
async createDocumentField(
|
||||||
const field = this.docFieldRepo.create({ ...body, DocumentType: parseInt(id, 10) });
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<DocumentField>,
|
||||||
|
) {
|
||||||
|
const field = this.docFieldRepo.create({
|
||||||
|
...body,
|
||||||
|
DocumentType: parseInt(id, 10),
|
||||||
|
});
|
||||||
return this.docFieldRepo.save(field);
|
return this.docFieldRepo.save(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('document-fields/:fieldId')
|
@Put('document-fields/:fieldId')
|
||||||
async updateDocumentField(@Param('fieldId') fieldId: string, @Body() body: Partial<DocumentField>) {
|
async updateDocumentField(
|
||||||
|
@Param('fieldId') fieldId: string,
|
||||||
|
@Body() body: Partial<DocumentField>,
|
||||||
|
) {
|
||||||
await this.docFieldRepo.update(parseInt(fieldId, 10), body);
|
await this.docFieldRepo.update(parseInt(fieldId, 10), body);
|
||||||
return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) });
|
return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) });
|
||||||
}
|
}
|
||||||
@@ -110,7 +148,10 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('postprocessing/:id')
|
@Put('postprocessing/:id')
|
||||||
async updatePostprocessingRule(@Param('id') id: string, @Body() body: Partial<Postprocessing>) {
|
async updatePostprocessingRule(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<Postprocessing>,
|
||||||
|
) {
|
||||||
await this.ppRepo.update(parseInt(id, 10), body);
|
await this.ppRepo.update(parseInt(id, 10), body);
|
||||||
return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
||||||
}
|
}
|
||||||
@@ -123,16 +164,26 @@ export class SettingsController {
|
|||||||
|
|
||||||
@Post('postprocessing/:id/duplicate')
|
@Post('postprocessing/:id/duplicate')
|
||||||
async duplicatePostprocessingRule(@Param('id') id: string) {
|
async duplicatePostprocessingRule(@Param('id') id: string) {
|
||||||
const original = await this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
const original = await this.ppRepo.findOneByOrFail({
|
||||||
const actions = await this.ppActionRepo.find({ where: { PostprocessingId: original.Id } });
|
Id: parseInt(id, 10),
|
||||||
|
});
|
||||||
|
const actions = await this.ppActionRepo.find({
|
||||||
|
where: { PostprocessingId: original.Id },
|
||||||
|
});
|
||||||
|
|
||||||
const { Id: _, ...ruleData } = original;
|
const { Id: _, ...ruleData } = original;
|
||||||
const clone = this.ppRepo.create({ ...ruleData, Name: `${original.Name} (Kopie)` });
|
const clone = this.ppRepo.create({
|
||||||
|
...ruleData,
|
||||||
|
Name: `${original.Name} (Kopie)`,
|
||||||
|
});
|
||||||
const saved = await this.ppRepo.save(clone);
|
const saved = await this.ppRepo.save(clone);
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
const { Id: __, ...actionData } = action;
|
const { Id: __, ...actionData } = action;
|
||||||
const clonedAction = this.ppActionRepo.create({ ...actionData, PostprocessingId: saved.Id });
|
const clonedAction = this.ppActionRepo.create({
|
||||||
|
...actionData,
|
||||||
|
PostprocessingId: saved.Id,
|
||||||
|
});
|
||||||
await this.ppActionRepo.save(clonedAction);
|
await this.ppActionRepo.save(clonedAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +200,22 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('postprocessing/:id/actions')
|
@Post('postprocessing/:id/actions')
|
||||||
async createAction(@Param('id') id: string, @Body() body: Partial<PostprocessingAction>) {
|
async createAction(
|
||||||
const action = this.ppActionRepo.create({ ...body, PostprocessingId: parseInt(id, 10) });
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<PostprocessingAction>,
|
||||||
|
) {
|
||||||
|
const action = this.ppActionRepo.create({
|
||||||
|
...body,
|
||||||
|
PostprocessingId: parseInt(id, 10),
|
||||||
|
});
|
||||||
return this.ppActionRepo.save(action);
|
return this.ppActionRepo.save(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('postprocessing-actions/:actionId')
|
@Put('postprocessing-actions/:actionId')
|
||||||
async updateAction(@Param('actionId') actionId: string, @Body() body: Partial<PostprocessingAction>) {
|
async updateAction(
|
||||||
|
@Param('actionId') actionId: string,
|
||||||
|
@Body() body: Partial<PostprocessingAction>,
|
||||||
|
) {
|
||||||
await this.ppActionRepo.update(parseInt(actionId, 10), body);
|
await this.ppActionRepo.update(parseInt(actionId, 10), body);
|
||||||
return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) });
|
return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) });
|
||||||
}
|
}
|
||||||
@@ -184,7 +244,7 @@ export class SettingsController {
|
|||||||
throw new Error('ActionType ist erforderlich');
|
throw new Error('ActionType ist erforderlich');
|
||||||
}
|
}
|
||||||
const entity = this.inboxActionRepo.create({
|
const entity = this.inboxActionRepo.create({
|
||||||
ActionType: body.ActionType as InboxActionType,
|
ActionType: body.ActionType,
|
||||||
Content: body.Content ?? {},
|
Content: body.Content ?? {},
|
||||||
Order: body.Order ?? 0,
|
Order: body.Order ?? 0,
|
||||||
IsActive: body.IsActive ?? true,
|
IsActive: body.IsActive ?? true,
|
||||||
@@ -205,7 +265,7 @@ export class SettingsController {
|
|||||||
throw new Error('ActionType ist erforderlich');
|
throw new Error('ActionType ist erforderlich');
|
||||||
}
|
}
|
||||||
const entity = this.inboxActionRepo.create({
|
const entity = this.inboxActionRepo.create({
|
||||||
ActionType: body.ActionType as InboxActionType,
|
ActionType: body.ActionType,
|
||||||
Content: body.Content ?? {},
|
Content: body.Content ?? {},
|
||||||
Order: body.Order ?? 0,
|
Order: body.Order ?? 0,
|
||||||
IsActive: body.IsActive ?? true,
|
IsActive: body.IsActive ?? true,
|
||||||
@@ -214,10 +274,13 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('inbox-actions/:id')
|
@Put('inbox-actions/:id')
|
||||||
async updateInboxAction(@Param('id') id: string, @Body() body: Partial<InboxPostprocessingAction>) {
|
async updateInboxAction(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<InboxPostprocessingAction>,
|
||||||
|
) {
|
||||||
const numId = parseInt(id, 10);
|
const numId = parseInt(id, 10);
|
||||||
const existing = await this.inboxActionRepo.findOneByOrFail({ Id: numId });
|
const existing = await this.inboxActionRepo.findOneByOrFail({ Id: numId });
|
||||||
if (body.ActionType !== undefined) existing.ActionType = body.ActionType as InboxActionType;
|
if (body.ActionType !== undefined) existing.ActionType = body.ActionType;
|
||||||
if (body.Content !== undefined) existing.Content = body.Content;
|
if (body.Content !== undefined) existing.Content = body.Content;
|
||||||
if (body.Order !== undefined) existing.Order = body.Order;
|
if (body.Order !== undefined) existing.Order = body.Order;
|
||||||
if (body.IsActive !== undefined) existing.IsActive = body.IsActive;
|
if (body.IsActive !== undefined) existing.IsActive = body.IsActive;
|
||||||
@@ -243,7 +306,10 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('export-targets/:id')
|
@Put('export-targets/:id')
|
||||||
async updateExportTarget(@Param('id') id: string, @Body() body: Partial<ExportTarget>) {
|
async updateExportTarget(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Partial<ExportTarget>,
|
||||||
|
) {
|
||||||
await this.exportTargetRepo.update(parseInt(id, 10), body);
|
await this.exportTargetRepo.update(parseInt(id, 10), body);
|
||||||
return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) });
|
||||||
}
|
}
|
||||||
@@ -300,7 +366,10 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('general/:id')
|
@Put('general/:id')
|
||||||
async updateSetting(@Param('id') id: string, @Body() body: { value: string }) {
|
async updateSetting(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { value: string },
|
||||||
|
) {
|
||||||
await this.settingRepo.update(parseInt(id, 10), { Wert: body.value });
|
await this.settingRepo.update(parseInt(id, 10), { Wert: body.value });
|
||||||
return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) });
|
return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) });
|
||||||
}
|
}
|
||||||
@@ -323,13 +392,18 @@ export class SettingsController {
|
|||||||
|
|
||||||
const response = await this.paperlessService.getCorrespondents(params);
|
const response = await this.paperlessService.getCorrespondents(params);
|
||||||
const paperlessCorrs = response.results;
|
const paperlessCorrs = response.results;
|
||||||
|
|
||||||
const settings = await this.corrSettingRepo.find();
|
const settings = await this.corrSettingRepo.find();
|
||||||
const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s]));
|
const settingsMap = new Map(settings.map((s) => [s.CorrespondentId, s]));
|
||||||
|
|
||||||
const newSettings = paperlessCorrs
|
const newSettings = paperlessCorrs
|
||||||
.filter((pc: any) => !settingsMap.has(pc.id))
|
.filter((pc: any) => !settingsMap.has(pc.id))
|
||||||
.map((pc: any) => this.corrSettingRepo.create({ CorrespondentId: pc.id, AgrarmonitorId: null }));
|
.map((pc: any) =>
|
||||||
|
this.corrSettingRepo.create({
|
||||||
|
CorrespondentId: pc.id,
|
||||||
|
AgrarmonitorId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (newSettings.length > 0) {
|
if (newSettings.length > 0) {
|
||||||
await this.corrSettingRepo.insert(newSettings);
|
await this.corrSettingRepo.insert(newSettings);
|
||||||
}
|
}
|
||||||
@@ -338,7 +412,9 @@ export class SettingsController {
|
|||||||
const finalSettings = await this.corrSettingRepo.find();
|
const finalSettings = await this.corrSettingRepo.find();
|
||||||
const merged = paperlessCorrs.map((pc: any) => ({
|
const merged = paperlessCorrs.map((pc: any) => ({
|
||||||
...pc,
|
...pc,
|
||||||
agrarmonitorId: finalSettings.find(s => s.CorrespondentId === pc.id)?.AgrarmonitorId || null,
|
agrarmonitorId:
|
||||||
|
finalSettings.find((s) => s.CorrespondentId === pc.id)
|
||||||
|
?.AgrarmonitorId || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -346,7 +422,10 @@ export class SettingsController {
|
|||||||
total: response.count,
|
total: response.count,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Synchronisieren der Korrespondenten von Paperless', err);
|
this.logger.error(
|
||||||
|
'Fehler beim Synchronisieren der Korrespondenten von Paperless',
|
||||||
|
err,
|
||||||
|
);
|
||||||
return { data: [], total: 0 };
|
return { data: [], total: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,24 +440,32 @@ export class SettingsController {
|
|||||||
owner: null, // User said 0, but null is standard for public in Paperless. I'll use null.
|
owner: null, // User said 0, but null is standard for public in Paperless. I'll use null.
|
||||||
};
|
};
|
||||||
const created = await this.paperlessService.addCorrespondent(data);
|
const created = await this.paperlessService.addCorrespondent(data);
|
||||||
|
|
||||||
// Also ensure setting entry exists
|
// Also ensure setting entry exists
|
||||||
const newSetting = this.corrSettingRepo.create({
|
const newSetting = this.corrSettingRepo.create({
|
||||||
CorrespondentId: created.id,
|
CorrespondentId: created.id,
|
||||||
AgrarmonitorId: null,
|
AgrarmonitorId: null,
|
||||||
});
|
});
|
||||||
await this.corrSettingRepo.save(newSetting);
|
await this.corrSettingRepo.save(newSetting);
|
||||||
|
|
||||||
return { ...created, agrarmonitorId: null };
|
return { ...created, agrarmonitorId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('correspondents/:id')
|
@Put('correspondents/:id')
|
||||||
async updateCorrespondentSetting(@Param('id') id: string, @Body() body: { agrarmonitorId: number | null }) {
|
async updateCorrespondentSetting(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { agrarmonitorId: number | null },
|
||||||
|
) {
|
||||||
const corrId = parseInt(id, 10);
|
const corrId = parseInt(id, 10);
|
||||||
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corrId });
|
let setting = await this.corrSettingRepo.findOneBy({
|
||||||
|
CorrespondentId: corrId,
|
||||||
|
});
|
||||||
|
|
||||||
if (!setting) {
|
if (!setting) {
|
||||||
setting = this.corrSettingRepo.create({ CorrespondentId: corrId, AgrarmonitorId: body.agrarmonitorId });
|
setting = this.corrSettingRepo.create({
|
||||||
|
CorrespondentId: corrId,
|
||||||
|
AgrarmonitorId: body.agrarmonitorId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setting.AgrarmonitorId = body.agrarmonitorId;
|
setting.AgrarmonitorId = body.agrarmonitorId;
|
||||||
}
|
}
|
||||||
@@ -393,9 +480,14 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('clients/:id')
|
@Put('clients/:id')
|
||||||
async updateClient(@Param('id') id: string, @Body() body: { AgrarmonitorBetriebId: number | null }) {
|
async updateClient(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { AgrarmonitorBetriebId: number | null },
|
||||||
|
) {
|
||||||
const clientId = parseInt(id, 10);
|
const clientId = parseInt(id, 10);
|
||||||
await this.clientRepo.update(clientId, { AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null });
|
await this.clientRepo.update(clientId, {
|
||||||
|
AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null,
|
||||||
|
});
|
||||||
return this.clientRepo.findOneByOrFail({ Id: clientId });
|
return this.clientRepo.findOneByOrFail({ Id: clientId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { InboxModule } from '../inbox/inbox.module';
|
|||||||
import { PaperlessModule } from '../paperless/paperless.module';
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([Email]), InboxModule, PaperlessModule],
|
||||||
TypeOrmModule.forFeature([Email]),
|
|
||||||
InboxModule,
|
|
||||||
PaperlessModule,
|
|
||||||
],
|
|
||||||
controllers: [StatsController],
|
controllers: [StatsController],
|
||||||
providers: [StatsService],
|
providers: [StatsService],
|
||||||
exports: [StatsService],
|
exports: [StatsService],
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export class StatsService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getDashboardCounts(preferredUsername?: string): Promise<DashboardCounts> {
|
async getDashboardCounts(
|
||||||
|
preferredUsername?: string,
|
||||||
|
): Promise<DashboardCounts> {
|
||||||
let inboxCount = 0;
|
let inboxCount = 0;
|
||||||
let posteingangCount = 0;
|
let posteingangCount = 0;
|
||||||
let manuellCount = 0;
|
let manuellCount = 0;
|
||||||
@@ -33,25 +35,42 @@ export class StatsService {
|
|||||||
let agrarmonitorCount = 0;
|
let agrarmonitorCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await this.inboxService.listFiles(preferredUsername ?? null);
|
const files = await this.inboxService.listFiles(
|
||||||
|
preferredUsername ?? null,
|
||||||
|
);
|
||||||
inboxCount = files.length;
|
inboxCount = files.length;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message);
|
this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 1 });
|
const response = await this.paperlessService.getDocuments({
|
||||||
|
page: 1,
|
||||||
|
page_size: 1,
|
||||||
|
tags__id__all: 1,
|
||||||
|
});
|
||||||
posteingangCount = response.count || 0;
|
posteingangCount = response.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message);
|
this.logger.error(
|
||||||
|
'Fehler beim Abrufen der Posteingang-Stats: ' + err.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
|
const errorTag = this.configService.get<number>(
|
||||||
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: errorTag });
|
'MANUELL_BEARBEITEN_TAG',
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
const response = await this.paperlessService.getDocuments({
|
||||||
|
page: 1,
|
||||||
|
page_size: 1,
|
||||||
|
tags__id__all: errorTag,
|
||||||
|
});
|
||||||
manuellCount = response.count || 0;
|
manuellCount = response.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message);
|
this.logger.error(
|
||||||
|
'Fehler beim Abrufen der Manuell-Stats: ' + err.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -61,12 +80,24 @@ export class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 3 });
|
const response = await this.paperlessService.getDocuments({
|
||||||
|
page: 1,
|
||||||
|
page_size: 1,
|
||||||
|
tags__id__all: 3,
|
||||||
|
});
|
||||||
agrarmonitorCount = response.count || 0;
|
agrarmonitorCount = response.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message);
|
this.logger.error(
|
||||||
|
'Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { inbox: inboxCount, posteingang: posteingangCount, manuell: manuellCount, mailpostfach: mailpostfachCount, agrarmonitor: agrarmonitorCount };
|
return {
|
||||||
|
inbox: inboxCount,
|
||||||
|
posteingang: posteingangCount,
|
||||||
|
manuell: manuellCount,
|
||||||
|
mailpostfach: mailpostfachCount,
|
||||||
|
agrarmonitor: agrarmonitorCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { UserSettingsService } from './user-settings.service';
|
import { UserSettingsService } from './user-settings.service';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { Permission } from '../auth/permissions.enum';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
@@ -9,12 +17,23 @@ export class UserSettingsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getSettings(@Request() req: any) {
|
async getSettings(@Request() req: any) {
|
||||||
return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername, req.user.groups);
|
return this.userSettingsService.getSettings(
|
||||||
|
req.user.userId,
|
||||||
|
req.user.email,
|
||||||
|
req.user.preferredUsername,
|
||||||
|
req.user.groups,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
async updateSettings(@Request() req: any, @Body() body: any) {
|
async updateSettings(@Request() req: any, @Body() body: any) {
|
||||||
return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername, req.user.groups);
|
return this.userSettingsService.updateSettings(
|
||||||
|
req.user.userId,
|
||||||
|
body,
|
||||||
|
req.user.email,
|
||||||
|
req.user.preferredUsername,
|
||||||
|
req.user.groups,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('senders')
|
@Get('senders')
|
||||||
@@ -25,7 +44,16 @@ export class UserSettingsController {
|
|||||||
@Post('test-smtp')
|
@Post('test-smtp')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) {
|
async testSmtp(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.userSettingsService.testSmtp(body);
|
return this.userSettingsService.testSmtp(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export class UserSettingsService {
|
|||||||
if (!this.encKey) return plaintext;
|
if (!this.encKey) return plaintext;
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(16);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, this.encKey, iv);
|
const cipher = crypto.createCipheriv(ALGORITHM, this.encKey, iv);
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||||
}
|
}
|
||||||
@@ -62,10 +65,18 @@ export class UserSettingsService {
|
|||||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv);
|
const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv);
|
||||||
decipher.setAuthTag(authTag);
|
decipher.setAuthTag(authTag);
|
||||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
return Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSettings(userId: string, email?: string, preferredUsername?: string, groups?: string[]): Promise<UserSettingsDto> {
|
async getSettings(
|
||||||
|
userId: string,
|
||||||
|
email?: string,
|
||||||
|
preferredUsername?: string,
|
||||||
|
groups?: string[],
|
||||||
|
): Promise<UserSettingsDto> {
|
||||||
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
let entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||||
if (email || preferredUsername || groups) {
|
if (email || preferredUsername || groups) {
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
@@ -107,15 +118,24 @@ export class UserSettingsService {
|
|||||||
if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort;
|
if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort;
|
||||||
if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure;
|
if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure;
|
||||||
if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser;
|
if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser;
|
||||||
if (data.smtpPass !== undefined && data.smtpPass !== null && data.smtpPass !== '') {
|
if (
|
||||||
|
data.smtpPass !== undefined &&
|
||||||
|
data.smtpPass !== null &&
|
||||||
|
data.smtpPass !== ''
|
||||||
|
) {
|
||||||
entity.SmtpPass = this.encrypt(data.smtpPass);
|
entity.SmtpPass = this.encrypt(data.smtpPass);
|
||||||
}
|
}
|
||||||
if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom;
|
if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom;
|
||||||
if (data.smtpFromName !== undefined) entity.SmtpFromName = data.smtpFromName;
|
if (data.smtpFromName !== undefined)
|
||||||
if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml;
|
entity.SmtpFromName = data.smtpFromName;
|
||||||
if (data.defaultLabelTemplateId !== undefined) entity.DefaultLabelTemplateId = data.defaultLabelTemplateId;
|
if (data.mailSignatureHtml !== undefined)
|
||||||
if (data.emailRecipientHistory !== undefined) entity.EmailRecipientHistory = data.emailRecipientHistory;
|
entity.MailSignatureHtml = data.mailSignatureHtml;
|
||||||
if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled;
|
if (data.defaultLabelTemplateId !== undefined)
|
||||||
|
entity.DefaultLabelTemplateId = data.defaultLabelTemplateId;
|
||||||
|
if (data.emailRecipientHistory !== undefined)
|
||||||
|
entity.EmailRecipientHistory = data.emailRecipientHistory;
|
||||||
|
if (data.dailyDigestEnabled !== undefined)
|
||||||
|
entity.DailyDigestEnabled = data.dailyDigestEnabled;
|
||||||
if (email) entity.UserEmail = email;
|
if (email) entity.UserEmail = email;
|
||||||
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
|
||||||
if (groups) entity.UserGroups = groups;
|
if (groups) entity.UserGroups = groups;
|
||||||
@@ -146,12 +166,19 @@ export class UserSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSmtpConfig(userId: string): Promise<{
|
async getSmtpConfig(userId: string): Promise<{
|
||||||
host: string; port: number; secure: boolean; user: string; pass: string; from: string;
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const entity = await this.repo.findOne({ where: { UserId: userId } });
|
const entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||||
if (!entity?.SmtpHost || !entity?.SmtpPass) return null;
|
if (!entity?.SmtpHost || !entity?.SmtpPass) return null;
|
||||||
const fromEmail = entity.SmtpFrom ?? entity.SmtpUser ?? '';
|
const fromEmail = entity.SmtpFrom ?? entity.SmtpUser ?? '';
|
||||||
const from = entity.SmtpFromName ? `"${entity.SmtpFromName}" <${fromEmail}>` : fromEmail;
|
const from = entity.SmtpFromName
|
||||||
|
? `"${entity.SmtpFromName}" <${fromEmail}>`
|
||||||
|
: fromEmail;
|
||||||
return {
|
return {
|
||||||
host: entity.SmtpHost,
|
host: entity.SmtpHost,
|
||||||
port: entity.SmtpPort ?? 587,
|
port: entity.SmtpPort ?? 587,
|
||||||
@@ -163,21 +190,34 @@ export class UserSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAllDigestSubscribers(): Promise<UserSettings[]> {
|
async findAllDigestSubscribers(): Promise<UserSettings[]> {
|
||||||
return this.repo.find({
|
return this.repo
|
||||||
where: { DailyDigestEnabled: true },
|
.find({
|
||||||
}).then(rows => rows.filter(r => !!r.UserEmail));
|
where: { DailyDigestEnabled: true },
|
||||||
|
})
|
||||||
|
.then((rows) => rows.filter((r) => !!r.UserEmail));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableSenders(userId: string): Promise<{ id: string; label: string }[]> {
|
async getAvailableSenders(
|
||||||
const defaultEmail = this.configService.get<string>('SMTP_FROM', 'paperless@localhost');
|
userId: string,
|
||||||
|
): Promise<{ id: string; label: string }[]> {
|
||||||
|
const defaultEmail = this.configService.get<string>(
|
||||||
|
'SMTP_FROM',
|
||||||
|
'paperless@localhost',
|
||||||
|
);
|
||||||
const defaultName = this.configService.get<string>('SMTP_FROM_NAME', '');
|
const defaultName = this.configService.get<string>('SMTP_FROM_NAME', '');
|
||||||
const defaultLabel = defaultName ? `${defaultName} <${defaultEmail}>` : defaultEmail;
|
const defaultLabel = defaultName
|
||||||
const senders: { id: string; label: string }[] = [{ id: 'default', label: defaultLabel }];
|
? `${defaultName} <${defaultEmail}>`
|
||||||
|
: defaultEmail;
|
||||||
|
const senders: { id: string; label: string }[] = [
|
||||||
|
{ id: 'default', label: defaultLabel },
|
||||||
|
];
|
||||||
|
|
||||||
const entity = await this.repo.findOne({ where: { UserId: userId } });
|
const entity = await this.repo.findOne({ where: { UserId: userId } });
|
||||||
if (entity?.SmtpHost && entity?.SmtpPass) {
|
if (entity?.SmtpHost && entity?.SmtpPass) {
|
||||||
const userEmail = entity.SmtpFrom ?? entity.SmtpUser ?? '';
|
const userEmail = entity.SmtpFrom ?? entity.SmtpUser ?? '';
|
||||||
const userLabel = entity.SmtpFromName ? `${entity.SmtpFromName} <${userEmail}>` : userEmail;
|
const userLabel = entity.SmtpFromName
|
||||||
|
? `${entity.SmtpFromName} <${userEmail}>`
|
||||||
|
: userEmail;
|
||||||
senders.push({ id: 'user', label: userLabel });
|
senders.push({ id: 'user', label: userLabel });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +230,7 @@ export class UserSettingsService {
|
|||||||
smtpPort: entity?.SmtpPort ?? null,
|
smtpPort: entity?.SmtpPort ?? null,
|
||||||
smtpSecure: entity?.SmtpSecure ?? false,
|
smtpSecure: entity?.SmtpSecure ?? false,
|
||||||
smtpUser: entity?.SmtpUser ?? null,
|
smtpUser: entity?.SmtpUser ?? null,
|
||||||
smtpPassSet: !!(entity?.SmtpPass),
|
smtpPassSet: !!entity?.SmtpPass,
|
||||||
smtpFrom: entity?.SmtpFrom ?? null,
|
smtpFrom: entity?.SmtpFrom ?? null,
|
||||||
smtpFromName: entity?.SmtpFromName ?? null,
|
smtpFromName: entity?.SmtpFromName ?? null,
|
||||||
mailSignatureHtml: entity?.MailSignatureHtml ?? null,
|
mailSignatureHtml: entity?.MailSignatureHtml ?? null,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpCode, HttpStatus } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Logger,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Public } from '../auth/public.decorator';
|
import { Public } from '../auth/public.decorator';
|
||||||
export interface PaperlessWebhookPayload {
|
export interface PaperlessWebhookPayload {
|
||||||
document_id: number;
|
document_id: number;
|
||||||
@@ -13,8 +20,12 @@ export class WebhookController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('paperless')
|
@Post('paperless')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload): Promise<{ status: string }> {
|
async handlePaperlessWebhook(
|
||||||
this.logger.log(`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`);
|
@Body() payload: PaperlessWebhookPayload,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
this.logger.log(
|
||||||
|
`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Business-Logik für verschiedene Webhook-Events
|
// TODO: Business-Logik für verschiedene Webhook-Events
|
||||||
// - document_updated → Felder prüfen, Postprocessing auslösen
|
// - document_updated → Felder prüfen, Postprocessing auslösen
|
||||||
|
|||||||
Reference in New Issue
Block a user