215 lines
6.3 KiB
TypeScript
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 {};
|
|
}
|
|
}
|