'Ciphaler Units', '1781749537-e6eef0c0-00a8-4828-b24b-a20cf54d2d00' => 'SB Units', '1784689836-ba81d3f3-124a-42a9-ac75-1a5d9f11d008' => 'ACE Units', '1781688915-36a926b8-b505-4dd2-867c-a544980a4373' => 'All Units', ]; private const REPORT_TYPES = [ 'ciphaler' => [ 'label' => 'Ciphaler', 'title' => 'Ciphaler RCPA doctor wise report', 'tag_attributes' => ['po5_ciphaler'], 'metrics' => [ '1781749605-cb8ed99b-2f03-4c6a-b3a5-3de66354b7a8', '1781688915-36a926b8-b505-4dd2-867c-a544980a4373', ], ], 'sb' => [ 'label' => 'Synchrobreathe', 'title' => 'Synchrobreathe RCPA doctor wise report', 'tag_attributes' => ['po5_synchrobreathe'], 'metrics' => [ '1781749537-e6eef0c0-00a8-4828-b24b-a20cf54d2d00', '1781688915-36a926b8-b505-4dd2-867c-a544980a4373', ], ], 'ace' => [ 'label' => 'Ace Doctors', 'title' => 'Ace Doctors RCPA doctor wise report', 'tag_attributes' => ['ace_dr_tagging_report_ace_novel_meet'], 'metrics' => [ '1784689836-ba81d3f3-124a-42a9-ac75-1a5d9f11d008', '1781688915-36a926b8-b505-4dd2-867c-a544980a4373', ], ], ]; private ?string $accessToken = null; private ?object $tokenUser = null; public function __construct() { $this->_service = 'reports'; $this->_module = 'contact_metric_monthly'; $this->_viewname = 'team2unitsreport'; $this->_title = 'Ciphaler / SB / Ace Units Report'; $this->_base_url = '/team2unitsreport'; } public function list(Request $request) { $this->bootstrapTokenSession($request); if ($request->hasSession()) { $request->session()->save(); } return redirect($this->buildUrl('/report', $request)); } public function report(Request $request) { $this->bootstrapTokenSession($request); $token = $this->assertTeam2Access($request); if ($request->hasSession()) { $request->session()->save(); } $perPage = $this->resolvePerPage($request); $search = $this->resolveSearchTerm($request); $payload = $this->buildReportPayload($request, false, $perPage, $search); $response = LambdaRequest::getrequest( $this->_service, $this->_module . '/doctor_units', $payload, $token, 300 ); $reportError = $this->resolveReportError($response); if ($reportError !== null) { if ($this->isAuthReportError($reportError)) { $this->forgetPersistedToken($request); abort(401, $reportError); } abort(502, $reportError); } $report = $response->data ?? (object) []; [$doctors, $data, $pagination] = $this->resolveReportPage($report, $request, $perPage, $search); $type = $this->resolveReportType($request); $typeLabel = self::REPORT_TYPES[$type]['label']; $title = self::REPORT_TYPES[$type]['title']; return $this->view('report')->with([ 'title' => $title, 'base_url' => $this->_base_url, 'token' => $token, 'type' => $type, 'typeLabel' => $typeLabel, 'months' => $report->months ?? [], 'metrics' => $report->metrics ?? [], 'doctors' => $doctors, 'data' => $data, 'pagination' => $pagination, 'perPage' => $perPage, 'search' => $search, ]); } public function export(Request $request) { $this->bootstrapTokenSession($request); $token = $this->assertTeam2Access($request); $type = $this->resolveReportType($request); $search = $this->resolveSearchTerm($request); $payload = $this->buildReportPayload($request, false, 200, $search); unset($payload['page'], $payload['per_page']); $response = LambdaRequest::getrequest( $this->_service, $this->_module . '/doctor_units_export', $payload, $token, 60 ); $reportError = $this->resolveReportError($response); if ($reportError !== null) { if ($this->isAuthReportError($reportError)) { $this->forgetPersistedToken($request); abort(401, $reportError); } abort(502, $reportError); } $export = $response->data ?? (object) []; $exportId = trim((string) ($export->export_id ?? '')); if ($exportId === '') { abort(502, 'Export failed: the report service did not return an export id.'); } $title = self::REPORT_TYPES[$type]['title']; return $this->view('export')->with([ 'title' => $title . ' Export', 'base_url' => $this->_base_url, 'export_id' => $exportId, 'search' => $search, 'token' => $token, 'type' => $type, ]); } public function exportStatus(Request $request) { $token = $this->assertTeam2Access($request); $exportId = trim((string) $request->query('export_id', '')); if ($exportId === '') { return response()->json(['status' => 'failed', 'message' => 'export_id is required'], 400); } $response = LambdaRequest::getrequest( $this->_service, $this->_module . '/doctor_units_export', ['export_id' => $exportId], $token, 30 ); $reportError = $this->resolveReportError($response); if ($reportError !== null) { if ($this->isAuthReportError($reportError)) { $this->forgetPersistedToken($request); return response()->json(['status' => 'failed', 'message' => $reportError], 401); } return response()->json(['status' => 'failed', 'message' => $reportError], 502); } $status = $response->data ?? (object) []; return response()->json([ 'export_id' => $status->export_id ?? $exportId, 'status' => $status->status ?? 'processing', 'url' => $status->url ?? null, 'row_count' => (int) ($status->row_count ?? $status->rows ?? 0), 'rows' => (int) ($status->rows ?? 0), 'batches' => (int) ($status->batches ?? 0), 'message' => $status->message ?? null, ]); } protected function resolveReportPage(object $report, Request $request, int $perPage, string $search = ''): array { $doctors = $this->normalizeDoctorsList($report->doctors ?? null); // Search is applied server-side (tagged-doctor haystack). Do not re-filter the page here. $data = $report->data ?? (object) []; $page = max(1, (int) $request->input('page', 1)); $apiPagination = $report->pagination ?? null; $apiPaginated = is_object($apiPagination) && isset($apiPagination->total_pages) && (int) $apiPagination->total_pages >= 0 && ($search !== '' || count($doctors) <= $perPage || (int) $apiPagination->total_pages > 1); if ($apiPaginated) { $apiPagination->page = (int) ($apiPagination->page ?? $page); $apiPagination->per_page = (int) ($apiPagination->per_page ?? $perPage); return [$doctors, $data, $apiPagination]; } $total = count($doctors); $totalPages = max(1, (int) ceil($total / $perPage)); $page = min($page, $totalPages); $offset = ($page - 1) * $perPage; $pagedDoctors = array_slice($doctors, $offset, $perPage); $pagedData = (object) []; foreach ($pagedDoctors as $doctor) { $doctorId = is_object($doctor) ? ($doctor->id ?? null) : null; if ($doctorId && isset($data->{$doctorId})) { $pagedData->{$doctorId} = $data->{$doctorId}; } } $pagination = (object) [ 'page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => $totalPages, ]; return [$pagedDoctors, $pagedData, $pagination]; } protected function resolveSearchTerm(Request $request): string { return trim((string) $request->query('search', '')); } protected function filterDoctorsBySearch(array $doctors, string $search): array { if ($search === '') { return $doctors; } $needle = mb_strtolower($search); $words = preg_split('/\s+/u', $needle, -1, PREG_SPLIT_NO_EMPTY); if (empty($words)) { return $doctors; } return array_values(array_filter($doctors, function ($doctor) use ($words) { if (!is_object($doctor)) { return false; } $haystack = $this->doctorSearchHaystack($doctor); foreach ($words as $word) { if (!str_contains($haystack, $word)) { return false; } } return true; })); } protected function doctorSearchHaystack(object $doctor): string { $parts = [ $doctor->name ?? $doctor->id ?? '', $doctor->msl_code ?? '', $doctor->user ?? '', $doctor->user_firstname ?? '', $doctor->user_lastname ?? '', $doctor->contact_user_name ?? '', $doctor->employee_code ?? '', $doctor->zone ?? '', $doctor->region ?? '', $doctor->area ?? '', $doctor->territory ?? '', ]; $normalized = []; foreach ($parts as $part) { $value = trim((string) $part); if ($value === '' || $value === '-') { continue; } $normalized[] = mb_strtolower($value); } return preg_replace('/\s+/u', ' ', implode(' ', $normalized)); } protected function normalizeDoctorsList($raw): array { $doctors = []; if (is_array($raw)) { $doctors = array_values($raw); } elseif (is_object($raw)) { $doctors = array_values((array) $raw); } return array_values(array_filter($doctors, function ($doctor) { if (!is_object($doctor)) { return false; } $name = trim((string) ($doctor->name ?? '')); $id = trim((string) ($doctor->id ?? '')); return $name !== '' && $name !== $id; })); } protected function resolveReportType(Request $request): string { $type = strtolower(trim((string) $request->query('type', 'ciphaler'))); if (!isset(self::REPORT_TYPES[$type])) { $type = 'ciphaler'; } return $type; } protected function metricsForType(string $type): array { $ids = self::REPORT_TYPES[$type]['metrics'] ?? self::REPORT_TYPES['ciphaler']['metrics']; $metrics = []; foreach ($ids as $id) { $metrics[$id] = self::ALL_METRICS[$id] ?? $id; } return $metrics; } protected function buildReportPayload(Request $request, bool $fetchAll = false, int $perPage = 50, string $search = ''): array { $type = $this->resolveReportType($request); $metrics = $this->metricsForType($type); $payload = [ 'partition' => self::PARTITION, 'metric_ids' => json_encode(array_keys($metrics)), 'metric_labels' => json_encode($metrics), 'start_month' => self::REPORT_START, 'type' => $type, 'tag_attributes' => json_encode(self::REPORT_TYPES[$type]['tag_attributes'] ?? []), // Tagging columns are used only for doctor filtering, not shown in UI/export. 'include_tagging_columns' => '0', ]; if ($search !== '') { $payload['search'] = $search; } if ($fetchAll) { $payload['all'] = '1'; } else { $payload['page'] = max(1, (int) $request->input('page', 1)); $payload['per_page'] = $perPage; } return $payload; } protected function resolvePerPage(Request $request): int { $allowed = [25, 50, 100, 200]; $default = 50; $raw = $request->query('per_page'); if ($raw !== null && $raw !== '') { $perPage = (int) $raw; if (!in_array($perPage, $allowed, true)) { $perPage = $default; } $request->session()->put('team2_units_report_per_page', $perPage); return $perPage; } if ($request->session()->has('team2_units_report_per_page')) { $perPage = (int) $request->session()->get('team2_units_report_per_page'); if (in_array($perPage, $allowed, true)) { return $perPage; } } return $default; } protected function assertTeam2Access(Request $request): string { $token = $this->resolveAccessToken($request); if (!$token) { 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.'); } $payload = $this->decodeTokenPayload($token); $user = is_object($payload) ? ($payload->user ?? null) : null; if (!$user || !$this->isTeam2User($user)) { abort(403, 'Access denied. A valid 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->tokenUser = $user; return $token; } protected function bootstrapTokenSession(Request $request): void { $token = $this->extractRequestToken($request); if ($token === '') { return; } $user = $this->getUserFromToken($token); if ($user && $this->isTeam2User($user)) { $payload = $this->decodeTokenPayload($token); if (!$this->isTokenPayloadExpired($payload)) { $this->persistAccessToken($request, $token); } } } protected function isTeam2User(object $user): bool { return ($user->domain ?? '') === 'team2' || ($user->partition ?? '') === self::PARTITION; } protected function normalizeRawToken(string $token): string { $token = trim($token); if (stripos($token, 'Bearer ') === 0) { $token = trim(substr($token, 7)); } return $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->isTeam2User($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->isTeam2User($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('team2_units_report_token', $token); $this->tokenStorage()->put($this->tokenStoragePath($request), $token); } protected function loadPersistedToken(Request $request): string { if ($request->session()->has('team2_units_report_token')) { $sessionToken = $this->normalizeRawToken((string) $request->session()->get('team2_units_report_token')); 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('team2_units_report_token'); $path = $this->tokenStoragePath($request); if ($this->tokenStorage()->exists($path)) { $this->tokenStorage()->delete($path); } } protected function tokenStoragePath(Request $request): string { return 'team2_report_tokens/' . hash('sha256', $request->session()->getId()); } protected function tokenStorage() { return Storage::disk('local'); } protected function getUserFromToken(string $token): ?object { $payload = $this->decodeTokenPayload($token); if (!is_object($payload)) { return null; } return $payload->user ?? null; } protected function decodeTokenPayload(string $token): ?object { $token = $this->normalizeRawToken($token); $parts = explode('.', $token); if (count($parts) < 2) { 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 isTokenPayloadExpired(?object $payload): bool { if (!is_object($payload) || !isset($payload->exp)) { return false; } return (int) $payload->exp < time(); } protected function resolveReportError($response): ?string { if (!is_object($response)) { return 'Report service returned an invalid response.'; } $status = strtolower((string) ($response->status ?? '')); $statusCode = (int) ($response->statusCode ?? 0); if ($status !== 'failed' && ($statusCode === 0 || $statusCode < 400)) { return null; } $message = trim((string) ($response->message ?? '')); if ($message === '') { $message = 'Failed to load report data.'; } if ($this->isAuthReportError($message) || $statusCode === 401 || $statusCode === 403) { return 'Your access token has expired or is invalid. Please open a fresh link from the app.'; } return $message; } protected function isAuthReportError(string $message): bool { $normalized = strtolower($message); return str_contains($normalized, 'unauthorized') || str_contains($normalized, 'expired') || str_contains($normalized, 'invalid token'); } protected function buildUrl(string $path, Request $request): string { $perPage = $this->resolvePerPage($request); $query = array_filter([ 'type' => $this->resolveReportType($request), 'page' => $request->input('page'), 'per_page' => $perPage, 'search' => $this->resolveSearchTerm($request), ], function ($value) { return $value !== null && $value !== ''; }); $token = $this->extractRequestToken($request); if ($token === '') { $token = $this->loadPersistedToken($request); } if ($token !== '') { $query['token'] = $token; } $url = $this->_base_url . $path; if (!empty($query)) { $url .= '?' . http_build_query($query); } return $url; } }