77602bb4ac
VVL = Vertragsverlängerung beim selben Anbieter (vs. Folgevertrag = i.d.R. Anbieterwechsel). Im Gegensatz zu createFollowUpContract wird ALLES kopiert: - Provider, Tarif, Portal-Username/Passwort (verschlüsselt) - Preise (basePrice/unitPrice/bonus etc.) - Notes, Commission, Internet-Zugangsdaten, SIP-Daten, SIM-PINs - ContractDocuments (1:1, gleiche Datei-Referenz) - Detail-Tabellen (Energy/Internet/Mobile/TV/CarInsurance) komplett Berechnet: - newStartDate = oldStartDate + Vertragslaufzeit (Monate aus ContractDuration.code/description geparsed: "24M" / "24 Monate" / "2J") - newEndDate = newStartDate + Laufzeit - status = DRAFT (User bestätigt manuell) NICHT kopiert: - documentType "Auftragsformular" (das wird neu unterschrieben) - cancellation*-Felder (alter Cancel-Flow nicht relevant) Frontend: - Split-Button: Hauptaktion "Folgevertrag anlegen" + ChevronDown-Pfeil - Dropdown: "VVL anlegen" mit Bestätigungs-Modal - Modal zeigt Vorhersage des neuen Startdatums (alter Start + Vertragslaufzeit als Hinweis) History-Einträge wie bei Folgevertrag, mit eigenem VVL-Wording. Doppel-Schutz: maximal 1 Folge-/VVL-Vertrag pro Vorgänger. Live-verifiziert: - Contract #17 (FIBER, 2026-05-01, 24M) → VVL mit Start 2028-05-01 ✓ - Provider/Tarif/Preise/Credentials 1:1 übernommen - 2 Dokumente kopiert (außer Auftragsformular) - History-Einträge in beiden Verträgen vorhanden Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1192 lines
40 KiB
TypeScript
1192 lines
40 KiB
TypeScript
import { ContractType, ContractStatus } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||
import { encrypt, decrypt } from '../utils/encryption.js';
|
||
|
||
export interface ContractFilters {
|
||
customerId?: number;
|
||
customerIds?: number[]; // Für Kundenportal: eigene ID + vertretene Kunden
|
||
type?: ContractType;
|
||
status?: ContractStatus;
|
||
search?: string;
|
||
page?: number;
|
||
limit?: number;
|
||
}
|
||
|
||
export async function getAllContracts(filters: ContractFilters) {
|
||
const { customerId, customerIds, type, status, search, page = 1, limit = 20 } = filters;
|
||
const { skip, take } = paginate(page, limit);
|
||
|
||
const where: Record<string, unknown> = {};
|
||
|
||
// Entweder einzelne customerId ODER Liste von customerIds (für Kundenportal)
|
||
if (customerIds && customerIds.length > 0) {
|
||
where.customerId = { in: customerIds };
|
||
} else if (customerId) {
|
||
where.customerId = customerId;
|
||
}
|
||
if (type) where.type = type;
|
||
|
||
// Status-Filter: Deaktivierte Verträge standardmäßig ausblenden
|
||
if (status) {
|
||
where.status = status;
|
||
} else {
|
||
// Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen
|
||
where.status = { not: ContractStatus.DEACTIVATED };
|
||
}
|
||
|
||
if (search) {
|
||
where.OR = [
|
||
// Basis-Vertragsfelder
|
||
{ contractNumber: { contains: search } },
|
||
{ providerName: { contains: search } },
|
||
{ tariffName: { contains: search } },
|
||
{ customerNumberAtProvider: { contains: search } },
|
||
{ provider: { name: { contains: search } } },
|
||
{ tariff: { name: { contains: search } } },
|
||
// Kundenname
|
||
{ customer: { firstName: { contains: search } } },
|
||
{ customer: { lastName: { contains: search } } },
|
||
{ customer: { companyName: { contains: search } } },
|
||
{ customer: { customerNumber: { contains: search } } },
|
||
// Internet-Vertragsdetails
|
||
{ internetDetails: { routerSerialNumber: { contains: search } } },
|
||
{ internetDetails: { homeId: { contains: search } } },
|
||
{ internetDetails: { activationCode: { contains: search } } },
|
||
{ internetDetails: { phoneNumbers: { some: { phoneNumber: { contains: search } } } } },
|
||
// Mobilfunk-Vertragsdetails
|
||
{ mobileDetails: { phoneNumber: { contains: search } } },
|
||
{ mobileDetails: { simCardNumber: { contains: search } } },
|
||
{ mobileDetails: { deviceImei: { contains: search } } },
|
||
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
||
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
||
// Energie-Vertragsdetails (Zählernummer)
|
||
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
||
// TV-Vertragsdetails
|
||
{ tvDetails: { smartcardNumber: { contains: search } } },
|
||
// KFZ-Versicherung
|
||
{ carInsuranceDetails: { licensePlate: { contains: search } } },
|
||
{ carInsuranceDetails: { vin: { contains: search } } },
|
||
{ carInsuranceDetails: { policyNumber: { contains: search } } },
|
||
];
|
||
}
|
||
|
||
const [contracts, total] = await Promise.all([
|
||
prisma.contract.findMany({
|
||
where,
|
||
skip,
|
||
take,
|
||
orderBy: [{ createdAt: 'desc' }],
|
||
include: {
|
||
customer: {
|
||
select: {
|
||
id: true,
|
||
customerNumber: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
companyName: true,
|
||
},
|
||
},
|
||
address: true,
|
||
billingAddress: true,
|
||
salesPlatform: true,
|
||
cancellationPeriod: true,
|
||
contractDuration: true,
|
||
provider: true,
|
||
tariff: true,
|
||
contractCategory: true,
|
||
mobileDetails: {
|
||
select: {
|
||
phoneNumber: true,
|
||
simCards: { select: { phoneNumber: true, isMain: true } },
|
||
},
|
||
},
|
||
carInsuranceDetails: { select: { licensePlate: true } },
|
||
},
|
||
}),
|
||
prisma.contract.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
contracts,
|
||
pagination: buildPaginationResponse(page, limit, total),
|
||
};
|
||
}
|
||
|
||
export async function getContractById(id: number, decryptPassword = false) {
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id },
|
||
include: {
|
||
customer: true,
|
||
address: true,
|
||
billingAddress: true,
|
||
bankCard: true,
|
||
identityDocument: true,
|
||
salesPlatform: true,
|
||
cancellationPeriod: true,
|
||
contractDuration: true,
|
||
provider: true,
|
||
tariff: true,
|
||
contractCategory: true,
|
||
previousProvider: true,
|
||
previousContract: {
|
||
include: {
|
||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
|
||
internetDetails: { include: { phoneNumbers: true } },
|
||
mobileDetails: { include: { simCards: true } },
|
||
tvDetails: true,
|
||
carInsuranceDetails: true,
|
||
},
|
||
},
|
||
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
|
||
internetDetails: { include: { phoneNumbers: true } },
|
||
mobileDetails: { include: { simCards: true } },
|
||
tvDetails: true,
|
||
carInsuranceDetails: true,
|
||
stressfreiEmail: true,
|
||
invoices: { orderBy: { invoiceDate: 'desc' as const } },
|
||
documents: { orderBy: { createdAt: 'desc' as const } },
|
||
followUpContract: {
|
||
select: { id: true, contractNumber: true, status: true },
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!contract) return null;
|
||
|
||
// Decrypt password if requested and exists
|
||
if (decryptPassword && contract.portalPasswordEncrypted) {
|
||
try {
|
||
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
|
||
contract.portalPasswordEncrypted
|
||
);
|
||
} catch {
|
||
// Password decryption failed, leave as is
|
||
}
|
||
}
|
||
|
||
return contract;
|
||
}
|
||
|
||
interface ContractCreateData {
|
||
customerId: number;
|
||
type: ContractType;
|
||
contractCategoryId?: number;
|
||
status?: ContractStatus;
|
||
addressId?: number;
|
||
billingAddressId?: number;
|
||
bankCardId?: number;
|
||
identityDocumentId?: number;
|
||
salesPlatformId?: number;
|
||
previousContractId?: number;
|
||
providerId?: number;
|
||
tariffId?: number;
|
||
providerName?: string;
|
||
tariffName?: string;
|
||
customerNumberAtProvider?: string;
|
||
priceFirst12Months?: string;
|
||
priceFrom13Months?: string;
|
||
priceAfter24Months?: string;
|
||
startDate?: Date;
|
||
endDate?: Date;
|
||
cancellationPeriodId?: number;
|
||
contractDurationId?: number;
|
||
commission?: number;
|
||
portalUsername?: string;
|
||
portalPassword?: string;
|
||
stressfreiEmailId?: number;
|
||
notes?: string;
|
||
// Kündigungsdaten
|
||
cancellationConfirmationDate?: Date;
|
||
cancellationConfirmationOptionsDate?: Date;
|
||
wasSpecialCancellation?: boolean;
|
||
// Type-specific details
|
||
energyDetails?: {
|
||
meterId?: number;
|
||
annualConsumption?: number;
|
||
basePrice?: number;
|
||
unitPrice?: number;
|
||
bonus?: number;
|
||
previousProviderName?: string;
|
||
previousCustomerNumber?: string;
|
||
};
|
||
internetDetails?: {
|
||
downloadSpeed?: number;
|
||
uploadSpeed?: number;
|
||
routerModel?: string;
|
||
routerSerialNumber?: string;
|
||
installationDate?: Date;
|
||
// Internet-Zugangsdaten
|
||
internetUsername?: string;
|
||
internetPassword?: string;
|
||
// Objekt & Lage
|
||
propertyType?: string;
|
||
propertyLocation?: string;
|
||
connectionLocation?: string;
|
||
// Glasfaser-spezifisch
|
||
homeId?: string;
|
||
// Vodafone DSL/Kabel spezifisch
|
||
activationCode?: string;
|
||
phoneNumbers?: {
|
||
id?: number;
|
||
phoneNumber: string;
|
||
isMain?: boolean;
|
||
sipUsername?: string;
|
||
sipPassword?: string;
|
||
sipServer?: string;
|
||
}[];
|
||
};
|
||
mobileDetails?: {
|
||
requiresMultisim?: boolean;
|
||
dataVolume?: number;
|
||
includedMinutes?: number;
|
||
includedSMS?: number;
|
||
deviceModel?: string;
|
||
deviceImei?: string;
|
||
// Legacy-Felder
|
||
phoneNumber?: string;
|
||
simCardNumber?: string;
|
||
// SIM-Karten Liste
|
||
simCards?: {
|
||
id?: number;
|
||
phoneNumber?: string;
|
||
simCardNumber?: string;
|
||
pin?: string;
|
||
puk?: string;
|
||
isMultisim?: boolean;
|
||
isMain?: boolean;
|
||
}[];
|
||
};
|
||
tvDetails?: {
|
||
receiverModel?: string;
|
||
smartcardNumber?: string;
|
||
package?: string;
|
||
};
|
||
carInsuranceDetails?: {
|
||
licensePlate?: string;
|
||
hsn?: string;
|
||
tsn?: string;
|
||
vin?: string;
|
||
vehicleType?: string;
|
||
firstRegistration?: Date;
|
||
noClaimsClass?: string;
|
||
insuranceType?: 'LIABILITY' | 'PARTIAL' | 'FULL';
|
||
deductiblePartial?: number;
|
||
deductibleFull?: number;
|
||
policyNumber?: string;
|
||
previousInsurer?: string;
|
||
};
|
||
}
|
||
|
||
export async function createContract(data: ContractCreateData) {
|
||
const {
|
||
energyDetails,
|
||
internetDetails,
|
||
mobileDetails,
|
||
tvDetails,
|
||
carInsuranceDetails,
|
||
portalPassword,
|
||
...contractData
|
||
} = data;
|
||
|
||
// Encrypt password if provided
|
||
const portalPasswordEncrypted = portalPassword
|
||
? encrypt(portalPassword)
|
||
: undefined;
|
||
|
||
const contract = await prisma.contract.create({
|
||
data: {
|
||
...contractData,
|
||
contractNumber: generateContractNumber(data.type),
|
||
portalPasswordEncrypted,
|
||
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
||
? { energyDetails: { create: energyDetails } }
|
||
: {}),
|
||
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
||
? {
|
||
internetDetails: {
|
||
create: {
|
||
downloadSpeed: internetDetails.downloadSpeed,
|
||
uploadSpeed: internetDetails.uploadSpeed,
|
||
routerModel: internetDetails.routerModel,
|
||
routerSerialNumber: internetDetails.routerSerialNumber,
|
||
installationDate: internetDetails.installationDate,
|
||
internetUsername: internetDetails.internetUsername,
|
||
internetPasswordEncrypted: internetDetails.internetPassword
|
||
? encrypt(internetDetails.internetPassword)
|
||
: undefined,
|
||
propertyType: internetDetails.propertyType,
|
||
propertyLocation: internetDetails.propertyLocation,
|
||
connectionLocation: internetDetails.connectionLocation,
|
||
homeId: internetDetails.homeId,
|
||
activationCode: internetDetails.activationCode,
|
||
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
|
||
? {
|
||
create: internetDetails.phoneNumbers.map((pn) => ({
|
||
phoneNumber: pn.phoneNumber,
|
||
isMain: pn.isMain ?? false,
|
||
sipUsername: pn.sipUsername,
|
||
sipPasswordEncrypted: pn.sipPassword
|
||
? encrypt(pn.sipPassword)
|
||
: undefined,
|
||
sipServer: pn.sipServer,
|
||
})),
|
||
}
|
||
: undefined,
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
...(mobileDetails && data.type === 'MOBILE'
|
||
? {
|
||
mobileDetails: {
|
||
create: {
|
||
requiresMultisim: mobileDetails.requiresMultisim,
|
||
dataVolume: mobileDetails.dataVolume,
|
||
includedMinutes: mobileDetails.includedMinutes,
|
||
includedSMS: mobileDetails.includedSMS,
|
||
deviceModel: mobileDetails.deviceModel,
|
||
deviceImei: mobileDetails.deviceImei,
|
||
phoneNumber: mobileDetails.phoneNumber,
|
||
simCardNumber: mobileDetails.simCardNumber,
|
||
simCards: mobileDetails.simCards
|
||
? {
|
||
create: mobileDetails.simCards.map((sc) => ({
|
||
phoneNumber: sc.phoneNumber,
|
||
simCardNumber: sc.simCardNumber,
|
||
pin: sc.pin ? encrypt(sc.pin) : undefined,
|
||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||
isMultisim: sc.isMultisim ?? false,
|
||
isMain: sc.isMain ?? false,
|
||
})),
|
||
}
|
||
: undefined,
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
...(tvDetails && data.type === 'TV'
|
||
? { tvDetails: { create: tvDetails } }
|
||
: {}),
|
||
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
||
? { carInsuranceDetails: { create: carInsuranceDetails } }
|
||
: {}),
|
||
},
|
||
include: {
|
||
customer: true,
|
||
address: true,
|
||
billingAddress: true,
|
||
salesPlatform: true,
|
||
energyDetails: true,
|
||
internetDetails: { include: { phoneNumbers: true } },
|
||
mobileDetails: { include: { simCards: true } },
|
||
tvDetails: true,
|
||
carInsuranceDetails: true,
|
||
},
|
||
});
|
||
|
||
return contract;
|
||
}
|
||
|
||
export async function updateContract(
|
||
id: number,
|
||
data: Partial<ContractCreateData>
|
||
) {
|
||
const {
|
||
energyDetails,
|
||
internetDetails,
|
||
mobileDetails,
|
||
tvDetails,
|
||
carInsuranceDetails,
|
||
portalPassword,
|
||
...contractData
|
||
} = data;
|
||
|
||
// Encrypt password if provided
|
||
const portalPasswordEncrypted = portalPassword
|
||
? encrypt(portalPassword)
|
||
: undefined;
|
||
|
||
// Update main contract
|
||
await prisma.contract.update({
|
||
where: { id },
|
||
data: {
|
||
...contractData,
|
||
...(portalPasswordEncrypted ? { portalPasswordEncrypted } : {}),
|
||
},
|
||
});
|
||
|
||
// Update type-specific details
|
||
if (energyDetails) {
|
||
const existingEcd = await prisma.energyContractDetails.findUnique({
|
||
where: { contractId: id },
|
||
select: { id: true, meterId: true },
|
||
});
|
||
|
||
await prisma.energyContractDetails.upsert({
|
||
where: { contractId: id },
|
||
update: energyDetails,
|
||
create: { contractId: id, ...energyDetails },
|
||
});
|
||
|
||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||
if (energyDetails.meterId !== undefined && existingEcd) {
|
||
const oldMeterId = existingEcd.meterId;
|
||
const newMeterId = energyDetails.meterId;
|
||
|
||
if (oldMeterId !== newMeterId) {
|
||
// Alle alten ContractMeter-Einträge entfernen
|
||
await prisma.contractMeter.deleteMany({
|
||
where: { energyContractDetailsId: existingEcd.id },
|
||
});
|
||
|
||
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
|
||
if (newMeterId) {
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id },
|
||
select: { startDate: true },
|
||
});
|
||
await prisma.contractMeter.create({
|
||
data: {
|
||
energyContractDetailsId: existingEcd.id,
|
||
meterId: newMeterId,
|
||
position: 0,
|
||
installedAt: contract?.startDate,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (internetDetails) {
|
||
const { phoneNumbers, internetPassword, ...internetData } = internetDetails;
|
||
const existing = await prisma.internetContractDetails.findUnique({
|
||
where: { contractId: id },
|
||
include: { phoneNumbers: true },
|
||
});
|
||
|
||
// Prepare internet data with encryption
|
||
const preparedInternetData = {
|
||
downloadSpeed: internetData.downloadSpeed,
|
||
uploadSpeed: internetData.uploadSpeed,
|
||
routerModel: internetData.routerModel,
|
||
routerSerialNumber: internetData.routerSerialNumber,
|
||
installationDate: internetData.installationDate,
|
||
internetUsername: internetData.internetUsername,
|
||
// Only update password if new value provided, otherwise keep existing
|
||
...(internetPassword
|
||
? { internetPasswordEncrypted: encrypt(internetPassword) }
|
||
: {}),
|
||
propertyType: internetData.propertyType,
|
||
propertyLocation: internetData.propertyLocation,
|
||
connectionLocation: internetData.connectionLocation,
|
||
homeId: internetData.homeId,
|
||
activationCode: internetData.activationCode,
|
||
};
|
||
|
||
if (existing) {
|
||
await prisma.internetContractDetails.update({
|
||
where: { contractId: id },
|
||
data: preparedInternetData,
|
||
});
|
||
|
||
if (phoneNumbers) {
|
||
// Get existing phone numbers for preserving encrypted passwords
|
||
const existingPhoneNumbers = existing.phoneNumbers || [];
|
||
|
||
// Delete all existing phone numbers
|
||
await prisma.phoneNumber.deleteMany({
|
||
where: { internetContractDetailsId: existing.id },
|
||
});
|
||
|
||
// Create new phone numbers with encryption
|
||
await prisma.phoneNumber.createMany({
|
||
data: phoneNumbers.map((pn) => {
|
||
// Find existing entry to preserve sipPassword if not changed
|
||
const existingPn = pn.id
|
||
? existingPhoneNumbers.find((e) => e.id === pn.id)
|
||
: undefined;
|
||
|
||
return {
|
||
internetContractDetailsId: existing.id,
|
||
phoneNumber: pn.phoneNumber,
|
||
isMain: pn.isMain ?? false,
|
||
sipUsername: pn.sipUsername,
|
||
// Preserve existing sipPassword if no new value provided
|
||
sipPasswordEncrypted: pn.sipPassword
|
||
? encrypt(pn.sipPassword)
|
||
: existingPn?.sipPasswordEncrypted ?? undefined,
|
||
sipServer: pn.sipServer,
|
||
};
|
||
}),
|
||
});
|
||
}
|
||
} else {
|
||
await prisma.internetContractDetails.create({
|
||
data: {
|
||
contractId: id,
|
||
...preparedInternetData,
|
||
...(internetPassword
|
||
? { internetPasswordEncrypted: encrypt(internetPassword) }
|
||
: {}),
|
||
phoneNumbers: phoneNumbers
|
||
? {
|
||
create: phoneNumbers.map((pn) => ({
|
||
phoneNumber: pn.phoneNumber,
|
||
isMain: pn.isMain ?? false,
|
||
sipUsername: pn.sipUsername,
|
||
sipPasswordEncrypted: pn.sipPassword
|
||
? encrypt(pn.sipPassword)
|
||
: undefined,
|
||
sipServer: pn.sipServer,
|
||
})),
|
||
}
|
||
: undefined,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (mobileDetails) {
|
||
const { simCards, ...mobileData } = mobileDetails;
|
||
const existing = await prisma.mobileContractDetails.findUnique({
|
||
where: { contractId: id },
|
||
});
|
||
|
||
if (existing) {
|
||
await prisma.mobileContractDetails.update({
|
||
where: { contractId: id },
|
||
data: mobileData,
|
||
});
|
||
|
||
if (simCards) {
|
||
// Get existing sim cards to preserve PIN/PUK if not provided
|
||
const existingSimCards = await prisma.simCard.findMany({
|
||
where: { mobileDetailsId: existing.id },
|
||
});
|
||
const existingSimCardMap = new Map(existingSimCards.map(sc => [sc.id, sc]));
|
||
|
||
// Delete existing sim cards
|
||
await prisma.simCard.deleteMany({
|
||
where: { mobileDetailsId: existing.id },
|
||
});
|
||
|
||
// Create new sim cards, preserving PIN/PUK if not provided
|
||
await prisma.simCard.createMany({
|
||
data: simCards.map((sc) => {
|
||
const existingSc = sc.id ? existingSimCardMap.get(sc.id) : undefined;
|
||
return {
|
||
mobileDetailsId: existing.id,
|
||
phoneNumber: sc.phoneNumber,
|
||
simCardNumber: sc.simCardNumber,
|
||
// Preserve existing PIN/PUK if no new value provided
|
||
pin: sc.pin ? encrypt(sc.pin) : (existingSc?.pin ?? undefined),
|
||
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
||
isMultisim: sc.isMultisim ?? false,
|
||
isMain: sc.isMain ?? false,
|
||
};
|
||
}),
|
||
});
|
||
}
|
||
} else {
|
||
await prisma.mobileContractDetails.create({
|
||
data: {
|
||
contractId: id,
|
||
...mobileData,
|
||
simCards: simCards
|
||
? {
|
||
create: simCards.map((sc) => ({
|
||
phoneNumber: sc.phoneNumber,
|
||
simCardNumber: sc.simCardNumber,
|
||
pin: sc.pin ? encrypt(sc.pin) : undefined,
|
||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||
isMultisim: sc.isMultisim ?? false,
|
||
isMain: sc.isMain ?? false,
|
||
})),
|
||
}
|
||
: undefined,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (tvDetails) {
|
||
await prisma.tvContractDetails.upsert({
|
||
where: { contractId: id },
|
||
update: tvDetails,
|
||
create: { contractId: id, ...tvDetails },
|
||
});
|
||
}
|
||
|
||
if (carInsuranceDetails) {
|
||
await prisma.carInsuranceDetails.upsert({
|
||
where: { contractId: id },
|
||
update: carInsuranceDetails,
|
||
create: { contractId: id, ...carInsuranceDetails },
|
||
});
|
||
}
|
||
|
||
return getContractById(id);
|
||
}
|
||
|
||
export async function deleteContract(id: number) {
|
||
// Vertragskette erhalten beim Löschen:
|
||
// Wenn A → B → C und B gelöscht wird, soll C direkt auf A zeigen (A → C)
|
||
|
||
// 1. Zu löschenden Vertrag holen um dessen Vorgänger zu kennen
|
||
const contractToDelete = await prisma.contract.findUnique({
|
||
where: { id },
|
||
select: { previousContractId: true },
|
||
});
|
||
|
||
// 2. Folgevertrag(e) mit dem Vorgänger des gelöschten Vertrags verbinden
|
||
// So bleibt die Kette erhalten: A → B → C wird zu A → C
|
||
await prisma.contract.updateMany({
|
||
where: { previousContractId: id },
|
||
data: { previousContractId: contractToDelete?.previousContractId ?? null },
|
||
});
|
||
|
||
return prisma.contract.delete({ where: { id } });
|
||
}
|
||
|
||
export async function createFollowUpContract(previousContractId: number) {
|
||
const previousContract = await getContractById(previousContractId);
|
||
if (!previousContract) {
|
||
throw new Error('Vorgängervertrag nicht gefunden');
|
||
}
|
||
|
||
// Prüfen ob bereits ein Folgevertrag existiert
|
||
const existingFollowUp = await prisma.contract.findFirst({
|
||
where: { previousContractId },
|
||
select: { id: true, contractNumber: true },
|
||
});
|
||
if (existingFollowUp) {
|
||
throw new Error(`Es existiert bereits ein Folgevertrag: ${existingFollowUp.contractNumber}`);
|
||
}
|
||
|
||
// Copy data but exclude provider credentials and some fields
|
||
const newContractData: ContractCreateData = {
|
||
customerId: previousContract.customerId,
|
||
type: previousContract.type,
|
||
status: 'DRAFT',
|
||
addressId: previousContract.addressId ?? undefined,
|
||
bankCardId: previousContract.bankCardId ?? undefined,
|
||
identityDocumentId: previousContract.identityDocumentId ?? undefined,
|
||
salesPlatformId: previousContract.salesPlatformId ?? undefined,
|
||
previousContractId: previousContract.id,
|
||
// Explicitly NOT copying: providerName, tariffName, portalUsername, portalPassword, price fields
|
||
cancellationPeriodId: previousContract.cancellationPeriodId ?? undefined,
|
||
contractDurationId: previousContract.contractDurationId ?? undefined,
|
||
// notes nicht mehr automatisch setzen - wird jetzt über Historie-Eintrag dokumentiert
|
||
};
|
||
|
||
// Copy type-specific details (without credentials)
|
||
if (previousContract.energyDetails) {
|
||
newContractData.energyDetails = {
|
||
meterId: previousContract.energyDetails.meterId ?? undefined,
|
||
annualConsumption:
|
||
previousContract.energyDetails.annualConsumption ?? undefined,
|
||
basePrice: previousContract.energyDetails.basePrice ?? undefined,
|
||
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
|
||
bonus: previousContract.energyDetails.bonus ?? undefined,
|
||
previousProviderName: previousContract.providerName ?? undefined,
|
||
previousCustomerNumber:
|
||
previousContract.customerNumberAtProvider ?? undefined,
|
||
};
|
||
}
|
||
|
||
if (previousContract.internetDetails) {
|
||
newContractData.internetDetails = {
|
||
downloadSpeed:
|
||
previousContract.internetDetails.downloadSpeed ?? undefined,
|
||
uploadSpeed: previousContract.internetDetails.uploadSpeed ?? undefined,
|
||
routerModel: previousContract.internetDetails.routerModel ?? undefined,
|
||
routerSerialNumber:
|
||
previousContract.internetDetails.routerSerialNumber ?? undefined,
|
||
phoneNumbers: previousContract.internetDetails.phoneNumbers.map((pn) => ({
|
||
phoneNumber: pn.phoneNumber,
|
||
isMain: pn.isMain,
|
||
})),
|
||
};
|
||
}
|
||
|
||
if (previousContract.mobileDetails) {
|
||
newContractData.mobileDetails = {
|
||
requiresMultisim: previousContract.mobileDetails.requiresMultisim ?? undefined,
|
||
dataVolume: previousContract.mobileDetails.dataVolume ?? undefined,
|
||
includedMinutes:
|
||
previousContract.mobileDetails.includedMinutes ?? undefined,
|
||
includedSMS: previousContract.mobileDetails.includedSMS ?? undefined,
|
||
deviceModel: previousContract.mobileDetails.deviceModel ?? undefined,
|
||
deviceImei: previousContract.mobileDetails.deviceImei ?? undefined,
|
||
phoneNumber: previousContract.mobileDetails.phoneNumber ?? undefined,
|
||
simCardNumber: previousContract.mobileDetails.simCardNumber ?? undefined,
|
||
// Copy simCards without PIN/PUK (security)
|
||
simCards: previousContract.mobileDetails.simCards?.map((sc) => ({
|
||
phoneNumber: sc.phoneNumber ?? undefined,
|
||
simCardNumber: sc.simCardNumber ?? undefined,
|
||
isMultisim: sc.isMultisim,
|
||
isMain: sc.isMain,
|
||
})),
|
||
};
|
||
}
|
||
|
||
if (previousContract.tvDetails) {
|
||
newContractData.tvDetails = {
|
||
receiverModel: previousContract.tvDetails.receiverModel ?? undefined,
|
||
smartcardNumber:
|
||
previousContract.tvDetails.smartcardNumber ?? undefined,
|
||
package: previousContract.tvDetails.package ?? undefined,
|
||
};
|
||
}
|
||
|
||
if (previousContract.carInsuranceDetails) {
|
||
newContractData.carInsuranceDetails = {
|
||
licensePlate:
|
||
previousContract.carInsuranceDetails.licensePlate ?? undefined,
|
||
hsn: previousContract.carInsuranceDetails.hsn ?? undefined,
|
||
tsn: previousContract.carInsuranceDetails.tsn ?? undefined,
|
||
vin: previousContract.carInsuranceDetails.vin ?? undefined,
|
||
vehicleType: previousContract.carInsuranceDetails.vehicleType ?? undefined,
|
||
firstRegistration:
|
||
previousContract.carInsuranceDetails.firstRegistration ?? undefined,
|
||
noClaimsClass:
|
||
previousContract.carInsuranceDetails.noClaimsClass ?? undefined,
|
||
insuranceType: previousContract.carInsuranceDetails.insuranceType,
|
||
deductiblePartial:
|
||
previousContract.carInsuranceDetails.deductiblePartial ?? undefined,
|
||
deductibleFull:
|
||
previousContract.carInsuranceDetails.deductibleFull ?? undefined,
|
||
previousInsurer: previousContract.providerName ?? undefined,
|
||
};
|
||
}
|
||
|
||
return createContract(newContractData);
|
||
}
|
||
|
||
/**
|
||
* Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration.
|
||
* Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt
|
||
* sie auf 12 Monate als sicheren Default zurück.
|
||
*/
|
||
function durationToMonths(code: string | null | undefined, description: string | null | undefined): number {
|
||
const c = (code || '').trim();
|
||
const d = (description || '').trim();
|
||
let m = c.match(/^(\d+)\s*M$/i);
|
||
if (m) return parseInt(m[1], 10);
|
||
m = c.match(/^(\d+)\s*J$/i);
|
||
if (m) return parseInt(m[1], 10) * 12;
|
||
m = d.match(/(\d+)\s*Monat/i);
|
||
if (m) return parseInt(m[1], 10);
|
||
m = d.match(/(\d+)\s*Jahr/i);
|
||
if (m) return parseInt(m[1], 10) * 12;
|
||
return 12;
|
||
}
|
||
|
||
/**
|
||
* VVL = Vertragsverlängerung beim selben Anbieter.
|
||
*
|
||
* Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert:
|
||
* Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments.
|
||
*
|
||
* Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit.
|
||
* Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann
|
||
* der User es im Vertrag manuell anpassen.
|
||
*
|
||
* NICHT mitkopiert wird:
|
||
* - das Auftragsdokument (documentType "Auftragsformular") – das ist
|
||
* schließlich die NEU zu unterschreibende VVL.
|
||
* - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow,
|
||
* bei einer VVL nicht relevant)
|
||
*/
|
||
export async function createRenewalContract(previousContractId: number) {
|
||
const previousContract = await getContractById(previousContractId, true);
|
||
if (!previousContract) {
|
||
throw new Error('Vorgängervertrag nicht gefunden');
|
||
}
|
||
|
||
// Bereits ein Folge-/VVL-Vertrag vorhanden?
|
||
const existing = await prisma.contract.findFirst({
|
||
where: { previousContractId },
|
||
select: { id: true, contractNumber: true },
|
||
});
|
||
if (existing) {
|
||
throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`);
|
||
}
|
||
|
||
// Neues Startdatum = altes Start + Laufzeit
|
||
let newStartDate: Date | null = null;
|
||
let newEndDate: Date | null = null;
|
||
if (previousContract.startDate && previousContract.contractDuration) {
|
||
const months = durationToMonths(
|
||
previousContract.contractDuration.code,
|
||
previousContract.contractDuration.description,
|
||
);
|
||
newStartDate = new Date(previousContract.startDate);
|
||
newStartDate.setMonth(newStartDate.getMonth() + months);
|
||
newEndDate = new Date(newStartDate);
|
||
newEndDate.setMonth(newEndDate.getMonth() + months);
|
||
}
|
||
|
||
// Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder)
|
||
const contractNumber = generateContractNumber(previousContract.type);
|
||
|
||
const newContract = await prisma.contract.create({
|
||
data: {
|
||
contractNumber,
|
||
customerId: previousContract.customerId,
|
||
type: previousContract.type,
|
||
status: 'DRAFT',
|
||
contractCategoryId: previousContract.contractCategoryId,
|
||
addressId: previousContract.addressId,
|
||
billingAddressId: previousContract.billingAddressId,
|
||
bankCardId: previousContract.bankCardId,
|
||
identityDocumentId: previousContract.identityDocumentId,
|
||
salesPlatformId: previousContract.salesPlatformId,
|
||
cancellationPeriodId: previousContract.cancellationPeriodId,
|
||
contractDurationId: previousContract.contractDurationId,
|
||
previousContractId: previousContract.id,
|
||
previousProviderId: previousContract.previousProviderId,
|
||
providerId: previousContract.providerId,
|
||
tariffId: previousContract.tariffId,
|
||
providerName: previousContract.providerName,
|
||
tariffName: previousContract.tariffName,
|
||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||
portalUsername: previousContract.portalUsername,
|
||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||
commission: previousContract.commission,
|
||
notes: previousContract.notes,
|
||
startDate: newStartDate,
|
||
endDate: newEndDate,
|
||
// Cancellation-Felder bewusst leer lassen – die VVL hat den alten
|
||
// Cancel-Flow nicht geerbt.
|
||
},
|
||
});
|
||
|
||
// Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu)
|
||
if (previousContract.energyDetails) {
|
||
const ed = previousContract.energyDetails;
|
||
const newEnergy = await prisma.energyContractDetails.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
meterId: ed.meterId,
|
||
maloId: ed.maloId,
|
||
annualConsumption: ed.annualConsumption,
|
||
annualConsumptionKwh: ed.annualConsumptionKwh,
|
||
basePrice: ed.basePrice,
|
||
unitPrice: ed.unitPrice,
|
||
unitPriceNt: ed.unitPriceNt,
|
||
bonus: ed.bonus,
|
||
previousProviderName: ed.previousProviderName,
|
||
previousCustomerNumber: ed.previousCustomerNumber,
|
||
},
|
||
});
|
||
// ContractMeter-Verknüpfungen mitkopieren
|
||
for (const cm of ed.contractMeters || []) {
|
||
await prisma.contractMeter.create({
|
||
data: {
|
||
energyContractDetailsId: newEnergy.id,
|
||
meterId: cm.meterId,
|
||
position: cm.position,
|
||
installedAt: cm.installedAt,
|
||
removedAt: cm.removedAt,
|
||
finalReading: cm.finalReading,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
if (previousContract.internetDetails) {
|
||
const id = previousContract.internetDetails;
|
||
const newInet = await prisma.internetContractDetails.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
downloadSpeed: id.downloadSpeed,
|
||
uploadSpeed: id.uploadSpeed,
|
||
routerModel: id.routerModel,
|
||
routerSerialNumber: id.routerSerialNumber,
|
||
installationDate: id.installationDate,
|
||
internetUsername: id.internetUsername,
|
||
internetPasswordEncrypted: id.internetPasswordEncrypted,
|
||
propertyType: id.propertyType,
|
||
propertyLocation: id.propertyLocation,
|
||
connectionLocation: id.connectionLocation,
|
||
homeId: id.homeId,
|
||
activationCode: id.activationCode,
|
||
},
|
||
});
|
||
for (const pn of id.phoneNumbers || []) {
|
||
await prisma.phoneNumber.create({
|
||
data: {
|
||
internetContractDetailsId: newInet.id,
|
||
phoneNumber: pn.phoneNumber,
|
||
isMain: pn.isMain,
|
||
sipUsername: pn.sipUsername,
|
||
sipPasswordEncrypted: pn.sipPasswordEncrypted,
|
||
sipServer: pn.sipServer,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
if (previousContract.mobileDetails) {
|
||
const md = previousContract.mobileDetails;
|
||
const newMob = await prisma.mobileContractDetails.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
requiresMultisim: md.requiresMultisim,
|
||
dataVolume: md.dataVolume,
|
||
includedMinutes: md.includedMinutes,
|
||
includedSMS: md.includedSMS,
|
||
deviceModel: md.deviceModel,
|
||
deviceImei: md.deviceImei,
|
||
phoneNumber: md.phoneNumber,
|
||
simCardNumber: md.simCardNumber,
|
||
},
|
||
});
|
||
for (const sc of md.simCards || []) {
|
||
await prisma.simCard.create({
|
||
data: {
|
||
mobileDetailsId: newMob.id,
|
||
phoneNumber: sc.phoneNumber,
|
||
simCardNumber: sc.simCardNumber,
|
||
isMultisim: sc.isMultisim,
|
||
isMain: sc.isMain,
|
||
pin: sc.pin,
|
||
puk: sc.puk,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
if (previousContract.tvDetails) {
|
||
await prisma.tvContractDetails.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
receiverModel: previousContract.tvDetails.receiverModel,
|
||
smartcardNumber: previousContract.tvDetails.smartcardNumber,
|
||
package: previousContract.tvDetails.package,
|
||
},
|
||
});
|
||
}
|
||
if (previousContract.carInsuranceDetails) {
|
||
const ci = previousContract.carInsuranceDetails;
|
||
await prisma.carInsuranceDetails.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
licensePlate: ci.licensePlate,
|
||
hsn: ci.hsn,
|
||
tsn: ci.tsn,
|
||
vin: ci.vin,
|
||
vehicleType: ci.vehicleType,
|
||
firstRegistration: ci.firstRegistration,
|
||
noClaimsClass: ci.noClaimsClass,
|
||
insuranceType: ci.insuranceType,
|
||
deductiblePartial: ci.deductiblePartial,
|
||
deductibleFull: ci.deductibleFull,
|
||
previousInsurer: ci.previousInsurer,
|
||
},
|
||
});
|
||
}
|
||
|
||
// ContractDocuments mitkopieren – AUSSER "Auftragsformular" (das ist die
|
||
// neue Unterschrift, die der User selbst hochlädt). Files werden NICHT
|
||
// physisch dupliziert; beide Verträge zeigen auf dieselbe Datei.
|
||
const docs = await prisma.contractDocument.findMany({
|
||
where: { contractId: previousContract.id },
|
||
});
|
||
for (const d of docs) {
|
||
if (d.documentType.toLowerCase().includes('auftragsformular')) continue;
|
||
await prisma.contractDocument.create({
|
||
data: {
|
||
contractId: newContract.id,
|
||
documentType: d.documentType,
|
||
documentPath: d.documentPath,
|
||
originalName: d.originalName,
|
||
notes: d.notes,
|
||
uploadedBy: d.uploadedBy,
|
||
},
|
||
});
|
||
}
|
||
|
||
return prisma.contract.findUnique({ where: { id: newContract.id } });
|
||
}
|
||
|
||
// Decrypt password for viewing
|
||
export async function getContractPassword(id: number): Promise<string | null> {
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id },
|
||
select: { portalPasswordEncrypted: true },
|
||
});
|
||
|
||
if (!contract?.portalPasswordEncrypted) return null;
|
||
|
||
try {
|
||
return decrypt(contract.portalPasswordEncrypted);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Decrypt SimCard PIN/PUK
|
||
export async function getSimCardCredentials(simCardId: number): Promise<{ pin: string | null; puk: string | null }> {
|
||
const simCard = await prisma.simCard.findUnique({
|
||
where: { id: simCardId },
|
||
select: { pin: true, puk: true },
|
||
});
|
||
|
||
if (!simCard) return { pin: null, puk: null };
|
||
|
||
try {
|
||
return {
|
||
pin: simCard.pin ? decrypt(simCard.pin) : null,
|
||
puk: simCard.puk ? decrypt(simCard.puk) : null,
|
||
};
|
||
} catch {
|
||
return { pin: null, puk: null };
|
||
}
|
||
}
|
||
|
||
// Decrypt Internet password
|
||
export async function getInternetCredentials(contractId: number): Promise<{ password: string | null }> {
|
||
const internetDetails = await prisma.internetContractDetails.findUnique({
|
||
where: { contractId },
|
||
select: { internetPasswordEncrypted: true },
|
||
});
|
||
|
||
if (!internetDetails?.internetPasswordEncrypted) return { password: null };
|
||
|
||
try {
|
||
return {
|
||
password: decrypt(internetDetails.internetPasswordEncrypted),
|
||
};
|
||
} catch {
|
||
return { password: null };
|
||
}
|
||
}
|
||
|
||
// Decrypt SIP password for a phone number
|
||
export async function getSipCredentials(phoneNumberId: number): Promise<{ password: string | null }> {
|
||
const phoneNumber = await prisma.phoneNumber.findUnique({
|
||
where: { id: phoneNumberId },
|
||
select: { sipPasswordEncrypted: true },
|
||
});
|
||
|
||
if (!phoneNumber?.sipPasswordEncrypted) return { password: null };
|
||
|
||
try {
|
||
return {
|
||
password: decrypt(phoneNumber.sipPasswordEncrypted),
|
||
};
|
||
} catch {
|
||
return { password: null };
|
||
}
|
||
}
|
||
|
||
// ==================== VERTRAGSBAUM FÜR KUNDENANSICHT ====================
|
||
|
||
export interface ContractTreeNode {
|
||
contract: {
|
||
id: number;
|
||
contractNumber: string;
|
||
type: ContractType;
|
||
status: ContractStatus;
|
||
startDate: Date | null;
|
||
endDate: Date | null;
|
||
providerName: string | null;
|
||
tariffName: string | null;
|
||
previousContractId: number | null;
|
||
provider?: { id: number; name: string } | null;
|
||
tariff?: { id: number; name: string } | null;
|
||
contractCategory?: { id: number; name: string } | null;
|
||
customer?: { id: number; firstName: string; lastName: string; companyName: string | null; customerNumber: string } | null;
|
||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||
mobileDetails?: {
|
||
phoneNumber: string | null;
|
||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
||
} | null;
|
||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||
};
|
||
predecessors: ContractTreeNode[];
|
||
hasHistory: boolean;
|
||
}
|
||
|
||
/**
|
||
* Verträge eines Kunden als Baumstruktur abrufen.
|
||
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
|
||
* Vorgänger werden rekursiv eingebettet.
|
||
*/
|
||
export async function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]> {
|
||
// Alle Verträge des Kunden laden (außer DEACTIVATED)
|
||
const allContracts = await prisma.contract.findMany({
|
||
where: {
|
||
customerId,
|
||
status: { not: ContractStatus.DEACTIVATED },
|
||
},
|
||
select: {
|
||
id: true,
|
||
contractNumber: true,
|
||
type: true,
|
||
status: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
providerName: true,
|
||
tariffName: true,
|
||
previousContractId: true,
|
||
provider: { select: { id: true, name: true } },
|
||
tariff: { select: { id: true, name: true } },
|
||
contractCategory: { select: { id: true, name: true } },
|
||
customer: { select: { id: true, firstName: true, lastName: true, companyName: true, customerNumber: true } },
|
||
address: { select: { street: true, houseNumber: true, postalCode: true, city: true } },
|
||
mobileDetails: {
|
||
select: {
|
||
phoneNumber: true,
|
||
simCards: { select: { phoneNumber: true, isMain: true } },
|
||
},
|
||
},
|
||
carInsuranceDetails: { select: { licensePlate: true } },
|
||
},
|
||
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
|
||
});
|
||
|
||
// Map für schnellen Zugriff: contractId -> contract
|
||
const contractMap = new Map(allContracts.map(c => [c.id, c]));
|
||
|
||
// Set der IDs die als Vorgänger referenziert werden
|
||
const predecessorIds = new Set(
|
||
allContracts
|
||
.filter(c => c.previousContractId !== null)
|
||
.map(c => c.previousContractId!)
|
||
);
|
||
|
||
// Wurzelverträge = Verträge die keinen Nachfolger haben
|
||
// (werden von keinem anderen Vertrag als previousContractId referenziert)
|
||
const rootContracts = allContracts.filter(c => !predecessorIds.has(c.id));
|
||
|
||
// Rekursive Funktion um Vorgängerkette aufzubauen
|
||
function buildPredecessorChain(contractId: number | null): ContractTreeNode[] {
|
||
if (contractId === null) return [];
|
||
|
||
const contract = contractMap.get(contractId);
|
||
if (!contract) return [];
|
||
|
||
const predecessors = buildPredecessorChain(contract.previousContractId);
|
||
|
||
return [{
|
||
contract,
|
||
predecessors,
|
||
hasHistory: predecessors.length > 0,
|
||
}];
|
||
}
|
||
|
||
// Baumstruktur für jeden Wurzelvertrag aufbauen
|
||
const tree: ContractTreeNode[] = rootContracts.map(contract => {
|
||
const predecessors = buildPredecessorChain(contract.previousContractId);
|
||
return {
|
||
contract,
|
||
predecessors,
|
||
hasHistory: predecessors.length > 0,
|
||
};
|
||
});
|
||
|
||
return tree;
|
||
}
|