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:
2026-10-02 11:38:22 +02:00
parent 1f85277a3b
commit 44c1d4351f
21 changed files with 2186 additions and 1 deletions
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
const csv = (text: string, enc: BufferEncoding = 'utf8') => Buffer.from(text, enc);
describe('parseKontenCsv', () => {
it('liest CRLF und LF gleich', () => {
const a = parseKontenCsv(csv('Kaffee;2010;4000\r\nTee;2011;4001\r\n'), 4711);
const b = parseKontenCsv(csv('Kaffee;2010;4000\nTee;2011;4001'), 4711);
expect(a.accounts).toEqual(b.accounts);
expect(a.accounts).toHaveLength(2);
expect(a.errors).toEqual([]);
});
it('dekodiert Windows-1252 und UTF-8 mit BOM', () => {
expect(parseKontenCsv(csv('Käse;2010;4000', 'latin1'), null).accounts[0].name).toBe('Käse');
const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), csv('Käse;2010;4000')]);
expect(parseKontenCsv(bom, null).accounts[0].name).toBe('Käse');
});
it('ueberspringt eine Kopfzeile, wenn die zweite Spalte keine Zahl ist', () => {
const r = parseKontenCsv(csv('Name;Gegenkonto;Konto\nKaffee;2010;4000'), null);
expect(r.accounts).toEqual([{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 }]);
expect(r.errors).toEqual([]);
});
it('nimmt fuer eine fehlende dritte Spalte das Standard-Erloeskonto', () => {
const r = parseKontenCsv(csv('Kaffee;2010'), 4711);
expect(r.accounts[0].erloeskonto).toBe(4711);
});
it('meldet missingErloeskonto, wenn das Standard-Erloeskonto leer ist', () => {
const r = parseKontenCsv(csv('Kaffee;2010'), null);
expect(r.errors).toEqual([expect.objectContaining({ line: 1, code: 'missingErloeskonto' })]);
});
it('meldet ungueltige Zahlen und leere Namen mit Zeilennummer', () => {
const r = parseKontenCsv(csv('ok;1;2\n;5;6\nx;abc;6\ny;5;-1\nz;0;6'), null);
expect(r.errors.map((e) => [e.line, e.code])).toEqual([
[2, 'nameEmpty'],
[3, 'gegenkontoInvalid'],
[4, 'erloeskontoInvalid'],
[5, 'gegenkontoInvalid'],
]);
});
it('meldet doppelte Namen', () => {
const r = parseKontenCsv(csv('Kaffee;1;2\nKaffee;3;4'), null);
expect(r.errors).toEqual([expect.objectContaining({ line: 2, code: 'duplicateName' })]);
});
it('meldet zu lange Namen', () => {
const r = parseKontenCsv(csv(`${'x'.repeat(121)};1;2`), null);
expect(r.errors[0].code).toBe('nameTooLong');
});
it('erlaubt Semikolons im Namen', () => {
const r = parseKontenCsv(csv('Tee; gruen;2010;4000'), null);
expect(r.accounts[0]).toEqual({ name: 'Tee; gruen', gegenkonto: 2010, erloeskonto: 4000 });
});
});
describe('generateKontenCsv', () => {
it('beginnt mit BOM, nutzt Semikolon und CRLF', () => {
const out = generateKontenCsv([
{ name: 'Käse', gegenkonto: 2010, erloeskonto: 4000 },
{ name: 'Tee', gegenkonto: 2011, erloeskonto: 4001 },
]);
expect(out.startsWith('')).toBe(true);
expect(out.slice(1)).toBe('Käse;2010;4000\r\nTee;2011;4001\r\n');
});
it.each([
'=SUMME(A1)',
'+1',
'-5 % Aktion',
'@cmd',
])('schuetzt %j mit einem Apostroph', (name) => {
const out = generateKontenCsv([{ name, gegenkonto: 1, erloeskonto: 2 }]);
expect(out.slice(1).startsWith(`'${name};`)).toBe(true);
});
it('Export und Import ergeben dieselbe Liste (Rundlauf)', () => {
const accounts = [
{ name: '=1+1', gegenkonto: 2010, erloeskonto: 4000 },
{ name: '-5 % Aktion', gegenkonto: 2011, erloeskonto: 4000 },
{ name: 'Käse', gegenkonto: 2012, erloeskonto: 4001 },
];
const back = parseKontenCsv(Buffer.from(generateKontenCsv(accounts), 'utf8'), null);
expect(back.errors).toEqual([]);
expect(back.accounts).toEqual(accounts);
});
it('leere Liste ergibt nur das BOM', () => {
expect(generateKontenCsv([])).toBe('');
});
});