Files
edr-platform/apps/edr-freight-api/src/modules/overview/overview.service.ts
2026-08-13 18:13:42 +00:00

398 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
BOOKING_LIST_TABS,
mapStatusCountsToTabs,
} from '../bookings/booking-list-tabs.config';
import type { OverviewRangeQuery } from './dto/overview-query.dto';
import type { OverviewResponseDto } from './dto/overview-response.dto';
import type {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewClearanceTabDto,
OverviewContractsTabDto,
OverviewCustomersTabDto,
OverviewFleetTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
import {
OVERVIEW_RANGE_DAYS,
mapContractStatusCountsToPipeline,
} from './overview.constants';
import { OverviewRepository } from './overview.repository';
@Injectable()
export class OverviewService {
constructor(private readonly overviewRepository: OverviewRepository) {}
private mapStatusCounts(statusCounts: Record<string, number>) {
const pipelineTabs = mapStatusCountsToTabs(statusCounts);
const bookingsByPipeline = BOOKING_LIST_TABS.filter(
(tab) => tab.key !== 'all',
).map((tab) => ({
stage: tab.key,
count: pipelineTabs[tab.key],
}));
const bookingsByStatus = Object.entries(statusCounts)
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count);
return { bookingsByPipeline, bookingsByStatus };
}
async getDashboard(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewResponseDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
bookingKpis,
contractKpis,
operationsKpis,
customerKpis,
billingKpis,
staffKpis,
bookingTrend,
statusCounts,
paymentTrend,
current,
previous,
revenueByDirection,
revenueByFreightType,
previousPaymentTrend,
tonsTrend,
revenueFlows,
bookingHeatmap,
] = await Promise.all([
this.overviewRepository.getBookingKpis(dirs),
this.overviewRepository.getContractKpis(dirs),
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getBillingKpis(dirs),
this.overviewRepository.getStaffKpis(),
this.overviewRepository.getBookingTrend(days, dirs),
this.overviewRepository.getStatusCounts(dirs),
this.overviewRepository.getPaymentTrend(days, dirs),
this.overviewRepository.getPeriodTotals(days, 0, dirs),
this.overviewRepository.getPeriodTotals(days, days, dirs),
this.overviewRepository.getRevenueByDirection(days, dirs),
this.overviewRepository.getRevenueByFreightType(days, dirs),
this.overviewRepository.getPaymentTrend(days, dirs, days),
this.overviewRepository.getTonsTrend(days, dirs),
this.overviewRepository.getRevenueFlows(days, dirs),
this.overviewRepository.getBookingHeatmap(days, dirs),
]);
const { bookingsByPipeline, bookingsByStatus } =
this.mapStatusCounts(statusCounts);
return {
kpis: {
bookings: bookingKpis,
contracts: contractKpis,
operations: operationsKpis,
customers: customerKpis,
billing: billingKpis,
staff: staffKpis,
},
bookingTrend,
bookingsByStatus,
bookingsByPipeline,
paymentTrend,
current,
previous,
revenueByDirection,
revenueByFreightType,
previousPaymentTrend,
tonsTrend,
revenueFlows,
bookingHeatmap,
generatedAt: new Date().toISOString(),
};
}
async getBookingsTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBookingsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
bookingTrend,
statusCounts,
bookingsByFreightType,
bookingsByCurrency,
recentBookings,
] = await Promise.all([
this.overviewRepository.getBookingKpis(dirs),
this.overviewRepository.getBookingTrend(days, dirs),
this.overviewRepository.getStatusCounts(dirs),
this.overviewRepository.getBookingsByFreightType(dirs),
this.overviewRepository.getBookingsByCurrency(dirs),
this.overviewRepository.getRecentBookings(8, dirs),
]);
const { bookingsByPipeline, bookingsByStatus } =
this.mapStatusCounts(statusCounts);
return {
kpis,
bookingTrend,
bookingsByStatus,
bookingsByPipeline,
bookingsByFreightType,
bookingsByCurrency,
recentBookings: recentBookings.map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
})),
generatedAt: new Date().toISOString(),
};
}
async getContractsTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewContractsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
contractTrend,
statusCounts,
contractsByKind,
contractsByFreightType,
recentContracts,
] = await Promise.all([
this.overviewRepository.getContractKpis(dirs),
this.overviewRepository.getContractTrend(days, dirs),
this.overviewRepository.getContractStatusCounts(dirs),
this.overviewRepository.getContractsByKind(dirs),
this.overviewRepository.getContractsByFreightType(dirs),
this.overviewRepository.getRecentContracts(8, dirs),
]);
const contractsByStatus = Object.entries(statusCounts)
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count);
const contractsByPipeline = mapContractStatusCountsToPipeline(statusCounts);
return {
kpis,
contractTrend,
contractsByStatus,
contractsByPipeline,
contractsByKind,
contractsByFreightType,
recentContracts: recentContracts.map((row) => ({
...row,
validUntil: row.validUntil ? row.validUntil.toISOString() : null,
createdAt: row.createdAt.toISOString(),
})),
generatedAt: new Date().toISOString(),
};
}
async getBillingTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBillingTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
await Promise.all([
this.overviewRepository.getBillingKpis(dirs),
this.overviewRepository.getPaymentTrend(days, dirs),
this.overviewRepository.getPaymentsByStatus(dirs),
this.overviewRepository.getPaymentsByMethod(dirs),
this.overviewRepository.getRevenueByCurrency(dirs),
]);
return {
kpis,
paymentTrend,
paymentsByStatus,
paymentsByMethod,
revenueByCurrency,
generatedAt: new Date().toISOString(),
};
}
async getOperationsTab(
range: OverviewRangeQuery = '30d',
): Promise<OverviewOperationsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
departureTrend,
scheduleStatusBreakdown,
wagonsByType,
wagonsByYard,
containersBySize,
cargoTonnageByType,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
] = await Promise.all([
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getDepartureTrend(days),
this.overviewRepository.getScheduleStatusBreakdown(),
this.overviewRepository.getWagonsByType(),
this.overviewRepository.getWagonsByYard(8),
this.overviewRepository.getContainersBySize(),
this.overviewRepository.getCargoTonnageByType(8),
this.overviewRepository.getTrainStatusBreakdown(),
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getContainerStatusBreakdown(),
this.overviewRepository.getCargoStatusBreakdown(),
this.overviewRepository.getBookingsByPort(days),
this.overviewRepository.getBookingStatusByPort(days),
this.overviewRepository.getTrainLoads(8),
]);
return {
kpis,
departureTrend,
scheduleStatusBreakdown,
wagonsByType,
wagonsByYard,
containersBySize,
cargoTonnageByType,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
generatedAt: new Date().toISOString(),
};
}
/**
* Rolling stock in depth: wagons and locomotives crossed with type and yard,
* plus how fast each train set turns around. Feeds the operations and control
* centre dashboards.
*/
async getFleetTab(
range: OverviewRangeQuery = '30d',
): Promise<OverviewFleetTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
turnaround,
] = await Promise.all([
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getWagonStatusByType(),
this.overviewRepository.getWagonStatusByYard(),
this.overviewRepository.getLocomotiveStatusBreakdown(),
this.overviewRepository.getLocomotivesByYard(),
this.overviewRepository.getLocomotivesByType(),
this.overviewRepository.getTrainTurnaround(days, 8),
]);
return {
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
avgTurnaroundHours: turnaround.avgHours,
turnaroundByTrain: turnaround.rows,
generatedAt: new Date().toISOString(),
};
}
/** Document and invoice queues — the GL desks' and marketing's work in progress. */
async getClearanceTab(): Promise<OverviewClearanceTabDto> {
const [
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
] = await Promise.all([
this.overviewRepository.getBookingDocumentsByStatus(),
this.overviewRepository.getContractDocumentsByStatus(),
this.overviewRepository.getInvoicesByStatus(),
this.overviewRepository.getInvoicesByType(),
this.overviewRepository.getHandoversByMile(),
]);
return {
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
generatedAt: new Date().toISOString(),
};
}
async getCustomersTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
] = await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
this.overviewRepository.getProfilesByTypeStatus(),
]);
return {
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
generatedAt: new Date().toISOString(),
};
}
async getStaffTab(range: OverviewRangeQuery = '30d'): Promise<OverviewStaffTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] =
await Promise.all([
this.overviewRepository.getStaffKpis(),
this.overviewRepository.getUsersByStatus(),
this.overviewRepository.getEmployeeGrowthTrend(days),
this.overviewRepository.getActiveUsersBreakdown(),
]);
return {
kpis,
usersByStatus,
employeeGrowthTrend,
activeUsersBreakdown,
generatedAt: new Date().toISOString(),
};
}
}