43aaf697a1
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>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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,
|
||
};
|
||
}
|