Files
opencrm/frontend/src/utils/energyCalculations.ts
T
duffyduck 43aaf697a1 Fix: Multi-Meter-Verbrauch auf Vertragslaufzeit clampen
Bei Verträgen, die Vorgänger einer Folgevertrags-Kette sind, sind
über ContractMeter auch Folgezähler verknüpft, die nach Vertragsende
installiert wurden. Die Berechnung nahm cm.installedAt..cm.removedAt
1:1 ohne Clamp gegen Contract.startDate/endDate – damit flossen
Zählerstände aus der Folgevertrags-Phase in den Verbrauch dieses
Vertrags ein.

Fix: meterStart = max(installedAt, contractStart),
meterEnd = min(removedAt, contractEnd). Zähler komplett außerhalb
der Laufzeit werden übersprungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:07:19 +02:00

312 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MeterReading, ContractMeter } from '../types';
// Konstante für Gas → kWh Umrechnung (Standard Erdgas H)
export const GAS_TO_KWH_FACTOR = 10.5;
// Ergebnis der Verbrauchsberechnung
export interface ConsumptionCalculation {
type: 'exact' | 'projected' | 'insufficient' | 'none';
consumptionM3?: number; // Nur bei Gas
consumptionKwh: number;
consumptionHt?: number; // Nur bei HT/NT: HT-Verbrauch in kWh
consumptionNt?: number; // Nur bei HT/NT: NT-Verbrauch in kWh
startReading?: MeterReading;
endReading?: MeterReading;
projectedEndDate?: string; // Nur bei Hochrechnung
message?: string;
}
// Ergebnis der Kostenberechnung
export interface CostCalculation {
annualBaseCost: number; // basePrice × 12
annualConsumptionCost: number; // verbrauch × unitPrice (HT bei Zweitarif)
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
annualTotalCost: number; // Summe
monthlyPayment: number; // annualTotalCost / 12
instantBonus?: number; // Sofort-Bonus
newCustomerBonus?: number; // Neukunden-Bonus
totalBonus?: number; // Summe = instantBonus + newCustomerBonus
effectiveAnnualCost: number; // annualTotalCost - totalBonus
}
/**
* Berechnet die Differenz in Tagen zwischen zwei Daten
*/
function daysDiff(startDate: string, endDate: string): number {
const start = new Date(startDate);
const end = new Date(endDate);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const diffTime = end.getTime() - start.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Filtert Zählerstände nach Vertragszeitraum
* Ein Zählerstand gilt als "im Zeitraum" wenn:
* readingDate >= startDate UND readingDate <= endDate
*/
export function filterReadingsByContractPeriod(
readings: MeterReading[],
startDate: string,
endDate: string
): MeterReading[] {
const start = new Date(startDate);
const end = new Date(endDate);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return readings.filter((reading) => {
const readingDate = new Date(reading.readingDate);
readingDate.setHours(0, 0, 0, 0);
return readingDate >= start && readingDate <= end;
});
}
/**
* Berechnet den Verbrauch basierend auf den Zählerständen
*
* Fälle:
* A) Anfangs- UND Endzählerstand vorhanden -> exakter Verbrauch
* B) >= 2 Stände, kein Endzählerstand -> Hochrechnung
* C) Nur 1 Stand -> insufficient
* D) Keine Stände -> none
*/
export function calculateConsumption(
readings: MeterReading[],
startDate: string,
endDate: string,
contractType: 'ELECTRICITY' | 'GAS'
): ConsumptionCalculation {
const filteredReadings = filterReadingsByContractPeriod(readings, startDate, endDate);
// Fall D: Keine Zählerstände
if (filteredReadings.length === 0) {
return { type: 'none', consumptionKwh: 0 };
}
// Fall C: Nur 1 Zählerstand
if (filteredReadings.length === 1) {
return {
type: 'insufficient',
consumptionKwh: 0,
message: 'Berechnung auf Grund fehlender Stände nicht möglich',
};
}
// Sortieren nach Datum (älteste zuerst)
const sorted = [...filteredReadings].sort(
(a, b) => new Date(a.readingDate).getTime() - new Date(b.readingDate).getTime()
);
const lastReading = sorted[sorted.length - 1];
// Prüfen ob Endzählerstand am/nach Vertragsende liegt
const lastReadingDate = new Date(lastReading.readingDate);
const contractEndDate = new Date(endDate);
lastReadingDate.setHours(0, 0, 0, 0);
contractEndDate.setHours(0, 0, 0, 0);
// Verbrauch zwischen aufeinanderfolgenden Ständen berechnen
// (Erkennt Zählerwechsel: wenn ein Wert sinkt, wird ab dem neuen Stand weitergerechnet)
let totalConsumption = 0;
let totalConsumptionNt = 0;
const hasNt = sorted.some(r => r.valueNt !== undefined && r.valueNt !== null);
let effectiveFirstReading = sorted[0];
for (let i = 1; i < sorted.length; i++) {
const diff = sorted[i].value - sorted[i - 1].value;
if (diff < 0) {
totalConsumption = 0;
totalConsumptionNt = 0;
effectiveFirstReading = sorted[i];
} else {
totalConsumption += diff;
if (hasNt && sorted[i].valueNt != null && sorted[i - 1].valueNt != null) {
const diffNt = sorted[i].valueNt! - sorted[i - 1].valueNt!;
if (diffNt >= 0) totalConsumptionNt += diffNt;
}
}
}
const effectiveLastReading = sorted[sorted.length - 1];
// Fall A: Exakter Verbrauch
if (lastReadingDate >= contractEndDate) {
const result = formatConsumptionResult('exact', totalConsumption, contractType, effectiveFirstReading, effectiveLastReading);
if (hasNt) { result.consumptionHt = totalConsumption; result.consumptionNt = totalConsumptionNt; }
return result;
}
// Fall B: Hochrechnung erforderlich
const daysBetweenReadings = daysDiff(effectiveFirstReading.readingDate, effectiveLastReading.readingDate);
if (daysBetweenReadings < 1) {
return {
type: 'insufficient',
consumptionKwh: 0,
message: 'Zeitraum zwischen Zählerständen zu kurz für Berechnung',
};
}
const totalContractDays = daysDiff(startDate, endDate);
const projectedConsumption = (totalConsumption / daysBetweenReadings) * totalContractDays;
const result = formatConsumptionResult(
'projected',
projectedConsumption,
contractType,
effectiveFirstReading,
effectiveLastReading,
endDate
);
if (hasNt) {
const projectedNt = (totalConsumptionNt / daysBetweenReadings) * totalContractDays;
result.consumptionHt = projectedConsumption;
result.consumptionNt = projectedNt;
// Gesamt-kWh = HT + NT
result.consumptionKwh = projectedConsumption + projectedNt;
}
return result;
}
/**
* Formatiert das Ergebnis der Verbrauchsberechnung
*/
function formatConsumptionResult(
type: 'exact' | 'projected',
consumption: number,
contractType: 'ELECTRICITY' | 'GAS',
startReading: MeterReading,
endReading: MeterReading,
projectedEndDate?: string
): ConsumptionCalculation {
if (contractType === 'GAS') {
return {
type,
consumptionM3: consumption,
consumptionKwh: consumption * GAS_TO_KWH_FACTOR,
startReading,
endReading,
projectedEndDate,
};
}
return {
type,
consumptionKwh: consumption,
startReading,
endReading,
projectedEndDate,
};
}
/**
* Berechnet die Kosten basierend auf Verbrauch und Preisen
* Gibt null zurück wenn keine Preise vorhanden sind
*/
export function calculateCosts(
consumptionKwh: number,
basePrice?: number,
unitPrice?: number,
instantBonus?: number,
consumptionNtKwh?: number,
unitPriceNt?: number,
newCustomerBonus?: number,
): CostCalculation | null {
// Mindestens ein Preis muss vorhanden sein
if (basePrice == null && unitPrice == null) {
return null;
}
const annualBaseCost = (basePrice ?? 0) * 12;
// Bei HT/NT: consumptionKwh ist nur HT, NT wird separat berechnet
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
const totalBonus = (instantBonus ?? 0) + (newCustomerBonus ?? 0);
const effectiveAnnualCost = annualTotalCost - totalBonus;
const monthlyPayment = effectiveAnnualCost / 12;
return {
annualBaseCost,
annualConsumptionCost,
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
annualTotalCost,
monthlyPayment,
instantBonus: instantBonus && instantBonus > 0 ? instantBonus : undefined,
newCustomerBonus: newCustomerBonus && newCustomerBonus > 0 ? newCustomerBonus : undefined,
totalBonus: totalBonus > 0 ? totalBonus : undefined,
effectiveAnnualCost,
};
}
/**
* Berechnet den Verbrauch über mehrere Zähler (Folgezähler).
* Pro Zähler wird der Verbrauch einzeln berechnet und dann summiert.
*/
export function calculateMultiMeterConsumption(
contractMeters: ContractMeter[],
startDate: string,
endDate: string,
contractType: 'ELECTRICITY' | 'GAS'
): ConsumptionCalculation {
if (contractMeters.length === 0) {
return { type: 'none', consumptionKwh: 0 };
}
let totalConsumption = 0;
let totalConsumptionM3 = 0;
let hasExact = true;
let hasAny = false;
let firstStart: MeterReading | undefined;
let lastEnd: MeterReading | undefined;
const contractStartMs = new Date(startDate).getTime();
const contractEndMs = new Date(endDate).getTime();
for (const cm of contractMeters) {
const readings = cm.meter?.readings || [];
if (readings.length === 0) continue;
// Zeitraum für diesen Zähler bestimmen, GE-CLAMPED auf die Vertragslaufzeit.
// Ohne Clamp würden Folgezähler, die nach Vertragsende installiert wurden
// (typisch bei Vorgängerverträgen einer Folgevertrags-Kette), zukünftige
// Zählerstände in den Verbrauch dieses Vertrags einrechnen.
const installedMs = cm.installedAt ? new Date(cm.installedAt).getTime() : contractStartMs;
const removedMs = cm.removedAt ? new Date(cm.removedAt).getTime() : contractEndMs;
const meterStartMs = Math.max(installedMs, contractStartMs);
const meterEndMs = Math.min(removedMs, contractEndMs);
if (meterStartMs > meterEndMs) continue; // Zähler liegt komplett außerhalb der Laufzeit
const meterStart = new Date(meterStartMs).toISOString();
const meterEnd = new Date(meterEndMs).toISOString();
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
if (result.type === 'none' || result.type === 'insufficient') continue;
hasAny = true;
if (result.type === 'projected') hasExact = false;
totalConsumption += result.consumptionKwh;
if (result.consumptionM3) totalConsumptionM3 += result.consumptionM3;
if (!firstStart && result.startReading) firstStart = result.startReading;
if (result.endReading) lastEnd = result.endReading;
}
if (!hasAny) {
// Fallback: Einzelzähler-Berechnung mit allen Readings
const allReadings = contractMeters.flatMap(cm => cm.meter?.readings || []);
return calculateConsumption(allReadings, startDate, endDate, contractType);
}
return {
type: hasExact ? 'exact' : 'projected',
consumptionKwh: totalConsumption,
consumptionM3: contractType === 'GAS' ? totalConsumptionM3 : undefined,
startReading: firstStart,
endReading: lastEnd,
};
}