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,70 @@
|
||||
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
|
||||
--
|
||||
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
|
||||
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
|
||||
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
|
||||
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
|
||||
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
|
||||
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
|
||||
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
|
||||
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
|
||||
-- auf dasselbe Gegenkonto laufen).
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
|
||||
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
|
||||
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
|
||||
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
|
||||
-- der diese Tabellen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
-- 1) HandelswareDatevConfig
|
||||
CREATE TABLE "HandelswareDatevConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"erloeskonto" INTEGER,
|
||||
"startGegenkonto" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
|
||||
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
|
||||
|
||||
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
|
||||
-- 2) HandelswareKonto
|
||||
CREATE TABLE "HandelswareKonto" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"gegenkonto" INTEGER NOT NULL,
|
||||
"erloeskonto" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
|
||||
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
|
||||
|
||||
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -361,6 +361,39 @@ model KantineDatevConfig {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// quick-261002-fm5: Handelsware (Modul handelsware-datev). Einstellungen je
|
||||
// Mandant (Singleton wie KantineDatevConfig): Standard-Erloeskonto fuer neue
|
||||
// Konten und Startwert fuer die Gegenkonto-Vergabe bei leerer Kontenliste.
|
||||
// Beide Zahlen haben bewusst KEINEN Standardwert — der Administrator hinterlegt
|
||||
// sie einmalig, bis dahin ist die Verarbeitung gesperrt.
|
||||
model HandelswareDatevConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
erloeskonto Int?
|
||||
startGegenkonto Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// quick-261002-fm5: Kontenliste der Handelsware (Produktname -> Gegenkonto,
|
||||
// Erloeskonto). Der Name ist je Mandant eindeutig; das Gegenkonto bewusst
|
||||
// NICHT (mehrere Produkte duerfen auf dasselbe Gegenkonto laufen, wie in der
|
||||
// Desktop-Vorlage). Keine Relation zu Tenant, Zeilenschutz nach ProxmoxServer.
|
||||
model HandelswareKonto {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
gegenkonto Int
|
||||
erloeskonto Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
||||
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
||||
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeUploadFilename } from './decode-upload-filename';
|
||||
|
||||
describe('decodeUploadFilename', () => {
|
||||
it('laesst ASCII-Namen unveraendert', () => {
|
||||
expect(decodeUploadFilename('HWA 0326 Test.xlsx')).toBe('HWA 0326 Test.xlsx');
|
||||
});
|
||||
|
||||
it('kehrt latin1-gelesenes UTF-8 um', () => {
|
||||
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
|
||||
expect(decodeUploadFilename(mojibake)).toBe('Käse 0326.xlsx');
|
||||
});
|
||||
|
||||
it('laesst einen schon richtigen Namen mit Umlaut stehen', () => {
|
||||
expect(decodeUploadFilename('Käse.xlsx')).toBe('Käse.xlsx');
|
||||
});
|
||||
|
||||
it('laesst Namen mit Zeichen ueber 255 stehen', () => {
|
||||
expect(decodeUploadFilename('Preis €.xlsx')).toBe('Preis €.xlsx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Multer liefert `originalname` je nach Version als latin1-gelesene Bytes: ein
|
||||
* UTF-8-Dateiname wie "Käse.xlsx" kommt als "Käse.xlsx" an. Diese Funktion
|
||||
* kehrt das um, ohne einen schon richtigen Namen zu zerstoeren: ist der Name
|
||||
* nicht aus latin1-Zeichen zusammengesetzt (Zeichen > 255) oder ergibt die
|
||||
* Umkehrung kein gueltiges UTF-8, bleibt er unveraendert.
|
||||
*/
|
||||
export function decodeUploadFilename(name: string): string {
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
if (name.charCodeAt(i) > 255) return name;
|
||||
}
|
||||
const converted = Buffer.from(name, 'latin1').toString('utf8');
|
||||
return converted.includes('�') ? name : converted;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
|
||||
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
@@ -57,6 +58,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
KantineDatevModule,
|
||||
HandelswareDatevModule,
|
||||
CustomModulesModule,
|
||||
RemindersModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsInt, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
|
||||
|
||||
/** Anlegen und Aendern eines Kontos der Kontenliste (quick-261002-fm5). */
|
||||
export class HandelswareAccountDto {
|
||||
@Transform(trim)
|
||||
@IsString({ message: 'Der Name muss angegeben werden' })
|
||||
@Length(1, 120, { message: 'Der Name muss 1 bis 120 Zeichen lang sein' })
|
||||
@Matches(/^[^\t\r\n]*$/, {
|
||||
message: 'Der Name darf keine Tabulatoren oder Zeilenumbrüche enthalten',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@IsInt({ message: 'Das Gegenkonto muss eine ganze Zahl sein' })
|
||||
@Min(1, { message: 'Das Gegenkonto muss mindestens 1 sein' })
|
||||
@Max(999999999, { message: 'Das Gegenkonto darf höchstens 999999999 sein' })
|
||||
gegenkonto!: number;
|
||||
|
||||
@IsInt({ message: 'Das Erlöskonto muss eine ganze Zahl sein' })
|
||||
@Min(1, { message: 'Das Erlöskonto muss mindestens 1 sein' })
|
||||
@Max(999999999, { message: 'Das Erlöskonto darf höchstens 999999999 sein' })
|
||||
erloeskonto!: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Einstellungen der Handelsware (quick-261002-fm5): Standard-Erloeskonto fuer
|
||||
* neue Konten und Startwert fuer die Gegenkonto-Vergabe. Ganze Zahlen,
|
||||
* bewusst ohne Standardwert — der Administrator hinterlegt sie einmalig.
|
||||
*/
|
||||
export class HandelswareSettingsDto {
|
||||
@IsInt({ message: 'Das Standard-Erlöskonto muss eine ganze Zahl sein' })
|
||||
@Min(1, { message: 'Das Standard-Erlöskonto muss mindestens 1 sein' })
|
||||
@Max(999999999, { message: 'Das Standard-Erlöskonto darf höchstens 999999999 sein' })
|
||||
erloeskonto!: number;
|
||||
|
||||
@IsInt({ message: 'Der Startwert Gegenkonto muss eine ganze Zahl sein' })
|
||||
@Min(1, { message: 'Der Startwert Gegenkonto muss mindestens 1 sein' })
|
||||
@Max(999999999, { message: 'Der Startwert Gegenkonto darf höchstens 999999999 sein' })
|
||||
startGegenkonto!: number;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'reflect-metadata';
|
||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||
import { HandelswareDatevController, parseNewAccountsField } from './handelsware-datev.controller';
|
||||
|
||||
const proto = HandelswareDatevController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
preview: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
export: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
listAccounts: vi.fn(async (..._a: unknown[]) => []),
|
||||
createAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
updateAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
deleteAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
exportAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
importAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('HandelswareDatevController — Metadaten', () => {
|
||||
it('haengt an modules/handelsware-datev und traegt @UseModule', () => {
|
||||
expect(Reflect.getMetadata('path', HandelswareDatevController)).toBe(
|
||||
'modules/handelsware-datev',
|
||||
);
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, HandelswareDatevController)).toBe(
|
||||
'handelsware-datev',
|
||||
);
|
||||
});
|
||||
|
||||
it('PUT settings verlangt ADMIN/SUPER_ADMIN, alles andere keine Routen-Rolle', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([
|
||||
Role.ADMIN,
|
||||
Role.SUPER_ADMIN,
|
||||
]);
|
||||
for (const name of [
|
||||
'getSettings',
|
||||
'preview',
|
||||
'export',
|
||||
'listAccounts',
|
||||
'createAccount',
|
||||
'exportAccountsCsv',
|
||||
'importAccountsCsv',
|
||||
'updateAccount',
|
||||
'deleteAccount',
|
||||
]) {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Pfade und Methoden', () => {
|
||||
const route = (name: string) => [
|
||||
Reflect.getMetadata('method', proto[name]),
|
||||
Reflect.getMetadata('path', proto[name]),
|
||||
];
|
||||
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
|
||||
expect(route('getSettings')).toEqual([0, 'settings']);
|
||||
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||
expect(route('preview')).toEqual([1, 'preview']);
|
||||
expect(route('export')).toEqual([1, 'export']);
|
||||
expect(route('listAccounts')).toEqual([0, 'accounts']);
|
||||
expect(route('createAccount')).toEqual([1, 'accounts']);
|
||||
expect(route('exportAccountsCsv')).toEqual([0, 'accounts/export-csv']);
|
||||
expect(route('importAccountsCsv')).toEqual([1, 'accounts/import-csv']);
|
||||
expect(route('updateAccount')).toEqual([2, 'accounts/:id']);
|
||||
expect(route('deleteAccount')).toEqual([3, 'accounts/:id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
it('deklariert alle statischen Konten-Routen vor accounts/:id', () => {
|
||||
const methods = Object.getOwnPropertyNames(HandelswareDatevController.prototype);
|
||||
const idx = (name: string) => {
|
||||
const i = methods.indexOf(name);
|
||||
expect(i, `${name} fehlt`).toBeGreaterThanOrEqual(0);
|
||||
return i;
|
||||
};
|
||||
const firstIdRoute = Math.min(idx('updateAccount'), idx('deleteAccount'));
|
||||
for (const staticRoute of [
|
||||
'listAccounts',
|
||||
'createAccount',
|
||||
'exportAccountsCsv',
|
||||
'importAccountsCsv',
|
||||
]) {
|
||||
expect(idx(staticRoute), `${staticRoute} muss vor :id stehen`).toBeLessThan(firstIdRoute);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevController — Verhalten', () => {
|
||||
it('reicht req.tenantId weiter und decodiert den Dateinamen', async () => {
|
||||
const service = makeService();
|
||||
const c = new HandelswareDatevController(service as any);
|
||||
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
|
||||
const buffer = Buffer.from('x');
|
||||
await c.preview(req('t1'), { buffer, originalname: mojibake } as any);
|
||||
expect(service.preview).toHaveBeenCalledWith('t1', { buffer, originalname: 'Käse 0326.xlsx' });
|
||||
await c.export(
|
||||
req('t1'),
|
||||
{ buffer, originalname: 'a.xlsx' } as any,
|
||||
' 3103 ',
|
||||
'[{"name":"A","gegenkonto":5}]',
|
||||
);
|
||||
expect(service.export).toHaveBeenCalledWith('t1', { buffer, originalname: 'a.xlsx' }, '3103', [
|
||||
{ name: 'A', gegenkonto: 5 },
|
||||
]);
|
||||
await c.listAccounts(req('t1'));
|
||||
await c.deleteAccount(req('t1'), 'x');
|
||||
expect(service.listAccounts).toHaveBeenCalledWith('t1');
|
||||
expect(service.deleteAccount).toHaveBeenCalledWith('t1', 'x');
|
||||
});
|
||||
|
||||
it('antwortet ohne Datei mit 400', async () => {
|
||||
const c = new HandelswareDatevController(makeService() as any);
|
||||
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
await expect(c.importAccountsCsv(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('antwortet ohne Mandantenkontext mit 403', async () => {
|
||||
const c = new HandelswareDatevController(makeService() as any);
|
||||
await expect(c.listAccounts(req(undefined))).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNewAccountsField', () => {
|
||||
it('akzeptiert eine Liste und leere Werte', () => {
|
||||
expect(parseNewAccountsField('[{"name":"A","gegenkonto":1,"erloeskonto":2}]')).toEqual([
|
||||
{ name: 'A', gegenkonto: 1 },
|
||||
]);
|
||||
expect(parseNewAccountsField(undefined)).toEqual([]);
|
||||
expect(parseNewAccountsField('[]')).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'kein json',
|
||||
'{"a":1}',
|
||||
'[1]',
|
||||
'[{"name":1,"gegenkonto":1}]',
|
||||
'[{"name":"A","gegenkonto":"1"}]',
|
||||
'[{"name":"A","gegenkonto":1.5}]',
|
||||
'[null]',
|
||||
])('lehnt %s ab', (raw) => {
|
||||
expect(() => parseNewAccountsField(raw)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('lehnt mehr als 10 000 Eintraege ab', () => {
|
||||
const big = JSON.stringify(
|
||||
Array.from({ length: 10_001 }, (_, i) => ({ name: `n${i}`, gegenkonto: i })),
|
||||
);
|
||||
expect(() => parseNewAccountsField(big)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DTOs', () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
|
||||
it('Einstellungen: nur ganze Zahlen 1 bis 999999999', async () => {
|
||||
const run = (value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype: HandelswareSettingsDto });
|
||||
await expect(run({ erloeskonto: 5, startGegenkonto: 6 })).resolves.toBeDefined();
|
||||
for (const bad of [
|
||||
{ erloeskonto: 0, startGegenkonto: 6 },
|
||||
{ erloeskonto: 5, startGegenkonto: 1000000000 },
|
||||
{ erloeskonto: 1.5, startGegenkonto: 6 },
|
||||
{ erloeskonto: '5', startGegenkonto: 6 },
|
||||
{ erloeskonto: 5 },
|
||||
]) {
|
||||
await expect(run(bad)).rejects.toThrow(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('Konto: Name wird getrimmt, Tabulator im Namen und leerer Name werden abgelehnt', async () => {
|
||||
const run = (value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype: HandelswareAccountDto });
|
||||
const ok: any = await run({ name: ' Kaffee ', gegenkonto: 1, erloeskonto: 2 });
|
||||
expect(ok.name).toBe('Kaffee');
|
||||
await expect(run({ name: 'a\tb', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(run({ name: ' ', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(run({ name: 'x'.repeat(121), gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { HandelswareDatevController } from './handelsware-datev.controller';
|
||||
import { seedHandelswareDatevModule } from './handelsware-datev.seed';
|
||||
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||
|
||||
/**
|
||||
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
|
||||
* DATEV-Buchungsdatei exportieren. Traegt sich beim Start in die
|
||||
* Modulverwaltung ein; aktiviert wird per Marktplatz.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [HandelswareDatevController],
|
||||
providers: [HandelswareDatevService],
|
||||
})
|
||||
export class HandelswareDatevModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(HandelswareDatevModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedHandelswareDatevModule(this.moduleRegistryService);
|
||||
this.logger.log('Handelsware-DATEV module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed handelsware-datev module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Traegt das Modul "Handelsware" in die Modulverwaltung ein (quick-261002-fm5).
|
||||
* `isSystem: true` legt den Eintrag an, aktiviert ihn aber NICHT je Mandant —
|
||||
* der Administrator aktiviert ueber den Marktplatz und erteilt die Freigabe.
|
||||
*/
|
||||
export async function seedHandelswareDatevModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'handelsware-datev',
|
||||
name: 'Handelsware',
|
||||
version: '1.0.0',
|
||||
category: 'accounting',
|
||||
description: {
|
||||
de: 'Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren',
|
||||
en: 'Map merchandise sales from Excel to revenue accounts and export a DATEV booking file',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/**
|
||||
* Zwei Klienten wie in favorites.service.spec.ts: `forTenant` und
|
||||
* `withTenantTransaction` werden auf den Nachbau umgeleitet. Die Transaktion
|
||||
* arbeitet auf einer KOPIE des Bestands und uebernimmt sie nur, wenn die
|
||||
* Funktion ohne Fehler endet — so ist Alles-oder-nichts pruefbar.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
||||
withTenantTransaction: vi.fn((db: any, tenantId: string, fn: (tx: any) => any) =>
|
||||
db.__transaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||
|
||||
interface Konto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
gegenkonto: number;
|
||||
erloeskonto: number;
|
||||
}
|
||||
|
||||
function uniqueError() {
|
||||
return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' });
|
||||
}
|
||||
|
||||
function makeDb(opts: {
|
||||
config?: { erloeskonto: number | null; startGegenkonto: number | null } | null;
|
||||
konten?: Konto[];
|
||||
}) {
|
||||
const state = {
|
||||
config: opts.config === undefined ? { erloeskonto: 4711, startGegenkonto: 2000 } : opts.config,
|
||||
konten: [...(opts.konten ?? [])],
|
||||
writes: [] as string[],
|
||||
seq: 100,
|
||||
};
|
||||
|
||||
function client(tenantId: string, s: { konten: Konto[] }, record: (w: string) => void) {
|
||||
const own = () => s.konten.filter((k) => k.tenantId === tenantId);
|
||||
return {
|
||||
handelswareDatevConfig: {
|
||||
findUnique: vi.fn(async () => state.config),
|
||||
upsert: vi.fn(async ({ create, update }: any) => {
|
||||
record('config.upsert');
|
||||
state.config = { ...(state.config ?? {}), ...update, ...create } as any;
|
||||
return state.config;
|
||||
}),
|
||||
},
|
||||
handelswareKonto: {
|
||||
findMany: vi.fn(async () => [...own()].sort((a, b) => a.name.localeCompare(b.name))),
|
||||
findFirst: vi.fn(async ({ where }: any) => own().find((k) => k.id === where.id) ?? null),
|
||||
create: vi.fn(async ({ data }: any) => {
|
||||
record('konto.create');
|
||||
if (own().some((k) => k.name === data.name)) throw uniqueError();
|
||||
const row = { id: `k${++state.seq}`, ...data };
|
||||
s.konten.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }: any) => {
|
||||
record('konto.update');
|
||||
const row = s.konten.find((k) => k.id === where.id) as Konto;
|
||||
if (data.name !== row.name && own().some((k) => k.name === data.name))
|
||||
throw uniqueError();
|
||||
Object.assign(row, data);
|
||||
return row;
|
||||
}),
|
||||
delete: vi.fn(async ({ where }: any) => {
|
||||
record('konto.delete');
|
||||
s.konten.splice(
|
||||
s.konten.findIndex((k) => k.id === where.id),
|
||||
1,
|
||||
);
|
||||
}),
|
||||
deleteMany: vi.fn(async () => {
|
||||
record('konto.deleteMany');
|
||||
const keep = s.konten.filter((k) => k.tenantId !== tenantId);
|
||||
s.konten.length = 0;
|
||||
s.konten.push(...keep);
|
||||
}),
|
||||
createMany: vi.fn(async ({ data }: any) => {
|
||||
record('konto.createMany');
|
||||
for (const d of data) {
|
||||
if (own().some((k) => k.name === d.name)) throw uniqueError();
|
||||
s.konten.push({ id: `k${++state.seq}`, ...d });
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const db: any = {
|
||||
__state: state,
|
||||
__bound: (tenantId: string) => client(tenantId, state, (w) => state.writes.push(w)),
|
||||
__transaction: async (tenantId: string, fn: (tx: any) => any) => {
|
||||
const copy = { konten: state.konten.map((k) => ({ ...k })) };
|
||||
const txWrites: string[] = [];
|
||||
const result = await fn(client(tenantId, copy, (w) => txWrites.push(w)));
|
||||
state.konten = copy.konten;
|
||||
state.writes.push(...txWrites.map((w) => `tx:${w}`));
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const FILE = {
|
||||
buffer: workbook([
|
||||
['', '2026'],
|
||||
['Kaffee', 12.5],
|
||||
['Kakao', -3],
|
||||
['Kakao', 1],
|
||||
]),
|
||||
originalname: 'HWA 0326 Test.xlsx',
|
||||
};
|
||||
|
||||
const konto = (name: string, gegenkonto: number, erloeskonto = 4000): Konto => ({
|
||||
id: `id-${name}`,
|
||||
tenantId: 't1',
|
||||
name,
|
||||
gegenkonto,
|
||||
erloeskonto,
|
||||
});
|
||||
|
||||
describe('HandelswareDatevService — Vorschau', () => {
|
||||
it('sperrt mit settingsMissing, solange Erloeskonto oder Startwert fehlen', async () => {
|
||||
for (const config of [
|
||||
null,
|
||||
{ erloeskonto: 1, startGegenkonto: null },
|
||||
{ erloeskonto: null, startGegenkonto: 1 },
|
||||
]) {
|
||||
const service = new HandelswareDatevService(makeDb({ config }));
|
||||
const err: any = await service.preview('t1', FILE).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse().code).toBe('settingsMissing');
|
||||
}
|
||||
});
|
||||
|
||||
it('liefert Zeilen, neue Konten, Datumsvorschlag und Dateinamen — und schreibt nichts', async () => {
|
||||
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||
const res = await new HandelswareDatevService(db).preview('t1', FILE);
|
||||
expect(res.headerText).toBe('2026');
|
||||
expect(res.suggestedBuchungsdatum).toBe('3103');
|
||||
expect(res.exportFilename).toBe('HWA_0326.txt');
|
||||
expect(res.rows.map((r) => [r.buchungstext, r.gegenkonto, r.isNew])).toEqual([
|
||||
['Kaffee', 2010, false],
|
||||
['Kakao', 2011, true],
|
||||
['Kakao', 2011, true],
|
||||
]);
|
||||
expect(res.newAccounts).toEqual([{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }]);
|
||||
expect(db.__state.writes).toEqual([]);
|
||||
expect(db.__state.konten).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('meldet eine kaputte Datei als 400 invalidFile', async () => {
|
||||
const service = new HandelswareDatevService(makeDb({}));
|
||||
const err: any = await service
|
||||
.preview('t1', { buffer: Buffer.from('xx'), originalname: 'a.xlsx' })
|
||||
.catch((e) => e);
|
||||
expect(err.getResponse().code).toBe('invalidFile');
|
||||
});
|
||||
|
||||
it('gibt Zeilenfehler zurueck statt zu werfen', async () => {
|
||||
const buffer = workbook([
|
||||
['', 'X'],
|
||||
['Kaffee', 'viel'],
|
||||
]);
|
||||
const res = await new HandelswareDatevService(makeDb({})).preview('t1', {
|
||||
buffer,
|
||||
originalname: 'a.xlsx',
|
||||
});
|
||||
expect(res.rowErrors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevService — Export', () => {
|
||||
const submitted = [{ name: 'Kakao', gegenkonto: 2011 }];
|
||||
|
||||
it('speichert die neuen Konten erst beim Export, in der Transaktion, und liefert die TXT', async () => {
|
||||
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||
const res = await new HandelswareDatevService(db).export('t1', FILE, '3103', submitted);
|
||||
expect(res.createdCount).toBe(1);
|
||||
expect(res.filename).toBe('HWA_0326.txt');
|
||||
expect(res.mimeType).toBe('text/plain;charset=utf-8');
|
||||
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe(
|
||||
'\t2026\t\t\t\t\r\nKaffee\t12.50\tS\t2010\t3103\t4000\r\nKakao\t3.00\tH\t2011\t3103\t4711\r\nKakao\t1.00\tS\t2011\t3103\t4711\r\n',
|
||||
);
|
||||
expect(db.__state.konten.map((k: Konto) => k.name).sort()).toEqual(['Kaffee', 'Kakao']);
|
||||
expect(db.__state.writes).toEqual(['tx:konto.createMany']);
|
||||
});
|
||||
|
||||
it('409 accountsChanged, wenn sich die Liste seit der Vorschau geaendert hat — nichts gespeichert', async () => {
|
||||
// Inzwischen gibt es schon ein Konto mit Gegenkonto 2011 -> neues Konto waere 2012.
|
||||
const db = makeDb({ konten: [konto('Kaffee', 2010), konto('Saft', 2011)] });
|
||||
const err: any = await new HandelswareDatevService(db)
|
||||
.export('t1', FILE, '3103', submitted)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect(err.getResponse().code).toBe('accountsChanged');
|
||||
expect(err.getResponse().message).toBe(
|
||||
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
|
||||
);
|
||||
expect(db.__state.konten).toHaveLength(2);
|
||||
expect(db.__state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('409, wenn der Client ein neues Konto verschweigt oder erfindet', async () => {
|
||||
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||
const service = new HandelswareDatevService(db);
|
||||
await expect(service.export('t1', FILE, '3103', [])).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(
|
||||
service.export('t1', FILE, '3103', [...submitted, { name: 'Erfunden', gegenkonto: 9 }]),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(db.__state.konten).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Wettlauf: Eindeutigkeit (P2002) beim Anlegen wird zu 409', async () => {
|
||||
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||
const original = db.__transaction;
|
||||
// Ein zweiter Export hat "Kakao" zwischen Berechnung und Speichern angelegt.
|
||||
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
|
||||
original(tenantId, (tx: any) => {
|
||||
tx.handelswareKonto.createMany = async () => {
|
||||
throw uniqueError();
|
||||
};
|
||||
return fn(tx);
|
||||
});
|
||||
const err: any = await new HandelswareDatevService(db)
|
||||
.export('t1', FILE, '3103', submitted)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect(err.getResponse().code).toBe('accountsChanged');
|
||||
});
|
||||
|
||||
it('400 bei ungueltigem Buchungsdatum', async () => {
|
||||
const err: any = await new HandelswareDatevService(makeDb({}))
|
||||
.export('t1', FILE, '3102', submitted)
|
||||
.catch((e) => e);
|
||||
expect(err.getResponse().code).toBe('buchungsdatumInvalid');
|
||||
});
|
||||
|
||||
it('400 bei Zeilenfehlern', async () => {
|
||||
const buffer = workbook([
|
||||
['', 'X'],
|
||||
['Kaffee', 'viel'],
|
||||
]);
|
||||
const err: any = await new HandelswareDatevService(makeDb({}))
|
||||
.export('t1', { buffer, originalname: 'a 0326.xlsx' }, '3103', [])
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse().code).toBe('rowErrors');
|
||||
});
|
||||
|
||||
it('400 settingsMissing beim Export ohne Einstellungen', async () => {
|
||||
const err: any = await new HandelswareDatevService(makeDb({ config: null }))
|
||||
.export('t1', FILE, '3103', submitted)
|
||||
.catch((e) => e);
|
||||
expect(err.getResponse().code).toBe('settingsMissing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevService — Kontenliste', () => {
|
||||
it('legt an, sortiert nach Name und meldet doppelte Namen als 409 nameTaken', async () => {
|
||||
const db = makeDb({});
|
||||
const service = new HandelswareDatevService(db);
|
||||
await service.createAccount('t1', { name: 'Tee', gegenkonto: 2, erloeskonto: 3 });
|
||||
await service.createAccount('t1', { name: 'Kaffee', gegenkonto: 4, erloeskonto: 5 });
|
||||
expect((await service.listAccounts('t1')).map((a) => a.name)).toEqual(['Kaffee', 'Tee']);
|
||||
const err: any = await service
|
||||
.createAccount('t1', { name: 'Tee', gegenkonto: 9, erloeskonto: 9 })
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect(err.getResponse().code).toBe('nameTaken');
|
||||
});
|
||||
|
||||
it('aendert ein Konto; Namensklau ist 409; unbekannte id ist 404', async () => {
|
||||
const db = makeDb({ konten: [konto('A', 1), konto('B', 2)] });
|
||||
const service = new HandelswareDatevService(db);
|
||||
const updated = await service.updateAccount('t1', 'id-A', {
|
||||
name: 'A2',
|
||||
gegenkonto: 7,
|
||||
erloeskonto: 8,
|
||||
});
|
||||
expect(updated).toMatchObject({ name: 'A2', gegenkonto: 7 });
|
||||
await expect(
|
||||
service.updateAccount('t1', 'id-A', { name: 'B', gegenkonto: 1, erloeskonto: 1 }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(
|
||||
service.updateAccount('t1', 'nope', { name: 'X', gegenkonto: 1, erloeskonto: 1 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('loescht ein Konto; unbekannte id ist 404', async () => {
|
||||
const db = makeDb({ konten: [konto('A', 1)] });
|
||||
const service = new HandelswareDatevService(db);
|
||||
await expect(service.deleteAccount('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.deleteAccount('t1', 'id-A')).resolves.toEqual({ deleted: true });
|
||||
expect(db.__state.konten).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('CSV-Import ersetzt die Liste in EINER Transaktion (deleteMany + createMany)', async () => {
|
||||
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||
const res = await new HandelswareDatevService(db).importAccountsCsv(
|
||||
't1',
|
||||
Buffer.from('Name;Gegenkonto;Konto\nNeu1;10;20\nNeu2;11'),
|
||||
);
|
||||
expect(res).toEqual({ count: 2 });
|
||||
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Neu1', 'Neu2']);
|
||||
expect(db.__state.konten[1].erloeskonto).toBe(4711);
|
||||
expect(db.__state.writes).toEqual(['tx:konto.deleteMany', 'tx:konto.createMany']);
|
||||
});
|
||||
|
||||
it('CSV-Import mit einer ungueltigen Zeile aendert nichts', async () => {
|
||||
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||
const err: any = await new HandelswareDatevService(db)
|
||||
.importAccountsCsv('t1', Buffer.from('Neu1;10;20\nNeu2;abc;20'))
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse().code).toBe('csvErrors');
|
||||
expect(err.getResponse().errors).toHaveLength(1);
|
||||
expect(db.__state.writes).toEqual([]);
|
||||
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
|
||||
});
|
||||
|
||||
it('CSV-Import: scheitert das Schreiben mittendrin, bleibt die alte Liste', async () => {
|
||||
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||
const original = db.__transaction;
|
||||
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
|
||||
original(tenantId, (tx: any) => {
|
||||
tx.handelswareKonto.createMany = async () => {
|
||||
throw new Error('Datenbank weg');
|
||||
};
|
||||
return fn(tx);
|
||||
});
|
||||
await expect(
|
||||
new HandelswareDatevService(db).importAccountsCsv('t1', Buffer.from('Neu;1;2')),
|
||||
).rejects.toThrow('Datenbank weg');
|
||||
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
|
||||
});
|
||||
|
||||
it('CSV-Export liefert BOM-CSV als Base64', async () => {
|
||||
const db = makeDb({ konten: [konto('Käse', 1, 2)] });
|
||||
const res = await new HandelswareDatevService(db).exportAccountsCsv('t1');
|
||||
expect(res.filename).toBe('Konten.csv');
|
||||
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe('Käse;1;2\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevService — Einstellungen', () => {
|
||||
it('configured nur, wenn beide Zahlen gesetzt sind', async () => {
|
||||
expect(await new HandelswareDatevService(makeDb({ config: null })).getSettings('t1')).toEqual({
|
||||
erloeskonto: null,
|
||||
startGegenkonto: null,
|
||||
configured: false,
|
||||
});
|
||||
expect((await new HandelswareDatevService(makeDb({})).getSettings('t1')).configured).toBe(true);
|
||||
});
|
||||
|
||||
it('speichert per upsert', async () => {
|
||||
const db = makeDb({ config: null });
|
||||
const res = await new HandelswareDatevService(db).saveSettings('t1', {
|
||||
erloeskonto: 5,
|
||||
startGegenkonto: 6,
|
||||
});
|
||||
expect(res).toEqual({ erloeskonto: 5, startGegenkonto: 6, configured: true });
|
||||
expect(db.__state.writes).toEqual(['config.upsert']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import type { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||
import type { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||
import type {
|
||||
AccountEntry,
|
||||
FileResponse,
|
||||
HandelswareSettings,
|
||||
HandelswareSettingsReady,
|
||||
NewAccount,
|
||||
PreviewResult,
|
||||
} from './handelsware-datev.types';
|
||||
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
|
||||
import {
|
||||
assignAccounts,
|
||||
calculateBuchungsdatum,
|
||||
generateTxt,
|
||||
getExportFilename,
|
||||
isValidBuchungsdatum,
|
||||
} from './handelsware-transform';
|
||||
import { HandelswareFileError, parseHandelswareXlsx } from './handelsware-xlsx';
|
||||
|
||||
export interface UploadedWorkbook {
|
||||
buffer: Buffer;
|
||||
/** Bereits als UTF-8 dekodierter Dateiname. */
|
||||
originalname: string;
|
||||
}
|
||||
|
||||
export interface HandelswareSettingsResponse extends HandelswareSettings {
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface AccountResponse extends AccountEntry {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const MAX_ACCOUNTS_IMPORT = 10_000;
|
||||
|
||||
const SETTINGS_MISSING = {
|
||||
code: 'settingsMissing',
|
||||
message: 'Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.',
|
||||
};
|
||||
|
||||
const ACCOUNTS_CHANGED = {
|
||||
code: 'accountsChanged',
|
||||
message:
|
||||
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
|
||||
};
|
||||
|
||||
const NAME_TAKEN = {
|
||||
code: 'nameTaken',
|
||||
message: 'Ein Konto mit diesem Namen gibt es bereits.',
|
||||
};
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
|
||||
function isReady(settings: HandelswareSettings | null): settings is HandelswareSettingsReady {
|
||||
return Boolean(settings && settings.erloeskonto !== null && settings.startGegenkonto !== null);
|
||||
}
|
||||
|
||||
/** Gleichheit der berechneten und der von der Vorschau gemeldeten neuen Konten (Name + Gegenkonto). */
|
||||
function sameNewAccounts(
|
||||
computed: NewAccount[],
|
||||
submitted: { name: string; gegenkonto: number }[],
|
||||
): boolean {
|
||||
if (computed.length !== submitted.length) return false;
|
||||
const byName = new Map(submitted.map((a) => [a.name, a.gegenkonto]));
|
||||
if (byName.size !== submitted.length) return false;
|
||||
return computed.every((a) => byName.get(a.name) === a.gegenkonto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handelsware (quick-261002-fm5): Excel-Umsaetze den Erloeskonten zuordnen,
|
||||
* Kontenliste je Mandant pflegen, TXT fuer DATEV erzeugen. Alle
|
||||
* Datenbankzugriffe mandantengebunden (`forTenant` bzw. eine gemeinsame
|
||||
* `withTenantTransaction`); neue Konten werden NUR beim Export gespeichert,
|
||||
* die Vorschau schreibt nie.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HandelswareDatevService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// --- Einstellungen -------------------------------------------------------
|
||||
|
||||
async getSettings(tenantId: string): Promise<HandelswareSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.handelswareDatevConfig.findUnique({ where: { tenantId } });
|
||||
const settings: HandelswareSettings = {
|
||||
erloeskonto: row?.erloeskonto ?? null,
|
||||
startGegenkonto: row?.startGegenkonto ?? null,
|
||||
};
|
||||
return { ...settings, configured: isReady(settings) };
|
||||
}
|
||||
|
||||
async saveSettings(
|
||||
tenantId: string,
|
||||
dto: HandelswareSettingsDto,
|
||||
): Promise<HandelswareSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const data = { erloeskonto: dto.erloeskonto, startGegenkonto: dto.startGegenkonto };
|
||||
await tenantPrisma.handelswareDatevConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return { ...data, configured: true };
|
||||
}
|
||||
|
||||
// --- Kontenliste ---------------------------------------------------------
|
||||
|
||||
async listAccounts(tenantId: string): Promise<AccountResponse[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = await tenantPrisma.handelswareKonto.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
gegenkonto: r.gegenkonto,
|
||||
erloeskonto: r.erloeskonto,
|
||||
}));
|
||||
}
|
||||
|
||||
async createAccount(tenantId: string, dto: HandelswareAccountDto): Promise<AccountResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
try {
|
||||
const row = await tenantPrisma.handelswareKonto.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
gegenkonto: dto.gegenkonto,
|
||||
erloeskonto: dto.erloeskonto,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
gegenkonto: row.gegenkonto,
|
||||
erloeskonto: row.erloeskonto,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: HandelswareAccountDto,
|
||||
): Promise<AccountResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException('Konto nicht gefunden');
|
||||
try {
|
||||
const row = await tenantPrisma.handelswareKonto.update({
|
||||
where: { id },
|
||||
data: { name: dto.name, gegenkonto: dto.gegenkonto, erloeskonto: dto.erloeskonto },
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
gegenkonto: row.gegenkonto,
|
||||
erloeskonto: row.erloeskonto,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(tenantId: string, id: string): Promise<{ deleted: true }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException('Konto nicht gefunden');
|
||||
await tenantPrisma.handelswareKonto.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async exportAccountsCsv(tenantId: string): Promise<FileResponse> {
|
||||
const accounts = await this.listAccounts(tenantId);
|
||||
return {
|
||||
filename: 'Konten.csv',
|
||||
content: Buffer.from(generateKontenCsv(accounts), 'utf8').toString('base64'),
|
||||
mimeType: 'text/csv;charset=utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
/** Ersetzt die gesamte Kontenliste durch den CSV-Inhalt — alles oder nichts. */
|
||||
async importAccountsCsv(tenantId: string, buffer: Buffer): Promise<{ count: number }> {
|
||||
const settings = await this.getSettings(tenantId);
|
||||
const { accounts, errors } = parseKontenCsv(buffer, settings.erloeskonto);
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'csvErrors',
|
||||
message: 'Die CSV-Datei enthält Fehler. Es wurde nichts geändert.',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
if (accounts.length > MAX_ACCOUNTS_IMPORT) {
|
||||
throw new BadRequestException({
|
||||
code: 'tooManyRows',
|
||||
message: `Die Datei enthält mehr als ${MAX_ACCOUNTS_IMPORT} Konten.`,
|
||||
});
|
||||
}
|
||||
|
||||
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.handelswareKonto.deleteMany({ where: { tenantId } });
|
||||
if (accounts.length > 0) {
|
||||
await tx.handelswareKonto.createMany({
|
||||
data: accounts.map((a) => ({ tenantId, ...a })),
|
||||
});
|
||||
}
|
||||
});
|
||||
return { count: accounts.length };
|
||||
}
|
||||
|
||||
// --- Import / Export -----------------------------------------------------
|
||||
|
||||
private parseWorkbook(buffer: Buffer) {
|
||||
try {
|
||||
return parseHandelswareXlsx(buffer);
|
||||
} catch (error) {
|
||||
if (error instanceof HandelswareFileError) {
|
||||
throw new BadRequestException({ code: error.code, message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Vorschau: liest die Datei, ordnet Konten zu — schreibt NICHTS in die Datenbank. */
|
||||
async preview(tenantId: string, file: UploadedWorkbook): Promise<PreviewResult> {
|
||||
const settings = await this.getSettings(tenantId);
|
||||
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
|
||||
|
||||
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
|
||||
const accounts = await this.listAccounts(tenantId);
|
||||
const { rows, newAccounts } = assignAccounts(importRows, accounts, settings);
|
||||
|
||||
return {
|
||||
headerText,
|
||||
suggestedBuchungsdatum: calculateBuchungsdatum(file.originalname),
|
||||
exportFilename: getExportFilename(file.originalname),
|
||||
rows,
|
||||
newAccounts,
|
||||
rowErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export: berechnet die Zuordnung INNERHALB einer mandantengebundenen
|
||||
* Transaktion neu und vergleicht mit den neuen Konten, die die Vorschau
|
||||
* gemeldet hat (409 `accountsChanged`, wenn die Liste sich inzwischen
|
||||
* geaendert hat). Nur dann werden die neuen Konten gespeichert — in derselben
|
||||
* Transaktion, in der die Datei erzeugt wird.
|
||||
*/
|
||||
async export(
|
||||
tenantId: string,
|
||||
file: UploadedWorkbook,
|
||||
buchungsdatum: string,
|
||||
submittedNewAccounts: { name: string; gegenkonto: number }[],
|
||||
): Promise<FileResponse & { createdCount: number }> {
|
||||
if (!isValidBuchungsdatum(buchungsdatum)) {
|
||||
throw new BadRequestException({
|
||||
code: 'buchungsdatumInvalid',
|
||||
message: 'Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.',
|
||||
});
|
||||
}
|
||||
|
||||
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
|
||||
if (rowErrors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'rowErrors',
|
||||
message: 'Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.',
|
||||
errors: rowErrors,
|
||||
});
|
||||
}
|
||||
if (importRows.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'noRows',
|
||||
message: 'Die Datei enthält keine Datenzeilen.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const config = await tx.handelswareDatevConfig.findUnique({ where: { tenantId } });
|
||||
const settings: HandelswareSettings = {
|
||||
erloeskonto: config?.erloeskonto ?? null,
|
||||
startGegenkonto: config?.startGegenkonto ?? null,
|
||||
};
|
||||
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
|
||||
|
||||
const stored: AccountEntry[] = await tx.handelswareKonto.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const { rows, newAccounts } = assignAccounts(importRows, stored, settings);
|
||||
|
||||
if (!sameNewAccounts(newAccounts, submittedNewAccounts)) {
|
||||
throw new ConflictException(ACCOUNTS_CHANGED);
|
||||
}
|
||||
|
||||
if (newAccounts.length > 0) {
|
||||
await tx.handelswareKonto.createMany({
|
||||
data: newAccounts.map((a) => ({
|
||||
tenantId,
|
||||
name: a.name,
|
||||
gegenkonto: a.gegenkonto,
|
||||
erloeskonto: a.erloeskonto,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const txt = generateTxt(headerText, rows, buchungsdatum);
|
||||
return {
|
||||
filename: getExportFilename(file.originalname),
|
||||
content: Buffer.from(txt, 'utf8').toString('base64'),
|
||||
mimeType: 'text/plain;charset=utf-8',
|
||||
createdCount: newAccounts.length,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Zwei Exporte gleichzeitig: die Eindeutigkeit (Mandant, Name) faengt den Wettlauf.
|
||||
if (isUniqueViolation(error)) throw new ConflictException(ACCOUNTS_CHANGED);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Typen des Moduls Handelsware (quick-261002-fm5): Excel-Umsaetze den
|
||||
* Erloeskonten zuordnen und als DATEV-Buchungsdatei (TXT) exportieren.
|
||||
*/
|
||||
|
||||
/** Eine Zeile aus der hochgeladenen Excel-Datei. */
|
||||
export interface ImportRow {
|
||||
/** Zeile in der Excel-Datei (1-basiert) */
|
||||
line: number;
|
||||
buchungstext: string;
|
||||
umsatz: number;
|
||||
}
|
||||
|
||||
export type RowErrorCode = 'umsatzInvalid';
|
||||
|
||||
export interface RowError {
|
||||
line: number;
|
||||
code: RowErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Vorschauzeile (ohne Buchungsdatum — das tragen Vorschau und Export einmal fuer alle). */
|
||||
export interface PreviewRow {
|
||||
line: number;
|
||||
buchungstext: string;
|
||||
/** Betrag als Text, Punkt, genau 2 Nachkommastellen, ohne Vorzeichen */
|
||||
umsatz: string;
|
||||
sollHaben: 'S' | 'H';
|
||||
gegenkonto: number;
|
||||
erloeskonto: number;
|
||||
/** true, wenn das Konto fuer dieses Produkt neu vergeben wurde */
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
name: string;
|
||||
gegenkonto: number;
|
||||
erloeskonto: number;
|
||||
}
|
||||
|
||||
export type NewAccount = AccountEntry;
|
||||
|
||||
/** Einstellungen des Mandanten, wie in der Datenbank (leer = noch nicht hinterlegt). */
|
||||
export interface HandelswareSettings {
|
||||
erloeskonto: number | null;
|
||||
startGegenkonto: number | null;
|
||||
}
|
||||
|
||||
/** Vollstaendige Einstellungen — Voraussetzung fuer jede Verarbeitung. */
|
||||
export interface HandelswareSettingsReady {
|
||||
erloeskonto: number;
|
||||
startGegenkonto: number;
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
filename: string;
|
||||
/** Base64 */
|
||||
content: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
headerText: string;
|
||||
suggestedBuchungsdatum: string;
|
||||
exportFilename: string;
|
||||
rows: PreviewRow[];
|
||||
newAccounts: NewAccount[];
|
||||
rowErrors: RowError[];
|
||||
}
|
||||
|
||||
export type KontenCsvErrorCode =
|
||||
| 'nameEmpty'
|
||||
| 'nameTooLong'
|
||||
| 'gegenkontoInvalid'
|
||||
| 'erloeskontoInvalid'
|
||||
| 'missingErloeskonto'
|
||||
| 'duplicateName';
|
||||
|
||||
export interface KontenCsvError {
|
||||
line: number;
|
||||
code: KontenCsvErrorCode;
|
||||
message: string;
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { decodeCsvText } from '../accounting/decode-csv-text';
|
||||
import type { AccountEntry, KontenCsvError } from './handelsware-datev.types';
|
||||
|
||||
export const MAX_NAME_LENGTH = 120;
|
||||
const MAX_ACCOUNT_NUMBER = 999_999_999;
|
||||
/** Zeichen, mit denen Excel einen Zelltext als Formel liest. */
|
||||
const FORMULA_TRIGGERS = ['=', '+', '-', '@'];
|
||||
|
||||
function parseAccountNumber(value: string): number | null {
|
||||
if (!/^\d{1,9}$/.test(value)) return null;
|
||||
const num = Number.parseInt(value, 10);
|
||||
return num >= 1 && num <= MAX_ACCOUNT_NUMBER ? num : null;
|
||||
}
|
||||
|
||||
/** Entfernt den Schutz-Apostroph, den `generateKontenCsv` vor Formelzeichen setzt. */
|
||||
function stripFormulaGuard(name: string): string {
|
||||
if (name.length > 1 && name[0] === "'" && FORMULA_TRIGGERS.includes(name[1])) {
|
||||
return name.slice(1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Konten-CSV (Semikolon): Name;Gegenkonto;Konto. UTF-8 oder
|
||||
* Windows-1252, CRLF oder LF. Eine Kopfzeile (zweite Spalte keine Zahl) wird
|
||||
* uebersprungen. Fehlt die dritte Spalte, gilt das Standard-Erloeskonto. Der
|
||||
* Name steht vor den letzten beiden Semikolons, darf also selbst Semikolons
|
||||
* enthalten. Es wird alles geprueft; bei Fehlern ist `accounts` unbrauchbar.
|
||||
*/
|
||||
export function parseKontenCsv(
|
||||
buffer: Buffer,
|
||||
defaultErloeskonto: number | null,
|
||||
): { accounts: AccountEntry[]; errors: KontenCsvError[] } {
|
||||
const accounts: AccountEntry[] = [];
|
||||
const errors: KontenCsvError[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const lines = decodeCsvText(buffer).split(/\r?\n/);
|
||||
let firstContentLine = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
if (raw.trim() === '') continue;
|
||||
const line = i + 1;
|
||||
const parts = raw.split(';');
|
||||
|
||||
let name: string;
|
||||
let gegenText: string;
|
||||
let kontoText: string;
|
||||
if (parts.length >= 3) {
|
||||
kontoText = parts[parts.length - 1].trim();
|
||||
gegenText = parts[parts.length - 2].trim();
|
||||
name = parts.slice(0, -2).join(';').trim();
|
||||
} else {
|
||||
name = (parts[0] ?? '').trim();
|
||||
gegenText = (parts[1] ?? '').trim();
|
||||
kontoText = '';
|
||||
}
|
||||
|
||||
// Kopfzeile: nur als allererste Inhaltszeile, wenn die zweite Spalte keine Zahl ist.
|
||||
if (firstContentLine) {
|
||||
firstContentLine = false;
|
||||
if (!/^\d+$/.test(gegenText)) continue;
|
||||
}
|
||||
|
||||
name = stripFormulaGuard(name);
|
||||
|
||||
if (name === '') {
|
||||
errors.push({ line, code: 'nameEmpty', message: 'Der Name fehlt.' });
|
||||
continue;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
errors.push({
|
||||
line,
|
||||
code: 'nameTooLong',
|
||||
message: `Der Name ist länger als ${MAX_NAME_LENGTH} Zeichen.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const gegenkonto = parseAccountNumber(gegenText);
|
||||
if (gegenkonto === null) {
|
||||
errors.push({
|
||||
line,
|
||||
code: 'gegenkontoInvalid',
|
||||
message: 'Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let erloeskonto: number | null;
|
||||
if (kontoText === '') {
|
||||
if (defaultErloeskonto === null) {
|
||||
errors.push({
|
||||
line,
|
||||
code: 'missingErloeskonto',
|
||||
message: 'Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
erloeskonto = defaultErloeskonto;
|
||||
} else {
|
||||
erloeskonto = parseAccountNumber(kontoText);
|
||||
if (erloeskonto === null) {
|
||||
errors.push({
|
||||
line,
|
||||
code: 'erloeskontoInvalid',
|
||||
message: 'Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.has(name)) {
|
||||
errors.push({
|
||||
line,
|
||||
code: 'duplicateName',
|
||||
message: 'Der Name kommt in der Datei mehrfach vor.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seen.add(name);
|
||||
accounts.push({ name, gegenkonto, erloeskonto });
|
||||
}
|
||||
|
||||
return { accounts, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Konten-CSV fuer Excel: UTF-8 mit BOM (damit Umlaute stimmen), Semikolon,
|
||||
* CRLF. Namen, die mit Formelzeichen beginnen, bekommen einen Apostroph
|
||||
* davor (Schutz vor Formeleinschleusung, T-FM5-07); `parseKontenCsv` nimmt ihn
|
||||
* wieder weg.
|
||||
*/
|
||||
export function generateKontenCsv(accounts: AccountEntry[]): string {
|
||||
const lines = accounts.map((a) => {
|
||||
const name = FORMULA_TRIGGERS.includes(a.name[0] ?? '') ? `'${a.name}` : a.name;
|
||||
return `${name};${a.gegenkonto};${a.erloeskonto}`;
|
||||
});
|
||||
return `${lines.join('\r\n')}${lines.length > 0 ? '\r\n' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportRow } from './handelsware-datev.types';
|
||||
import {
|
||||
assignAccounts,
|
||||
calculateBuchungsdatum,
|
||||
formatAmount,
|
||||
generateTxt,
|
||||
getExportFilename,
|
||||
isValidBuchungsdatum,
|
||||
} from './handelsware-transform';
|
||||
|
||||
// Neutrale Testwerte, keine Zahlen aus einem echten Kontenrahmen.
|
||||
const SETTINGS = { erloeskonto: 4711, startGegenkonto: 2000 };
|
||||
|
||||
const row = (buchungstext: string, umsatz: number, line = 2): ImportRow => ({
|
||||
line,
|
||||
buchungstext,
|
||||
umsatz,
|
||||
});
|
||||
|
||||
describe('calculateBuchungsdatum', () => {
|
||||
it.each([
|
||||
['HWA 0326 Test.xlsx', '3103'],
|
||||
['HWA 0226.xlsx', '2802'],
|
||||
['x 0228.xlsx', '2902'],
|
||||
['HWA 0426.xlsx', '3004'],
|
||||
['HWA 0026.xlsx', ''],
|
||||
['HWA 1326.xlsx', ''],
|
||||
['HWA.xlsx', ''],
|
||||
['HWA 12.xlsx', ''],
|
||||
])('%s -> %j', (name, expected) => {
|
||||
expect(calculateBuchungsdatum(name)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBuchungsdatum', () => {
|
||||
it.each(['3103', '0101', '2902', '3012'])('akzeptiert %s', (v) => {
|
||||
expect(isValidBuchungsdatum(v)).toBe(true);
|
||||
});
|
||||
it.each([
|
||||
'3102',
|
||||
'0013',
|
||||
'0000',
|
||||
'3204',
|
||||
'abc',
|
||||
'310',
|
||||
'31033',
|
||||
'3104',
|
||||
'',
|
||||
])('lehnt %j ab', (v) => {
|
||||
expect(isValidBuchungsdatum(v)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAmount', () => {
|
||||
it('Soll fuer positive Werte und Null, Haben fuer negative', () => {
|
||||
expect(formatAmount(12.5)).toEqual({ formatted: '12.50', sollHaben: 'S' });
|
||||
expect(formatAmount(0)).toEqual({ formatted: '0.00', sollHaben: 'S' });
|
||||
expect(formatAmount(-3.456)).toEqual({ formatted: '3.46', sollHaben: 'H' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignAccounts', () => {
|
||||
const accounts = [
|
||||
{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 },
|
||||
{ name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 },
|
||||
];
|
||||
|
||||
it('bekannter Name bekommt sein Gegenkonto und Erloeskonto, isNew false', () => {
|
||||
const r = assignAccounts([row('Kaffee', 5)], accounts, SETTINGS);
|
||||
expect(r.rows[0]).toMatchObject({ gegenkonto: 2010, erloeskonto: 4000, isNew: false });
|
||||
expect(r.newAccounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('unbekannte Namen: hoechstes Gegenkonto + 1, dann + 2, mit Standard-Erloeskonto', () => {
|
||||
const r = assignAccounts([row('Kakao', 1), row('Saft', 2)], accounts, SETTINGS);
|
||||
expect(r.newAccounts).toEqual([
|
||||
{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 },
|
||||
{ name: 'Saft', gegenkonto: 2012, erloeskonto: 4711 },
|
||||
]);
|
||||
expect(r.rows.map((x) => x.isNew)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('leere Liste: erstes neues Konto ist genau der Startwert, dann + 1', () => {
|
||||
const r = assignAccounts([row('A', 1), row('B', 1)], [], SETTINGS);
|
||||
expect(r.newAccounts.map((a) => a.gegenkonto)).toEqual([2000, 2001]);
|
||||
});
|
||||
|
||||
it('derselbe unbekannte Name zweimal: ein neues Konto, beide Zeilen als neu', () => {
|
||||
const r = assignAccounts(
|
||||
[row('Kakao', 1, 2), row('Saft', 1, 3), row('Kakao', 2, 4)],
|
||||
accounts,
|
||||
SETTINGS,
|
||||
);
|
||||
expect(r.newAccounts.map((a) => a.name)).toEqual(['Kakao', 'Saft']);
|
||||
expect(r.rows[2]).toMatchObject({ gegenkonto: 2011, isNew: true, line: 4 });
|
||||
});
|
||||
|
||||
it('vergleicht Namen genau (Gross-/Kleinschreibung zaehlt)', () => {
|
||||
const r = assignAccounts([row('kaffee', 1)], accounts, SETTINGS);
|
||||
expect(r.rows[0].isNew).toBe(true);
|
||||
});
|
||||
|
||||
it('formatiert Betrag und Soll/Haben je Zeile', () => {
|
||||
const r = assignAccounts([row('Kaffee', -2.5)], accounts, SETTINGS);
|
||||
expect(r.rows[0]).toMatchObject({ umsatz: '2.50', sollHaben: 'H' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTxt', () => {
|
||||
const rows = assignAccounts([row('Müller Käse', 12.5), row('Tee', -3)], [], SETTINGS).rows;
|
||||
const txt = generateTxt('2026', rows, '3103');
|
||||
|
||||
it('Kopfzeile: TAB Kopftext und vier weitere Tabs', () => {
|
||||
expect(txt.split('\r\n')[0]).toBe('\t2026\t\t\t\t');
|
||||
});
|
||||
|
||||
it('Datenzeilen: Text, Umsatz, S/H, Gegenkonto, TTMM, Erloeskonto', () => {
|
||||
const lines = txt.split('\r\n');
|
||||
expect(lines[1]).toBe('Müller Käse\t12.50\tS\t2000\t3103\t4711');
|
||||
expect(lines[2]).toBe('Tee\t3.00\tH\t2001\t3103\t4711');
|
||||
});
|
||||
|
||||
it('endet mit CRLF und enthaelt kein einzelnes LF', () => {
|
||||
expect(txt.endsWith('\r\n')).toBe(true);
|
||||
expect(txt.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('behaelt Umlaute bei UTF-8 bei', () => {
|
||||
expect(Buffer.from(txt, 'utf8').toString('utf8')).toContain('Müller Käse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExportFilename', () => {
|
||||
it.each([
|
||||
['HWA 0326 Test.xlsx', 'HWA_0326.txt'],
|
||||
['HWA0326.xlsx', 'HWA_0326.txt'],
|
||||
['Liste.xlsx', 'Handelsware_Export.txt'],
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(getExportFilename(name)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
AccountEntry,
|
||||
HandelswareSettingsReady,
|
||||
ImportRow,
|
||||
NewAccount,
|
||||
PreviewRow,
|
||||
} from './handelsware-datev.types';
|
||||
|
||||
/**
|
||||
* Buchungsdatum (TTMM) aus dem Dateinamen: die erste vierstellige Ziffernfolge
|
||||
* ist MMYY, ergibt den letzten Tag dieses Monats.
|
||||
* "HWA 0326 Test.xlsx" -> "3103". Ohne Treffer oder mit Monat ausserhalb 1-12: "".
|
||||
*/
|
||||
export function calculateBuchungsdatum(filename: string): string {
|
||||
const match = filename.match(/(\d{2})(\d{2})/);
|
||||
if (!match) return '';
|
||||
const month = Number.parseInt(match[1], 10);
|
||||
if (month < 1 || month > 12) return '';
|
||||
const year = 2000 + Number.parseInt(match[2], 10);
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
return `${String(lastDay).padStart(2, '0')}${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29). */
|
||||
export function isValidBuchungsdatum(ttmm: string): boolean {
|
||||
if (!/^\d{4}$/.test(ttmm)) return false;
|
||||
const day = Number.parseInt(ttmm.slice(0, 2), 10);
|
||||
const month = Number.parseInt(ttmm.slice(2, 4), 10);
|
||||
if (month < 1 || month > 12) return false;
|
||||
return day >= 1 && day <= DAYS_PER_MONTH[month - 1];
|
||||
}
|
||||
|
||||
/** Betrag ohne Vorzeichen, Punkt, genau 2 Nachkommastellen; Soll fuer >= 0, Haben fuer < 0. */
|
||||
export function formatAmount(value: number): { formatted: string; sollHaben: 'S' | 'H' } {
|
||||
const sollHaben = value < 0 ? 'H' : 'S';
|
||||
return { formatted: Math.abs(value).toFixed(2), sollHaben };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet jeder Zeile ihr Konto zu. Bekannte Produkte (genauer, gross-/
|
||||
* kleinschreibungsabhaengiger Name) bekommen ihr Gegenkonto und Erloeskonto;
|
||||
* unbekannte bekommen das naechste freie Gegenkonto (hoechstes vorhandenes + 1,
|
||||
* bei leerer Liste genau der Startwert aus den Einstellungen) und das
|
||||
* Standard-Erloeskonto. Dasselbe unbekannte Produkt mehrfach in einer Datei
|
||||
* bekommt EIN neues Konto. `newAccounts` steht in der Reihenfolge des ersten
|
||||
* Auftretens.
|
||||
*/
|
||||
export function assignAccounts(
|
||||
importRows: ImportRow[],
|
||||
accounts: AccountEntry[],
|
||||
settings: HandelswareSettingsReady,
|
||||
): { rows: PreviewRow[]; newAccounts: NewAccount[] } {
|
||||
const known = new Map<string, AccountEntry>();
|
||||
for (const account of accounts) known.set(account.name, account);
|
||||
|
||||
const created = new Map<string, NewAccount>();
|
||||
const newAccounts: NewAccount[] = [];
|
||||
let next =
|
||||
accounts.length > 0
|
||||
? Math.max(...accounts.map((a) => a.gegenkonto)) + 1
|
||||
: settings.startGegenkonto;
|
||||
|
||||
const rows: PreviewRow[] = [];
|
||||
for (const row of importRows) {
|
||||
const { formatted, sollHaben } = formatAmount(row.umsatz);
|
||||
|
||||
let account = known.get(row.buchungstext);
|
||||
let isNew = false;
|
||||
if (!account) {
|
||||
isNew = true;
|
||||
account = created.get(row.buchungstext);
|
||||
if (!account) {
|
||||
account = { name: row.buchungstext, gegenkonto: next++, erloeskonto: settings.erloeskonto };
|
||||
created.set(row.buchungstext, account);
|
||||
newAccounts.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
line: row.line,
|
||||
buchungstext: row.buchungstext,
|
||||
umsatz: formatted,
|
||||
sollHaben,
|
||||
gegenkonto: account.gegenkonto,
|
||||
erloeskonto: account.erloeskonto,
|
||||
isNew,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, newAccounts };
|
||||
}
|
||||
|
||||
/**
|
||||
* TXT-Datei fuer DATEV: Kopfzeile TAB Kopftext + 4 Tabs, dann je Zeile
|
||||
* Text, Umsatz, S/H, Gegenkonto, Datum (TTMM), Erloeskonto — tabgetrennt, CRLF,
|
||||
* die Datei endet mit CRLF. UTF-8 (offene Frage: DATEV erwartet oft ANSI).
|
||||
*/
|
||||
export function generateTxt(headerText: string, rows: PreviewRow[], buchungsdatum: string): string {
|
||||
const lines: string[] = [`\t${headerText}\t\t\t\t`];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
`${row.buchungstext}\t${row.umsatz}\t${row.sollHaben}\t${row.gegenkonto}\t${buchungsdatum}\t${row.erloeskonto}`,
|
||||
);
|
||||
}
|
||||
return `${lines.join('\r\n')}\r\n`;
|
||||
}
|
||||
|
||||
/** Dateiname des Exports: "HWA 0326 Test.xlsx" -> "HWA_0326.txt", sonst "Handelsware_Export.txt". */
|
||||
export function getExportFilename(importFilename: string): string {
|
||||
const match = importFilename.match(/(\w+)\s*(\d{4})/);
|
||||
if (match) return `${match[1]}_${match[2]}.txt`;
|
||||
return 'Handelsware_Export.txt';
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { ImportRow, RowError } from './handelsware-datev.types';
|
||||
|
||||
/** Obergrenze der Datenzeilen je Datei (T-FM5-04). */
|
||||
export const MAX_DATA_ROWS = 10_000;
|
||||
|
||||
export type HandelswareFileErrorCode = 'invalidFile' | 'tooManyRows';
|
||||
|
||||
export class HandelswareFileError extends Error {
|
||||
constructor(
|
||||
readonly code: HandelswareFileErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HandelswareFileError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tabulator und Zeilenumbrueche wuerden die Spalten der TXT-Datei zerreissen. */
|
||||
export function sanitizeText(value: string): string {
|
||||
return value.replace(/[\t\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt einen Umsatzwert in eine Zahl: Zahl unveraendert, Text im deutschen
|
||||
* Format ("1.234,56", "-12,5") oder mit Punkt ("12.5"). `null` bei allem, was
|
||||
* keine Zahl ist.
|
||||
*/
|
||||
export function parseUmsatz(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') return null;
|
||||
let text = value.replace(/\s/g, '');
|
||||
if (text === '') return null;
|
||||
if (text.includes(',')) {
|
||||
// Deutsches Format: Punkte sind Tausendertrenner, das Komma ist das Dezimalzeichen.
|
||||
text = text.replace(/\./g, '').replace(',', '.');
|
||||
}
|
||||
if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
|
||||
const num = Number(text);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
/** Signatur einer xlsx-Datei (ZIP) oder einer alten xls-Datei (OLE2). */
|
||||
function looksLikeWorkbook(buffer: Buffer): boolean {
|
||||
if (buffer.length < 4) return false;
|
||||
const zip = buffer[0] === 0x50 && buffer[1] === 0x4b;
|
||||
const ole = buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
|
||||
return zip || ole;
|
||||
}
|
||||
|
||||
function cellText(cell: XLSX.CellObject | undefined): string {
|
||||
if (!cell || cell.v === undefined || cell.v === null) return '';
|
||||
return String(cell.v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Handelsware-Excel-Datei: Zelle B1 = Kopftext, ab Zeile 2 Spalte A =
|
||||
* Buchungstext und Spalte B = Umsatz, bis A und B beide leer sind. Nur das erste
|
||||
* Blatt, keine Formeln/HTML/Formatvorlagen (T-FM5-05), hoechstens
|
||||
* `MAX_DATA_ROWS` Datenzeilen (T-FM5-04).
|
||||
*
|
||||
* Abweichung von der Vorlage: ein Umsatz, der keine Zahl ist, wurde dort still
|
||||
* als 0 gebucht — hier wird er ein Zeilenfehler.
|
||||
*/
|
||||
export function parseHandelswareXlsx(buffer: Buffer): {
|
||||
headerText: string;
|
||||
rows: ImportRow[];
|
||||
rowErrors: RowError[];
|
||||
} {
|
||||
if (!looksLikeWorkbook(buffer)) {
|
||||
throw new HandelswareFileError('invalidFile', 'Die Datei ist keine gültige Excel-Datei.');
|
||||
}
|
||||
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(buffer, {
|
||||
type: 'buffer',
|
||||
cellFormula: false,
|
||||
cellHTML: false,
|
||||
cellStyles: false,
|
||||
// Zeile 1 (Kopf) + MAX_DATA_ROWS Datenzeilen + 1 Zeile, um "zu viele" zu erkennen.
|
||||
sheetRows: MAX_DATA_ROWS + 2,
|
||||
});
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
throw new HandelswareFileError(
|
||||
'invalidFile',
|
||||
'Die Datei konnte nicht als Excel-Datei gelesen werden.',
|
||||
);
|
||||
}
|
||||
if (!sheet) {
|
||||
throw new HandelswareFileError('invalidFile', 'Die Excel-Datei enthält kein Tabellenblatt.');
|
||||
}
|
||||
|
||||
const headerText = sanitizeText(cellText(sheet.B1));
|
||||
|
||||
const rows: ImportRow[] = [];
|
||||
const rowErrors: RowError[] = [];
|
||||
for (let line = 2; ; line++) {
|
||||
const textA = sanitizeText(cellText(sheet[`A${line}`]));
|
||||
const rawB = sheet[`B${line}`]?.v;
|
||||
const emptyB = rawB === undefined || rawB === null || String(rawB).trim() === '';
|
||||
if (textA === '' && emptyB) break;
|
||||
|
||||
if (line - 1 > MAX_DATA_ROWS) {
|
||||
throw new HandelswareFileError(
|
||||
'tooManyRows',
|
||||
`Die Datei enthält mehr als ${MAX_DATA_ROWS} Datenzeilen.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Zeile ohne Buchungstext, aber mit Wert: uebersprungen (Verhalten der Vorlage).
|
||||
if (textA === '') continue;
|
||||
|
||||
const umsatz = parseUmsatz(rawB);
|
||||
if (umsatz === null) {
|
||||
rowErrors.push({
|
||||
line,
|
||||
code: 'umsatzInvalid',
|
||||
message: 'Der Umsatz ist keine gültige Zahl.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rows.push({ line, buchungstext: textA, umsatz });
|
||||
}
|
||||
|
||||
return { headerText, rows, rowErrors };
|
||||
}
|
||||
Reference in New Issue
Block a user