885 lines
29 KiB
PHP
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);
|
|
}
|
|
}
|