Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:18:24 +02:00
parent d92d0b1eaf
commit c099b41796
8 changed files with 231 additions and 17 deletions
@@ -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;
+5
View File
@@ -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)
@@ -635,7 +635,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
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;
+34 -1
View File
@@ -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 },
});
}