diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index 2c30a9f4..17fa3247 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -4,6 +4,7 @@ import * as contractService from '../services/contract.service.js'; import * as contractCockpitService from '../services/contractCockpit.service.js'; import * as contractHistoryService from '../services/contractHistory.service.js'; import * as authorizationService from '../services/authorization.service.js'; +import { recordPredecessorFinalReading } from '../services/customer.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js'; @@ -554,6 +555,38 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis const existingMeters = [...contract.energyDetails.contractMeters]; const switchAt = installedAt ? new Date(installedAt) : new Date(); + // Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag) + const predecessorMeterId = existingMeters.length > 0 + ? existingMeters[existingMeters.length - 1].meterId + : contract.energyDetails.meterId; + + // Endstand bereits hier validieren (monoton-steigend gegen vorhandene + // Zählerstände des Vorgängers), damit wir nicht halb-geschriebene + // Zustände hinterlassen. + if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) { + const finalReadingValue = parseFloat(finalReadingPrevious); + // recordPredecessorFinalReading läuft erst NACH den Writes – Pre-Check + // ohne Write hier separat über die Service-Validierung (idempotent, weil + // sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert). + // Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler + // beim Reading die Kette nicht zerreißt. + const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1); + const sameDay = await prisma.meterReading.findFirst({ + where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } }, + }); + if (!sameDay) { + const lastBefore = await prisma.meterReading.findFirst({ + where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } }, + orderBy: { readingDate: 'desc' }, + }); + if (lastBefore && finalReadingValue < lastBefore.value) { + const fmtDate = (d: Date) => d.toLocaleDateString('de-DE'); + throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`); + } + } + } + // Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den // bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die // Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem @@ -605,6 +638,16 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis data: { meterId: parseInt(meterId) }, }); + // Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in + // die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht. + if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) { + await recordPredecessorFinalReading( + predecessorMeterId, + switchAt, + parseFloat(finalReadingPrevious), + ); + } + await logChange({ req, action: 'CREATE', resourceType: 'ContractMeter', resourceId: contractMeter.id.toString(), diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index 6713946c..5868e7f4 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -421,6 +421,45 @@ export async function getCustomerMeters( }); } +// Schreibt den Endstand des Vorgänger-Zählers beim Zählerwechsel als +// MeterReading. Wird beim Folgezähler-Anlegen aufgerufen (sowohl aus der +// Kundenakte als auch aus der Vertragsansicht). Idempotent: existiert am +// Wechseltag schon ein Reading, wird nichts angelegt. Validierung +// monoton-steigend wird durchgereicht – wirft bei Konflikt. +export async function recordPredecessorFinalReading( + predecessorMeterId: number, + switchAt: Date, + value: number, +) { + const meter = await prisma.meter.findUnique({ + where: { id: predecessorMeterId }, + select: { type: true }, + }); + if (!meter) return; + + const dayStart = new Date(switchAt); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + + const existingSameDay = await prisma.meterReading.findFirst({ + where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } }, + }); + if (existingSameDay) return; + + await validateReadingValue(predecessorMeterId, switchAt, value, undefined, 'HT'); + + await prisma.meterReading.create({ + data: { + meterId: predecessorMeterId, + readingDate: switchAt, + value, + unit: meter.type === 'GAS' ? 'm³' : 'kWh', + notes: 'Endstand bei Zählerwechsel (automatisch beim Folgezähler-Anlegen erfasst)', + }, + }); +} + // 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) { @@ -471,6 +510,21 @@ export async function createMeter( throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)'); } predecessor = pred; + + // Endstand bereits hier validieren, damit kein verwaister Meter entsteht + // wenn der Wert mit bestehenden Zählerständen kollidiert. + if (data.successorOf.finalReadingPrevious != null) { + const switchAt = data.successorOf.installedAt + ? new Date(data.successorOf.installedAt) + : new Date(); + await validateReadingValue( + pred.id, + switchAt, + data.successorOf.finalReadingPrevious, + undefined, + 'HT', + ); + } } const created = await prisma.meter.create({ @@ -557,6 +611,17 @@ export async function createMeter( data: { meterId: created.id }, }); } + + // Endstand des Vorgängers als regulären Zählerstand erfassen, damit er + // in die Verbrauchsberechnung einfließt und in der Zählerstände-Liste + // sichtbar ist. Idempotent gegen Doppel-Submit. + if (data.successorOf.finalReadingPrevious != null) { + await recordPredecessorFinalReading( + predecessor.id, + installedAt, + data.successorOf.finalReadingPrevious, + ); + } } return created; diff --git a/docs/todo.md b/docs/todo.md index 0b33ad24..5937d2ac 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein** + - Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als + `ContractMeter.finalReading` gespeichert, aber nirgends gelesen + – weder in der UI noch in `calculateMultiMeterConsumption`. + - Neuer Helper `recordPredecessorFinalReading(meterId, switchAt, value)` + in customer.service.ts: legt am Wechseldatum einen regulären + `MeterReading`-Eintrag für den Vorgänger an (Notes: + „Endstand bei Zählerwechsel"). Idempotent: existiert am + Wechseltag schon ein Reading, wird nichts geschrieben. + Validierung (monoton-steigend) wird vorab durchgeführt – + Konflikt führt zu sprechender 400-Fehlermeldung, ohne + halb-geschriebene Zustände zu hinterlassen. + - Wird aus beiden Pfaden aufgerufen: `addSuccessorMeter` im + contract.controller (Vertragsansicht → „Folgezähler hinzufügen") + und `createMeter` mit `successorOf` im customer.service + (Kundenakte → „Als Folgezähler deklarieren"). + - Folge: Der Endstand erscheint jetzt in der Zählerstände-Liste + des Vorgänger-Zählers und fließt über + `calculateMultiMeterConsumption` automatisch in den Verbrauch + (Zeitraum bis `removedAt` ist inklusive). + - UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal) + erklären den neuen Effekt. + +- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen** + - Bisher nur sichtbar im Multi-Meter-Zweig (`contractMeters.length > 0`) + – Folgeverträge ohne ContractMeter-Eintrag konnten so keinen + Folgezähler bekommen. + - Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald + entweder ein Single-Meter (`energyDetails.meter`) oder + ContractMeter-Einträge vorhanden sind. + - Im Backend `addSuccessorMeter`: bei Single-Meter-Verträgen wird + der bisherige `energyDetails.meterId` automatisch als + ContractMeter (position 0, `removedAt` = Wechseldatum) backfillt, + damit der alte Zähler nicht aus der Vertragshistorie verschwindet. + - [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)** - **Backend**: Neues Feld `Meter.predecessorMeterId` (Self-Relation, `ON DELETE SET NULL`). Migration diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index e9098f77..8eded20a 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -433,6 +433,12 @@ function SuccessorMeterButton({ placeholder="Optional" /> + {finalReading && ( +

+ Wird automatisch als Zählerstand des alten Zählers zum Wechseldatum + erfasst und fließt damit in die Verbrauchsberechnung ein. +

+ )}
)}