File

src/analytics/analytics.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async exportOrdersCsv
exportOrdersCsv(from: Date, to: Date, filters: literal type, orgId: string)
Parameters :
Name Type Optional
from Date No
to Date No
filters literal type No
orgId string No
Returns : Promise<string>
Async generateReport
generateReport(type: string, from: Date, to: Date, clientId?: string, orgId?: string)
Parameters :
Name Type Optional
type string No
from Date No
to Date No
clientId string Yes
orgId string Yes
Returns : unknown
Async getDashboard
getDashboard(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AnalyticsService {
  constructor(private prisma: PrismaService) {}

  async getDashboard(organizationId: string) {
    const [
      totalOrders,
      ordersByStatus,
      ordersBySource,
      totalTrips,
      tripsByStatus,
      totalVehicles,
      vehiclesByStatus,
      totalDrivers,
      totalClients,
      recentOrders,
      ordersByClient,
      onTimeDelivery,
      fleetUtilization,
    ] = await Promise.all([
      // Total orders
      this.prisma.order.count({ where: { organizationId } }),

      // Orders by status
      this.getOrdersByStatus(organizationId),

      // Orders by source (MANUAL / UPLOAD / PORTAL_FORM / PORTAL_UPLOAD / API / EMAIL)
      this.getOrdersBySource(organizationId),

      // Total trips
      this.prisma.trip.count({ where: { organizationId } }),

      // Trips by status
      this.getTripsByStatus(organizationId),

      // Total vehicles
      this.prisma.vehicle.count({ where: { organizationId } }),

      // Vehicles by status
      this.getVehiclesByStatus(organizationId),

      // Total drivers
      this.prisma.driver.count({ where: { organizationId, isActive: true } }),

      // Total clients
      this.prisma.client.count({ where: { organizationId, isActive: true } }),

      // Recent orders (last 10)
      this.prisma.order.findMany({
        where: { organizationId },
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: {
          client: { select: { id: true, name: true, code: true } },
        },
      }),

      // Orders by client (top 10)
      this.getOrdersByClient(organizationId),

      // On-time delivery rate
      this.getOnTimeDeliveryRate(organizationId),

      // Fleet utilization
      this.getFleetUtilization(organizationId),
    ]);

    // Orders this week — group by day
    const ordersThisWeek = await this.getOrdersThisWeek(organizationId);

    return {
      kpis: {
        totalOrders,
        totalTrips,
        totalVehicles,
        totalDrivers,
        totalClients,
        onTimeDeliveryRate: onTimeDelivery,
        fleetUtilizationRate: fleetUtilization,
      },
      ordersByStatus,
      ordersBySource,
      tripsByStatus,
      vehiclesByStatus,
      ordersByClient,
      recentOrders,
      ordersThisWeek,
    };
  }

  private async getOrdersBySource(organizationId: string) {
    const results = await this.prisma.order.groupBy({
      by: ['source'],
      where: { organizationId },
      _count: { source: true },
    });
    const map: Record<string, number> = {};
    for (const r of results) {
      map[r.source] = r._count.source;
    }
    return map;
  }

  private async getOrdersByStatus(organizationId: string) {
    const results = await this.prisma.order.groupBy({
      by: ['status'],
      where: { organizationId },
      _count: { status: true },
    });

    const statusMap: Record<string, number> = {};
    for (const r of results) {
      statusMap[r.status] = r._count.status;
    }
    return statusMap;
  }

  private async getTripsByStatus(organizationId: string) {
    const results = await this.prisma.trip.groupBy({
      by: ['status'],
      where: { organizationId },
      _count: { status: true },
    });

    const statusMap: Record<string, number> = {};
    for (const r of results) {
      statusMap[r.status] = r._count.status;
    }
    return statusMap;
  }

  private async getVehiclesByStatus(organizationId: string) {
    const results = await this.prisma.vehicle.groupBy({
      by: ['status'],
      where: { organizationId },
      _count: { status: true },
    });

    const statusMap: Record<string, number> = {};
    for (const r of results) {
      statusMap[r.status] = r._count.status;
    }
    return statusMap;
  }

  private async getOrdersByClient(organizationId: string) {
    const results = await this.prisma.order.groupBy({
      by: ['clientId'],
      where: { organizationId },
      _count: { clientId: true },
      orderBy: { _count: { clientId: 'desc' } },
      take: 10,
    });

    const clientIds = results.map((r) => r.clientId);
    const clients = await this.prisma.client.findMany({
      where: { id: { in: clientIds } },
      select: { id: true, name: true, code: true },
    });

    const clientMap = new Map(clients.map((c: { id: string; name: string; code: string }) => [c.id, c]));

    return results.map((r: { clientId: string; _count: { clientId: number } }) => ({
      client: clientMap.get(r.clientId) || { id: r.clientId, name: 'Unknown', code: 'UNK' },
      orderCount: r._count.clientId,
    }));
  }

  private async getOnTimeDeliveryRate(organizationId: string): Promise<number> {
    const delivered = await this.prisma.order.count({
      where: { organizationId, status: 'DELIVERED' },
    });

    if (delivered === 0) return 0;

    // Get orders where actual <= planned
    const deliveredOrders = await this.prisma.order.findMany({
      where: {
        organizationId,
        status: 'DELIVERED',
        actualDelivery: { not: null },
        deliveryDate: { not: null },
      },
      select: { actualDelivery: true, deliveryDate: true },
    });

    const onTimeCount = deliveredOrders.filter(
      (o: { actualDelivery: Date | null; deliveryDate: Date | null }) =>
        o.actualDelivery && o.deliveryDate && o.actualDelivery <= o.deliveryDate,
    ).length;

    return delivered > 0
      ? Math.round((onTimeCount / delivered) * 10000) / 100
      : 0;
  }

  private async getFleetUtilization(organizationId: string): Promise<number> {
    const total = await this.prisma.vehicle.count({
      where: { organizationId },
    });

    if (total === 0) return 0;

    const active = await this.prisma.vehicle.count({
      where: {
        organizationId,
        status: { in: ['ASSIGNED', 'IN_TRANSIT'] },
      },
    });

    return Math.round((active / total) * 10000) / 100;
  }

  private async getOrdersThisWeek(organizationId: string) {
    const now = new Date();
    const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
    const result: { day: string; orders: number }[] = [];

    for (let i = 6; i >= 0; i--) {
      const date = new Date(now);
      date.setDate(date.getDate() - i);
      const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
      const endOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);

      const count = await this.prisma.order.count({
        where: {
          organizationId,
          createdAt: { gte: startOfDay, lt: endOfDay },
        },
      });

      result.push({ day: dayNames[date.getDay()], orders: count });
    }

    return result;
  }

  // ── Report Generation ──────────────────────────────────────

  async generateReport(
    type: string,
    from: Date,
    to: Date,
    clientId?: string,
    orgId?: string,
  ) {
    const organizationId = orgId || '';
    const dateFilter = { gte: from, lte: to };

    switch (type) {
      case 'daily':
      case 'weekly':
      case 'monthly':
        return this.generateOperationsReport(organizationId, dateFilter, type, from, to);
      case 'client':
        return this.generateClientReport(organizationId, dateFilter, clientId, from, to);
      case 'vehicle':
        return this.generateVehicleReport(organizationId, dateFilter, from, to);
      default:
        return this.generateOperationsReport(organizationId, dateFilter, type, from, to);
    }
  }

  private async generateOperationsReport(
    organizationId: string,
    dateFilter: { gte: Date; lte: Date },
    periodType: string,
    from: Date,
    to: Date,
  ) {
    const baseWhere = { organizationId, createdAt: dateFilter };

    const [
      totalOrders,
      deliveredOrders,
      cancelledOrders,
      inTransitOrders,
      pendingOrders,
      allDeliveredOrders,
      totalWeight,
      topClients,
      alertsSummary,
      fleetTotal,
      fleetActive,
      allOrdersInPeriod,
    ] = await Promise.all([
      this.prisma.order.count({ where: baseWhere }),
      this.prisma.order.count({ where: { ...baseWhere, status: 'DELIVERED' } }),
      this.prisma.order.count({ where: { ...baseWhere, status: 'CANCELLED' } }),
      this.prisma.order.count({ where: { ...baseWhere, status: 'IN_TRANSIT' } }),
      this.prisma.order.count({
        where: { ...baseWhere, status: { in: ['DRAFT', 'PENDING_VALIDATION', 'VALIDATED'] } },
      }),
      this.prisma.order.findMany({
        where: {
          organizationId,
          status: 'DELIVERED',
          createdAt: dateFilter,
          actualDelivery: { not: null },
          deliveryDate: { not: null },
        },
        select: { actualDelivery: true, deliveryDate: true },
      }),
      this.prisma.order.aggregate({
        where: baseWhere,
        _sum: { weight: true },
      }),
      this.getTopClientsForPeriod(organizationId, dateFilter),
      this.getAlertsSummary(organizationId, dateFilter),
      this.prisma.vehicle.count({ where: { organizationId } }),
      this.prisma.vehicle.count({
        where: { organizationId, status: { in: ['ASSIGNED', 'IN_TRANSIT'] } },
      }),
      this.prisma.order.findMany({
        where: baseWhere,
        include: {
          client: { select: { id: true, name: true, code: true } },
        },
        orderBy: { createdAt: 'desc' },
        take: 200,
      }),
    ]);

    const onTimeCount = allDeliveredOrders.filter(
      (o: { actualDelivery: Date | null; deliveryDate: Date | null }) =>
        o.actualDelivery && o.deliveryDate && o.actualDelivery <= o.deliveryDate,
    ).length;
    const onTimeRate =
      allDeliveredOrders.length > 0
        ? Math.round((onTimeCount / allDeliveredOrders.length) * 10000) / 100
        : 0;

    const fleetUtilization =
      fleetTotal > 0 ? Math.round((fleetActive / fleetTotal) * 10000) / 100 : 0;

    const totalDistanceResult = await this.prisma.trip.aggregate({
      where: { organizationId, createdAt: dateFilter },
      _sum: { totalDistance: true },
    });

    return {
      reportType: 'operations',
      periodType,
      from: from.toISOString(),
      to: to.toISOString(),
      generatedAt: new Date().toISOString(),
      summary: {
        totalOrders,
        delivered: deliveredOrders,
        cancelled: cancelledOrders,
        inTransit: inTransitOrders,
        pending: pendingOrders,
        onTimeRate,
        fleetUtilization,
        totalWeight: totalWeight._sum.weight || 0,
        totalDistance: totalDistanceResult._sum.totalDistance || 0,
      },
      topClients,
      alerts: alertsSummary,
      orders: allOrdersInPeriod.map((o: any) => ({
        id: o.id,
        orderNumber: o.orderNumber,
        clientName: o.client?.name || 'Unknown',
        clientCode: o.client?.code || 'UNK',
        status: o.status,
        priority: o.priority,
        originCity: o.originCity,
        destCity: o.destCity,
        weight: o.weight,
        pickupDate: o.pickupDate,
        deliveryDate: o.deliveryDate,
        actualDelivery: o.actualDelivery,
        createdAt: o.createdAt,
      })),
    };
  }

  private async generateClientReport(
    organizationId: string,
    dateFilter: { gte: Date; lte: Date },
    clientId: string | undefined,
    from: Date,
    to: Date,
  ) {
    const baseWhere: any = { organizationId, createdAt: dateFilter };
    if (clientId) baseWhere.clientId = clientId;

    let clientInfo: { id: string; name: string; code: string; contactName: string | null; contactEmail: string | null } | null = null;
    if (clientId) {
      clientInfo = await this.prisma.client.findUnique({
        where: { id: clientId },
        select: { id: true, name: true, code: true, contactName: true, contactEmail: true },
      });
    }

    const [totalOrders, deliveredOrders, cancelledOrders, exceptionOrders, allDelivered, orders] =
      await Promise.all([
        this.prisma.order.count({ where: baseWhere }),
        this.prisma.order.count({ where: { ...baseWhere, status: 'DELIVERED' } }),
        this.prisma.order.count({ where: { ...baseWhere, status: 'CANCELLED' } }),
        this.prisma.order.count({ where: { ...baseWhere, status: 'EXCEPTION' } }),
        this.prisma.order.findMany({
          where: {
            ...baseWhere,
            status: 'DELIVERED',
            actualDelivery: { not: null },
            deliveryDate: { not: null },
          },
          select: { actualDelivery: true, deliveryDate: true, pickupDate: true },
        }),
        this.prisma.order.findMany({
          where: baseWhere,
          include: {
            client: { select: { id: true, name: true, code: true } },
          },
          orderBy: { createdAt: 'desc' },
          take: 200,
        }),
      ]);

    const onTimeCount = allDelivered.filter(
      (o: { actualDelivery: Date | null; deliveryDate: Date | null }) =>
        o.actualDelivery && o.deliveryDate && o.actualDelivery <= o.deliveryDate,
    ).length;
    const otifRate =
      allDelivered.length > 0
        ? Math.round((onTimeCount / allDelivered.length) * 10000) / 100
        : 0;

    // Average delivery time in hours
    const deliveryTimes = allDelivered
      .filter((o: any) => o.actualDelivery && o.pickupDate)
      .map((o: any) => {
        const diff = new Date(o.actualDelivery).getTime() - new Date(o.pickupDate).getTime();
        return diff / (1000 * 60 * 60);
      });
    const avgDeliveryTime =
      deliveryTimes.length > 0
        ? Math.round((deliveryTimes.reduce((a: number, b: number) => a + b, 0) / deliveryTimes.length) * 10) / 10
        : 0;

    return {
      reportType: 'client',
      from: from.toISOString(),
      to: to.toISOString(),
      generatedAt: new Date().toISOString(),
      client: clientInfo,
      summary: {
        totalOrders,
        delivered: deliveredOrders,
        cancelled: cancelledOrders,
        exceptions: exceptionOrders,
        otifRate,
        avgDeliveryTimeHours: avgDeliveryTime,
      },
      orders: orders.map((o: any) => ({
        id: o.id,
        orderNumber: o.orderNumber,
        clientName: o.client?.name || 'Unknown',
        clientCode: o.client?.code || 'UNK',
        status: o.status,
        priority: o.priority,
        originCity: o.originCity,
        destCity: o.destCity,
        weight: o.weight,
        pickupDate: o.pickupDate,
        deliveryDate: o.deliveryDate,
        actualDelivery: o.actualDelivery,
        createdAt: o.createdAt,
      })),
    };
  }

  private async generateVehicleReport(
    organizationId: string,
    dateFilter: { gte: Date; lte: Date },
    from: Date,
    to: Date,
  ) {
    const vehicles = await this.prisma.vehicle.findMany({
      where: { organizationId },
      select: {
        id: true,
        unitNumber: true,
        type: true,
        status: true,
        make: true,
        model: true,
        year: true,
        licensePlate: true,
      },
    });

    const vehicleIds = vehicles.map((v: { id: string }) => v.id);

    const trips = await this.prisma.trip.findMany({
      where: {
        organizationId,
        vehicleId: { in: vehicleIds },
        createdAt: dateFilter,
      },
      select: {
        vehicleId: true,
        totalDistance: true,
        totalDuration: true,
        status: true,
      },
    });

    const tripsByVehicle: Record<string, typeof trips> = {};
    for (const trip of trips) {
      if (trip.vehicleId) {
        if (!tripsByVehicle[trip.vehicleId]) tripsByVehicle[trip.vehicleId] = [];
        tripsByVehicle[trip.vehicleId].push(trip);
      }
    }

    const vehicleStats = vehicles.map((v: any) => {
      const vTrips = tripsByVehicle[v.id] || [];
      const totalDistance = vTrips.reduce(
        (sum: number, t: any) => sum + (t.totalDistance || 0),
        0,
      );
      const totalDuration = vTrips.reduce(
        (sum: number, t: any) => sum + (t.totalDuration || 0),
        0,
      );
      const completedTrips = vTrips.filter((t: any) => t.status === 'COMPLETED').length;

      return {
        id: v.id,
        unitNumber: v.unitNumber,
        type: v.type,
        status: v.status,
        make: v.make,
        model: v.model,
        year: v.year,
        licensePlate: v.licensePlate,
        totalTrips: vTrips.length,
        completedTrips,
        totalDistance: Math.round(totalDistance * 10) / 10,
        totalDurationMinutes: totalDuration,
      };
    });

    const totalVehicles = vehicles.length;
    const activeVehicles = vehicles.filter(
      (v: any) => v.status === 'ASSIGNED' || v.status === 'IN_TRANSIT',
    ).length;
    const maintenanceVehicles = vehicles.filter(
      (v: any) => v.status === 'MAINTENANCE',
    ).length;

    return {
      reportType: 'vehicle',
      from: from.toISOString(),
      to: to.toISOString(),
      generatedAt: new Date().toISOString(),
      summary: {
        totalVehicles,
        activeVehicles,
        maintenanceVehicles,
        utilization:
          totalVehicles > 0
            ? Math.round((activeVehicles / totalVehicles) * 10000) / 100
            : 0,
      },
      vehicles: vehicleStats,
    };
  }

  private async getTopClientsForPeriod(
    organizationId: string,
    dateFilter: { gte: Date; lte: Date },
  ) {
    const results = await this.prisma.order.groupBy({
      by: ['clientId'],
      where: { organizationId, createdAt: dateFilter },
      _count: { clientId: true },
      orderBy: { _count: { clientId: 'desc' } },
      take: 10,
    });

    const clientIds = results.map((r: { clientId: string }) => r.clientId);
    const clients = await this.prisma.client.findMany({
      where: { id: { in: clientIds } },
      select: { id: true, name: true, code: true },
    });

    const clientMap = new Map(clients.map((c: { id: string; name: string; code: string }) => [c.id, c]));

    return results.map((r: { clientId: string; _count: { clientId: number } }) => ({
      client: clientMap.get(r.clientId) || { id: r.clientId, name: 'Unknown', code: 'UNK' },
      orderCount: r._count.clientId,
    }));
  }

  private async getAlertsSummary(
    organizationId: string,
    dateFilter: { gte: Date; lte: Date },
  ) {
    const [critical, warning, info, resolved] = await Promise.all([
      this.prisma.alert.count({
        where: { organizationId, createdAt: dateFilter, severity: 'CRITICAL' },
      }),
      this.prisma.alert.count({
        where: { organizationId, createdAt: dateFilter, severity: 'WARNING' },
      }),
      this.prisma.alert.count({
        where: { organizationId, createdAt: dateFilter, severity: 'INFO' },
      }),
      this.prisma.alert.count({
        where: { organizationId, createdAt: dateFilter, status: 'RESOLVED' },
      }),
    ]);

    return { critical, warning, info, resolved };
  }

  // ── CSV Export ─────────────────────────────────────────────

  async exportOrdersCsv(
    from: Date,
    to: Date,
    filters: { status?: string; clientId?: string },
    orgId: string,
  ): Promise<string> {
    const where: any = {
      organizationId: orgId,
      createdAt: { gte: from, lte: to },
    };

    if (filters.status) where.status = filters.status;
    if (filters.clientId) where.clientId = filters.clientId;

    const orders = await this.prisma.order.findMany({
      where,
      include: {
        client: { select: { name: true } },
      },
      orderBy: { createdAt: 'desc' },
    });

    const headers = [
      'Order Number',
      'Client',
      'Origin',
      'Destination',
      'Status',
      'Priority',
      'Weight',
      'Pickup Date',
      'Delivery Date',
      'Created',
    ];

    const escCsv = (val: string | null | undefined): string => {
      if (val == null) return '';
      const s = String(val);
      if (s.includes(',') || s.includes('"') || s.includes('\n')) {
        return `"${s.replace(/"/g, '""')}"`;
      }
      return s;
    };

    const rows = orders.map((o: any) => {
      const origin = [o.originName, o.originCity, o.originState].filter(Boolean).join(', ');
      const dest = [o.destName, o.destCity, o.destState].filter(Boolean).join(', ');
      return [
        escCsv(o.orderNumber),
        escCsv(o.client?.name),
        escCsv(origin),
        escCsv(dest),
        escCsv(o.status),
        escCsv(o.priority),
        escCsv(o.weight?.toString()),
        escCsv(o.pickupDate ? new Date(o.pickupDate).toISOString().split('T')[0] : null),
        escCsv(o.deliveryDate ? new Date(o.deliveryDate).toISOString().split('T')[0] : null),
        escCsv(o.createdAt ? new Date(o.createdAt).toISOString().split('T')[0] : null),
      ].join(',');
    });

    return [headers.join(','), ...rows].join('\n');
  }
}

results matching ""

    No results matching ""