From c099b417960a93f0fa09ce23063ab363b5ffe6eb Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 13:18:24 +0200 Subject: [PATCH] =?UTF-8?q?Z=C3=A4hler=20=E2=86=92=20Lieferadresse-Pflicht?= =?UTF-8?q?feld=20+=20Vertragsfilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration 20260530100000_meter_address mit IF NOT EXISTS - Service erzwingt beim Create: Lieferadresse vorhanden + zum Kunden gehörig + Typ DELIVERY_RESIDENCE - MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen "nicht zugeordnet – bitte über Bearbeiten nachpflegen" - ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse; deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei Auswahl Toast-Warnung wegen möglichem Altvertrag Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 43 ++++++++++ backend/prisma/schema.prisma | 5 ++ .../src/controllers/customer.controller.ts | 2 +- backend/src/services/customer.service.ts | 35 +++++++- docs/todo.md | 23 +++++ frontend/src/pages/contracts/ContractForm.tsx | 52 +++++++++--- .../src/pages/customers/CustomerDetail.tsx | 84 +++++++++++++++++-- frontend/src/types/index.ts | 4 + 8 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 backend/prisma/migrations/20260530100000_meter_address/migration.sql diff --git a/backend/prisma/migrations/20260530100000_meter_address/migration.sql b/backend/prisma/migrations/20260530100000_meter_address/migration.sql new file mode 100644 index 00000000..5b4ec859 --- /dev/null +++ b/backend/prisma/migrations/20260530100000_meter_address/migration.sql @@ -0,0 +1,43 @@ +-- Zähler bekommen eine Lieferadresse, an der sie installiert sind. +-- Optional, damit Bestandszähler (vor diesem Feature) nicht brechen – +-- die werden manuell nachgepflegt. ON DELETE SET NULL, damit ein +-- gelöschter Adresseintrag den Zähler 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 `addressId` 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_addressId_fkey' +); +SET @sql := IF( + @idx_exists = 0, + 'CREATE INDEX `Meter_addressId_fkey` ON `Meter`(`addressId`)', + '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_addressId_fkey' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); +SET @sql := IF( + @fk_exists = 0, + 'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`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 4620a6c3..b2ea9f41 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -281,6 +281,7 @@ model Address { ownerEmail String? contractsAsDelivery Contract[] @relation("DeliveryAddress") contractsAsBilling Contract[] @relation("BillingAddress") + meters Meter[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -479,6 +480,10 @@ model Meter { id Int @id @default(autoincrement()) customerId Int customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + // Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler + // vor dem Feature noch keine Adresse haben (werden manuell nachgepflegt). + addressId Int? + address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull) meterNumber String type MeterType tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT) diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index b8c77773..282a8e96 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -635,7 +635,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise = {}; const fieldLabels: Record = { meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell', - location: 'Standort', isActive: 'Aktiv', + location: 'Standort', isActive: 'Aktiv', addressId: 'Lieferadresse', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index c722253e..2f8814ee 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -83,6 +83,7 @@ export async function getCustomerById(id: number) { meters: { orderBy: { isActive: 'desc' }, include: { + address: true, readings: { orderBy: { readingDate: 'desc' }, }, @@ -410,6 +411,7 @@ export async function getCustomerMeters( return prisma.meter.findMany({ where, include: { + address: true, readings: { orderBy: { readingDate: 'desc' }, take: 5, @@ -419,20 +421,43 @@ export async function getCustomerMeters( }); } +// Lieferadresse muss zum Kunden gehören und vom Typ DELIVERY_RESIDENCE sein. +// Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht. +async function assertDeliveryAddressBelongsToCustomer(addressId: number, customerId: number) { + const addr = await prisma.address.findUnique({ where: { id: addressId } }); + if (!addr || addr.customerId !== customerId) { + throw new Error('Ungültige Lieferadresse'); + } + if (addr.type !== 'DELIVERY_RESIDENCE') { + throw new Error('Nur Lieferadressen können einem Zähler zugeordnet werden'); + } +} + export async function createMeter( customerId: number, data: { meterNumber: string; type: 'ELECTRICITY' | 'GAS'; + tariffModel?: 'SINGLE' | 'DUAL'; location?: string; + addressId?: number | null; } ) { + if (data.addressId == null) { + throw new Error('Lieferadresse ist erforderlich'); + } + await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId); return prisma.meter.create({ data: { customerId, - ...data, + meterNumber: data.meterNumber, + type: data.type, + tariffModel: data.tariffModel, + location: data.location, + addressId: data.addressId, isActive: true, }, + include: { address: true }, }); } @@ -441,13 +466,21 @@ export async function updateMeter( data: { meterNumber?: string; type?: 'ELECTRICITY' | 'GAS'; + tariffModel?: 'SINGLE' | 'DUAL'; location?: string; isActive?: boolean; + addressId?: number | null; } ) { + if (data.addressId !== undefined && data.addressId !== null) { + const meter = await prisma.meter.findUnique({ where: { id }, select: { customerId: true } }); + if (!meter) throw new Error('Zähler nicht gefunden'); + await assertDeliveryAddressBelongsToCustomer(data.addressId, meter.customerId); + } return prisma.meter.update({ where: { id }, data, + include: { address: true }, }); } diff --git a/docs/todo.md b/docs/todo.md index a029689e..9f13a3ff 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter** + - **Backend**: Neues Feld `Meter.addressId` (optional FK auf + `Address`, `ON DELETE SET NULL`). Migration + `20260530100000_meter_address` mit `IF NOT EXISTS`. Service + erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden + gehören und Typ `DELIVERY_RESIDENCE` haben. + - **MeterModal** (Kundenakte → Zähler): Pflicht-Dropdown + "Lieferadresse" über allen Feldern. Button "Zähler hinzufügen" + ist disabled, wenn keine Lieferadresse existiert – mit gelbem + Hinweis-Banner. Bestandszähler ohne Adresse zeigen im + MetersTab "Lieferadresse: nicht zugeordnet – bitte über + Bearbeiten nachpflegen" in gelb. + - **ContractForm** (Strom/Gas): Zähler-Dropdown filtert jetzt + auf die im Vertrag gewählte Lieferadresse. Deaktivierte + Zähler bleiben sichtbar (Label-Suffix `(deaktiviert)`); wenn + sie ausgewählt werden, kommt ein Toast: *„Deaktivierter + Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen + Altvertrag?"*. Platzhalter wechselt zwischen "Erst + Lieferadresse wählen…" / "Kein Zähler für diese Adresse + vorhanden" / "Zähler wählen…". + - Audit-Log loggt Adress-Änderung am Zähler als Feld + "Lieferadresse". + - [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore** - Zwei neue Log-Panels auf der DB-Backup-Seite: links "Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index c52262a8..5ea52d1f 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams, useLocation } from 'react-rout import { popHistory } from '../../utils/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api'; import Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; @@ -964,16 +965,47 @@ export default function ContractForm() { {['ELECTRICITY', 'GAS'].includes(contractType) && (
- { + meterRegister.onChange(e); + const m = meters.find((x) => x.id.toString() === e.target.value); + if (m && !m.isActive) { + toast( + 'Deaktivierter Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen Altvertrag?', + { icon: '⚠️', duration: 6000 }, + ); + } + }} + options={filteredMeters.map((m) => ({ + value: m.id, + label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`, + }))} + placeholder={ + !selectedAddressId + ? 'Erst Lieferadresse wählen...' + : filteredMeters.length === 0 + ? 'Kein Zähler für diese Adresse vorhanden' + : 'Zähler wählen...' + } + /> + ); + })()} setShowInactive(!showInactive)} @@ -455,6 +456,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? isOpen={showMeterModal} onClose={() => setShowMeterModal(false)} customerId={customerId} + addresses={c.addresses || []} /> setEditingMeter(null)} customerId={customerId} meter={editingMeter} + addresses={c.addresses || []} /> void; onAdd: () => void; onEdit: (meter: Meter) => void; }) { + const hasDeliveryAddress = addresses.some((a) => a.type === 'DELIVERY_RESIDENCE'); const [showReadingModal, setShowReadingModal] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string } | null>(null); const [expandedMeter, setExpandedMeter] = useState(null); const [editingReading, setEditingReading] = useState<{ meterId: number; meterType: 'ELECTRICITY' | 'GAS'; tariffModel?: string; reading: any } | null>(null); @@ -1301,7 +1307,12 @@ function MetersTab({
{canEdit && ( - @@ -1316,6 +1327,12 @@ function MetersTab({ Inaktive anzeigen
+ {canEdit && !hasDeliveryAddress && ( +
+ Es ist noch keine Lieferadresse hinterlegt. Bitte zunächst im Tab „Adressen" eine + Lieferadresse anlegen, dann können hier Zähler erstellt werden. +
+ )} {filtered.length > 0 ? (
@@ -1405,6 +1422,16 @@ function MetersTab({ {meter.meterNumber}

+ {meter.address ? ( +

+ Lieferadresse: {meter.address.street} {meter.address.houseNumber},{' '} + {meter.address.postalCode} {meter.address.city} +

+ ) : ( +

+ Lieferadresse: nicht zugeordnet – bitte über „Bearbeiten" nachpflegen +

+ )} {meter.location && (

Standort: {meter.location} @@ -2799,14 +2826,17 @@ function MeterModal({ onClose, customerId, meter, + addresses, }: { isOpen: boolean; onClose: () => void; customerId: number; meter?: Meter | null; + addresses: Address[]; }) { const queryClient = useQueryClient(); const isEditing = !!meter; + const deliveryAddresses = addresses.filter((a) => a.type === 'DELIVERY_RESIDENCE'); const getInitialFormData = () => ({ meterNumber: meter?.meterNumber || '', @@ -2814,17 +2844,20 @@ function MeterModal({ tariffModel: meter?.tariffModel || 'SINGLE' as const, location: meter?.location || '', isActive: meter?.isActive ?? true, + addressId: meter?.addressId?.toString() || '', }); const [formData, setFormData] = useState(getInitialFormData); + const [error, setError] = useState(null); 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 }); + setFormData({ meterNumber: '', type: 'ELECTRICITY', tariffModel: 'SINGLE', location: '', isActive: true, addressId: '' }); }, + onError: (err) => setError(err instanceof Error ? err.message : 'Fehler beim Speichern'), }); const updateMutation = useMutation({ @@ -2833,14 +2866,28 @@ function MeterModal({ queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); onClose(); }, + onError: (err) => setError(err instanceof Error ? err.message : 'Fehler beim Speichern'), }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + setError(null); + if (!formData.addressId) { + setError('Bitte eine Lieferadresse auswählen'); + return; + } + const payload = { + meterNumber: formData.meterNumber, + type: formData.type, + tariffModel: formData.tariffModel, + location: formData.location, + isActive: formData.isActive, + addressId: parseInt(formData.addressId), + }; if (isEditing) { - updateMutation.mutate(formData); + updateMutation.mutate(payload); } else { - createMutation.mutate(formData); + createMutation.mutate(payload); } }; @@ -2851,9 +2898,30 @@ function MeterModal({ setFormData(getInitialFormData()); } + const noDeliveryAddresses = deliveryAddresses.length === 0; + return (

+ {noDeliveryAddresses && ( +
+ Keine Lieferadresse vorhanden. Bitte zunächst im Tab „Adressen" eine + Lieferadresse anlegen, bevor ein Zähler erstellt werden kann. +
+ )} + + )} + {error && ( +
+ {error} +
+ )} +
-
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4900b395..e0f45c48 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -216,6 +216,10 @@ export interface Meter { tariffModel: MeterTariffModel; location?: string; isActive: boolean; + // Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler + // (vor Einführung dieses Feldes) noch ohne Adresse existieren können. + addressId?: number | null; + address?: Address; readings?: MeterReading[]; }