From 5e606df49ed3e70304813cad419fdbb225664ae0 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 12 Aug 2026 08:47:49 +0200 Subject: [PATCH] Gutschrift: separater Lieferschein-Nummernkreis (GoBD, luecken-frei) Pentester R142: Uebergang Geld->betragsloser Sachwert setzte die schon vergebene Gutschriftsnummer auf null -> Luecke in der GS-Serie. Loesung: betragsloser Sachwert = Lieferschein mit eigener Lieferscheinnummer aus separatem Nummernkreis. - Schema: CreditNote.deliveryNoteNumber (nullbar, unique) + neues Model DeliveryNoteNumberRange (Default-Praefix 'LS-') + Migration. - deliveryNoteNumberRange.service (mirror, eigener Zaehler, FOR UPDATE). - Nummern lazy pro Serie, NIE freigeben: Uebergaenge behalten die jeweils vergebene Nummer der anderen Serie reserviert -> kein Doppelverbrauch, keine Luecke. effectiveNumber() liefert je nach Typ die passende (LS/GS) fuer Anzeige/PDF/Audit. - Endpunkte GET/PUT /credit-notes/delivery-note-number-range; Settings- Seite verwaltet jetzt beide Nummernkreise. PDF-Titel 'Sachwert- Uebergabe', Dateiname lieferschein-... - Frontend: Typ + displayNumber in Liste/Modal. Verifiziert: Sachwert 0 -> LS-Nr, GS-Zaehler unberuehrt; Geld -> GS-Nr; Uebergaenge behalten beide Nummern (kein Neuverbrauch, keine Luecke). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 26 +++ backend/prisma/schema.prisma | 24 ++- .../src/controllers/creditNote.controller.ts | 41 ++++- backend/src/routes/creditNote.routes.ts | 2 + backend/src/services/creditNote.service.ts | 47 +++-- backend/src/services/creditNotePdf.service.ts | 8 +- .../deliveryNoteNumberRange.service.ts | 90 +++++++++ docs/todo.md | 17 ++ .../contracts/CreditNotesSection.tsx | 13 +- frontend/src/pages/Settings.tsx | 4 +- .../pages/settings/CreditNoteNumberRange.tsx | 173 +++++++++++------- frontend/src/services/api.ts | 8 + frontend/src/types/index.ts | 1 + 13 files changed, 360 insertions(+), 94 deletions(-) create mode 100644 backend/prisma/migrations/20260812100000_delivery_note_numbering/migration.sql create mode 100644 backend/src/services/deliveryNoteNumberRange.service.ts diff --git a/backend/prisma/migrations/20260812100000_delivery_note_numbering/migration.sql b/backend/prisma/migrations/20260812100000_delivery_note_numbering/migration.sql new file mode 100644 index 00000000..98110546 --- /dev/null +++ b/backend/prisma/migrations/20260812100000_delivery_note_numbering/migration.sql @@ -0,0 +1,26 @@ +-- Lieferschein-Nummerierung fuer betragslose Sachwert-Uebergaben: +-- eigene Nummer am Beleg + eigener Nummernkreis (getrennt von Gutschriften). + +ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `deliveryNoteNumber` VARCHAR(191) NULL; + +-- UNIQUE-Index auf deliveryNoteNumber (idempotent). +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote' + AND INDEX_NAME = 'CreditNote_deliveryNoteNumber_key'); +SET @sql := IF(@idx = 0, + 'CREATE UNIQUE INDEX `CreditNote_deliveryNoteNumber_key` ON `CreditNote`(`deliveryNoteNumber`)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +CREATE TABLE IF NOT EXISTS `DeliveryNoteNumberRange` ( + `id` INT NOT NULL AUTO_INCREMENT, + `prefix` VARCHAR(191) NOT NULL DEFAULT 'LS-', + `includeYear` BOOLEAN NOT NULL DEFAULT true, + `separator` VARCHAR(191) NOT NULL DEFAULT '-', + `padding` INT NOT NULL DEFAULT 4, + `nextNumber` INT NOT NULL DEFAULT 1, + `resetYearly` BOOLEAN NOT NULL DEFAULT true, + `currentYear` INT NULL, + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8be37ca2..fedd3b88 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -984,10 +984,14 @@ model CreditNote { contractId Int contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) - // Gutschrift-Nummer aus dem Nummernkreis (z.B. GS-2026-0001). NULL bei - // betragslosen Sachwert-Übergaben – die sind eher ein Lieferschein und - // verbrauchen bewusst KEINE Gutschriftsnummer (Zähler bleibt unangetastet). + // Gutschrift-Nummer aus dem Gutschrift-Nummernkreis (z.B. GS-2026-0001). + // NULL bei betragslosen Sachwert-Übergaben (= Lieferschein, s.u.). number String? @unique + // Lieferschein-/Subventionsnummer aus dem separaten Lieferschein- + // Nummernkreis (z.B. LS-2026-0001) – nur bei betragslosen Sachwerten. + // Beide Nummern werden lazy pro Serie vergeben und NIE wieder freigegeben + // (keine Lücken); angezeigt wird je nach aktuellem Typ die passende. + deliveryNoteNumber String? @unique type CreditNoteType // Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55"). @@ -1071,6 +1075,20 @@ model CreditNoteNumberRange { updatedAt DateTime @updatedAt } +// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben). +// Eigene Serie, damit die Gutschrift-Nummerierung sauber/lückenlos bleibt. +model DeliveryNoteNumberRange { + id Int @id @default(autoincrement()) + prefix String @default("LS-") + includeYear Boolean @default(true) + separator String @default("-") + padding Int @default(4) + nextNumber Int @default(1) + resetYearly Boolean @default(true) + currentYear Int? + updatedAt DateTime @updatedAt +} + // ==================== INTERNET CONTRACT DETAILS ==================== model InternetContractDetails { diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts index 320ec04b..82774a40 100644 --- a/backend/src/controllers/creditNote.controller.ts +++ b/backend/src/controllers/creditNote.controller.ts @@ -3,7 +3,9 @@ import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import { ApiError } from '../utils/apiError.js'; import * as creditNoteService from '../services/creditNote.service.js'; +import { effectiveNumber } from '../services/creditNote.service.js'; import * as numberRangeService from '../services/creditNoteNumberRange.service.js'; +import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js'; import { generateCreditNotePdf } from '../services/creditNotePdf.service.js'; // Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne @@ -82,7 +84,7 @@ export async function create(req: AuthRequest, res: Response): Promise { action: 'CREATE', resourceType: 'CreditNote', resourceId: created.id.toString(), - label: `Gutschrift ${created.number ?? `Sachwert-Uebergabe #${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`, + label: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`, }); res.status(201).json({ success: true, data: created } as ApiResponse); } catch (error) { @@ -117,7 +119,7 @@ export async function update(req: AuthRequest, res: Response): Promise { action: 'UPDATE', resourceType: 'CreditNote', resourceId: updated.id.toString(), - label: `Gutschrift ${updated.number ?? `Sachwert-Uebergabe #${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`, + label: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`, }); res.json({ success: true, data: updated } as ApiResponse); } catch (error) { @@ -136,7 +138,7 @@ export async function remove(req: AuthRequest, res: Response): Promise { action: 'DELETE', resourceType: 'CreditNote', resourceId: id.toString(), - label: `Gutschrift ${deleted.number ?? `Sachwert-Uebergabe #${deleted.id}`} gelöscht`, + label: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} gelöscht`, }); res.json({ success: true } as ApiResponse); } catch (error) { @@ -193,3 +195,36 @@ export async function updateNumberRange(req: AuthRequest, res: Response): Promis handleError(res, error, 'Fehler beim Speichern des Nummernkreises'); } } + +// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ---- + +export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const [range, nextNumber] = await Promise.all([ + deliveryRangeService.getOrCreateRange(), + deliveryRangeService.previewNextNumber(), + ]); + res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse); + } catch (error) { + handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises'); + } +} + +export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise { + try { + if (blockPortal(req, res)) return; + const range = await deliveryRangeService.updateRange(req.body ?? {}); + const preview = await deliveryRangeService.previewNextNumber(); + await logChange({ + req, + action: 'UPDATE', + resourceType: 'DeliveryNoteNumberRange', + resourceId: range.id.toString(), + label: `Lieferschein-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`, + }); + res.json({ success: true, data: { ...range, preview } } as ApiResponse); + } catch (error) { + handleError(res, error, 'Fehler beim Speichern des Lieferschein-Nummernkreises'); + } +} diff --git a/backend/src/routes/creditNote.routes.ts b/backend/src/routes/creditNote.routes.ts index 430bdefd..500531f7 100644 --- a/backend/src/routes/creditNote.routes.ts +++ b/backend/src/routes/creditNote.routes.ts @@ -8,6 +8,8 @@ const router = Router(); // nicht als ID interpretiert wird. router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange); router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange); +router.get('/delivery-note-number-range', authenticate, requirePermission('settings:read'), creditNoteController.getDeliveryNoteNumberRange); +router.put('/delivery-note-number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateDeliveryNoteNumberRange); // Einzelne Gutschrift router.post('/:id/pdf', authenticate, requirePermission('contracts:update'), creditNoteController.generatePdf); diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index 04f32a90..2da1cdde 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -5,6 +5,7 @@ import prisma from '../lib/prisma.js'; import { ApiError } from '../utils/apiError.js'; import { assignNextNumber } from './creditNoteNumberRange.service.js'; +import { assignNextNumber as assignNextDeliveryNoteNumber } from './deliveryNoteNumberRange.service.js'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; @@ -45,11 +46,22 @@ export function computeAmounts(params: { } // Betragsloser Sachwert = reine Übergabe/Lieferschein (keine Rechnung, -// keine USt, keine Gutschriftsnummer, kein ZUGFeRD). -function isNonMonetary(n: { type: CreditNoteType; amountGross: number }): boolean { +// keine USt, kein ZUGFeRD; eigene Lieferscheinnummer statt Gutschriftsnummer). +export function isNonMonetary(n: { type: CreditNoteType; amountGross: number }): boolean { return n.type === 'SACHWERT' && n.amountGross === 0; } +// Die je nach aktuellem Typ „gültige" Belegnummer: Lieferscheinnummer bei +// betragslosem Sachwert, sonst Gutschriftsnummer. +export function effectiveNumber(cn: { + type: CreditNoteType; + amountGross: number; + number: string | null; + deliveryNoteNumber: string | null; +}): string | null { + return isNonMonetary(cn) ? cn.deliveryNoteNumber : cn.number; +} + const ALLOWED_TYPES = new Set(['GELD', 'SACHWERT']); const ALLOWED_CUSTOMER_TYPES = new Set(['PRIVAT', 'FIRMA']); const ALLOWED_BASIS = new Set(['NETTO', 'BRUTTO']); @@ -223,14 +235,18 @@ export async function createCreditNote( if (normalized.payoutBankCardId) { await assertBankCardBelongsToContract(contractId, normalized.payoutBankCardId); } - // Betragsloser Sachwert = Lieferschein → KEINE Gutschriftsnummer vergeben - // (Nummernkreis-Zähler bleibt unangetastet). Sonst nächste Nummer ziehen. - const number = isNonMonetary(normalized) ? null : await assignNextNumber(); + // Betragsloser Sachwert = Lieferschein → eigene Lieferscheinnummer aus dem + // separaten Kreis; der Gutschrift-Zähler bleibt unangetastet. Sonst echte + // Gutschrift → Gutschriftsnummer. + const nonMonetary = isNonMonetary(normalized); + const number = nonMonetary ? null : await assignNextNumber(); + const deliveryNoteNumber = nonMonetary ? await assignNextDeliveryNoteNumber() : null; return prisma.creditNote.create({ data: { contractId, number, + deliveryNoteNumber, ...normalized, createdBy, }, @@ -247,21 +263,24 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput) await assertBankCardBelongsToContract(existing.contractId, normalized.payoutBankCardId); } - // Nummer an den (neuen) monetären Status anpassen: - // - wird betragsloser Sachwert (Lieferschein) → Nummer entfernen, - // - wird eine echte Gutschrift und hatte noch keine Nummer → jetzt vergeben, - // - sonst bestehende Nummer beibehalten (einmal vergeben = fix). + // Nummern lazy pro Serie vergeben und NIE wieder freigeben (keine Lücken, + // GoBD): je nach aktuellem Typ bekommt der Beleg bei Bedarf die fehlende + // Nummer der passenden Serie; eine bereits vergebene Nummer der anderen + // Serie bleibt am Beleg reserviert (nicht angezeigt), damit ein späteres + // Zurückwechseln keine neue Nummer verbraucht. + const nonMonetary = isNonMonetary(normalized); let number = existing.number; - if (isNonMonetary(normalized)) { - number = null; - } else if (number === null) { - number = await assignNextNumber(); + let deliveryNoteNumber = existing.deliveryNoteNumber; + if (nonMonetary) { + if (deliveryNoteNumber === null) deliveryNoteNumber = await assignNextDeliveryNoteNumber(); + } else { + if (number === null) number = await assignNextNumber(); } // Ein evtl. schon erzeugtes PDF ist nach inhaltlicher Änderung veraltet → // Pfad leeren. Reihenfolge (R140): erst DB-Update, DANN die alte Datei // löschen – schlägt das Update fehl, bleibt die Datei. - const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, number, pdfPath: null } }); + const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, number, deliveryNoteNumber, pdfPath: null } }); deleteUploadByRelativePath(existing.pdfPath); return updated; } diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts index 44b8fecd..82041efa 100644 --- a/backend/src/services/creditNotePdf.service.ts +++ b/backend/src/services/creditNotePdf.service.ts @@ -8,6 +8,7 @@ import PDFDocument from 'pdfkit'; import prisma from '../lib/prisma.js'; import { ApiError } from '../utils/apiError.js'; import { getOrCreateProfile } from './companyProfile.service.js'; +import { effectiveNumber } from './creditNote.service.js'; import { buildZugferdXml } from './zugferd.service.js'; import { embedZugferd } from './zugferdPdf.service.js'; @@ -94,8 +95,9 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf doc.fontSize(18).text(nonMonetary ? 'Sachwert-Übergabe' : 'Gutschrift', { align: 'left' }); doc.moveDown(0.3); doc.fontSize(10); - // Betragslose Sachwert-Übergabe hat keine Gutschriftsnummer → interne Ref. - doc.text(`Nr.: ${cn.number ?? `Beleg #${cn.id}`}`); + // Belegnummer je nach Typ (Lieferschein- bzw. Gutschriftsnummer). + const belegNr = effectiveNumber(cn) ?? `#${cn.id}`; + doc.text(`Nr.: ${belegNr}`); doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`); // ---- Beschreibung / Betrag ---- @@ -243,7 +245,7 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf // ---- Speichern ---- const dir = path.join(process.cwd(), 'uploads', 'credit-notes'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const filename = `gutschrift-${(cn.number ?? 'sachwert').replace(/[^A-Za-z0-9_-]/g, '_')}-${cn.id}.pdf`; + const filename = `${nonMonetary ? 'lieferschein' : 'gutschrift'}-${(belegNr).replace(/[^A-Za-z0-9_-]/g, '_')}-${cn.id}.pdf`; const filepath = path.join(dir, filename); fs.writeFileSync(filepath, buffer); const pdfPath = `/uploads/credit-notes/${filename}`; diff --git a/backend/src/services/deliveryNoteNumberRange.service.ts b/backend/src/services/deliveryNoteNumberRange.service.ts new file mode 100644 index 00000000..41e1d610 --- /dev/null +++ b/backend/src/services/deliveryNoteNumberRange.service.ts @@ -0,0 +1,90 @@ +// ==================== LIEFERSCHEIN-NUMMERNKREIS ==================== +// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben), +// getrennt von den Gutschriften. Gleiche transaktionale Vergabe (Row-Lock via +// SELECT ... FOR UPDATE), damit keine Doppelvergaben/Lücken entstehen. + +import prisma from '../lib/prisma.js'; +import { Prisma } from '@prisma/client'; +import type { NumberRangeConfig } from './creditNoteNumberRange.service.js'; + +export async function getOrCreateRange() { + const existing = await prisma.deliveryNoteNumberRange.findFirst(); + if (existing) return existing; + return prisma.deliveryNoteNumberRange.create({ data: {} }); +} + +export async function updateRange(input: Partial) { + const range = await getOrCreateRange(); + + const data: Prisma.DeliveryNoteNumberRangeUpdateInput = {}; + if (typeof input.prefix === 'string') data.prefix = input.prefix.slice(0, 40); + if (typeof input.includeYear === 'boolean') data.includeYear = input.includeYear; + if (typeof input.separator === 'string') data.separator = input.separator.slice(0, 5); + if (typeof input.padding === 'number' && Number.isInteger(input.padding)) { + data.padding = Math.min(Math.max(input.padding, 1), 10); + } + if (typeof input.resetYearly === 'boolean') data.resetYearly = input.resetYearly; + if (typeof input.nextNumber === 'number' && Number.isInteger(input.nextNumber) && input.nextNumber >= 1) { + data.nextNumber = input.nextNumber; + } + + return prisma.deliveryNoteNumberRange.update({ where: { id: range.id }, data }); +} + +function formatNumber(cfg: { + prefix: string; + includeYear: boolean; + separator: string; + padding: number; +}, value: number, year: number): string { + const padded = String(value).padStart(cfg.padding, '0'); + const yearPart = cfg.includeYear ? `${year}${cfg.separator}` : ''; + return `${cfg.prefix}${yearPart}${padded}`; +} + +export async function previewNextNumber(): Promise { + const range = await getOrCreateRange(); + const year = new Date().getFullYear(); + const value = range.resetYearly && range.currentYear !== year ? 1 : range.nextNumber; + return formatNumber(range, value, year); +} + +export async function assignNextNumber(): Promise { + await getOrCreateRange(); + + return prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw>(Prisma.sql`SELECT * FROM DeliveryNoteNumberRange ORDER BY id ASC LIMIT 1 FOR UPDATE`); + + const row = rows[0]; + const year = new Date().getFullYear(); + const resetYearly = !!row.resetYearly; + const value = resetYearly && row.currentYear !== year ? 1 : row.nextNumber; + + const number = formatNumber( + { + prefix: row.prefix, + includeYear: !!row.includeYear, + separator: row.separator, + padding: row.padding, + }, + value, + year, + ); + + await tx.deliveryNoteNumberRange.update({ + where: { id: row.id }, + data: { nextNumber: value + 1, currentYear: year }, + }); + + return number; + }); +} diff --git a/docs/todo.md b/docs/todo.md index 20a16fd8..ce04fd98 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📦 Lieferschein-Nummernkreis für betragslose Sachwerte (GoBD)** (2026-08-12) + - Pentester-Hinweis R142: Beim Übergang Geld→betragsloser Sachwert wurde die + schon vergebene Gutschriftsnummer auf null gesetzt → **Lücke** in der GS-Serie. + - Lösung (User): betragsloser Sachwert = **Lieferschein** mit **eigener + Lieferscheinnummer** aus **separatem Nummernkreis** (`DeliveryNoteNumberRange`, + Default-Präfix „LS-"). `CreditNote.deliveryNoteNumber` (nullbar, unique) + + Migration. + - **Gap-frei:** Nummern werden lazy pro Serie vergeben und **nie wieder + freigegeben**. Übergänge (Geld↔betragsloser Sachwert) behalten die jeweils + schon vergebene Nummer der anderen Serie reserviert am Beleg → kein + Doppelverbrauch, keine Lücke. Angezeigt/erzeugt wird via `effectiveNumber` + je nach aktuellem Typ die passende (LS bzw. GS). + - Settings-Seite verwaltet jetzt **beide** Nummernkreise (Gutschrift + + Lieferschein); neue Endpunkte `GET/PUT /credit-notes/delivery-note-number- + range`. PDF-Titel „Sachwert-Übergabe", Dateiname `lieferschein-…`. + - Verifiziert: Sachwert 0 → LS-Nr, GS-Zähler unberührt; Geld → GS-Nr; + Übergänge behalten Nummern (kein Neuverbrauch). - [x] **🔐 Portal-Passwort: Reveal/Send prüft Konsistenz (kein „falsches" PW)** (2026-08-11) - Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und verschlüsseltes Reveal-Feld out-of-sync → Support hätte ein Passwort diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index 94017da9..2a6a1277 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -35,6 +35,13 @@ function calcAmounts(amount: number, vatRelevant: boolean, basis: CreditNoteAmou const todayIso = () => new Date().toISOString().split('T')[0]; +// Betragsloser Sachwert = Lieferschein → zeigt die Lieferscheinnummer, +// sonst die Gutschriftsnummer. +function displayNumber(cn: CreditNote): string { + const nonMonetary = cn.type === 'SACHWERT' && cn.amountGross === 0; + return (nonMonetary ? cn.deliveryNoteNumber : cn.number) ?? `#${cn.id}`; +} + interface FormDefaults { customerType: CreditNoteCustomerType; vatRelevant: boolean; @@ -158,7 +165,7 @@ function CreditNoteFormModal({ : amountNum > 0; // Geld: Betrag > 0 Pflicht return ( - +
setPrefix(e.target.value)} disabled={!canEdit} placeholder={defaultPrefix} /> + setSeparator(e.target.value)} disabled={!canEdit} placeholder="-" /> +
+ +
+ setPadding(Math.min(Math.max(parseInt(e.target.value) || 1, 1), 10))} + disabled={!canEdit} + /> + setNextNumber(Math.max(parseInt(e.target.value) || 1, 1))} + disabled={!canEdit} + /> +
+ + + + + +
+ Nächste Nummer: {preview} +
+ + {canEdit && ( +
+ +
+ )} + + )} +
+ ); +} + +export default function CreditNoteNumberRange() { + const { hasPermission } = useAuth(); + const canEdit = hasPermission('settings:update'); return (
@@ -62,57 +128,32 @@ export default function CreditNoteNumberRange() { Einstellungen

- Gutschrift-Nummernkreis + Nummernkreise: Gutschrift & Lieferschein

-

Fortlaufende Nummerierung für Gutschriften.

+

+ Getrennte fortlaufende Nummerierung für echte Gutschriften und für Lieferscheine + (betragslose Sachwert-Übergaben). +

-
-
- setPrefix(e.target.value)} disabled={!canEdit} placeholder="GS-" /> - setSeparator(e.target.value)} disabled={!canEdit} placeholder="-" /> -
- -
- setPadding(Math.min(Math.max(parseInt(e.target.value) || 1, 1), 10))} - disabled={!canEdit} - /> - setNextNumber(Math.max(parseInt(e.target.value) || 1, 1))} - disabled={!canEdit} - /> -
- - - - - -
- Nächste Nummer: {preview} -
- - {canEdit && ( -
- -
- )} +
+ creditNoteApi.getNumberRange()} + save={(p) => creditNoteApi.updateNumberRange(p)} + defaultPrefix="GS-" + canEdit={canEdit} + /> + creditNoteApi.getDeliveryNoteNumberRange()} + save={(p) => creditNoteApi.updateDeliveryNoteNumberRange(p)} + defaultPrefix="LS-" + canEdit={canEdit} + />
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e8da9102..e42b51d2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -313,6 +313,14 @@ export const creditNoteApi = { const res = await api.put>(`/credit-notes/number-range`, payload); return res.data; }, + getDeliveryNoteNumberRange: async () => { + const res = await api.get>(`/credit-notes/delivery-note-number-range`); + return res.data; + }, + updateDeliveryNoteNumberRange: async (payload: Partial) => { + const res = await api.put>(`/credit-notes/delivery-note-number-range`, payload); + return res.data; + }, uploadReceipt: async (id: number, file: File) => { const formData = new FormData(); formData.append('document', file); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 06bc53d8..89a745b6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -67,6 +67,7 @@ export interface CreditNote { id: number; contractId: number; number: string | null; + deliveryNoteNumber: string | null; type: CreditNoteType; sachwertDescription?: string | null; customerType: CreditNoteCustomerType;