import { Injectable, Logger } from '@nestjs/common'; import { CostType, CostTypeVariant, Tariff, TariffGroup, Tour, } from '@prisma/client'; import { Interval, addDays, isSaturday, isWithinInterval } from 'date-fns'; import { generateIntervalForTimeRange } from 'src/utils'; import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service'; import { BILLING_INFORMATION_BY_ORDINANCE_TYPE } from './billing-information-by-ordinance-type'; import { calculateDesinfektionszuschlag, calculateFahrzeugzuschlag, calculateFeiertagszuschlag, calculateKilometerzuschlag, calculateNachtzuschlag, calculatePrivatVersicherung, calculateTSW, calculateWochenendzuschlag, } from './cost-type-calculations'; import { PersonnelCostCalculationService } from './personnel-cost-calculation.service'; enum OrdinanceTypes { BTW = 'BTW', TSW = 'TSW', KTW = 'KTW', } @Injectable() export class CalculationService { private readonly logger = new Logger(CalculationService.name); private healthInsurancesWithNoTenant = []; constructor( private readonly personnelCostCalculationService: PersonnelCostCalculationService, private readonly tariffService: TariffService, ) {} async calculateTourRevenue(tour: Tour): Promise { const { healthInsurance, ordinanceType, startDate } = tour; if (!ordinanceType) { return 0; } const tariff = await this.findTariff( healthInsurance, ordinanceType.match(/([A-Z])\w+/).at(0), new Date(startDate), ); if (!tariff) { this.logger.warn( `No tariff found for tour ${tour.operationId} (${healthInsurance}, ${ordinanceType})`, ); return 0; } const revenue = this.calculateProvidedServicePositions(tour, tariff).reduce( (sum, [, value]) => (sum += value), 0, ) / 100; return revenue; } async listProvidedServicePositions(tour: Tour): Promise<{ tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup }; servicePositions: [CostType, number][]; }> { const { healthInsurance, ordinanceType, startDate } = tour; if (!ordinanceType) { return { tariff: undefined, servicePositions: [] }; } const tariff = await this.findTariff( healthInsurance, ordinanceType.match(/([A-Z])\w+/).at(0), new Date(startDate), ); if (!tariff) { this.logger.warn( `No tariff found for tour ${tour.operationId} (${healthInsurance}, ${ordinanceType})`, ); return { tariff: undefined, servicePositions: [] }; } return { tariff, servicePositions: this.calculateProvidedServicePositions(tour, tariff), }; } public calculatePersonnelCostInInterval( interval: Interval, employeeAmount: number, ordinanceType: string, ): number { return this.personnelCostCalculationService.calculatePersonnelCostInInterval( interval, ordinanceType, employeeAmount, ); } public getBillingInformationByOrdinanceType( ordinanceType: string, date: Date, ) { if (isSaturday(date)) { if (ordinanceType.indexOf('KTW') > -1) { return { personnelCostsPerHour: 64.81 / 2, consumptionCostsPerTour: 4.02, }; } return { personnelCostsPerHour: 23.27, consumptionCostsPerTour: 3.68, }; } if (ordinanceType.indexOf('KTW') > -1) { if ( isWithinInterval( date, generateIntervalForTimeRange('18:30-03:00', 'Europe/Berlin', date), ) ) { return { personnelCostsPerHour: 68.5 / 2, consumptionCostsPerTour: 4.02, }; } return { personnelCostsPerHour: 56.36 / 2, consumptionCostsPerTour: 4.02, }; } if ( isWithinInterval( date, generateIntervalForTimeRange('18:30-03:00', 'Europe/Berlin', date), ) ) { return { personnelCostsPerHour: 24.59, consumptionCostsPerTour: 3.68, }; } return { personnelCostsPerHour: 20.23, consumptionCostsPerTour: 3.68, }; } public getModellBillingInformationByOrdinanceType( ordinanceType: string, date: Date, ) { const daysKey = isSaturday(date) ? 'Sa' : 'Mo-Fr'; if ( !Object.values(OrdinanceTypes).includes(ordinanceType as OrdinanceTypes) ) { throw new Error( `OrdinanceType not supported. Supported types ${Object.values( OrdinanceTypes, ).join(', ')}, got ${ordinanceType}`, ); } const billingInformationsByIntervals = BILLING_INFORMATION_BY_ORDINANCE_TYPE[ordinanceType][daysKey] as Record< string, { personnelCostsPerHour: number; consumptionCostsPerTour: number; revenuePerTour: number; } >; const billingInformation = Object.entries( billingInformationsByIntervals, ).find(([timeRange]) => isWithinInterval( date, generateIntervalForTimeRange( timeRange, 'Europe/Berlin', date.getHours() >= 0 && date.getHours() <= 3 ? addDays(date, -1) : date, ), ), ); if (!billingInformation) { this.logger.warn("Couldn't find billing information", { date: date.toISOString(), ordinanceType, }); throw new Error("Couldn't find billing information"); } return billingInformation[1]; } private calculateProvidedServicePositions( tour: Tour, tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup }, ): [CostType, number][] { const highestZuschlag = this.getHighestZuschlag(tour, tariff); // if TariffGroup is VDEK KTW, sum up costTypes const sumZuschlaege = tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 || tariff.tariffGroup?.description?.indexOf('Privat') > -1; return tariff.costTypes .map((costType) => { switch (costType.variant) { case CostTypeVariant.GRUNDPREIS: return [costType, costType.costAmount]; case CostTypeVariant.PRIVATVERSICHERUNG: return [costType, calculatePrivatVersicherung(tour, costType)]; case CostTypeVariant.TSWDIFFERENZIERUNG: return [costType, calculateTSW(tour, costType)]; case CostTypeVariant.DESINFEKTIONSZUSCHLAG: return [costType, calculateDesinfektionszuschlag(tour, costType)]; case CostTypeVariant.KILOMETERZUSCHLAG: return [costType, calculateKilometerzuschlag(tour, costType)]; case CostTypeVariant.FAHRZEUGZUSCHLAG: return [costType, calculateFahrzeugzuschlag(tour, costType)]; case CostTypeVariant.GRUNDPREIS_ZEIT: case CostTypeVariant.NACHTZUSCHLAG: if ( sumZuschlaege || highestZuschlag === CostTypeVariant.NACHTZUSCHLAG || costType.variant !== CostTypeVariant.NACHTZUSCHLAG ) { return [costType, calculateNachtzuschlag(tour, costType)]; } return [costType, 0]; case CostTypeVariant.WOCHENENDZUSCHLAG: if ( highestZuschlag === CostTypeVariant.WOCHENENDZUSCHLAG || sumZuschlaege ) { return [costType, calculateWochenendzuschlag(tour, costType)]; } return [costType, 0]; case CostTypeVariant.FEIERTAGSZUSCHLAG: if ( highestZuschlag === CostTypeVariant.FEIERTAGSZUSCHLAG || sumZuschlaege ) { return [costType, calculateFeiertagszuschlag(tour, costType)]; } return [costType, 0]; } }) .filter(([, value]) => value !== 0) as [CostType, number][]; } private getHighestZuschlag( tour: Tour, tariff: Tariff & { costTypes: CostType[] }, ): CostTypeVariant { const zuschlaegeHighestToLowest = tariff.costTypes .filter((costType) => [ CostTypeVariant.NACHTZUSCHLAG, CostTypeVariant.FEIERTAGSZUSCHLAG, CostTypeVariant.WOCHENENDZUSCHLAG, ].includes(costType.variant as any), ) .map((costType): [CostTypeVariant, number] => { switch (costType.variant) { case CostTypeVariant.NACHTZUSCHLAG: return [ CostTypeVariant.NACHTZUSCHLAG, calculateNachtzuschlag(tour, costType), ]; case CostTypeVariant.WOCHENENDZUSCHLAG: return [ CostTypeVariant.WOCHENENDZUSCHLAG, calculateWochenendzuschlag(tour, costType), ]; case CostTypeVariant.FEIERTAGSZUSCHLAG: return [ CostTypeVariant.FEIERTAGSZUSCHLAG, calculateFeiertagszuschlag(tour, costType), ]; } }) .sort( ([, costAmountA], [, costAmountB]) => (costAmountA as number) - (costAmountB as number), ) .reverse(); if (zuschlaegeHighestToLowest.length === 0) { return undefined; } return zuschlaegeHighestToLowest[0][0]; } private async findTariff( healthInsurance: string, ordinanceType: string, tourDate: Date, ) { if (!healthInsurance || healthInsurance === '') { return this.tariffService.findByTariffGroupDescription( 'AOK', ordinanceType, tourDate, ); } const tenant = await this.tariffService.findTenant( healthInsurance, ordinanceType, ); if (!tenant) { this.healthInsurancesWithNoTenant.push(healthInsurance); return this.tariffService.findByTariffGroupDescription( 'AOK', ordinanceType, tourDate, ); } return this.tariffService.findByTenantId( tenant.id, ordinanceType, tourDate, ); } getHealthInsurancesWithNoTenant(): string[] { return [...new Set(this.healthInsurancesWithNoTenant)]; } }