Rapport-Laravel/app/Http/Controllers/MonsoonyatraactivitylistCon...

885 lines
29 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Exports\InvoicesExport;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
/**
* Monsoon Yatra Activity List — MySQL-backed import + filtered report.
*
* Quick Action (Rapport admin, titans or team2 partition):
* Type: external_with_token
* URL: https://<laravel-domain>/monsoonyatraactivitylist/report?token=TOKEN
*/
class MonsoonyatraactivitylistController extends Controller
{
private const ALLOWED_DOMAINS = ['titans', 'team2'];
private const ACTIVITIES_TABLE = 'team3_monsoon_yatra_activities';
private const IMPORTS_TABLE = 'team3_monsoon_yatra_imports';
private const SESSION_TOKEN_KEY = 'monsoon_yatra_report_token';
private ?string $accessToken = null;
private ?object $reportUser = null;
public function __construct()
{
$this->_viewname = 'monsoonyatraactivitylist';
$this->_title = 'Monsoon Yatra Activity List';
$this->_base_url = '/monsoonyatraactivitylist';
$this->middleware(function ($request, $next) {
$action = $request->route('action');
if (in_array($action, ['report', 'export'], true)) {
return $next($request);
}
if ($action === null && $request->route()->getName() === 'monsoonyatraactivitylist-list' && $request->filled('token')) {
return $next($request);
}
if (!Auth::check()) {
return redirect('/login');
}
$this->assertAllowedWebAccess();
return $next($request);
});
}
public function list(Request $request)
{
$this->bootstrapTokenSession($request);
if ($request->hasSession()) {
$request->session()->save();
}
if ($request->filled('token')) {
return redirect($this->buildUrl('/report', $request));
}
return redirect($this->_base_url . '/report');
}
public function report(Request $request)
{
$this->bootstrapTokenSession($request);
if ($request->hasSession()) {
$request->session()->save();
}
$token = $this->assertReportAccess($request);
$filters = $this->resolveFilters($request);
$query = $this->buildFilteredQuery($filters);
$perPage = $this->resolvePerPage($request);
$paginationAppends = $filters;
if ($token !== '') {
$paginationAppends['token'] = $token;
}
$rows = (clone $query)
->orderBy('zone')
->orderBy('state')
->orderBy('activity_date')
->orderBy('educator_name')
->orderBy('doctor_name')
->paginate($perPage)
->appends($paginationAppends);
return $this->view('report')->with([
'title' => $this->_title,
'base_url' => $this->_base_url,
'token' => $token,
'rows' => $rows,
'filters' => $filters,
'filterOptions' => $this->filterOptions(),
'canImport' => $this->userCanImport(),
'buttons' => $this->getReportButtons(),
]);
}
public function export(Request $request)
{
$this->bootstrapTokenSession($request);
$this->assertReportAccess($request);
$filters = $this->resolveFilters($request);
$rows = $this->buildFilteredQuery($filters)
->orderBy('zone')
->orderBy('state')
->orderBy('activity_date')
->orderBy('educator_name')
->orderBy('doctor_name')
->get();
$header = [
'Date', 'State', 'Zone', 'Educator HQ', 'Educator Name', 'Educator Manager',
'Camp Area', 'Time', 'MSL Code', 'Doctor Name', 'Doctor Speciality', 'Chemist Name',
];
$excelData = [$header];
foreach ($rows as $row) {
$excelData[] = [
$this->formatDisplayDate($row->activity_date),
$row->state,
$row->zone,
$row->educator_hq,
$row->educator_name,
$row->educator_manager,
$row->camp_area,
$row->activity_time,
$row->msl_code,
$row->doctor_name,
$row->doctor_speciality,
$row->chemist_name,
];
}
return Excel::download(new InvoicesExport($excelData), 'monsoon-yatra-activity-list.xlsx');
}
public function import(Request $request)
{
$this->assertAllowedAdmin();
$imports = DB::table(self::IMPORTS_TABLE)
->orderByDesc('created_at')
->limit(100)
->get();
return $this->view('import')->with([
'title' => 'Import Monsoon Yatra Activity Data',
'base_url' => $this->_base_url,
'imports' => $imports,
]);
}
public function postimport(Request $request)
{
$this->assertAllowedAdmin();
try {
$validator = Validator::make($request->all(), [
'import_file' => 'required|file|max:10240',
], [
'import_file.required' => 'Please choose a file to upload.',
'import_file.file' => 'The upload did not contain a valid file.',
'import_file.max' => 'The file may not be larger than 10MB.',
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput()
->with('error', $validator->errors()->first());
}
$file = $request->file('import_file');
$extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
return redirect()->back()
->withInput()
->with('error', 'Only .xlsx, .xls, and .csv files are supported.');
}
$importId = DB::table(self::IMPORTS_TABLE)->insertGetId([
'file_name' => $file->getClientOriginalName(),
'status' => 'processing',
'uploaded_by' => (string) Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]);
$inserted = 0;
$replaced = 0;
$skipped = 0;
$errors = [];
$rows = $this->parseImportFile($file);
if (empty($rows['headers']) || empty($rows['data'])) {
throw new \RuntimeException('The file is empty or has no data rows.');
}
$columnIndex = $this->mapHeaderIndexes($rows['headers']);
if (!isset($columnIndex['activity_date'])) {
throw new \RuntimeException('Required column Date was not found in the file header row.');
}
foreach ($rows['data'] as $rowIndex => $row) {
$lineNo = $rowIndex + 2;
try {
$record = $this->mapRowToRecord($row, $columnIndex);
if (empty($record['activity_date'])) {
$skipped++;
continue;
}
if (empty($record['msl_code']) && empty($record['doctor_name'])) {
$skipped++;
continue;
}
$existing = DB::table(self::ACTIVITIES_TABLE)
->where('activity_date', $record['activity_date'])
->where('msl_code', $record['msl_code'] ?? '')
->where('educator_name', $record['educator_name'] ?? '')
->where('activity_time', $record['activity_time'] ?? '')
->first();
$payload = array_merge($record, [
'import_id' => $importId,
'updated_at' => now(),
]);
if ($existing) {
DB::table(self::ACTIVITIES_TABLE)->where('id', $existing->id)->update($payload);
$replaced++;
} else {
DB::table(self::ACTIVITIES_TABLE)->insert(array_merge($payload, [
'created_at' => now(),
]));
$inserted++;
}
} catch (\Throwable $e) {
$errors[] = $this->formatRowError($lineNo, $e);
}
}
$status = empty($errors) ? 'completed' : 'completed_with_errors';
$this->updateImportLog($importId, [
'status' => $status,
'total_rows' => count($rows['data']),
'inserted_count' => $inserted,
'replaced_count' => $replaced,
'skipped_count' => $skipped,
'error_count' => count($errors),
'error_log' => empty($errors) ? null : implode("\n", array_slice($errors, 0, 50)),
'processed_at' => now(),
'updated_at' => now(),
]);
$message = "Import complete. Inserted: {$inserted}, Replaced: {$replaced}, Skipped: {$skipped}.";
if (!empty($errors)) {
$message .= ' Errors: ' . count($errors) . ' (see import log).';
}
return redirect($this->_base_url . '/import')->with('message', $message);
} catch (\Throwable $e) {
Log::error('Monsoon Yatra activity import failed', [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
]);
if (isset($importId)) {
$this->updateImportLog($importId, [
'status' => 'failed',
'error_log' => $e->getMessage(),
'processed_at' => now(),
'updated_at' => now(),
]);
}
return redirect()->back()
->withInput()
->with('error', $this->sanitizeDbString($e->getMessage()) ?: 'Import failed.');
}
}
protected function getReportButtons(): array
{
$buttons = [];
if (Auth::check() && $this->userCanImport()) {
$buttons[] = ['title' => 'Import', 'link' => $this->_base_url . '/import'];
}
return $buttons;
}
protected function userCanImport(): bool
{
$permissions = $this->resolveUserPermissions();
return in_array('all_data_access', $permissions, true)
|| in_array('formexports_get_all', $permissions, true);
}
protected function resolveUserPermissions(): array
{
if ($this->reportUser !== null) {
$permissions = $this->reportUser->permissions ?? [];
if (is_array($permissions)) {
return $permissions;
}
if (($this->reportUser->all_data_access ?? false) === true) {
return ['all_data_access'];
}
return [];
}
$user = Auth::user();
if ($user === null) {
return [];
}
$permissions = json_decode($user->permissions ?? '[]', true);
return is_array($permissions) ? $permissions : [];
}
protected function assertAllowedWebAccess(): void
{
$user = Auth::user();
if (!$this->userDomainIsAllowed((string) ($user->domain ?? ''))) {
abort(403);
}
}
protected function assertAllowedAdmin(): void
{
$this->assertAllowedWebAccess();
if (!$this->userCanImport()) {
abort(403);
}
}
protected function assertReportAccess(Request $request): string
{
$token = $this->resolveAccessToken($request);
if ($token) {
$payload = $this->decodeTokenPayload($token);
$user = $this->getUserFromToken($token);
if (!$user || !$this->isAllowedDomainUser($user)) {
abort(403, 'Access denied. A valid titans or team2 token is required.');
}
if ($this->isTokenPayloadExpired($payload)) {
$this->forgetPersistedToken($request);
abort(401, 'Your access token has expired. Please open a fresh link from the app.');
}
$this->accessToken = $token;
$this->reportUser = $user;
return $token;
}
if (Auth::check() && $this->userDomainIsAllowed((string) (Auth::user()->domain ?? ''))) {
return '';
}
if ($request->filled('token')) {
abort(403, 'Invalid or truncated access token. Please open the full link from the app.');
}
abort(401, 'Access token is required. Open this report with ?token=... from the app.');
}
protected function bootstrapTokenSession(Request $request): void
{
$token = $this->extractRequestToken($request);
if ($token === '') {
return;
}
$payload = $this->decodeTokenPayload($token);
$user = is_object($payload) ? ($payload->user ?? null) : null;
if ($user && $this->isAllowedDomainUser($user) && !$this->isTokenPayloadExpired($payload)) {
$this->persistAccessToken($request, $token);
}
}
protected function resolveAccessToken(Request $request): ?string
{
$fromRequest = $this->extractRequestToken($request);
if ($fromRequest !== '') {
$payload = $this->decodeTokenPayload($fromRequest);
$user = is_object($payload) ? ($payload->user ?? null) : null;
if ($user && $this->isAllowedDomainUser($user) && !$this->isTokenPayloadExpired($payload)) {
$this->persistAccessToken($request, $fromRequest);
return $fromRequest;
}
}
$storedToken = $this->loadPersistedToken($request);
if ($storedToken !== '') {
$payload = $this->decodeTokenPayload($storedToken);
$user = is_object($payload) ? ($payload->user ?? null) : null;
if ($user && $this->isAllowedDomainUser($user) && !$this->isTokenPayloadExpired($payload)) {
return $storedToken;
}
$this->forgetPersistedToken($request);
}
return null;
}
protected function extractRequestToken(Request $request): string
{
$raw = $request->query('token', $request->input('token'));
if ($raw === null || $raw === '') {
$raw = $request->header('Authorization') ?? '';
}
return $this->normalizeRawToken((string) $raw);
}
protected function persistAccessToken(Request $request, string $token): void
{
$request->session()->put(self::SESSION_TOKEN_KEY, $token);
$this->tokenStorage()->put($this->tokenStoragePath($request), $token);
}
protected function loadPersistedToken(Request $request): string
{
if ($request->session()->has(self::SESSION_TOKEN_KEY)) {
$sessionToken = $this->normalizeRawToken((string) $request->session()->get(self::SESSION_TOKEN_KEY));
if ($sessionToken !== '') {
return $sessionToken;
}
}
$path = $this->tokenStoragePath($request);
if ($this->tokenStorage()->exists($path)) {
return $this->normalizeRawToken((string) $this->tokenStorage()->get($path));
}
return '';
}
protected function forgetPersistedToken(Request $request): void
{
$request->session()->forget(self::SESSION_TOKEN_KEY);
$path = $this->tokenStoragePath($request);
if ($this->tokenStorage()->exists($path)) {
$this->tokenStorage()->delete($path);
}
}
protected function tokenStoragePath(Request $request): string
{
return 'monsoon_yatra_report_tokens/' . hash('sha256', $request->session()->getId());
}
protected function tokenStorage()
{
return Storage::disk('local');
}
protected function isTokenPayloadExpired(?object $payload): bool
{
if (!is_object($payload) || !isset($payload->exp)) {
return false;
}
return (int) $payload->exp < time();
}
protected function normalizeRawToken(string $token): string
{
$token = trim($token);
if (stripos($token, 'Bearer ') === 0) {
$token = trim(substr($token, 7));
}
if (substr_count($token, '.') === 2) {
$parts = explode('.', $token);
$parts[1] = str_replace(' ', '+', $parts[1]);
if (isset($parts[2])) {
$parts[2] = str_replace(' ', '+', $parts[2]);
}
$token = implode('.', $parts);
}
return $token;
}
protected function getUserFromToken(string $token): ?object
{
$payload = $this->decodeTokenPayload($token);
if (!is_object($payload)) {
return null;
}
$user = $payload->user ?? null;
if (!is_object($user)) {
return null;
}
if (!isset($user->permissions) && isset($payload->permissions)) {
$user->permissions = $payload->permissions;
}
if (!isset($user->all_data_access) && isset($payload->all_data_access)) {
$user->all_data_access = $payload->all_data_access;
}
return $user;
}
protected function decodeTokenPayload(string $token): ?object
{
$token = $this->normalizeRawToken($token);
$parts = explode('.', $token);
if (count($parts) < 2 || $parts[1] === '') {
return null;
}
$payloadSegment = strtr($parts[1], '-_', '+/');
$padding = strlen($payloadSegment) % 4;
if ($padding > 0) {
$payloadSegment .= str_repeat('=', 4 - $padding);
}
$payload = json_decode(base64_decode($payloadSegment));
return is_object($payload) ? $payload : null;
}
protected function userDomainIsAllowed(string $domain): bool
{
return in_array(strtolower($domain), self::ALLOWED_DOMAINS, true);
}
protected function isAllowedDomainUser(object $user): bool
{
$domain = strtolower((string) ($user->domain ?? ''));
if ($this->userDomainIsAllowed($domain)) {
return true;
}
$partition = strtolower((string) ($user->partition ?? ''));
foreach (self::ALLOWED_DOMAINS as $allowedDomain) {
if ($partition === 'rapport-' . $allowedDomain) {
return true;
}
}
return false;
}
protected function buildUrl(string $path, Request $request): string
{
$query = array_filter([
'token' => $request->input('token') ?: $request->session()->get(self::SESSION_TOKEN_KEY),
]);
$url = $this->_base_url . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
return $url;
}
protected function resolvePerPage(Request $request): int
{
$allowed = [25, 50, 100, 200];
$perPage = (int) $request->input('per_page', 50);
return in_array($perPage, $allowed, true) ? $perPage : 50;
}
protected function resolveFilters(Request $request): array
{
$dateMode = $request->input('date_mode', 'single');
if (!in_array($dateMode, ['single', 'range'], true)) {
$dateMode = 'single';
}
return [
'date_mode' => $dateMode,
'activity_date' => trim((string) $request->input('activity_date', '')),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'zone' => trim((string) $request->input('zone', '')),
'state' => trim((string) $request->input('state', '')),
'educator_hq' => trim((string) $request->input('educator_hq', '')),
'educator_name' => trim((string) $request->input('educator_name', '')),
'per_page' => (string) $this->resolvePerPage($request),
];
}
protected function buildFilteredQuery(array $filters)
{
$query = DB::table(self::ACTIVITIES_TABLE);
if ($filters['date_mode'] === 'range') {
$from = $this->normalizeFilterDate($filters['date_from']);
$to = $this->normalizeFilterDate($filters['date_to']);
if ($from) {
$query->where('activity_date', '>=', $from);
}
if ($to) {
$query->where('activity_date', '<=', $to);
}
} else {
$date = $this->normalizeFilterDate($filters['activity_date']);
if ($date) {
$query->where('activity_date', $date);
}
}
foreach (['zone', 'state', 'educator_hq', 'educator_name'] as $field) {
if ($filters[$field] !== '') {
$query->where($field, $filters[$field]);
}
}
return $query;
}
protected function filterOptions(): array
{
$zones = DB::table(self::ACTIVITIES_TABLE)->whereNotNull('zone')->where('zone', '!=', '')->distinct()->orderBy('zone')->pluck('zone');
$states = DB::table(self::ACTIVITIES_TABLE)->whereNotNull('state')->where('state', '!=', '')->distinct()->orderBy('state')->pluck('state');
$hqs = DB::table(self::ACTIVITIES_TABLE)->whereNotNull('educator_hq')->where('educator_hq', '!=', '')->distinct()->orderBy('educator_hq')->pluck('educator_hq');
$educators = DB::table(self::ACTIVITIES_TABLE)->whereNotNull('educator_name')->where('educator_name', '!=', '')->distinct()->orderBy('educator_name')->pluck('educator_name');
return [
'zones' => $zones,
'states' => $states,
'educator_hqs' => $hqs,
'educator_names' => $educators,
];
}
protected function normalizeFilterDate(string $value): ?string
{
if ($value === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value;
}
return $this->parseActivityDate($value);
}
protected function parseActivityDate(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value;
}
// Two-digit year formats must be tried before d/m/Y — otherwise "01/07/26" becomes year 0026.
foreach (['d/m/y', 'j/n/y', 'd-m-y', 'd/m/Y', 'j/n/Y', 'd-m-Y'] as $format) {
try {
$parsed = Carbon::createFromFormat('!' . $format, $value);
$year = (int) $parsed->format('Y');
if ($year >= 1970 && $year <= 2100) {
return $parsed->format('Y-m-d');
}
} catch (\Throwable $e) {
continue;
}
}
if (is_numeric($value)) {
$numeric = (float) $value;
if ($numeric > 30000 && $numeric < 60000) {
try {
return ExcelDate::excelToDateTimeObject($numeric)->format('Y-m-d');
} catch (\Throwable $e) {
// fall through
}
}
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value)) {
try {
$parsed = Carbon::parse($value);
$year = (int) $parsed->format('Y');
if ($year >= 1970 && $year <= 2100) {
return $parsed->format('Y-m-d');
}
} catch (\Throwable $e) {
return null;
}
}
return null;
}
protected function formatDisplayDate(?string $value): string
{
if ($value === null || $value === '') {
return '';
}
try {
return Carbon::parse($value)->format('d/m/Y');
} catch (\Throwable $e) {
return $value;
}
}
protected function parseImportFile($file): array
{
$extension = strtolower($file->getClientOriginalExtension());
if (in_array($extension, ['xlsx', 'xls'], true)) {
$data = Excel::toArray([], $file);
if (empty($data[0])) {
return ['headers' => [], 'data' => []];
}
$sheet = $data[0];
$headers = array_shift($sheet);
return ['headers' => $headers, 'data' => $sheet];
}
$lines = array_map('str_getcsv', file($file->getRealPath()));
$headers = array_shift($lines) ?: [];
return ['headers' => $headers, 'data' => $lines];
}
protected function normalizeHeader(string $header): string
{
$header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
$header = strtolower(trim(preg_replace('/\s+/', ' ', $header)));
return $header;
}
protected function mapHeaderIndexes(array $headers): array
{
$map = [];
foreach ($headers as $index => $header) {
$field = $this->headerToField($this->normalizeHeader((string) $header));
if ($field) {
$map[$field] = $index;
}
}
return $map;
}
protected function headerToField(string $normalized): ?string
{
$aliases = [
'date' => 'activity_date',
'state' => 'state',
'zone' => 'zone',
'educator hq' => 'educator_hq',
'educator name' => 'educator_name',
'educator manager' => 'educator_manager',
'camp area' => 'camp_area',
'time' => 'activity_time',
'msl code' => 'msl_code',
'doctor name' => 'doctor_name',
'doctor speciality' => 'doctor_speciality',
'doctor specialty' => 'doctor_speciality',
'chemist name' => 'chemist_name',
];
return $aliases[$normalized] ?? null;
}
protected function mapRowToRecord(array $row, array $columnIndex): array
{
$get = function (string $field) use ($row, $columnIndex) {
if (!isset($columnIndex[$field])) {
return null;
}
$value = $row[$columnIndex[$field]] ?? null;
if ($value === null || $value === '') {
return null;
}
return $this->sanitizeDbString((string) $value);
};
$rawDate = isset($columnIndex['activity_date']) ? ($row[$columnIndex['activity_date']] ?? null) : null;
$activityDate = $this->parseActivityDate($this->normalizeImportDateValue($rawDate));
return [
'activity_date' => $activityDate,
'state' => $get('state'),
'zone' => $get('zone'),
'educator_hq' => $get('educator_hq'),
'educator_name' => $get('educator_name'),
'educator_manager' => $get('educator_manager'),
'camp_area' => $get('camp_area'),
'activity_time' => $get('activity_time'),
'msl_code' => $get('msl_code'),
'doctor_name' => $get('doctor_name'),
'doctor_speciality' => $get('doctor_speciality'),
'chemist_name' => $get('chemist_name'),
];
}
protected function normalizeImportDateValue($value): ?string
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
if (is_numeric($value)) {
$numeric = (float) $value;
if ($numeric > 30000 && $numeric < 60000) {
try {
return ExcelDate::excelToDateTimeObject($numeric)->format('Y-m-d');
} catch (\Throwable $e) {
// fall through to string parsing
}
}
}
return $this->sanitizeDbString((string) $value);
}
protected function sanitizeDbString(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = str_replace("\0", '', $value);
if (!mb_check_encoding($value, 'UTF-8')) {
$converted = @iconv('Windows-1252', 'UTF-8//IGNORE', $value);
if ($converted !== false && $converted !== '') {
$value = $converted;
}
}
$value = trim($value);
return $value === '' ? null : $value;
}
protected function formatRowError(int $lineNo, \Throwable $e): string
{
$message = $this->sanitizeDbString($e->getMessage()) ?: 'Unknown row error.';
if (strlen($message) > 240) {
$message = substr($message, 0, 240) . '...';
}
return "Row {$lineNo}: {$message}";
}
protected function updateImportLog(int $importId, array $payload): void
{
if (array_key_exists('error_log', $payload)) {
$payload['error_log'] = $payload['error_log'] === null
? null
: $this->sanitizeDbString((string) $payload['error_log']);
}
DB::table(self::IMPORTS_TABLE)->where('id', $importId)->update($payload);
}
}