feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export
- Prisma-Modelle HandelswareDatevConfig und HandelswareKonto mit Zeilenschutz (Migration 20261002130000) - XLSX lesen (B1 Kopf, A/B ab Zeile 2, Zahl oder deutscher Text), Konten zuordnen, TXT erzeugen - neue Konten werden nur beim Export in einer mandantengebundenen Transaktion gespeichert (409 bei geaenderter Liste) - Konten-CSV Import (alles ersetzen) und Export mit Schutz vor Formeleinschleusung - Einstellungen nur fuer Administratoren, statische Routen vor accounts/:id Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import { decodeUploadFilename } from '../accounting/decode-upload-filename';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||
|
||||
const MAX_NEW_ACCOUNTS = 10_000;
|
||||
|
||||
/**
|
||||
* Das Formularfeld `newAccounts` ist ein JSON-Text (Liste der von der Vorschau
|
||||
* gemeldeten neuen Konten). Defensiv gelesen: gueltiges JSON, ein Feld, hoechstens
|
||||
* 10 000 Eintraege, jeder mit Text-`name` und ganzzahligem `gegenkonto`.
|
||||
*/
|
||||
export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto: number }[] {
|
||||
const bad = () =>
|
||||
new BadRequestException({
|
||||
code: 'newAccountsInvalid',
|
||||
message: 'Die Angaben zu den neuen Konten sind ungültig.',
|
||||
});
|
||||
if (raw === undefined || raw === null || raw === '') return [];
|
||||
if (typeof raw !== 'string') throw bad();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw bad();
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length > MAX_NEW_ACCOUNTS) throw bad();
|
||||
return parsed.map((entry) => {
|
||||
if (
|
||||
typeof entry !== 'object' ||
|
||||
entry === null ||
|
||||
typeof (entry as { name?: unknown }).name !== 'string' ||
|
||||
!Number.isInteger((entry as { gegenkonto?: unknown }).gegenkonto)
|
||||
) {
|
||||
throw bad();
|
||||
}
|
||||
const { name, gegenkonto } = entry as { name: string; gegenkonto: number };
|
||||
return { name, gegenkonto };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern
|
||||
* nur Administratoren (T-FM5-02); die Kontenliste pflegen alle Benutzer mit
|
||||
* Modulzugriff.
|
||||
*
|
||||
* REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`,
|
||||
* `accounts/import-csv`) stehen VOR `accounts/:id` — sonst faengt `:id` sie ab
|
||||
* (Unit-Tests sehen das nicht, `handelsware-datev.controller.spec.ts` prueft die
|
||||
* Deklarationsreihenfolge).
|
||||
*/
|
||||
@Controller('modules/handelsware-datev')
|
||||
@UseModule('handelsware-datev')
|
||||
export class HandelswareDatevController {
|
||||
constructor(private readonly service: HandelswareDatevService) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
async getSettings(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getSettings(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) {
|
||||
return this.service.saveSettings(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async preview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.preview(tenantId, {
|
||||
buffer: file.buffer,
|
||||
originalname: decodeUploadFilename(file.originalname),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async export(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('buchungsdatum') buchungsdatum?: string,
|
||||
@Body('newAccounts') newAccounts?: string,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.export(
|
||||
tenantId,
|
||||
{ buffer: file.buffer, originalname: decodeUploadFilename(file.originalname) },
|
||||
typeof buchungsdatum === 'string' ? buchungsdatum.trim() : '',
|
||||
parseNewAccountsField(newAccounts),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
async listAccounts(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.listAccounts(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('accounts')
|
||||
async createAccount(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareAccountDto) {
|
||||
return this.service.createAccount(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Get('accounts/export-csv')
|
||||
async exportAccountsCsv(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.exportAccountsCsv(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('accounts/import-csv')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 1024 * 1024 } }))
|
||||
async importAccountsCsv(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.importAccountsCsv(tenantId, file.buffer);
|
||||
}
|
||||
|
||||
@Put('accounts/:id')
|
||||
async updateAccount(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: HandelswareAccountDto,
|
||||
) {
|
||||
return this.service.updateAccount(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('accounts/:id')
|
||||
async deleteAccount(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.deleteAccount(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user