337 lines
9.8 KiB
TypeScript
337 lines
9.8 KiB
TypeScript
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<number> {
|
|
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)];
|
|
}
|
|
}
|