44c1d4351f
- 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>
147 lines
4.4 KiB
TypeScript
147 lines
4.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import * as XLSX from 'xlsx';
|
|
import {
|
|
HandelswareFileError,
|
|
MAX_DATA_ROWS,
|
|
parseHandelswareXlsx,
|
|
parseUmsatz,
|
|
} from './handelsware-xlsx';
|
|
|
|
/** Baut eine Arbeitsmappe aus einer Matrix (Zeile 1 = Kopf). */
|
|
function workbook(aoa: unknown[][]): Buffer {
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1');
|
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
|
}
|
|
|
|
describe('parseUmsatz', () => {
|
|
it('uebernimmt Zahlen unveraendert', () => {
|
|
expect(parseUmsatz(12.5)).toBe(12.5);
|
|
expect(parseUmsatz(-3)).toBe(-3);
|
|
});
|
|
|
|
it('liest deutsche Texte', () => {
|
|
expect(parseUmsatz('1.234,56')).toBe(1234.56);
|
|
expect(parseUmsatz('-12,5')).toBe(-12.5);
|
|
expect(parseUmsatz(' 7,00 ')).toBe(7);
|
|
});
|
|
|
|
it('liest Text mit Punkt als Dezimalzeichen', () => {
|
|
expect(parseUmsatz('12.5')).toBe(12.5);
|
|
});
|
|
|
|
it.each(['', 'abc', '1,2,3', '12,5x', '--1', null, undefined, true, NaN])('lehnt %j ab', (v) => {
|
|
expect(parseUmsatz(v)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseHandelswareXlsx', () => {
|
|
it('liest Kopftext aus B1 (Text oder Zahl) und die Zeilen ab Zeile 2', () => {
|
|
const textHeader = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 'Marz'],
|
|
['Kaffee', 12.5],
|
|
]),
|
|
);
|
|
expect(textHeader.headerText).toBe('Marz');
|
|
const numberHeader = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 2025],
|
|
['Kaffee', 1],
|
|
]),
|
|
);
|
|
expect(numberHeader.headerText).toBe('2025');
|
|
});
|
|
|
|
it('liest Zahlen und deutsche Texte als Umsatz und merkt sich die Zeilennummer', () => {
|
|
const r = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 'X'],
|
|
['Kaffee', 12.5],
|
|
['Tee', '1.234,56'],
|
|
['Kakao', '-12,5'],
|
|
]),
|
|
);
|
|
expect(r.rows).toEqual([
|
|
{ line: 2, buchungstext: 'Kaffee', umsatz: 12.5 },
|
|
{ line: 3, buchungstext: 'Tee', umsatz: 1234.56 },
|
|
{ line: 4, buchungstext: 'Kakao', umsatz: -12.5 },
|
|
]);
|
|
expect(r.rowErrors).toEqual([]);
|
|
});
|
|
|
|
it('endet an der ersten Zeile, in der A und B leer sind', () => {
|
|
const r = parseHandelswareXlsx(workbook([['', 'X'], ['Kaffee', 1], [], ['Tee', 2]]));
|
|
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
|
|
});
|
|
|
|
it('meldet nicht numerischen oder leeren Umsatz als Zeilenfehler', () => {
|
|
const r = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 'X'],
|
|
['Kaffee', 'viel'],
|
|
['Tee', null],
|
|
['Kakao', 3],
|
|
]),
|
|
);
|
|
expect(r.rowErrors).toEqual([
|
|
expect.objectContaining({ line: 2, code: 'umsatzInvalid' }),
|
|
expect.objectContaining({ line: 3, code: 'umsatzInvalid' }),
|
|
]);
|
|
expect(r.rows).toHaveLength(1);
|
|
});
|
|
|
|
it('ueberspringt eine Zeile ohne Buchungstext, aber mit Wert (wie die Vorlage)', () => {
|
|
const r = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 'X'],
|
|
['', 5],
|
|
['Kaffee', 1],
|
|
]),
|
|
);
|
|
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
|
|
expect(r.rowErrors).toEqual([]);
|
|
});
|
|
|
|
it('ersetzt Tabulatoren und Zeilenumbrueche im Text durch Leerzeichen', () => {
|
|
const r = parseHandelswareXlsx(
|
|
workbook([
|
|
['', 'Kopf\tText'],
|
|
['Kaf\tfee\nneu', 1],
|
|
]),
|
|
);
|
|
expect(r.headerText).toBe('Kopf Text');
|
|
expect(r.rows[0].buchungstext).toBe('Kaf fee neu');
|
|
});
|
|
|
|
it('wirft invalidFile bei Muelldaten', () => {
|
|
expect(() => parseHandelswareXlsx(Buffer.from('das ist keine Excel-Datei;1;2'))).toThrow(
|
|
HandelswareFileError,
|
|
);
|
|
try {
|
|
parseHandelswareXlsx(Buffer.from([1, 2, 3, 4, 5, 6]));
|
|
expect.unreachable();
|
|
} catch (e) {
|
|
expect((e as HandelswareFileError).code).toBe('invalidFile');
|
|
}
|
|
});
|
|
|
|
it('wirft invalidFile bei kaputtem ZIP', () => {
|
|
const broken = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('kaputt')]);
|
|
expect(() => parseHandelswareXlsx(broken)).toThrow(HandelswareFileError);
|
|
});
|
|
|
|
it('wirft tooManyRows ab mehr als 10 000 Datenzeilen, nicht davor', () => {
|
|
const header = ['', 'X'];
|
|
const make = (n: number) =>
|
|
workbook([header, ...Array.from({ length: n }, (_, i) => [`P${i}`, 1])]);
|
|
expect(parseHandelswareXlsx(make(MAX_DATA_ROWS)).rows).toHaveLength(MAX_DATA_ROWS);
|
|
try {
|
|
parseHandelswareXlsx(make(MAX_DATA_ROWS + 1));
|
|
expect.unreachable();
|
|
} catch (e) {
|
|
expect((e as HandelswareFileError).code).toBe('tooManyRows');
|
|
}
|
|
});
|
|
});
|