From ad4c2bae1d2eeedf520e01d659409f282e6b0b95 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 13:48:23 +0200 Subject: [PATCH] =?UTF-8?q?Folgez=C3=A4hler-Deklaration=20in=20der=20Kunde?= =?UTF-8?q?nakte=20(Auto-Propagation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Meter.predecessorMeterId (Self-Relation) + Migration 20260530140000_meter_predecessor mit IF NOT EXISTS - createMeter akzeptiert optional successorOf: {predecessorMeterId, installedAt?, finalReadingPrevious?}. Vorgänger wird validiert (gleicher Kunde + Typ); alle Verträge mit dem Vorgänger als aktuellen Zähler werden analog zu addSuccessorMeter automatisch auf den neuen Zähler umgestellt (ContractMeter-Eintrag mit removedAt/finalReading für den Vorgänger, neuer ContractMeter mit installedAt + nächster Position, energyDetails.meterId aktualisiert) - MeterModal: Checkbox "Als Folgezähler deklarieren" + Dropdown Vorgänger + Wechseldatum + Endstand. Typ/Tarifmodell/Adresse werden vom Vorgänger übernommen und disabled. Info-Banner über Vertragsauto-Update Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 46 ++++++++ backend/prisma/schema.prisma | 6 + .../src/controllers/customer.controller.ts | 5 +- backend/src/services/customer.service.ts | 105 +++++++++++++++++- docs/todo.md | 28 +++++ .../src/pages/customers/CustomerDetail.tsx | 93 +++++++++++++++- 6 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 backend/prisma/migrations/20260530140000_meter_predecessor/migration.sql diff --git a/backend/prisma/migrations/20260530140000_meter_predecessor/migration.sql b/backend/prisma/migrations/20260530140000_meter_predecessor/migration.sql new file mode 100644 index 00000000..d18ea801 --- /dev/null +++ b/backend/prisma/migrations/20260530140000_meter_predecessor/migration.sql @@ -0,0 +1,46 @@ +-- Folgezähler-Kette: Meter zeigt optional auf den Vorgänger. +-- Beim Wechsel können wir dann sowohl die Kette für die UI anzeigen +-- als auch alle Verträge mit dem Vorgänger automatisch auf den +-- Nachfolger umstellen. +-- +-- ON DELETE SET NULL, damit ein versehentlich gelöschter Vorgänger +-- den Nachfolger nicht killt. +-- +-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand +-- schon `prisma db push` gefahren hat. + +ALTER TABLE `Meter` + ADD COLUMN IF NOT EXISTS `predecessorMeterId` INT NULL; + +-- Index nur anlegen, wenn er noch nicht da ist +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Meter' + AND INDEX_NAME = 'Meter_predecessorMeterId_fkey' +); +SET @sql := IF( + @idx_exists = 0, + 'CREATE INDEX `Meter_predecessorMeterId_fkey` ON `Meter`(`predecessorMeterId`)', + 'SELECT "Index existiert bereits"' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Foreign Key nur anlegen, wenn er noch nicht da ist +SET @fk_exists := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'Meter' + AND CONSTRAINT_NAME = 'Meter_predecessorMeterId_fkey' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); +SET @sql := IF( + @fk_exists = 0, + 'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_predecessorMeterId_fkey` FOREIGN KEY (`predecessorMeterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE', + 'SELECT "FK existiert bereits"' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b2ea9f41..8fcc8ab6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -489,6 +489,12 @@ model Meter { tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT) location String? isActive Boolean @default(true) + // Folgezähler-Kette: zeigt auf den Vorgänger, den dieser Zähler abgelöst hat. + // Wird beim Anlegen als Folgezähler gesetzt; informational + zum Anzeigen + // der Kette. Auto-Propagation auf Verträge passiert beim Create. + predecessorMeterId Int? + predecessor Meter? @relation("MeterSuccessor", fields: [predecessorMeterId], references: [id], onDelete: SetNull) + successors Meter[] @relation("MeterSuccessor") readings MeterReading[] energyDetails EnergyContractDetails[] contractMeters ContractMeter[] @relation("ContractMeters") diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 282a8e96..fb18a994 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -603,10 +603,13 @@ export async function createMeter(req: AuthRequest, res: Response): Promise cm.meterId === predecessor!.id); + if (!predCM) { + predCM = await prisma.contractMeter.create({ + data: { + energyContractDetailsId: ecd.id, + meterId: predecessor.id, + position: 0, + installedAt: null, + }, + }); + ecd.contractMeters.push(predCM); + } + await prisma.contractMeter.update({ + where: { id: predCM.id }, + data: { + removedAt: installedAt, + finalReading: finalReading != null ? finalReading : predCM.finalReading, + }, + }); + + const nextPosition = ecd.contractMeters.length > 0 + ? Math.max(...ecd.contractMeters.map((cm) => cm.position)) + 1 + : 0; + + // Idempotenz: falls (durch Doppel-Klick o.ä.) schon ein ContractMeter + // mit dem neuen Zähler existiert, nicht doppelt anlegen. + const existsForNew = await prisma.contractMeter.findUnique({ + where: { + energyContractDetailsId_meterId: { + energyContractDetailsId: ecd.id, + meterId: created.id, + }, + }, + }); + if (!existsForNew) { + await prisma.contractMeter.create({ + data: { + energyContractDetailsId: ecd.id, + meterId: created.id, + position: nextPosition, + installedAt, + }, + }); + } + + // Aktuellen Zähler am Vertrag aktualisieren + await prisma.energyContractDetails.update({ + where: { id: ecd.id }, + data: { meterId: created.id }, + }); + } + } + + return created; } export async function updateMeter( diff --git a/docs/todo.md b/docs/todo.md index 9f13a3ff..0b33ad24 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)** + - **Backend**: Neues Feld `Meter.predecessorMeterId` + (Self-Relation, `ON DELETE SET NULL`). Migration + `20260530140000_meter_predecessor` mit `IF NOT EXISTS`. + `createMeter` akzeptiert optional `successorOf: { predecessorMeterId, + installedAt?, finalReadingPrevious? }`. Wenn gesetzt: Vorgänger + wird validiert (gleicher Kunde + gleicher Typ), und für alle + Verträge, die den Vorgänger als aktuellen Zähler nutzen, wird + der ContractMeter-Eintrag analog zu `addSuccessorMeter` + propagiert (vorhandener ContractMeter wird `removedAt` + + `finalReading` gesetzt; neuer ContractMeter wird mit nächster + Position + `installedAt` angelegt; `energyDetails.meterId` + auf den Neuzähler aktualisiert). Idempotent gegen Doppel-Klick. + - **MeterModal** (Kundenakte → Zähler): Bei Neuanlage neue + Checkbox „Diesen Zähler als Folgezähler deklarieren". Wenn + aktiv: Dropdown Vorgänger-Zähler (alle Zähler des Kunden, + inkl. inaktive – mit Suffix), Wechseldatum (default heute), + Endstand alter Zähler (optional). Bei Vorgänger-Auswahl werden + Typ, Tarifmodell und Adresse vom Vorgänger übernommen und + disabled. Info-Banner: „Alle Verträge mit dem alten Zähler + werden automatisch umgestellt". + - Audit-Log: „Zähler angelegt als Folgezähler von X für Kunde #N". + +- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler** + - In den Strom/Gas-Details neben der Zählernummer zusätzlich ein + rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls + hinterlegt. + - [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter** - **Backend**: Neues Feld `Meter.addressId` (optional FK auf `Address`, `ON DELETE SET NULL`). Migration diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 799878b8..8b295c03 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -457,6 +457,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? onClose={() => setShowMeterModal(false)} customerId={customerId} addresses={c.addresses || []} + existingMeters={c.meters || []} /> void; customerId: number; meter?: Meter | null; addresses: Address[]; + existingMeters: Meter[]; }) { const queryClient = useQueryClient(); const isEditing = !!meter; const deliveryAddresses = addresses.filter((a) => a.type === 'DELIVERY_RESIDENCE'); + const today = new Date().toISOString().split('T')[0]; const getInitialFormData = () => ({ meterNumber: meter?.meterNumber || '', @@ -2845,17 +2850,36 @@ function MeterModal({ location: meter?.location || '', isActive: meter?.isActive ?? true, addressId: meter?.addressId?.toString() || '', + isSuccessor: false, + predecessorMeterId: '', + installedAt: today, + finalReadingPrevious: '', }); const [formData, setFormData] = useState(getInitialFormData); const [error, setError] = useState(null); + const predecessor = formData.predecessorMeterId + ? existingMeters.find((m) => m.id.toString() === formData.predecessorMeterId) + : null; + + // Wenn Vorgänger gewählt: Typ + Adresse + Tarifmodell vom Vorgänger übernehmen + useEffect(() => { + if (!formData.isSuccessor || !predecessor) return; + setFormData((prev) => ({ + ...prev, + type: predecessor.type, + tariffModel: predecessor.tariffModel, + addressId: predecessor.addressId ? predecessor.addressId.toString() : prev.addressId, + })); + }, [formData.isSuccessor, formData.predecessorMeterId, predecessor?.id]); + const createMutation = useMutation({ mutationFn: (data: any) => meterApi.create(customerId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); - setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true, addressId: '' }); + setFormData(getInitialFormData()); }, onError: (err) => setError(err instanceof Error ? err.message : 'Fehler beim Speichern'), }); @@ -2876,7 +2900,11 @@ function MeterModal({ setError('Bitte eine Lieferadresse auswählen'); return; } - const payload = { + if (formData.isSuccessor && !formData.predecessorMeterId) { + setError('Bitte einen Vorgänger-Zähler auswählen'); + return; + } + const payload: any = { meterNumber: formData.meterNumber, type: formData.type, tariffModel: formData.tariffModel, @@ -2884,6 +2912,15 @@ function MeterModal({ isActive: formData.isActive, addressId: parseInt(formData.addressId), }; + if (!isEditing && formData.isSuccessor && formData.predecessorMeterId) { + payload.successorOf = { + predecessorMeterId: parseInt(formData.predecessorMeterId), + installedAt: formData.installedAt || undefined, + finalReadingPrevious: formData.finalReadingPrevious + ? parseFloat(formData.finalReadingPrevious) + : undefined, + }; + } if (isEditing) { updateMutation.mutate(payload); } else { @@ -2899,6 +2936,7 @@ function MeterModal({ } const noDeliveryAddresses = deliveryAddresses.length === 0; + const successorLocked = !isEditing && formData.isSuccessor && !!predecessor; return ( @@ -2920,6 +2958,7 @@ function MeterModal({ }))} placeholder="Lieferadresse wählen..." required + disabled={successorLocked && !!predecessor?.addressId} /> {formData.type === 'ELECTRICITY' && ( @@ -2948,6 +2988,7 @@ function MeterModal({ { value: 'SINGLE', label: 'Eintarifzähler (Standard)' }, { value: 'DUAL', label: 'Zweitarifzähler (HT/NT)' }, ]} + disabled={successorLocked} /> )} @@ -2958,6 +2999,54 @@ function MeterModal({ placeholder="z.B. Keller, Wohnung" /> + {!isEditing && existingMeters.length > 0 && ( +
+ + + {formData.isSuccessor && ( +
+ setFormData({ ...formData, installedAt: e.target.value })} + /> + setFormData({ ...formData, finalReadingPrevious: e.target.value })} + placeholder="Optional" + /> +

+ Typ, Adresse und Tarifmodell werden vom Vorgänger übernommen. Alle Verträge, + die den Vorgänger-Zähler verwenden, werden automatisch auf diesen neuen Zähler + umgestellt. +

+
+ )} +
+ )} + {isEditing && (