feat: implement two-step Freigabe→Zahlung workflow
Adds a payment step after document approval: PM_Freigabe approves (Field 15 = "freigegeben"), then PM_Zahlung can mark as paid (Field 16). - Backend: VIEW_ZAHLUNG permission mapped to PM_Zahlung OIDC group - Backend: ZahlungModule with endpoints to list documents by filter (ausstehend/freigegeben/alle), set Field 16, fetch options from Paperless - Backend: setZahlung() throws ForbiddenException if Field 15 ≠ "freigegeben" - Frontend: /zahlung route with 3-way filter, two status columns (Freigabe + Zahlung) - Frontend: "Zahlung verbuchen" button disabled with tooltip for non-approved docs - Frontend: Zahlung menu item with EuroOutlined icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
||||
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
|
||||
import { FreigabeModule } from './freigabe/freigabe.module';
|
||||
import { ZahlungModule } from './zahlung/zahlung.module';
|
||||
import { DailyDigestModule } from './daily-digest/daily-digest.module';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -52,6 +53,7 @@ import * as path from 'path';
|
||||
LabelPrintAgentModule,
|
||||
AgrarmonitorModule,
|
||||
FreigabeModule,
|
||||
ZahlungModule,
|
||||
DailyDigestModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Permission = {
|
||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permission)[keyof typeof Permission];
|
||||
@@ -27,6 +28,7 @@ export function mapGroupsToPermissions(
|
||||
permissions.add(Permission.VIEW_SCANNER);
|
||||
permissions.add(Permission.MANAGE_SETTINGS);
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
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_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
||||
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
|
||||
if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -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 !== '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user