import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee } from '@tria-plc/iamapi-common'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { Freight } from '@edr/types'; import { Repository, ObjectLiteral } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Container } from '../container-management/entities/container.entity'; import { Customer } from '../customers/entities/customer.entity'; import { PaymentEntity } from '../payment/entities/payment.entity'; import { Train } from '../trains/entities/train.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from './overview.constants'; export type OverviewBookingKpisRow = { totalActive: number; needsAction: number; urgent: number; inApproval: number; submittedToday: number; }; export type OverviewRecentBookingRow = { id: string; reference: string; customerLabel: string; status: string; priorityScore: number; totalAmount: number | null; paymentCurrency: string | null; createdAt: Date; }; @Injectable() export class OverviewRepository { constructor( @InjectRepository(Booking) private readonly bookingRepository: Repository, @InjectRepository(PaymentEntity) private readonly paymentRepository: Repository, @InjectRepository(Customer) private readonly customerRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) private readonly containerRepository: Repository, @InjectRepository(Cargo) private readonly cargoRepository: Repository, @InjectRepository(Employee) private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, ) {} async getBookingKpis(): Promise { const row = await this.bookingRepository .createQueryBuilder('booking') .select( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, 'totalActive', ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, 'needsAction', ) .addSelect( `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, 'urgent', ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, 'inApproval', ) .addSelect( `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, 'submittedToday', ) .where('booking.deleted_at IS NULL') .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, }) .getRawOne>(); return { totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), urgent: Number(row?.urgent ?? 0), inApproval: Number(row?.inApproval ?? 0), submittedToday: Number(row?.submittedToday ?? 0), }; } async getOperationsKpis(): Promise<{ trainsActive: number; wagonsAvailable: number; containersInTransit: number; cargoesLoaded: number; }> { const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = await Promise.all([ this.trainRepository .createQueryBuilder('train') .where('train.deleted_at IS NULL') .andWhere('train.status IN (:...statuses)', { statuses: [ Freight.TrainStatus.InService, Freight.TrainStatus.Scheduled, ], }) .getCount(), this.wagonRepository .createQueryBuilder('wagon') .where('wagon.deleted_at IS NULL') .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) .getCount(), this.containerRepository .createQueryBuilder('container') .where('container.deleted_at IS NULL') .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) .getCount(), this.cargoRepository .createQueryBuilder('cargo') .where('cargo.deleted_at IS NULL') .andWhere('cargo.status IN (:...statuses)', { statuses: ['LOADED', 'IN_TRANSIT'], }) .getCount(), ]); return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; } async getCustomerKpis(): Promise<{ totalCustomers: number; newCustomersThisMonth: number; }> { const row = await this.customerRepository .createQueryBuilder('customer') .select('COUNT(*)::int', 'totalCustomers') .addSelect( `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, 'newCustomersThisMonth', ) .where('customer.deleted_at IS NULL') .getRawOne>(); return { totalCustomers: Number(row?.totalCustomers ?? 0), newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0), }; } async getBillingKpis(): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { const revenueRow = await this.paymentRepository .createQueryBuilder('payment') .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, 'revenueMtdEtb', ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, 'revenueMtdUsd', ) .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') .where('payment.status = :status', { status: 'success' }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .getRawOne>(); const pendingPayments = await this.paymentRepository .createQueryBuilder('payment') .where('payment.status IN (:...statuses)', { statuses: ['action-required', 'processing'], }) .getCount(); return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; } async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { const [activeEmployees, activeUsers] = await Promise.all([ this.employeeRepository.count({ where: { isCurrent: true }, }), this.userRepository.count({ where: { isActive: true, status: EUserStatus.ACCEPTED, }, }), ]); return { activeEmployees, activeUsers }; } async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { const rows = await this.bookingRepository .createQueryBuilder('booking') .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy('booking.created_at::date') .orderBy('booking.created_at::date', 'ASC') .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getStatusCounts(): Promise> { const rows = await this.bookingRepository .createQueryBuilder('booking') .select('booking.status', 'status') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .groupBy('booking.status') .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( rows.map((row) => [row.status, Number(row.count)]), ); } async getPaymentTrend( days: number, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const rows = await this.paymentRepository .createQueryBuilder('payment') .select( `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, 'date', ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, 'amountEtb', ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, 'amountUsd', ) .where('payment.status = :status', { status: 'success' }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } async getRecentBookings(limit: number): Promise { const rows = await this.bookingRepository .createQueryBuilder('booking') .leftJoin('booking.company', 'company') .select('booking.id', 'id') .addSelect('booking.reference', 'reference') .addSelect('COALESCE(company.name, \'—\')', 'customerLabel') .addSelect('booking.status', 'status') .addSelect('booking.priority_score', 'priorityScore') .addSelect('booking.total_amount', 'totalAmount') .addSelect('booking.payment_currency', 'paymentCurrency') .addSelect('booking.created_at', 'createdAt') .where('booking.deleted_at IS NULL') .orderBy('booking.created_at', 'DESC') .limit(limit) .getRawMany<{ id: string; reference: string; customerLabel: string; status: string; priorityScore: string; totalAmount: string | null; paymentCurrency: string | null; createdAt: Date; }>(); return rows.map((row) => ({ id: row.id, reference: row.reference, customerLabel: row.customerLabel, status: row.status, priorityScore: Number(row.priorityScore), totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null, paymentCurrency: row.paymentCurrency, createdAt: row.createdAt, })); } async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository .createQueryBuilder('booking') .select('booking.freight_type', 'label') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .andWhere("booking.status != 'DRAFT'") .groupBy('booking.freight_type') .orderBy('count', 'DESC') .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository .createQueryBuilder('booking') .select('booking.payment_currency', 'label') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .andWhere("booking.status != 'DRAFT'") .groupBy('booking.payment_currency') .orderBy('count', 'DESC') .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.paymentRepository .createQueryBuilder('payment') .select('payment.status', 'status') .addSelect('COUNT(*)::int', 'count') .groupBy('payment.status') .orderBy('count', 'DESC') .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getPaymentsByMethod(): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { const rows = await this.paymentRepository .createQueryBuilder('payment') .select('payment.method', 'method') .addSelect('COUNT(*)::int', 'count') .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, 'amountEtb', ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, 'amountUsd', ) .groupBy('payment.method') .orderBy('count', 'DESC') .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ method: row.method, count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { const rows = await this.paymentRepository .createQueryBuilder('payment') .select('payment.currency', 'currency') .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') .where('payment.status = :status', { status: 'success' }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .groupBy('payment.currency') .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ currency: row.currency, amount: Number(row.amount), })); } async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { return this.statusBreakdown(this.trainRepository, 'train'); } async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { return this.statusBreakdown(this.wagonRepository, 'wagon'); } async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { return this.statusBreakdown(this.containerRepository, 'container'); } async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { return this.statusBreakdown(this.cargoRepository, 'cargo'); } private async statusBreakdown( repository: Repository, alias: string, ): Promise<{ status: string; count: number }[]> { const rows = await repository .createQueryBuilder(alias) .select(`${alias}.status`, 'status') .addSelect('COUNT(*)::int', 'count') .where(`${alias}.deleted_at IS NULL`) .groupBy(`${alias}.status`) .orderBy('count', 'DESC') .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { const rows = await this.customerRepository .createQueryBuilder('customer') .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') .addSelect('COUNT(*)::int', 'count') .where('customer.deleted_at IS NULL') .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy('customer.created_at::date') .orderBy('customer.created_at::date', 'ASC') .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getCustomersByType(): Promise<{ label: string; count: number }[]> { const rows = await this.customerRepository .createQueryBuilder('customer') .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') .addSelect('COUNT(*)::int', 'count') .where('customer.deleted_at IS NULL') .groupBy('customer.customer_type') .orderBy('count', 'DESC') .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository .createQueryBuilder('booking') .leftJoin('booking.company', 'company') .select(`COALESCE(company.name, 'Unknown')`, 'label') .addSelect('COUNT(*)::int', 'count') .where('booking.deleted_at IS NULL') .andWhere("booking.status != 'DRAFT'") .groupBy('company.name') .orderBy('count', 'DESC') .limit(limit) .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getUsersByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.userRepository .createQueryBuilder('user') .select('user.status', 'status') .addSelect('COUNT(*)::int', 'count') .groupBy('user.status') .orderBy('count', 'DESC') .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { const rows = await this.employeeRepository .createQueryBuilder('employee') .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') .addSelect('COUNT(*)::int', 'count') .where('employee.is_current = true') .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy('employee.created_at::date') .orderBy('employee.created_at::date', 'ASC') .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> { const [active, inactive] = await Promise.all([ this.userRepository.count({ where: { isActive: true, status: EUserStatus.ACCEPTED }, }), this.userRepository .createQueryBuilder('user') .where('user.is_active = false OR user.status != :status', { status: EUserStatus.ACCEPTED, }) .getCount(), ]); return [ { label: 'Active', count: active }, { label: 'Inactive', count: inactive }, ]; } }