Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:48:23 +02:00
parent 6f378d750c
commit ad4c2bae1d
6 changed files with 278 additions and 5 deletions
@@ -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;
+6
View File
@@ -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")
@@ -603,10 +603,13 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body);
const successorLabel = meter.predecessor
? ` als Folgezähler von ${meter.predecessor.meterNumber}`
: '';
await logChange({
req, action: 'CREATE', resourceType: 'Meter',
resourceId: meter.id.toString(),
label: `Zähler angelegt für Kunde #${customerId}`,
label: `Zähler angelegt${successorLabel} für Kunde #${customerId}`,
customerId,
});
res.status(201).json({ success: true, data: meter } as ApiResponse);
+103 -2
View File
@@ -441,13 +441,39 @@ export async function createMeter(
tariffModel?: 'SINGLE' | 'DUAL';
location?: string;
addressId?: number | null;
// Optional: dieser Zähler ersetzt einen bestehenden (Folgezähler).
// Beim Create werden alle Verträge, die den Vorgänger als aktuellen
// Zähler nutzen, automatisch auf den neuen Zähler umgestellt
// (ContractMeter-Eintrag analog zu Vertragsansicht).
successorOf?: {
predecessorMeterId: number;
installedAt?: string;
finalReadingPrevious?: number;
};
}
) {
if (data.addressId == null) {
throw new Error('Lieferadresse ist erforderlich');
}
await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId);
return prisma.meter.create({
// Vorgänger validieren (wenn Folgezähler)
let predecessor: { id: number; customerId: number; type: 'ELECTRICITY' | 'GAS' } | null = null;
if (data.successorOf) {
const pred = await prisma.meter.findUnique({
where: { id: data.successorOf.predecessorMeterId },
select: { id: true, customerId: true, type: true },
});
if (!pred || pred.customerId !== customerId) {
throw new Error('Vorgänger-Zähler nicht gefunden');
}
if (pred.type !== data.type) {
throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)');
}
predecessor = pred;
}
const created = await prisma.meter.create({
data: {
customerId,
meterNumber: data.meterNumber,
@@ -456,9 +482,84 @@ export async function createMeter(
location: data.location,
addressId: data.addressId,
isActive: true,
predecessorMeterId: predecessor?.id,
},
include: { address: true },
include: { address: true, predecessor: true },
});
// Folgezähler-Propagation: alle Verträge, die den Vorgänger als aktuellen
// Zähler nutzen, bekommen den neuen Zähler als Nachfolger angehängt
// (analog zu addSuccessorMeter im contract.controller).
if (predecessor && data.successorOf) {
const installedAt = data.successorOf.installedAt
? new Date(data.successorOf.installedAt)
: new Date();
const finalReading = data.successorOf.finalReadingPrevious;
const affectedContracts = await prisma.energyContractDetails.findMany({
where: { meterId: predecessor.id },
include: { contractMeters: { orderBy: { position: 'asc' } } },
});
for (const ecd of affectedContracts) {
// Vorhandenen ContractMeter für den Vorgänger als gewechselt markieren.
// Falls noch kein ContractMeter für den Vorgänger existiert (Single-Meter-
// Vertrag vor Multi-Meter-Refactor), legen wir ihn als position 0 an,
// damit die Kette lückenlos ist.
let predCM = ecd.contractMeters.find((cm) => 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(