MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.
Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.
Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).
Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1160 lines
34 KiB
TypeScript
1160 lines
34 KiB
TypeScript
import { CustomerType, ContractStatus } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||
import { ApiError } from '../utils/apiError.js';
|
||
import { deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||
|
||
export interface CustomerFilters {
|
||
search?: string;
|
||
type?: CustomerType;
|
||
page?: number;
|
||
limit?: number;
|
||
// Wenn gesetzt: nur Customer mit id in dieser Liste. Für Portal-User, damit
|
||
// weder Liste noch pagination.total die globale Kunden-Zahl preisgibt.
|
||
allowedIds?: number[];
|
||
}
|
||
|
||
export async function getAllCustomers(filters: CustomerFilters) {
|
||
const { search, type, page = 1, limit = 20, allowedIds } = filters;
|
||
const { skip, take } = paginate(page, limit);
|
||
|
||
const where: Record<string, unknown> = {};
|
||
|
||
if (type) {
|
||
where.type = type;
|
||
}
|
||
|
||
if (allowedIds) {
|
||
where.id = { in: allowedIds };
|
||
}
|
||
|
||
if (search) {
|
||
where.OR = [
|
||
{ firstName: { contains: search } },
|
||
{ lastName: { contains: search } },
|
||
{ companyName: { contains: search } },
|
||
{ email: { contains: search } },
|
||
{ customerNumber: { contains: search } },
|
||
];
|
||
}
|
||
|
||
const [customers, total] = await Promise.all([
|
||
prisma.customer.findMany({
|
||
where,
|
||
skip,
|
||
take,
|
||
orderBy: { createdAt: 'desc' },
|
||
include: {
|
||
addresses: { where: { isDefault: true }, take: 1 },
|
||
_count: {
|
||
select: { contracts: true },
|
||
},
|
||
},
|
||
}),
|
||
prisma.customer.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
customers,
|
||
pagination: buildPaginationResponse(page, limit, total),
|
||
};
|
||
}
|
||
|
||
export async function getCustomerById(id: number) {
|
||
return prisma.customer.findUnique({
|
||
where: { id },
|
||
include: {
|
||
addresses: true,
|
||
bankCards: { orderBy: { isActive: 'desc' } },
|
||
identityDocuments: { orderBy: { isActive: 'desc' } },
|
||
meters: {
|
||
orderBy: { isActive: 'desc' },
|
||
include: {
|
||
address: true,
|
||
readings: {
|
||
orderBy: { readingDate: 'desc' },
|
||
},
|
||
// Verträge, die diesen Zähler aktuell als Hauptzähler nutzen
|
||
// (energyDetails.meterId === meter.id)
|
||
energyDetails: {
|
||
include: {
|
||
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
|
||
},
|
||
},
|
||
// Verträge, in denen der Zähler in der ContractMeter-Kette steht
|
||
// (Vorgänger oder Nachfolger über Zählerwechsel)
|
||
contractMeters: {
|
||
include: {
|
||
energyContractDetails: {
|
||
include: {
|
||
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
stressfreiEmails: { orderBy: { isActive: 'desc' } },
|
||
contracts: {
|
||
where: {
|
||
// Deaktivierte Verträge ausblenden
|
||
status: { not: ContractStatus.DEACTIVATED },
|
||
},
|
||
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
|
||
include: {
|
||
address: true,
|
||
salesPlatform: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function getCustomersByIds(ids: number[]) {
|
||
return prisma.customer.findMany({
|
||
where: { id: { in: ids } },
|
||
select: {
|
||
id: true,
|
||
portalEmail: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function createCustomer(data: {
|
||
type?: CustomerType;
|
||
salutation?: string;
|
||
firstName: string;
|
||
lastName: string;
|
||
companyName?: string;
|
||
birthDate?: Date;
|
||
birthPlace?: string;
|
||
email?: string;
|
||
phone?: string;
|
||
mobile?: string;
|
||
taxNumber?: string;
|
||
vatExempt?: boolean;
|
||
businessRegistration?: string;
|
||
commercialRegister?: string;
|
||
notes?: string;
|
||
}) {
|
||
return prisma.customer.create({
|
||
data: {
|
||
...data,
|
||
customerNumber: generateCustomerNumber(),
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function updateCustomer(
|
||
id: number,
|
||
data: {
|
||
type?: CustomerType;
|
||
salutation?: string;
|
||
useInformalAddress?: boolean;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
companyName?: string;
|
||
birthDate?: Date;
|
||
birthPlace?: string;
|
||
email?: string;
|
||
phone?: string;
|
||
mobile?: string;
|
||
taxNumber?: string;
|
||
vatExempt?: boolean;
|
||
businessRegistration?: string;
|
||
commercialRegister?: string;
|
||
notes?: string;
|
||
autoBirthdayGreeting?: boolean;
|
||
autoBirthdayChannel?: string | null;
|
||
}
|
||
) {
|
||
return prisma.customer.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
}
|
||
|
||
export async function deleteCustomer(id: number) {
|
||
// Vor dem Löschen: Alle Dokumente (Dateien) des Kunden löschen
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id },
|
||
select: { businessRegistrationPath: true, commercialRegisterPath: true, privacyPolicyPath: true },
|
||
});
|
||
const bankCards = await prisma.bankCard.findMany({
|
||
where: { customerId: id },
|
||
select: { documentPath: true },
|
||
});
|
||
const identityDocs = await prisma.identityDocument.findMany({
|
||
where: { customerId: id },
|
||
select: { documentPath: true },
|
||
});
|
||
|
||
// Kundendokumente löschen
|
||
if (customer) {
|
||
deleteUploadByRelativePath(customer.businessRegistrationPath);
|
||
deleteUploadByRelativePath(customer.commercialRegisterPath);
|
||
deleteUploadByRelativePath(customer.privacyPolicyPath);
|
||
}
|
||
|
||
// Bankkarten- und Ausweisdokumente löschen
|
||
for (const card of bankCards) {
|
||
deleteUploadByRelativePath(card.documentPath);
|
||
}
|
||
for (const doc of identityDocs) {
|
||
deleteUploadByRelativePath(doc.documentPath);
|
||
}
|
||
|
||
// Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge)
|
||
return prisma.customer.delete({
|
||
where: { id },
|
||
});
|
||
}
|
||
|
||
// Address operations
|
||
export async function getCustomerAddresses(customerId: number) {
|
||
return prisma.address.findMany({
|
||
where: { customerId },
|
||
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
|
||
});
|
||
}
|
||
|
||
// Feld-Whitelist für Address-Create/Update (Mass-Assignment-Schutz, konsistent
|
||
// zu R156; autoritativ aus den DB-Spalten abgeleitet). BEWUSST NICHT enthalten:
|
||
// id/customerId/createdAt/updatedAt. Enthält die Eigentümer-Felder (owner*) –
|
||
// die waren beim ersten R156-Umbau versehentlich rausgefallen (Regression) –
|
||
// sowie die neuen MaLo-IDs.
|
||
const ADDRESS_SCALAR_FIELDS = [
|
||
'type', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'isDefault',
|
||
'maloIdElectricity', 'maloIdGas',
|
||
'ownerCompany', 'ownerFirstName', 'ownerLastName', 'ownerStreet', 'ownerHouseNumber',
|
||
'ownerPostalCode', 'ownerCity', 'ownerPhone', 'ownerMobile', 'ownerEmail',
|
||
] as const;
|
||
|
||
function pickAddressScalars(data: unknown): Record<string, unknown> {
|
||
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||
const out: Record<string, unknown> = {};
|
||
for (const key of ADDRESS_SCALAR_FIELDS) {
|
||
if (key in src) out[key] = src[key];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export async function createAddress(
|
||
customerId: number,
|
||
data: {
|
||
type: 'DELIVERY_RESIDENCE' | 'BILLING';
|
||
street: string;
|
||
houseNumber: string;
|
||
postalCode: string;
|
||
city: string;
|
||
country?: string;
|
||
isDefault?: boolean;
|
||
}
|
||
) {
|
||
// If this is set as default, unset other defaults of same type
|
||
if (data.isDefault) {
|
||
await prisma.address.updateMany({
|
||
where: { customerId, type: data.type },
|
||
data: { isDefault: false },
|
||
});
|
||
}
|
||
|
||
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||
// ADDRESS_SCALAR_FIELDS. customerId kommt ausschließlich aus dem Parameter.
|
||
return prisma.address.create({
|
||
data: {
|
||
customerId,
|
||
...(pickAddressScalars(data) as any),
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function updateAddress(
|
||
id: number,
|
||
data: {
|
||
type?: 'DELIVERY_RESIDENCE' | 'BILLING';
|
||
street?: string;
|
||
houseNumber?: string;
|
||
postalCode?: string;
|
||
city?: string;
|
||
country?: string;
|
||
isDefault?: boolean;
|
||
}
|
||
) {
|
||
const address = await prisma.address.findUnique({ where: { id } });
|
||
if (!address) throw new Error('Adresse nicht gefunden');
|
||
|
||
if (data.isDefault) {
|
||
await prisma.address.updateMany({
|
||
where: {
|
||
customerId: address.customerId,
|
||
type: data.type || address.type,
|
||
id: { not: id },
|
||
},
|
||
data: { isDefault: false },
|
||
});
|
||
}
|
||
|
||
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||
// ADDRESS_SCALAR_FIELDS.
|
||
return prisma.address.update({
|
||
where: { id },
|
||
data: pickAddressScalars(data) as any,
|
||
});
|
||
}
|
||
|
||
export async function deleteAddress(id: number) {
|
||
return prisma.address.delete({ where: { id } });
|
||
}
|
||
|
||
// Bank card operations
|
||
export async function getCustomerBankCards(
|
||
customerId: number,
|
||
showInactive: boolean = false
|
||
) {
|
||
const where: Record<string, unknown> = { customerId };
|
||
if (!showInactive) {
|
||
where.isActive = true;
|
||
}
|
||
return prisma.bankCard.findMany({
|
||
where,
|
||
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
|
||
});
|
||
}
|
||
|
||
export async function createBankCard(
|
||
customerId: number,
|
||
data: {
|
||
accountHolder: string;
|
||
iban: string;
|
||
cardNumber?: string;
|
||
bic?: string;
|
||
bankName?: string;
|
||
expiryDate?: Date;
|
||
description?: string;
|
||
}
|
||
) {
|
||
// Explizite Feld-Whitelist statt `...data`-Spread: verhindert Mass-Assignment
|
||
// (Pentest R155). Sonst könnte roher req.body u.a. `customerId`/`id` setzen.
|
||
return prisma.bankCard.create({
|
||
data: {
|
||
customerId,
|
||
accountHolder: data.accountHolder,
|
||
iban: data.iban,
|
||
cardNumber: data.cardNumber,
|
||
bic: data.bic,
|
||
bankName: data.bankName,
|
||
expiryDate: data.expiryDate,
|
||
description: data.description,
|
||
isActive: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function updateBankCard(
|
||
id: number,
|
||
data: {
|
||
accountHolder?: string;
|
||
iban?: string;
|
||
cardNumber?: string;
|
||
bic?: string;
|
||
bankName?: string;
|
||
expiryDate?: Date;
|
||
description?: string;
|
||
isActive?: boolean;
|
||
}
|
||
) {
|
||
// Explizite Feld-Whitelist statt `data`-Passthrough: verhindert Mass-
|
||
// Assignment (Pentest R155 – roher req.body konnte `customerId`/`id`
|
||
// mutieren). Undefined-Felder ignoriert Prisma → Partial-Update bleibt.
|
||
return prisma.bankCard.update({
|
||
where: { id },
|
||
data: {
|
||
accountHolder: data.accountHolder,
|
||
iban: data.iban,
|
||
cardNumber: data.cardNumber,
|
||
bic: data.bic,
|
||
bankName: data.bankName,
|
||
expiryDate: data.expiryDate,
|
||
description: data.description,
|
||
isActive: data.isActive,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function deleteBankCard(id: number) {
|
||
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
|
||
const bankCard = await prisma.bankCard.findUnique({ where: { id } });
|
||
if (bankCard?.documentPath) {
|
||
deleteUploadByRelativePath(bankCard.documentPath);
|
||
}
|
||
return prisma.bankCard.delete({ where: { id } });
|
||
}
|
||
|
||
// Identity document operations
|
||
export async function getCustomerDocuments(
|
||
customerId: number,
|
||
showInactive: boolean = false
|
||
) {
|
||
const where: Record<string, unknown> = { customerId };
|
||
if (!showInactive) {
|
||
where.isActive = true;
|
||
}
|
||
return prisma.identityDocument.findMany({
|
||
where,
|
||
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
|
||
});
|
||
}
|
||
|
||
export async function createDocument(
|
||
customerId: number,
|
||
data: {
|
||
type: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER';
|
||
documentNumber: string;
|
||
issuingAuthority?: string;
|
||
issueDate?: Date;
|
||
expiryDate?: Date;
|
||
licenseClasses?: string;
|
||
licenseIssueDate?: Date;
|
||
}
|
||
) {
|
||
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||
return prisma.identityDocument.create({
|
||
data: {
|
||
customerId,
|
||
type: data.type,
|
||
documentNumber: data.documentNumber,
|
||
issuingAuthority: data.issuingAuthority,
|
||
issueDate: data.issueDate,
|
||
expiryDate: data.expiryDate,
|
||
licenseClasses: data.licenseClasses,
|
||
licenseIssueDate: data.licenseIssueDate,
|
||
isActive: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function updateDocument(
|
||
id: number,
|
||
data: {
|
||
type?: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER';
|
||
documentNumber?: string;
|
||
issuingAuthority?: string;
|
||
issueDate?: Date;
|
||
expiryDate?: Date;
|
||
licenseClasses?: string;
|
||
licenseIssueDate?: Date;
|
||
isActive?: boolean;
|
||
}
|
||
) {
|
||
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||
return prisma.identityDocument.update({
|
||
where: { id },
|
||
data: {
|
||
type: data.type,
|
||
documentNumber: data.documentNumber,
|
||
issuingAuthority: data.issuingAuthority,
|
||
issueDate: data.issueDate,
|
||
expiryDate: data.expiryDate,
|
||
licenseClasses: data.licenseClasses,
|
||
licenseIssueDate: data.licenseIssueDate,
|
||
isActive: data.isActive,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function deleteDocument(id: number) {
|
||
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
|
||
const document = await prisma.identityDocument.findUnique({ where: { id } });
|
||
if (document?.documentPath) {
|
||
deleteUploadByRelativePath(document.documentPath);
|
||
}
|
||
return prisma.identityDocument.delete({ where: { id } });
|
||
}
|
||
|
||
// Meter operations
|
||
export async function getCustomerMeters(
|
||
customerId: number,
|
||
showInactive: boolean = false
|
||
) {
|
||
const where: Record<string, unknown> = { customerId };
|
||
if (!showInactive) {
|
||
where.isActive = true;
|
||
}
|
||
return prisma.meter.findMany({
|
||
where,
|
||
include: {
|
||
address: true,
|
||
readings: {
|
||
orderBy: { readingDate: 'desc' },
|
||
take: 5,
|
||
},
|
||
},
|
||
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
|
||
});
|
||
}
|
||
|
||
// 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) {
|
||
const addr = await prisma.address.findUnique({ where: { id: addressId } });
|
||
if (!addr || addr.customerId !== customerId) {
|
||
throw new Error('Ungültige Lieferadresse');
|
||
}
|
||
if (addr.type !== 'DELIVERY_RESIDENCE') {
|
||
throw new Error('Nur Lieferadressen können einem Zähler zugeordnet werden');
|
||
}
|
||
}
|
||
|
||
export async function createMeter(
|
||
customerId: number,
|
||
data: {
|
||
meterNumber: string;
|
||
type: 'ELECTRICITY' | 'GAS';
|
||
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;
|
||
// Default true im UI: alter Zähler wird nach dem Wechsel auf
|
||
// isActive=false gesetzt. Kann ausgeschaltet werden, wenn der alte
|
||
// Zähler aus irgendeinem Grund noch aktiv bleiben soll.
|
||
deactivatePredecessor?: boolean;
|
||
};
|
||
}
|
||
) {
|
||
if (data.addressId == null) {
|
||
throw new Error('Lieferadresse ist erforderlich');
|
||
}
|
||
await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId);
|
||
|
||
// 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;
|
||
|
||
// 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({
|
||
data: {
|
||
customerId,
|
||
meterNumber: data.meterNumber,
|
||
type: data.type,
|
||
tariffModel: data.tariffModel,
|
||
location: data.location,
|
||
addressId: data.addressId,
|
||
isActive: true,
|
||
predecessorMeterId: predecessor?.id,
|
||
},
|
||
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 },
|
||
});
|
||
}
|
||
|
||
// 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,
|
||
);
|
||
}
|
||
|
||
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
|
||
// explizit auf false setzt. Macht den typischen Zählerwechsel-Workflow
|
||
// ein-klick-fähig.
|
||
if (data.successorOf.deactivatePredecessor !== false) {
|
||
await prisma.meter.update({
|
||
where: { id: predecessor.id },
|
||
data: { isActive: false },
|
||
});
|
||
}
|
||
}
|
||
|
||
return created;
|
||
}
|
||
|
||
export async function updateMeter(
|
||
id: number,
|
||
data: {
|
||
meterNumber?: string;
|
||
type?: 'ELECTRICITY' | 'GAS';
|
||
tariffModel?: 'SINGLE' | 'DUAL';
|
||
location?: string;
|
||
isActive?: boolean;
|
||
addressId?: number | null;
|
||
}
|
||
) {
|
||
if (data.addressId !== undefined && data.addressId !== null) {
|
||
const meter = await prisma.meter.findUnique({ where: { id }, select: { customerId: true } });
|
||
if (!meter) throw new Error('Zähler nicht gefunden');
|
||
await assertDeliveryAddressBelongsToCustomer(data.addressId, meter.customerId);
|
||
}
|
||
return prisma.meter.update({
|
||
where: { id },
|
||
data,
|
||
include: { address: true },
|
||
});
|
||
}
|
||
|
||
export async function deleteMeter(id: number) {
|
||
// Prüfen ob der Zähler noch an Verträgen hängt
|
||
const linkedContracts = await prisma.contractMeter.findMany({
|
||
where: { meterId: id },
|
||
include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } },
|
||
});
|
||
|
||
if (linkedContracts.length > 0) {
|
||
const contractNumbers = linkedContracts
|
||
.map(cm => cm.energyContractDetails.contract.contractNumber)
|
||
.join(', ');
|
||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||
}
|
||
|
||
// Auch direkte meterId-Referenz auf EnergyContractDetails prüfen
|
||
const directLinks = await prisma.energyContractDetails.findMany({
|
||
where: { meterId: id },
|
||
include: { contract: { select: { contractNumber: true } } },
|
||
});
|
||
|
||
if (directLinks.length > 0) {
|
||
const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', ');
|
||
throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`);
|
||
}
|
||
|
||
return prisma.meter.delete({ where: { id } });
|
||
}
|
||
|
||
export async function addMeterReading(
|
||
meterId: number,
|
||
data: {
|
||
readingDate: Date;
|
||
value: number;
|
||
valueNt?: number;
|
||
unit?: string;
|
||
notes?: string;
|
||
}
|
||
) {
|
||
// Validierung: Zählerstand muss monoton steigend sein
|
||
await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT');
|
||
if (data.valueNt !== undefined) {
|
||
await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT');
|
||
}
|
||
|
||
return prisma.meterReading.create({
|
||
data: {
|
||
meterId,
|
||
readingDate: data.readingDate,
|
||
value: data.value,
|
||
valueNt: data.valueNt,
|
||
unit: data.unit,
|
||
notes: data.notes,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function getMeterReadings(meterId: number) {
|
||
return prisma.meterReading.findMany({
|
||
where: { meterId },
|
||
orderBy: { readingDate: 'desc' },
|
||
});
|
||
}
|
||
|
||
export async function updateMeterReading(
|
||
meterId: number,
|
||
readingId: number,
|
||
data: {
|
||
readingDate?: Date;
|
||
value?: number;
|
||
valueNt?: number | null;
|
||
unit?: string;
|
||
notes?: string;
|
||
}
|
||
) {
|
||
// Verify the reading belongs to the meter
|
||
const reading = await prisma.meterReading.findFirst({
|
||
where: { id: readingId, meterId },
|
||
});
|
||
|
||
if (!reading) {
|
||
throw new Error('Zählerstand nicht gefunden');
|
||
}
|
||
|
||
// Validierung bei Wertänderung
|
||
if (data.value !== undefined || data.readingDate !== undefined) {
|
||
await validateReadingValue(
|
||
meterId,
|
||
data.readingDate || reading.readingDate,
|
||
data.value ?? reading.value,
|
||
readingId,
|
||
'HT'
|
||
);
|
||
}
|
||
if (data.valueNt !== undefined || data.readingDate !== undefined) {
|
||
const ntVal = data.valueNt ?? reading.valueNt;
|
||
if (ntVal !== undefined && ntVal !== null) {
|
||
await validateReadingValue(
|
||
meterId,
|
||
data.readingDate || reading.readingDate,
|
||
ntVal,
|
||
readingId,
|
||
'NT'
|
||
);
|
||
}
|
||
}
|
||
|
||
return prisma.meterReading.update({
|
||
where: { id: readingId },
|
||
data,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Validiert, dass ein Zählerstand monoton steigend ist.
|
||
* tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif
|
||
*/
|
||
async function validateReadingValue(meterId: number, readingDate: Date, value: number, excludeReadingId?: number, tariffLabel: 'HT' | 'NT' = 'HT') {
|
||
const existing = await prisma.meterReading.findMany({
|
||
where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) },
|
||
orderBy: { readingDate: 'asc' },
|
||
});
|
||
|
||
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
const fmtVal = (v: number) => v.toLocaleString('de-DE');
|
||
const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand';
|
||
|
||
// Vergleichswert aus bestehendem Reading extrahieren
|
||
const getVal = (r: typeof existing[0]) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value;
|
||
|
||
// Stand vor dem neuen Datum
|
||
const before = [...existing].filter(r => r.readingDate <= readingDate).pop();
|
||
if (before && value < getVal(before)) {
|
||
throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`);
|
||
}
|
||
|
||
// Stand nach dem neuen Datum
|
||
const after = existing.find(r => r.readingDate > readingDate);
|
||
if (after && value > getVal(after)) {
|
||
throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`);
|
||
}
|
||
}
|
||
|
||
export async function deleteMeterReading(meterId: number, readingId: number) {
|
||
// Verify the reading belongs to the meter
|
||
const reading = await prisma.meterReading.findFirst({
|
||
where: { id: readingId, meterId },
|
||
});
|
||
|
||
if (!reading) {
|
||
throw new Error('Zählerstand nicht gefunden');
|
||
}
|
||
|
||
return prisma.meterReading.delete({
|
||
where: { id: readingId },
|
||
});
|
||
}
|
||
|
||
// ==================== PORTAL SETTINGS ====================
|
||
|
||
export async function updatePortalSettings(
|
||
customerId: number,
|
||
data: {
|
||
portalEnabled?: boolean;
|
||
portalEmail?: string | null;
|
||
}
|
||
) {
|
||
// Wenn Portal deaktiviert wird, Passwort-Hash nicht löschen (für spätere Reaktivierung)
|
||
return prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: {
|
||
portalEnabled: data.portalEnabled,
|
||
portalEmail: data.portalEmail,
|
||
},
|
||
select: {
|
||
id: true,
|
||
portalEnabled: true,
|
||
portalEmail: true,
|
||
portalLastLogin: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function getPortalSettings(customerId: number) {
|
||
return prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: {
|
||
id: true,
|
||
portalEnabled: true,
|
||
portalEmail: true,
|
||
portalLastLogin: true,
|
||
portalPasswordHash: true, // Nur um zu prüfen ob Passwort gesetzt (wird als boolean zurückgegeben)
|
||
},
|
||
});
|
||
}
|
||
|
||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||
|
||
export async function getCustomerRepresentatives(customerId: number) {
|
||
// Holt alle Kunden, die der angegebene Kunde vertreten kann (dieser ist der Vertreter)
|
||
return prisma.customerRepresentative.findMany({
|
||
where: { representativeId: customerId, isActive: true },
|
||
include: {
|
||
customer: {
|
||
select: {
|
||
id: true,
|
||
customerNumber: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
companyName: true,
|
||
type: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
}
|
||
|
||
export async function getRepresentedByList(customerId: number) {
|
||
// Holt alle Kunden, die den angegebenen Kunden vertreten können.
|
||
// `portalEnabled` mit rausgeben, damit die UI ein Badge zeigen kann,
|
||
// falls der Vertreter noch keinen Portal-Zugang hat (Vertretung ist
|
||
// dann formal eingetragen, greift aber erst mit aktivem Portal).
|
||
return prisma.customerRepresentative.findMany({
|
||
where: { customerId: customerId, isActive: true },
|
||
include: {
|
||
representative: {
|
||
select: {
|
||
id: true,
|
||
customerNumber: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
companyName: true,
|
||
type: true,
|
||
portalEnabled: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
}
|
||
|
||
export async function addRepresentative(
|
||
customerId: number, // Der Kunde, dessen Verträge eingesehen werden dürfen
|
||
representativeId: number, // Der Kunde, der einsehen darf
|
||
notes?: string
|
||
) {
|
||
// Prüfen, ob beide Kunden existieren
|
||
const [customer, representative] = await Promise.all([
|
||
prisma.customer.findUnique({ where: { id: customerId } }),
|
||
prisma.customer.findUnique({ where: { id: representativeId } }),
|
||
]);
|
||
|
||
if (!customer) {
|
||
throw new Error('Kunde nicht gefunden');
|
||
}
|
||
if (!representative) {
|
||
throw new Error('Vertreter-Kunde nicht gefunden');
|
||
}
|
||
if (customerId === representativeId) {
|
||
throw new Error('Ein Kunde kann sich nicht selbst vertreten');
|
||
}
|
||
|
||
// Portal-Zwang entfernt (2026-07-03): der Vertreter darf ohne Portal
|
||
// eingetragen werden; die Beziehung ist erst dann effektiv aktiv,
|
||
// wenn der Vertreter einen Portal-Zugang bekommt (die Frontend-UI
|
||
// zeigt in dem Fall ein Warn-Badge).
|
||
|
||
return prisma.customerRepresentative.upsert({
|
||
where: {
|
||
customerId_representativeId: { customerId, representativeId },
|
||
},
|
||
create: {
|
||
customerId,
|
||
representativeId,
|
||
notes,
|
||
isActive: true,
|
||
},
|
||
update: {
|
||
isActive: true,
|
||
notes,
|
||
},
|
||
include: {
|
||
representative: {
|
||
select: {
|
||
id: true,
|
||
customerNumber: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
companyName: true,
|
||
type: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function removeRepresentative(customerId: number, representativeId: number) {
|
||
// Anstatt zu löschen, setzen wir isActive auf false
|
||
return prisma.customerRepresentative.update({
|
||
where: {
|
||
customerId_representativeId: { customerId, representativeId },
|
||
},
|
||
data: { isActive: false },
|
||
});
|
||
}
|
||
|
||
export async function searchCustomersForRepresentative(search: string, excludeCustomerId: number) {
|
||
// Sucht Kunden, die als Vertreter hinzugefügt werden können.
|
||
// 2026-07-03: Portal-Filter entfernt – der Admin soll auch Kunden ohne
|
||
// Portal-Zugang als Vertreter markieren können; das Portal wird dann
|
||
// typischerweise nachträglich aktiviert. Der Zugriff wird ohnehin erst
|
||
// aktiv, sobald das Portal für den Vertreter angeschaltet ist – die UI
|
||
// zeigt jetzt ein Warn-Badge, wenn der Vertreter noch kein Portal hat.
|
||
return prisma.customer.findMany({
|
||
where: {
|
||
id: { not: excludeCustomerId },
|
||
OR: [
|
||
{ firstName: { contains: search } },
|
||
{ lastName: { contains: search } },
|
||
{ companyName: { contains: search } },
|
||
{ customerNumber: { contains: search } },
|
||
],
|
||
},
|
||
select: {
|
||
id: true,
|
||
customerNumber: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
companyName: true,
|
||
type: true,
|
||
portalEnabled: true,
|
||
},
|
||
take: 10,
|
||
});
|
||
}
|
||
|
||
// ==================== SALUTATION PREFERENCE ====================
|
||
// Per-User "Du"/"Sie" pro Kunde. Fehlender Eintrag ⇒ Kunden-Default
|
||
// (Customer.useInformalAddress). Nur Mitarbeiter-User, keine Portal-User.
|
||
|
||
export type SalutationPreference = 'DU' | 'SIE';
|
||
|
||
function isValidPreference(v: unknown): v is SalutationPreference {
|
||
return v === 'DU' || v === 'SIE';
|
||
}
|
||
|
||
/**
|
||
* Wirft ApiError(404), wenn kein Kunde mit dieser ID existiert. Vor
|
||
* jedem Salutation-Preference-Zugriff aufrufen – ohne den Check würde
|
||
* `upsert`/`deleteMany` einen Prisma-FK-Error werfen, den der Controller
|
||
* als 500 mappt (Pentest R104.1).
|
||
*/
|
||
async function assertCustomerExists(customerId: number): Promise<void> {
|
||
const exists = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { id: true },
|
||
});
|
||
if (!exists) {
|
||
throw new ApiError(404, 'Kunde nicht gefunden');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liefert die effektive Anrede für einen User/Kunde: eigene Präferenz
|
||
* bevorzugt, sonst Kunden-Default. `source` sagt der UI, ob's ein eigener
|
||
* Override ist oder der Fallback greift.
|
||
*/
|
||
export async function getSalutationPreference(
|
||
userId: number,
|
||
customerId: number,
|
||
): Promise<{
|
||
preference: SalutationPreference;
|
||
source: 'user' | 'customer-default';
|
||
}> {
|
||
const own = await prisma.userCustomerSalutation.findUnique({
|
||
where: { userId_customerId: { userId, customerId } },
|
||
});
|
||
if (own && isValidPreference(own.preference)) {
|
||
return { preference: own.preference, source: 'user' };
|
||
}
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { useInformalAddress: true },
|
||
});
|
||
if (!customer) {
|
||
// Pentest R104.1 (INFO, 2026-07-04): vorher `throw new Error(...)`,
|
||
// was der Controller-Catch als 500 mappte. Sauberes 404 macht die
|
||
// Response semantisch korrekt (existiert nicht / gerade gelöscht).
|
||
throw new ApiError(404, 'Kunde nicht gefunden');
|
||
}
|
||
return {
|
||
preference: customer.useInformalAddress ? 'DU' : 'SIE',
|
||
source: 'customer-default',
|
||
};
|
||
}
|
||
|
||
export async function setSalutationPreference(
|
||
userId: number,
|
||
customerId: number,
|
||
preference: SalutationPreference,
|
||
) {
|
||
await assertCustomerExists(customerId);
|
||
return prisma.userCustomerSalutation.upsert({
|
||
where: { userId_customerId: { userId, customerId } },
|
||
create: { userId, customerId, preference },
|
||
update: { preference },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Löscht den eigenen Override, sodass die UI wieder auf den Kunden-
|
||
* Default zurückfällt. Kein 404, wenn kein Eintrag da war – das ist
|
||
* idempotent gedacht ("stelle sicher, dass kein Override existiert").
|
||
*/
|
||
export async function clearSalutationPreference(userId: number, customerId: number) {
|
||
await assertCustomerExists(customerId);
|
||
await prisma.userCustomerSalutation.deleteMany({
|
||
where: { userId, customerId },
|
||
});
|
||
}
|