avicenna/service/data-hub/data-connector/src/modules/app-manager/time-per-tour-metrics.servi...

215 lines
6.3 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
addDays,
addMilliseconds,
areIntervalsOverlapping,
differenceInCalendarDays,
eachDayOfInterval,
eachHourOfInterval,
endOfDay,
endOfMonth,
getISODay,
Interval,
isSunday,
set,
startOfDay,
startOfMonth,
} from 'date-fns';
import { CarEntityService } from './car-entity/car-entity.service';
import { TourAmountForecastService } from './tour-amount-forecast.service';
import { TimePerTourMetricObjectType } from './manager.object-type';
import { CalculationService } from '../feat-calculation/calculation.service';
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
@Injectable()
export class TimePerTourMetricsService {
constructor(
private readonly calculationService: CalculationService,
private readonly tourAmountForecastService: TourAmountForecastService,
private readonly carEntityService: CarEntityService,
) {}
async forMonth(date: Date, ordinanceType: string) {
const fullRangeInterval = {
start: startOfMonth(date),
end: endOfMonth(date),
};
const subIntervals = eachDayOfInterval(fullRangeInterval)
.filter((day) => !isSunday(day))
.reduce(
(subIntervals, day) => [
...subIntervals,
generateIntervalForTimeRange('05:00-04:59', 'Europe/Berlin', day),
],
[] as Interval[],
);
return this.generateTimePerTourMetric(
fullRangeInterval,
subIntervals,
ordinanceType,
);
}
async forDay(date: Date, ordinanceType: string) {
const fullRangeInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
date,
);
const subIntervals = eachHourOfInterval(fullRangeInterval)
.reduce(
(subIntervals, hour, index, array) => [
...subIntervals,
...(index === array.length - 1
? []
: [{ start: hour, end: array[index + 1] }]),
],
[] as Interval[],
)
.map(({ start, end }, index, array) => ({
start,
end: index + 1 !== array.length ? addMilliseconds(end, -1) : end,
}));
return this.generateTimePerTourMetric(
fullRangeInterval,
subIntervals,
ordinanceType,
);
}
private async generateTimePerTourMetric(
intervalClamp: Interval,
subIntervals: Interval[],
ordinanceType: string,
): Promise<TimePerTourMetricObjectType[]> {
const carEntities = await this.carEntityService.getCarsInInterval(
intervalClamp,
ordinanceType,
);
const forecastPeriod: Interval = {
start: startOfDay(new Date()),
end: endOfDay(addDays(new Date(), 7)),
};
const forecastForOrdinanceType =
await this.calculateForecastForOrdinanceType(
forecastPeriod,
intervalClamp,
ordinanceType,
);
return Promise.all(
subIntervals.map(async (interval) => {
const tourAmount = carEntities.reduce(
(sum, carEntity) => (sum += carEntity.tourAmountInInterval(interval)),
0,
);
const availableTime = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.availableTimeInInterval(interval)),
0,
);
let revenue = 0;
let personnelCost = 0;
let consumptionCost = 0;
const categoryAmounts = Object.entries(
groupBy(
carEntities.flatMap((ce) => ce.toursInInterval(interval)),
({ category }) => category,
),
).reduce(
(sum, [category, tours]) => [
...sum,
{ category, amount: tours.length },
],
[],
);
for (const carEntity of carEntities) {
const {
personnelCosts,
consumptionCost: cc,
toursRevenue,
} = await carEntity.breakEvenInInterval(
interval,
this.calculationService,
);
revenue += toursRevenue;
personnelCost += personnelCosts;
consumptionCost += cc;
}
let forecast = 0;
let forecastRevenue = 0;
if (areIntervalsOverlapping(interval, forecastPeriod)) {
const dayDiffFromNow = differenceInCalendarDays(
interval.start,
new Date(),
);
const weekday = getISODay(interval.start);
const tourAmountAtDateCreatedToday = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.getTourAmountAtDateCreatedToday(
new Date(interval.start),
)),
0,
);
forecast =
forecastForOrdinanceType[weekday]
?.slice(0, dayDiffFromNow + 1)
.reduce((sum, tourAmount) => (sum += tourAmount), 0) -
(tourAmountAtDateCreatedToday ?? 0);
if (forecast >= 0) {
const billingInformation =
this.calculationService.getModellBillingInformationByOrdinanceType(
ordinanceType,
set(new Date(), {
hours: 13,
minutes: 0,
seconds: 0,
milliseconds: 0,
}),
);
forecastRevenue =
billingInformation.revenuePerTour * Math.round(forecast) -
billingInformation.consumptionCostsPerTour * Math.round(forecast);
}
}
return {
interval,
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
breakEven:
Math.round((revenue - personnelCost - consumptionCost) * 100) / 100,
revenue,
averageRevenue: isNaN(revenue / tourAmount)
? 0
: revenue / tourAmount,
personnelCost,
consumptionCost,
forecast: forecast < 0 || isNaN(forecast) ? 0 : forecast,
forecastRevenue,
categoryAmounts,
};
}),
);
}
private async calculateForecastForOrdinanceType(
forecastPeriod: Interval,
requestedInterval: Interval,
ordinanceType: string,
): Promise<Record<string, number[]>> {
if (areIntervalsOverlapping(requestedInterval, forecastPeriod)) {
return this.tourAmountForecastService.calculateTourAmountForecastFourWeekPeriod(
ordinanceType,
);
}
return {};
}
}