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 { 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> { if (areIntervalsOverlapping(requestedInterval, forecastPeriod)) { return this.tourAmountForecastService.calculateTourAmountForecastFourWeekPeriod( ordinanceType, ); } return {}; } }