Endstand alter Zähler fließt in Verbrauchsberechnung ein
Bisher wurde "Letzter Stand alter Zähler" zwar in ContractMeter.finalReading gespeichert, aber nirgends ausgewertet. Neuer Helper recordPredecessorFinalReading legt am Wechseldatum einen regulären MeterReading-Eintrag für den Vorgänger an (idempotent, mit Validierung gegen vorhandene Stände). Aufgerufen aus addSuccessorMeter (Vertragsansicht) und createMeter mit successorOf (Kundenakte). Folge: Der Endstand erscheint in der Zählerstände-Liste des alten Zählers und fließt automatisch über calculateMultiMeterConsumption in den Verbrauch (Zeitraum bis removedAt ist inklusive). UI-Hinweise in beiden Folgezähler-Forms erklären den Effekt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import * as contractService from '../services/contract.service.js';
|
|||||||
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||||
import * as authorizationService from '../services/authorization.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 { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.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 existingMeters = [...contract.energyDetails.contractMeters];
|
||||||
const switchAt = installedAt ? new Date(installedAt) : new Date();
|
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
|
// Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
|
||||||
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
|
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
|
||||||
// Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
|
// 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) },
|
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({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'ContractMeter',
|
req, action: 'CREATE', resourceType: 'ContractMeter',
|
||||||
resourceId: contractMeter.id.toString(),
|
resourceId: contractMeter.id.toString(),
|
||||||
|
|||||||
@@ -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.
|
// Lieferadresse muss zum Kunden gehören und vom Typ DELIVERY_RESIDENCE sein.
|
||||||
// Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht.
|
// Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht.
|
||||||
async function assertDeliveryAddressBelongsToCustomer(addressId: number, customerId: number) {
|
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)');
|
throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)');
|
||||||
}
|
}
|
||||||
predecessor = pred;
|
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({
|
const created = await prisma.meter.create({
|
||||||
@@ -557,6 +611,17 @@ export async function createMeter(
|
|||||||
data: { meterId: created.id },
|
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;
|
return created;
|
||||||
|
|||||||
@@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)**
|
- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)**
|
||||||
- **Backend**: Neues Feld `Meter.predecessorMeterId`
|
- **Backend**: Neues Feld `Meter.predecessorMeterId`
|
||||||
(Self-Relation, `ON DELETE SET NULL`). Migration
|
(Self-Relation, `ON DELETE SET NULL`). Migration
|
||||||
|
|||||||
@@ -433,6 +433,12 @@ function SuccessorMeterButton({
|
|||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{finalReading && (
|
||||||
|
<p className="text-xs text-blue-700 mt-2">
|
||||||
|
Wird automatisch als Zählerstand des alten Zählers zum Wechseldatum
|
||||||
|
erfasst und fließt damit in die Verbrauchsberechnung ein.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -3040,7 +3040,8 @@ function MeterModal({
|
|||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
Typ, Adresse und Tarifmodell werden vom Vorgänger übernommen. Alle Verträge,
|
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
|
die den Vorgänger-Zähler verwenden, werden automatisch auf diesen neuen Zähler
|
||||||
umgestellt.
|
umgestellt. Der Endstand wird als Zählerstand des alten Zählers zum Wechseldatum
|
||||||
|
erfasst und fließt damit in die Verbrauchsberechnung ein.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user