Your IP : 216.73.217.79


Current Path : /var/www/v3.cesa.co.za/src/EventBundle/Services/
Upload File :
Current File : /var/www/v3.cesa.co.za/src/EventBundle/Services/SchoolWeeksCoursesDashboardService.php

<?php

namespace App\EventBundle\Services;

use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

/**
 * SCE Weeks/Courses dashboard rows — outer joins so missing related data never drops an event.
 */
final class SchoolWeeksCoursesDashboardService
{
    public const DEFAULT_PER_PAGE = 40;

    /** @var list<int> */
    public const PER_PAGE_OPTIONS = [20, 40, 80, 160];

    public const DEFAULT_SORT = 'StartDateReport';

    public const DEFAULT_DIR = 'asc';

    /**
     * Allow-listed sort keys => SQL ORDER BY expression.
     *
     * @var array<string, string>
     */
    public const SORT_COLUMNS = [
        'EventName' => 'se.EventName',
        'CourseType' => 'se.CourseType',
        'StartDateReport' => 'se.StartDate',
        'EndDateReport' => 'se.EndDate',
        'AttendeeCount' => 'AttendeeCount',
        'ProviderName' => 'tp.CompanyName',
        'PresenterName' => 'sp.PresenterName',
        'Category' => 'sc.Category',
        'Cancelled' => 'se.Cancelled',
        'CostPerPerson' => 'se.CostPerPerson',
        'RatePerDay' => 'tpr.RatePerDay',
        'Days' => 'Days',
        'TotalTPRate' => 'TotalTPRate',
        'TotalIncome' => 'TotalIncome',
        'Profit' => 'ProfitRaw',
        'Invoice' => 'se.TrainerInvRecv',
        'Venue' => 'se.Venue',
    ];

    public function __construct(
        private readonly Connection $connection,
    ) {
    }

    /**
     * Resolve filters: when both from and to are empty, apply default window
     * (today − 14 days … today + 4 months).
     *
     * @param array{from?: string|null, to?: string|null, q?: string|null, page?: int|string|null, per_page?: int|string|null, sort?: string|null, dir?: string|null} $filters
     * @return array{
     *     from: string,
     *     to: string,
     *     q: string|null,
     *     page: int,
     *     per_page: int,
     *     sort: string,
     *     dir: string,
     *     defaults_applied: bool
     * }
     */
    public function resolveFilters(array $filters = []): array
    {
        $from = $this->normalizeDateInput($filters['from'] ?? null);
        $to = $this->normalizeDateInput($filters['to'] ?? null);
        $q = isset($filters['q']) ? trim((string) $filters['q']) : '';
        $q = $q !== '' ? $q : null;

        $defaultsApplied = false;
        if ($from === null && $to === null) {
            $today = new \DateTimeImmutable('today');
            $from = $today->modify('-14 days')->format('Y-m-d');
            $to = $today->modify('+4 months')->format('Y-m-d');
            $defaultsApplied = true;
        }

        $perPage = (int) ($filters['per_page'] ?? self::DEFAULT_PER_PAGE);
        if (!in_array($perPage, self::PER_PAGE_OPTIONS, true)) {
            $perPage = self::DEFAULT_PER_PAGE;
        }

        $page = max(1, (int) ($filters['page'] ?? 1));

        $sort = trim((string) ($filters['sort'] ?? self::DEFAULT_SORT));
        if (!isset(self::SORT_COLUMNS[$sort])) {
            $sort = self::DEFAULT_SORT;
        }

        $dir = strtolower(trim((string) ($filters['dir'] ?? self::DEFAULT_DIR)));
        if (!in_array($dir, ['asc', 'desc'], true)) {
            $dir = self::DEFAULT_DIR;
        }

        return [
            'from' => (string) $from,
            'to' => (string) $to,
            'q' => $q,
            'page' => $page,
            'per_page' => $perPage,
            'sort' => $sort,
            'dir' => $dir,
            'defaults_applied' => $defaultsApplied,
        ];
    }

    /**
     * @param array{from?: string|null, to?: string|null, q?: string|null, page?: int|string|null, per_page?: int|string|null, sort?: string|null, dir?: string|null} $filters
     * @return array{
     *     rows: list<array<string, mixed>>,
     *     total: int,
     *     page: int,
     *     per_page: int,
     *     pages: int,
     *     filters: array{from: string, to: string, q: string|null, page: int, per_page: int, sort: string, dir: string, defaults_applied: bool}
     * }
     */
    public function getDashboardPage(array $filters = []): array
    {
        $resolved = $this->resolveFilters($filters);
        [$whereSql, $params] = $this->buildWhere($resolved);

        $countSql = 'SELECT COUNT(*) FROM SchoolEvents se'
            . ' LEFT JOIN SchoolCourses sc ON sc.SchoolCourseID = se.SchoolCourseID'
            . ' LEFT JOIN SchoolWebCategories swc ON swc.WebCategoryID = se.WebCategory'
            . ' WHERE ' . $whereSql;

        $total = (int) $this->connection->fetchOne($countSql, $params);
        $pages = $total > 0 ? (int) ceil($total / $resolved['per_page']) : 1;
        if ($resolved['page'] > $pages) {
            $resolved['page'] = $pages;
        }

        $offset = ($resolved['page'] - 1) * $resolved['per_page'];
        $sql = str_replace('/*WHERE*/', 'WHERE ' . $whereSql, $this->buildSql());
        $sql .= ' ' . $this->buildOrderBy($resolved['sort'], $resolved['dir']);
        $sql .= ' LIMIT ' . (int) $resolved['per_page'] . ' OFFSET ' . (int) $offset;

        $rows = $this->connection->fetchAllAssociative($sql, $params);
        if (!is_array($rows)) {
            $rows = [];
        }

        return [
            'rows' => array_map([$this, 'normalizeRow'], $rows),
            'total' => $total,
            'page' => $resolved['page'],
            'per_page' => $resolved['per_page'],
            'pages' => $pages,
            'filters' => $resolved,
        ];
    }

    /**
     * All rows matching filters (no pagination) for Excel export.
     *
     * @param array{from?: string|null, to?: string|null, q?: string|null, sort?: string|null, dir?: string|null} $filters
     * @return list<array<string, mixed>>
     */
    public function getDashboardExportRows(array $filters = []): array
    {
        $resolved = $this->resolveFilters(array_merge($filters, [
            'page' => 1,
            'per_page' => self::DEFAULT_PER_PAGE,
        ]));
        [$whereSql, $params] = $this->buildWhere($resolved);

        $sql = str_replace('/*WHERE*/', 'WHERE ' . $whereSql, $this->buildSql());
        $sql .= ' ' . $this->buildOrderBy($resolved['sort'], $resolved['dir']);

        $rows = $this->connection->fetchAllAssociative($sql, $params);
        if (!is_array($rows)) {
            return [];
        }

        return array_map([$this, 'normalizeRow'], $rows);
    }

    /**
     * @param array{from?: string|null, to?: string|null, q?: string|null, sort?: string|null, dir?: string|null} $filters
     */
    public function buildExcelSpreadsheet(array $filters = []): Spreadsheet
    {
        $rows = $this->getDashboardExportRows($filters);
        $spreadsheet = new Spreadsheet();
        $sheet = $spreadsheet->getActiveSheet();
        $sheet->setTitle('Weeks Courses');

        $headers = [
            'Event Name',
            'Course Type',
            'Start Date',
            'End Date',
            'Del',
            'Provider',
            'Presenter',
            'Category',
            'X',
            'Cost / Person',
            'Rate / Day',
            'Days',
            'Total TP Rate',
            'Total Income',
            'Profit',
            'Invoice',
            'Venue',
        ];
        foreach ($headers as $col => $label) {
            $sheet->setCellValueByColumnAndRow($col + 1, 1, $label);
        }
        $sheet->getStyle('A1:Q1')->getFont()->setBold(true);

        $r = 2;
        foreach ($rows as $row) {
            $sheet->setCellValueByColumnAndRow(1, $r, (string) ($row['EventName'] ?? ''));
            $sheet->setCellValueByColumnAndRow(2, $r, (string) ($row['CourseType'] ?? ''));
            $sheet->setCellValueByColumnAndRow(3, $r, (string) ($row['StartDateReport'] ?? ''));
            $sheet->setCellValueByColumnAndRow(4, $r, (string) ($row['EndDateReport'] ?? ''));
            $sheet->setCellValueByColumnAndRow(5, $r, (string) ($row['AttendeeCount'] ?? ''));
            $sheet->setCellValueByColumnAndRow(6, $r, (string) ($row['ProviderName'] ?? ''));
            $sheet->setCellValueByColumnAndRow(7, $r, (string) ($row['PresenterName'] ?? ''));
            $sheet->setCellValueByColumnAndRow(8, $r, (string) ($row['Category'] ?? ''));
            $sheet->setCellValueByColumnAndRow(9, $r, !empty($row['Cancelled']) ? 'Y' : 'N');
            $sheet->setCellValueByColumnAndRow(10, $r, (string) ($row['CostPerPerson'] ?? ''));
            $sheet->setCellValueByColumnAndRow(11, $r, (string) ($row['RatePerDay'] ?? ''));
            $sheet->setCellValueByColumnAndRow(12, $r, (string) ($row['Days'] ?? ''));
            $sheet->setCellValueByColumnAndRow(13, $r, (string) ($row['TotalTPRate'] ?? ''));
            $sheet->setCellValueByColumnAndRow(14, $r, (string) ($row['TotalIncome'] ?? ''));
            $sheet->setCellValueByColumnAndRow(15, $r, (string) ($row['Profit'] ?? ''));
            $sheet->setCellValueByColumnAndRow(16, $r, (string) ($row['Invoice'] ?? 'No'));
            $sheet->setCellValueByColumnAndRow(17, $r, (string) ($row['Venue'] ?? ''));
            ++$r;
        }

        return $spreadsheet;
    }

    /**
     * @param array{from?: string|null, to?: string|null, q?: string|null, sort?: string|null, dir?: string|null} $filters
     */
    public function buildExcelBinary(array $filters = []): string
    {
        $spreadsheet = $this->buildExcelSpreadsheet($filters);
        $writer = new Xlsx($spreadsheet);
        $tmp = tmpfile();
        if ($tmp === false) {
            throw new \RuntimeException('Could not create temporary file for Excel export.');
        }
        $meta = stream_get_meta_data($tmp);
        $path = $meta['uri'] ?? null;
        if (!is_string($path) || $path === '') {
            fclose($tmp);
            throw new \RuntimeException('Could not resolve temporary Excel path.');
        }
        $writer->save($path);
        rewind($tmp);
        $binary = stream_get_contents($tmp);
        fclose($tmp);
        if ($binary === false) {
            throw new \RuntimeException('Could not read Excel export binary.');
        }

        return $binary;
    }

    /**
     * @param array{from?: string|null, to?: string|null, q?: string|null} $filters
     * @return list<array<string, mixed>>
     */
    public function getDashboardRows(array $filters = []): array
    {
        return $this->getDashboardPage(array_merge($filters, [
            'page' => 1,
            'per_page' => self::DEFAULT_PER_PAGE,
        ]))['rows'];
    }

    /**
     * Next sort direction when clicking a column header.
     */
    public function nextSortDir(string $currentSort, string $currentDir, string $clickedSort): string
    {
        if ($clickedSort === $currentSort && strtolower($currentDir) === 'asc') {
            return 'desc';
        }

        return 'asc';
    }

    /**
     * @param array{from: string, to: string, q: string|null} $filters
     * @return array{0: string, 1: array<string, mixed>}
     */
    private function buildWhere(array $filters): array
    {
        $where = ['1=1'];
        $params = [];

        if ($filters['from'] !== '') {
            $where[] = 'se.StartDate >= :fromDate';
            $params['fromDate'] = $filters['from'] . ' 00:00:00';
        }
        if ($filters['to'] !== '') {
            $where[] = 'se.StartDate <= :toDate';
            $params['toDate'] = $filters['to'] . ' 23:59:59';
        }
        if (!empty($filters['q'])) {
            $where[] = '(se.EventName LIKE :q OR se.Venue LIKE :q OR sc.Category LIKE :q OR swc.WebCategoryName LIKE :q)';
            $params['q'] = '%' . $filters['q'] . '%';
        }

        return [implode(' AND ', $where), $params];
    }

    private function buildOrderBy(string $sort, string $dir): string
    {
        $expr = self::SORT_COLUMNS[$sort] ?? self::SORT_COLUMNS[self::DEFAULT_SORT];
        $dirSql = strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC';

        return 'ORDER BY ' . $expr . ' ' . $dirSql . ', se.EventID ASC';
    }

    private function normalizeDateInput(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }
        $raw = trim((string) $value);
        if ($raw === '') {
            return null;
        }

        $raw = str_replace('/', '-', $raw);
        $ts = strtotime($raw);
        if ($ts === false) {
            return null;
        }

        return date('Y-m-d', $ts);
    }

    private function buildSql(): string
    {
        // Days / TP / Profit use schedule-day counts only (no calendar DATEDIFF fallback).
        return <<<SQL
SELECT
    se.EventID AS EventID,
    se.EventName AS EventName,
    se.CourseType AS CourseType,
    se.CostPerPerson AS CostPerPerson,
    se.Venue AS Venue,
    se.StartDate AS StartDateReport,
    se.EndDate AS EndDateReport,
    se.TrainerInvRecv AS TrainerInvRecv,
    se.TrainerInvRecv AS TrainerInvRecvSE,
    se.Cancelled AS Cancelled,
    tp.CompanyName AS ProviderName,
    sp.PresenterName AS PresenterName,
    tpr.RateDesc AS RateDesc,
    tpr.RatePerDay AS RatePerDay,
    CASE
        WHEN tpr.RatePerDay IS NULL OR IFNULL(sch.ScheduleDays, 0) = 0 THEN NULL
        ELSE tpr.RatePerDay * sch.ScheduleDays
    END AS TotalTPRate,
    IFNULL(att.AttendeeCount, 0) AS AttendeeCount,
    CASE
        WHEN se.CostPerPerson IS NULL THEN NULL
        ELSE IFNULL(att.AttendeeCount, 0) * se.CostPerPerson
    END AS TotalIncome,
    CASE
        WHEN IFNULL(se.Cancelled, 0) = 1 OR IFNULL(att.AttendeeCount, 0) = 0 THEN NULL
        WHEN se.CostPerPerson IS NULL
            OR tpr.RatePerDay IS NULL
            OR IFNULL(sch.ScheduleDays, 0) = 0
        THEN NULL
        ELSE (IFNULL(att.AttendeeCount, 0) * se.CostPerPerson)
            - (tpr.RatePerDay * sch.ScheduleDays)
    END AS ProfitRaw,
    sch.ScheduleDays AS Days,
    sc.Category AS Category,
    swc.WebCategoryName AS WebCategoryName
FROM SchoolEvents se
LEFT JOIN SchoolCourses sc ON sc.SchoolCourseID = se.SchoolCourseID
LEFT JOIN SchoolWebCategories swc ON swc.WebCategoryID = se.WebCategory
LEFT JOIN SchoolPresenters sp ON sp.PresenterID = se.PresenterID
LEFT JOIN TrainingProviders tp ON tp.ProviderID = sc.TrainingProviderID
LEFT JOIN (
    SELECT EventID, COUNT(AttendeeID) AS AttendeeCount
    FROM SchoolAttendees
    WHERE Cancelled = 0 OR Cancelled IS NULL
    GROUP BY EventID
) att ON att.EventID = se.EventID
LEFT JOIN (
    SELECT EventID, COUNT(DISTINCT ScheduleDate) AS ScheduleDays
    FROM EventSchedules
    WHERE ScheduleDate IS NOT NULL
    GROUP BY EventID
) sch ON sch.EventID = se.EventID
LEFT JOIN (
    SELECT ProviderID, MIN(RateDesc) AS RateDesc, MIN(RatePerDay) AS RatePerDay
    FROM TrainingProviderRates
    WHERE RatePerDay IS NOT NULL
    GROUP BY ProviderID
) tpr ON tpr.ProviderID = sc.TrainingProviderID
/*WHERE*/
SQL;
    }

    /**
     * @param array<string, mixed> $row
     * @return array<string, mixed>
     */
    private function normalizeRow(array $row): array
    {
        $display = static function ($value): string {
            if ($value === null || $value === '') {
                return '';
            }

            return (string) $value;
        };

        $money = static function ($value): string {
            if ($value === null || $value === '') {
                return '';
            }

            return number_format((float) $value, 2, '.', ' ');
        };

        $attendeeCount = isset($row['AttendeeCount']) ? (int) $row['AttendeeCount'] : 0;
        $cancelled = $this->isEventCancelled($row['Cancelled'] ?? null);
        $profitNotApplicable = $cancelled || $attendeeCount === 0;

        $profitRaw = $row['ProfitRaw'] ?? $row['Profit'] ?? null;
        if (!$profitNotApplicable && ($profitRaw === null || $profitRaw === '')) {
            // Fallback if SQL omitted ProfitRaw but income/cost components are present.
            $income = $row['TotalIncome'] ?? null;
            $tpRate = $row['TotalTPRate'] ?? null;
            if ($income !== null && $income !== '' && $tpRate !== null && $tpRate !== '') {
                $profitRaw = (float) $income - (float) $tpRate;
            }
        }

        $profitDisplay = $profitNotApplicable ? '-' : $money($profitRaw);
        $profitIsNegative = !$profitNotApplicable
            && $profitRaw !== null
            && $profitRaw !== ''
            && (float) $profitRaw < 0;

        return [
            'EventID' => isset($row['EventID']) ? (int) $row['EventID'] : 0,
            'EventName' => $display($row['EventName'] ?? null),
            'CourseType' => $display($row['CourseType'] ?? null),
            'CostPerPerson' => $money($row['CostPerPerson'] ?? null),
            'Venue' => $display($row['Venue'] ?? null),
            'StartDateReport' => $this->formatDate($row['StartDateReport'] ?? null),
            'EndDateReport' => $this->formatDate($row['EndDateReport'] ?? null),
            'TrainerInvRecv' => $display($row['TrainerInvRecv'] ?? null),
            'TrainerInvRecvSE' => $display($row['TrainerInvRecvSE'] ?? null),
            'Invoice' => $this->formatYesNo($row['TrainerInvRecv'] ?? $row['TrainerInvRecvSE'] ?? null),
            'ProviderName' => $display($row['ProviderName'] ?? null),
            'PresenterName' => $display($row['PresenterName'] ?? null),
            'RateDesc' => $display($row['RateDesc'] ?? null),
            'RatePerDay' => $money($row['RatePerDay'] ?? null),
            'TotalTPRate' => $money($row['TotalTPRate'] ?? null),
            'AttendeeCount' => (string) $attendeeCount,
            'TotalIncome' => $money($row['TotalIncome'] ?? null),
            'Profit' => $profitDisplay,
            'ProfitIsNegative' => $profitIsNegative,
            'Days' => $display($row['Days'] ?? null),
            'Category' => $display($row['Category'] ?? null),
            'WebCategoryName' => $display($row['WebCategoryName'] ?? null),
            'Cancelled' => $cancelled,
        ];
    }

    private function formatYesNo(mixed $value): string
    {
        if ($value === null || $value === '') {
            return 'No';
        }
        if (is_bool($value)) {
            return $value ? 'Yes' : 'No';
        }
        if (is_numeric($value)) {
            return ((int) $value) === 1 ? 'Yes' : 'No';
        }

        $normalized = strtolower(trim((string) $value));
        if (in_array($normalized, ['1', 'yes', 'y', 'true'], true)) {
            return 'Yes';
        }
        if (in_array($normalized, ['0', 'no', 'n', 'false'], true)) {
            return 'No';
        }

        return 'No';
    }

    private function isEventCancelled(mixed $value): bool
    {
        if ($value === null || $value === '') {
            return false;
        }
        if (is_bool($value)) {
            return $value;
        }
        if (is_numeric($value)) {
            return (int) $value === 1;
        }

        $normalized = strtolower(trim((string) $value));

        return in_array($normalized, ['1', 'yes', 'y', 'true'], true);
    }

    private function formatDate(mixed $value): string
    {
        if ($value === null || $value === '') {
            return '';
        }

        if ($value instanceof \DateTimeInterface) {
            return $value->format('Y/m/d');
        }

        $raw = (string) $value;
        $ts = strtotime($raw);
        if ($ts === false) {
            return $raw;
        }

        return date('Y/m/d', $ts);
    }
}