From 15ac003dad9aa3f5bbeeab379af9074071a3e19a Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 9 Aug 2026 17:40:15 +0200 Subject: [PATCH] Gutschrift: betragsloser Sachwert bekommt keine Gutschriftsnummer Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift -> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen. - Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE bleibt - MySQL erlaubt mehrere NULLs). - createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber wird NICHT aufgerufen (Zaehler unangetastet). - updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen; bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben. - PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher). Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer + Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 4 +++ backend/prisma/schema.prisma | 5 ++- .../src/controllers/creditNote.controller.ts | 6 ++-- backend/src/services/creditNote.service.ts | 31 +++++++++++++++---- backend/src/services/creditNotePdf.service.ts | 9 +++--- docs/todo.md | 7 +++++ .../contracts/CreditNotesSection.tsx | 6 ++-- frontend/src/types/index.ts | 2 +- 8 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 backend/prisma/migrations/20260809100000_credit_note_number_nullable/migration.sql diff --git a/backend/prisma/migrations/20260809100000_credit_note_number_nullable/migration.sql b/backend/prisma/migrations/20260809100000_credit_note_number_nullable/migration.sql new file mode 100644 index 00000000..b79ca50e --- /dev/null +++ b/backend/prisma/migrations/20260809100000_credit_note_number_nullable/migration.sql @@ -0,0 +1,4 @@ +-- Gutschrift-Nummer nullbar: betragslose Sachwert-Uebergaben (Lieferschein) +-- verbrauchen keine Gutschriftsnummer. UNIQUE bleibt (MySQL erlaubt mehrere +-- NULLs in einem UNIQUE-Index). +ALTER TABLE `CreditNote` MODIFY COLUMN `number` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dc35891a..8be37ca2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -984,7 +984,10 @@ model CreditNote { contractId Int contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) - number String @unique // aus Nummernkreis, z.B. GS-2026-0001 + // 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). + number String? @unique type CreditNoteType // Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55"). diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts index 571c4358..320ec04b 100644 --- a/backend/src/controllers/creditNote.controller.ts +++ b/backend/src/controllers/creditNote.controller.ts @@ -82,7 +82,7 @@ export async function create(req: AuthRequest, res: Response): Promise { action: 'CREATE', resourceType: 'CreditNote', resourceId: created.id.toString(), - label: `Gutschrift ${created.number} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`, + label: `Gutschrift ${created.number ?? `Sachwert-Uebergabe #${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 +117,7 @@ export async function update(req: AuthRequest, res: Response): Promise { action: 'UPDATE', resourceType: 'CreditNote', resourceId: updated.id.toString(), - label: `Gutschrift ${updated.number} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`, + label: `Gutschrift ${updated.number ?? `Sachwert-Uebergabe #${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`, }); res.json({ success: true, data: updated } as ApiResponse); } catch (error) { @@ -136,7 +136,7 @@ export async function remove(req: AuthRequest, res: Response): Promise { action: 'DELETE', resourceType: 'CreditNote', resourceId: id.toString(), - label: `Gutschrift ${deleted.number} gelöscht`, + label: `Gutschrift ${deleted.number ?? `Sachwert-Uebergabe #${deleted.id}`} gelöscht`, }); res.json({ success: true } as ApiResponse); } catch (error) { diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index c5c7c976..04f32a90 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -44,6 +44,12 @@ export function computeAmounts(params: { return { amountNet: net, amountVat: round2(gross - net), amountGross: gross }; } +// Betragsloser Sachwert = reine Übergabe/Lieferschein (keine Rechnung, +// keine USt, keine Gutschriftsnummer, kein ZUGFeRD). +function isNonMonetary(n: { type: CreditNoteType; amountGross: number }): boolean { + return n.type === 'SACHWERT' && n.amountGross === 0; +} + const ALLOWED_TYPES = new Set(['GELD', 'SACHWERT']); const ALLOWED_CUSTOMER_TYPES = new Set(['PRIVAT', 'FIRMA']); const ALLOWED_BASIS = new Set(['NETTO', 'BRUTTO']); @@ -217,7 +223,9 @@ export async function createCreditNote( if (normalized.payoutBankCardId) { await assertBankCardBelongsToContract(contractId, normalized.payoutBankCardId); } - const number = await assignNextNumber(); + // Betragsloser Sachwert = Lieferschein → KEINE Gutschriftsnummer vergeben + // (Nummernkreis-Zähler bleibt unangetastet). Sonst nächste Nummer ziehen. + const number = isNonMonetary(normalized) ? null : await assignNextNumber(); return prisma.creditNote.create({ data: { @@ -238,11 +246,22 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput) if (normalized.payoutBankCardId) { await assertBankCardBelongsToContract(existing.contractId, normalized.payoutBankCardId); } - // Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon - // erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren - // (der User erzeugt es bei Bedarf neu). 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, pdfPath: null } }); + + // 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). + let number = existing.number; + if (isNonMonetary(normalized)) { + number = null; + } 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 } }); deleteUploadByRelativePath(existing.pdfPath); return updated; } diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts index 257f3bdc..44b8fecd 100644 --- a/backend/src/services/creditNotePdf.service.ts +++ b/backend/src/services/creditNotePdf.service.ts @@ -94,7 +94,8 @@ 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); - doc.text(`Nr.: ${cn.number}`); + // Betragslose Sachwert-Übergabe hat keine Gutschriftsnummer → interne Ref. + doc.text(`Nr.: ${cn.number ?? `Beleg #${cn.id}`}`); doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`); // ---- Beschreibung / Betrag ---- @@ -201,7 +202,7 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf if (!nonMonetary) { // ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ---- xml = buildZugferdXml({ - number: cn.number, + number: cn.number ?? '', issueDate: new Date(cn.creditDate), currency: cn.currency, seller: { @@ -234,7 +235,7 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf }); buffer = await embedZugferd(baseBuffer, xml, { - title: `Gutschrift ${cn.number}`, + title: `Gutschrift ${cn.number ?? ''}`.trim(), date: new Date(cn.creditDate), }); } @@ -242,7 +243,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.replace(/[^A-Za-z0-9_-]/g, '_')}-${cn.id}.pdf`; + const filename = `gutschrift-${(cn.number ?? 'sachwert').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/docs/todo.md b/docs/todo.md index c6130446..ddd7688d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -110,6 +110,13 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)". - Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 → ZUGFeRD). + - **Keine Gutschriftsnummer für betragslose Sachwerte:** `number` ist jetzt + nullbar (Migration `MODIFY … NULL`, UNIQUE bleibt); ein betragsloser + Sachwert (Lieferschein) bekommt **keine** Nummer und verbraucht den + Nummernkreis-Zähler **nicht**. Update-Übergänge: wird betragslos → Nummer + entfernt; bekommt nachträglich einen Betrag → jetzt Nummer vergeben. PDF/ + Liste/Audit zeigen dann „Sachwert-Übergabe"/„Beleg #id". Verifiziert + (Zähler bleibt bei Sachwert 0 stehen, springt nur bei echten Gutschriften). - [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07) - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index 4881e816..94017da9 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -158,7 +158,7 @@ function CreditNoteFormModal({ : amountNum > 0; // Geld: Betrag > 0 Pflicht return ( - +