Compare commits
2 Commits
969f0ae0b1
...
56596d4482
| Author | SHA1 | Date | |
|---|---|---|---|
| 56596d4482 | |||
| 4d05a94681 |
@@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
|
|||||||
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
||||||
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
|
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
|
||||||
import { FreigabeModule } from './freigabe/freigabe.module';
|
import { FreigabeModule } from './freigabe/freigabe.module';
|
||||||
|
import { ZahlungModule } from './zahlung/zahlung.module';
|
||||||
import { DailyDigestModule } from './daily-digest/daily-digest.module';
|
import { DailyDigestModule } from './daily-digest/daily-digest.module';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ import * as path from 'path';
|
|||||||
LabelPrintAgentModule,
|
LabelPrintAgentModule,
|
||||||
AgrarmonitorModule,
|
AgrarmonitorModule,
|
||||||
FreigabeModule,
|
FreigabeModule,
|
||||||
|
ZahlungModule,
|
||||||
DailyDigestModule,
|
DailyDigestModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const Permission = {
|
|||||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||||
|
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof Permission)[keyof typeof Permission];
|
export type Permission = (typeof Permission)[keyof typeof Permission];
|
||||||
@@ -27,6 +28,7 @@ export function mapGroupsToPermissions(
|
|||||||
permissions.add(Permission.VIEW_SCANNER);
|
permissions.add(Permission.VIEW_SCANNER);
|
||||||
permissions.add(Permission.MANAGE_SETTINGS);
|
permissions.add(Permission.MANAGE_SETTINGS);
|
||||||
permissions.add(Permission.VIEW_FREIGABE);
|
permissions.add(Permission.VIEW_FREIGABE);
|
||||||
|
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||||
return Array.from(permissions);
|
return Array.from(permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ export function mapGroupsToPermissions(
|
|||||||
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);
|
||||||
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
|
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
|
||||||
|
if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG);
|
||||||
|
|
||||||
return Array.from(permissions);
|
return Array.from(permissions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
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';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -8,6 +7,8 @@ import { DocumentType } from '../database/entities/document-type.entity';
|
|||||||
import { PaperlessService } from './paperless.service';
|
import { PaperlessService } from './paperless.service';
|
||||||
import { PostprocessingService } from '../postprocessing/postprocessing.service';
|
import { PostprocessingService } from '../postprocessing/postprocessing.service';
|
||||||
|
|
||||||
|
const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager"
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaperlessProcessorService {
|
export class PaperlessProcessorService {
|
||||||
private readonly logger = new Logger(PaperlessProcessorService.name);
|
private readonly logger = new Logger(PaperlessProcessorService.name);
|
||||||
@@ -22,11 +23,13 @@ export class PaperlessProcessorService {
|
|||||||
private readonly docFieldRepo: Repository<DocumentField>,
|
private readonly docFieldRepo: Repository<DocumentField>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
|
// Manueller Batch-Lauf ("alle paperlessmanager-Dokumente neu durchlaufen").
|
||||||
|
// Die laufende Verarbeitung erfolgt ereignisgesteuert über den Webhook
|
||||||
|
// (processDocumentById), daher ist hier kein @Cron-Trigger mehr gesetzt.
|
||||||
async processDocuments() {
|
async processDocuments() {
|
||||||
try {
|
try {
|
||||||
const response = await this.paperlessService.getDocuments({
|
const response = await this.paperlessService.getDocuments({
|
||||||
tags__id__all: 16,
|
tags__id__all: PAPERLESSMANAGER_TAG_ID,
|
||||||
page_size: 9999,
|
page_size: 9999,
|
||||||
});
|
});
|
||||||
const documents: any[] = Array.isArray(response)
|
const documents: any[] = Array.isArray(response)
|
||||||
@@ -38,17 +41,12 @@ export class PaperlessProcessorService {
|
|||||||
const validFieldIds = new Set(customFields.map((f: any) => f.id));
|
const validFieldIds = new Set(customFields.map((f: any) => f.id));
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`,
|
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: ${PAPERLESSMANAGER_TAG_ID}).`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
try {
|
try {
|
||||||
const updatedDoc = await this.processSingleDocument(
|
await this.processAndEvaluate(doc, validFieldIds);
|
||||||
doc,
|
|
||||||
validFieldIds,
|
|
||||||
);
|
|
||||||
// Postprocessing nach dem Speichern evaluieren
|
|
||||||
await this.postprocessingService.evaluate(updatedDoc || doc);
|
|
||||||
} catch (innerErr: any) {
|
} catch (innerErr: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
|
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
|
||||||
@@ -65,6 +63,45 @@ export class PaperlessProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitet ein einzelnes Dokument anhand seiner ID – die ereignisgesteuerte
|
||||||
|
* Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird
|
||||||
|
* nur verarbeitet, wenn das Dokument den Tag "paperlessmanager" trägt.
|
||||||
|
*/
|
||||||
|
async processDocumentById(
|
||||||
|
documentId: number,
|
||||||
|
): Promise<{ processed: boolean; reason?: string }> {
|
||||||
|
const doc = await this.paperlessService.getDocument(documentId);
|
||||||
|
const tags: number[] = doc.tags || [];
|
||||||
|
if (!tags.includes(PAPERLESSMANAGER_TAG_ID)) {
|
||||||
|
this.logger.log(
|
||||||
|
`Dokument ${documentId} ohne Tag "paperlessmanager" (ID ${PAPERLESSMANAGER_TAG_ID}) – übersprungen.`,
|
||||||
|
);
|
||||||
|
return { processed: false, reason: 'tag-missing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFields = await this.paperlessService.getCustomFields();
|
||||||
|
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
|
||||||
|
|
||||||
|
await this.processAndEvaluate(doc, validFieldIds);
|
||||||
|
this.logger.log(`Dokument ${documentId} per Webhook verarbeitet.`);
|
||||||
|
return { processed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reichert ein Dokument an (Tags, Pflichtfelder, Titel) und evaluiert
|
||||||
|
* anschließend das Postprocessing. Gemeinsame Logik von Batch-Lauf und Webhook.
|
||||||
|
*/
|
||||||
|
private async processAndEvaluate(
|
||||||
|
doc: any,
|
||||||
|
validFieldIds: Set<number>,
|
||||||
|
): Promise<any> {
|
||||||
|
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
|
||||||
|
// Postprocessing nach dem Speichern evaluieren
|
||||||
|
await this.postprocessingService.evaluate(updatedDoc || doc);
|
||||||
|
return updatedDoc;
|
||||||
|
}
|
||||||
|
|
||||||
private async processSingleDocument(
|
private async processSingleDocument(
|
||||||
doc: any,
|
doc: any,
|
||||||
validFieldIds: Set<number>,
|
validFieldIds: Set<number>,
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
PaperlessProcessorService,
|
PaperlessProcessorService,
|
||||||
PaperlessTaskProcessorService,
|
PaperlessTaskProcessorService,
|
||||||
],
|
],
|
||||||
exports: [PaperlessService],
|
exports: [PaperlessService, PaperlessProcessorService],
|
||||||
})
|
})
|
||||||
export class PaperlessModule {}
|
export class PaperlessModule {}
|
||||||
|
|||||||
@@ -5,32 +5,78 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Public } from '../auth/public.decorator';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
|
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
||||||
|
|
||||||
export interface PaperlessWebhookPayload {
|
export interface PaperlessWebhookPayload {
|
||||||
document_id: number;
|
doc_url?: string;
|
||||||
action: string;
|
document_id?: number; // optional, für manuelles Testen
|
||||||
|
action?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('webhook')
|
@Controller('api/webhook')
|
||||||
export class WebhookController {
|
export class WebhookController {
|
||||||
private readonly logger = new Logger(WebhookController.name);
|
private readonly logger = new Logger(WebhookController.name);
|
||||||
|
|
||||||
@Public()
|
constructor(private readonly paperlessProcessor: PaperlessProcessorService) {}
|
||||||
|
|
||||||
|
@UseGuards(ApiKeyGuard)
|
||||||
@Post('paperless')
|
@Post('paperless')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async handlePaperlessWebhook(
|
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
|
||||||
@Body() payload: PaperlessWebhookPayload,
|
const documentId = this.extractDocumentId(payload);
|
||||||
): Promise<{ status: string }> {
|
if (!documentId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`,
|
`Webhook: action=${payload.action}, document=${documentId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Business-Logik für verschiedene Webhook-Events
|
try {
|
||||||
// - document_updated → Felder prüfen, Postprocessing auslösen
|
const result =
|
||||||
// - document_consumed → GoBD-Archivierung prüfen
|
await this.paperlessProcessor.processDocumentById(documentId);
|
||||||
|
return {
|
||||||
|
status: result.processed ? 'processed' : 'skipped',
|
||||||
|
reason: result.reason,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
|
||||||
|
// damit Paperless keine Retry-Schleife startet.
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
|
||||||
|
);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { status: 'received' };
|
/**
|
||||||
|
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
|
||||||
|
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
|
||||||
|
* ID aus dem Paperless-Platzhalter `{{doc_url}}` extrahiert
|
||||||
|
* (z.B. ".../documents/123/").
|
||||||
|
*/
|
||||||
|
private extractDocumentId(payload: PaperlessWebhookPayload): number | null {
|
||||||
|
if (
|
||||||
|
payload?.document_id != null &&
|
||||||
|
!Number.isNaN(Number(payload.document_id))
|
||||||
|
) {
|
||||||
|
return Number(payload.document_id);
|
||||||
|
}
|
||||||
|
if (typeof payload?.doc_url === 'string') {
|
||||||
|
const m = payload.doc_url.match(/\/documents\/(\d+)/);
|
||||||
|
if (m) return Number(m[1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WebhookController } from './webhook.controller';
|
import { WebhookController } from './webhook.controller';
|
||||||
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PaperlessModule, AuthModule],
|
||||||
controllers: [WebhookController],
|
controllers: [WebhookController],
|
||||||
})
|
})
|
||||||
export class WebhookModule {}
|
export class WebhookModule {}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
import { ZahlungService, type ZahlungFilter } from './zahlung.service';
|
||||||
|
|
||||||
|
@Controller('api/zahlung')
|
||||||
|
@RequirePermissions(Permission.VIEW_ZAHLUNG)
|
||||||
|
export class ZahlungController {
|
||||||
|
constructor(private readonly zahlungService: ZahlungService) {}
|
||||||
|
|
||||||
|
@Get('documents')
|
||||||
|
getDocuments(
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '25',
|
||||||
|
@Query('filter') filter: ZahlungFilter = 'ausstehend',
|
||||||
|
) {
|
||||||
|
return this.zahlungService.getZahlungDocuments(
|
||||||
|
parseInt(page, 10),
|
||||||
|
Math.min(parseInt(pageSize, 10), 100),
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('documents/:id/zahlung')
|
||||||
|
setZahlung(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body('value') value: string | null,
|
||||||
|
) {
|
||||||
|
return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('options')
|
||||||
|
getOptions() {
|
||||||
|
return this.zahlungService.getZahlungOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
|
import { ZahlungController } from './zahlung.controller';
|
||||||
|
import { ZahlungService } from './zahlung.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule],
|
||||||
|
controllers: [ZahlungController],
|
||||||
|
providers: [ZahlungService],
|
||||||
|
})
|
||||||
|
export class ZahlungModule {}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
|
||||||
|
const FREIGABE_FIELD_ID = 15;
|
||||||
|
const ZAHLUNG_FIELD_ID = 16;
|
||||||
|
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||||
|
|
||||||
|
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ZahlungService {
|
||||||
|
private readonly logger = new Logger(ZahlungService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DocumentType)
|
||||||
|
private readonly documentTypeRepo: Repository<DocumentType>,
|
||||||
|
private readonly paperlessService: PaperlessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getZahlungDocuments(page: number, pageSize: number, filter: ZahlungFilter) {
|
||||||
|
const docTypes = await this.documentTypeRepo.find({
|
||||||
|
where: { FreigabeErforderlich: true as any },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docTypes.length === 0) {
|
||||||
|
return { count: 0, results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(',');
|
||||||
|
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
page: 1,
|
||||||
|
page_size: 9999,
|
||||||
|
document_type__id__in: docTypeIds,
|
||||||
|
ordering: '-created',
|
||||||
|
truncate_content: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let allDocs: any[];
|
||||||
|
try {
|
||||||
|
const result = await this.paperlessService.getDocuments(params);
|
||||||
|
allDocs = result.results ?? [];
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn('Fehler beim Laden der Belege für Zahlung', err?.message);
|
||||||
|
return { count: 0, results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = this.applyFilter(allDocs, filter);
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return {
|
||||||
|
count: filtered.length,
|
||||||
|
results: filtered.slice(start, start + pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFilter(docs: any[], filter: ZahlungFilter): any[] {
|
||||||
|
if (filter === 'alle') return docs;
|
||||||
|
|
||||||
|
return docs.filter((doc: any) => {
|
||||||
|
const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID);
|
||||||
|
const istFreigegeben = freigabeValue === FREIGABE_WERT_FREIGEGEBEN;
|
||||||
|
|
||||||
|
if (filter === 'freigegeben') return istFreigegeben;
|
||||||
|
|
||||||
|
// 'ausstehend': freigegeben aber noch nicht bezahlt
|
||||||
|
const zahlungValue = this.getCfValue(doc, ZAHLUNG_FIELD_ID);
|
||||||
|
return istFreigegeben && !zahlungValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCfValue(doc: any, fieldId: number): string | null {
|
||||||
|
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId);
|
||||||
|
if (!cf || cf.value === null || cf.value === undefined || cf.value === '') return null;
|
||||||
|
if (typeof cf.value === 'object') {
|
||||||
|
return String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null;
|
||||||
|
}
|
||||||
|
return String(cf.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setZahlung(documentId: number, value: string | null) {
|
||||||
|
const doc = await this.paperlessService.getDocument(documentId);
|
||||||
|
|
||||||
|
const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID);
|
||||||
|
if (freigabeValue !== FREIGABE_WERT_FREIGEGEBEN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Zahlung kann nur für freigegebene Belege gesetzt werden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
||||||
|
const existing = customFields.find((f: any) => f.field === ZAHLUNG_FIELD_ID);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = value;
|
||||||
|
} else if (value !== null && value !== '') {
|
||||||
|
customFields.push({ field: ZAHLUNG_FIELD_ID, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.paperlessService.updateDocument(documentId, { custom_fields: customFields });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getZahlungOptions(): Promise<{ id: string; label: string }[]> {
|
||||||
|
const fields = await this.paperlessService.getCustomFields();
|
||||||
|
const field = (fields as any[]).find((f: any) => f.id === ZAHLUNG_FIELD_ID);
|
||||||
|
if (!field) return [];
|
||||||
|
|
||||||
|
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
||||||
|
return rawOptions
|
||||||
|
.filter((o) => o !== null && o !== undefined && o !== '')
|
||||||
|
.map((o) => {
|
||||||
|
if (typeof o === 'object') {
|
||||||
|
return {
|
||||||
|
id: String(o.id ?? o.value ?? o.label ?? ''),
|
||||||
|
label: String(o.label ?? o.name ?? o.id ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { id: String(o), label: String(o) };
|
||||||
|
})
|
||||||
|
.filter((o) => o.id !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage'));
|
|||||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||||
const FreigabePage = lazy(() => import('./pages/FreigabePage'));
|
const FreigabePage = lazy(() => import('./pages/FreigabePage'));
|
||||||
|
const ZahlungPage = lazy(() => import('./pages/ZahlungPage'));
|
||||||
import { Permission } from './auth/permissions';
|
import { Permission } from './auth/permissions';
|
||||||
|
|
||||||
function UnauthorizedPage() {
|
function UnauthorizedPage() {
|
||||||
@@ -133,6 +134,7 @@ function ThemedApp() {
|
|||||||
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
|
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
|
||||||
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
|
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
|
||||||
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
|
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
|
||||||
|
<Route path="/zahlung" element={<PermissionRoute permission={Permission.VIEW_ZAHLUNG}><ZahlungPage /></PermissionRoute>} />
|
||||||
<Route path="/user-settings" element={<UserSettingsPage />} />
|
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import api from './client';
|
||||||
|
|
||||||
|
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||||
|
|
||||||
|
export interface ZahlungDocument {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
created: string;
|
||||||
|
created_date: string;
|
||||||
|
correspondent: number | null;
|
||||||
|
document_type: number | null;
|
||||||
|
archive_serial_number: number | null;
|
||||||
|
tags: number[];
|
||||||
|
custom_fields: { field: number; value: any }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZahlungOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZahlungResult {
|
||||||
|
count: number;
|
||||||
|
results: ZahlungDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zahlungApi = {
|
||||||
|
getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') =>
|
||||||
|
api
|
||||||
|
.get<ZahlungResult>('/api/zahlung/documents', { params: { page, pageSize, filter } })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
setZahlung: (docId: number, value: string | null) =>
|
||||||
|
api
|
||||||
|
.put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getOptions: () =>
|
||||||
|
api.get<ZahlungOption[]>('/api/zahlung/options').then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ export const Permission = {
|
|||||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||||
|
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = typeof Permission[keyof typeof Permission];
|
export type Permission = typeof Permission[keyof typeof Permission];
|
||||||
@@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
|||||||
permissions.add(Permission.VIEW_SCANNER);
|
permissions.add(Permission.VIEW_SCANNER);
|
||||||
permissions.add(Permission.MANAGE_SETTINGS);
|
permissions.add(Permission.MANAGE_SETTINGS);
|
||||||
permissions.add(Permission.VIEW_FREIGABE);
|
permissions.add(Permission.VIEW_FREIGABE);
|
||||||
|
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||||
return Array.from(permissions);
|
return Array.from(permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
|||||||
if (groups.includes('PM_Freigabe')) {
|
if (groups.includes('PM_Freigabe')) {
|
||||||
permissions.add(Permission.VIEW_FREIGABE);
|
permissions.add(Permission.VIEW_FREIGABE);
|
||||||
}
|
}
|
||||||
|
if (groups.includes('PM_Zahlung')) {
|
||||||
|
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||||
|
}
|
||||||
|
|
||||||
return Array.from(permissions);
|
return Array.from(permissions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
EuroOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useTheme } from '../theme/ThemeContext';
|
import { useTheme } from '../theme/ThemeContext';
|
||||||
@@ -40,6 +41,7 @@ const allMenuItems: MenuItemDef[] = [
|
|||||||
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
|
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
|
||||||
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
|
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
|
||||||
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
|
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
|
||||||
|
{ key: '/zahlung', icon: <EuroOutlined />, label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG },
|
||||||
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
|
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd';
|
||||||
|
import { EuroOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
|
||||||
|
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
const FREIGABE_FIELD_ID = 15;
|
||||||
|
const ZAHLUNG_FIELD_ID = 16;
|
||||||
|
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||||
|
|
||||||
|
export default function ZahlungPage() {
|
||||||
|
const [data, setData] = useState<ZahlungDocument[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [filter, setFilter] = useState<ZahlungFilter>('ausstehend');
|
||||||
|
|
||||||
|
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
|
||||||
|
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
|
||||||
|
const [zahlungOptions, setZahlungOptions] = useState<ZahlungOption[]>([]);
|
||||||
|
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState<ZahlungDocument | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
paperlessApi.getDocumentTypes(),
|
||||||
|
paperlessApi.getCorrespondents(),
|
||||||
|
zahlungApi.getOptions(),
|
||||||
|
]).then(([dts, corrs, opts]) => {
|
||||||
|
setDocTypes(dts);
|
||||||
|
setCorrespondents(corrs);
|
||||||
|
setZahlungOptions(opts);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await zahlungApi.getDocuments(page, pageSize, filter);
|
||||||
|
setData(result.results ?? []);
|
||||||
|
setTotal(result.count ?? 0);
|
||||||
|
} catch {
|
||||||
|
message.error('Fehler beim Laden der Belege');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const getDocTypeName = (id: number | null) => {
|
||||||
|
if (!id) return '—';
|
||||||
|
return docTypes.find((d) => d.id === id)?.name ?? String(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCorrespondentName = (id: number | null) => {
|
||||||
|
if (!id) return '—';
|
||||||
|
return correspondents.find((c) => c.id === id)?.name ?? String(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCfString = (value: any): string | null => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null;
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCfValue = (doc: ZahlungDocument, fieldId: number) => {
|
||||||
|
const cf = doc.custom_fields?.find((f) => f.field === fieldId);
|
||||||
|
return toCfString(cf?.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFreigabeTag = (doc: ZahlungDocument) => {
|
||||||
|
const val = getCfValue(doc, FREIGABE_FIELD_ID);
|
||||||
|
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||||
|
if (val === FREIGABE_WERT_FREIGEGEBEN) return <Tag color="success">Freigegeben</Tag>;
|
||||||
|
return <Tag color="warning">{val}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderZahlungTag = (doc: ZahlungDocument) => {
|
||||||
|
const val = getCfValue(doc, ZAHLUNG_FIELD_ID);
|
||||||
|
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||||
|
const opt = zahlungOptions.find((o) => o.id === val);
|
||||||
|
return <Tag color="blue">{opt?.label ?? val}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const istFreigegeben = (doc: ZahlungDocument) =>
|
||||||
|
getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN;
|
||||||
|
|
||||||
|
const openModal = (doc: ZahlungDocument) => {
|
||||||
|
setSelectedDoc(doc);
|
||||||
|
setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID));
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZahlung = async () => {
|
||||||
|
if (!selectedDoc) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await zahlungApi.setZahlung(selectedDoc.id, selectedValue);
|
||||||
|
message.success('Zahlung gesetzt');
|
||||||
|
setModalOpen(false);
|
||||||
|
setSelectedDoc(null);
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
message.error('Fehler beim Speichern der Zahlung');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<ZahlungDocument> = [
|
||||||
|
{
|
||||||
|
title: 'Dokumenttyp',
|
||||||
|
dataIndex: 'document_type',
|
||||||
|
key: 'doctype',
|
||||||
|
render: getDocTypeName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Titel',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Erstellt',
|
||||||
|
dataIndex: 'created_date',
|
||||||
|
key: 'created',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Absender',
|
||||||
|
dataIndex: 'correspondent',
|
||||||
|
key: 'correspondent',
|
||||||
|
render: getCorrespondentName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Freigabe',
|
||||||
|
key: 'freigabe',
|
||||||
|
width: 130,
|
||||||
|
render: (_, doc) => renderFreigabeTag(doc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zahlung',
|
||||||
|
key: 'zahlung',
|
||||||
|
width: 130,
|
||||||
|
render: (_, doc) => renderZahlungTag(doc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: 150,
|
||||||
|
render: (_, doc) => {
|
||||||
|
const freigegeben = istFreigegeben(doc);
|
||||||
|
return (
|
||||||
|
<Tooltip title={!freigegeben ? 'Beleg muss zuerst freigegeben werden' : undefined}>
|
||||||
|
<Button
|
||||||
|
icon={<EuroOutlined />}
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
disabled={!freigegeben}
|
||||||
|
onClick={() => openModal(doc)}
|
||||||
|
>
|
||||||
|
Zahlung verbuchen
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilter(e.target.value);
|
||||||
|
}}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value="ausstehend">Freigegeben, noch nicht bezahlt</Radio.Button>
|
||||||
|
<Radio.Button value="freigegeben">Alle freigegebenen</Radio.Button>
|
||||||
|
<Radio.Button value="alle">Alle</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table<ZahlungDocument>
|
||||||
|
dataSource={data}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['25', '50', '100'],
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
showTotal: (t) => `${t} Belege`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Zahlung verbuchen"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleZahlung}
|
||||||
|
onCancel={() => { setModalOpen(false); setSelectedDoc(null); }}
|
||||||
|
okText="Speichern"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
confirmLoading={saving}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: 12 }}>
|
||||||
|
<strong>{selectedDoc?.title}</strong>
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="Zahlungsstatus wählen"
|
||||||
|
allowClear
|
||||||
|
value={selectedValue ?? undefined}
|
||||||
|
onChange={(v) => setSelectedValue(v ?? null)}
|
||||||
|
options={zahlungOptions.map((o) => ({ value: o.id, label: o.label }))}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user