647 lines
20 KiB
PHP
647 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Http\Requests\LambdaRequest;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Titans Brand Units Report — ES-backed doctor monthly units by brand group.
|
|
*
|
|
* Quick Action (Rapport admin, titans partition):
|
|
* Type: external_with_token
|
|
* URL: https://<laravel-domain>/titansbrandunitsreport/report?token=TOKEN
|
|
*/
|
|
class TitansbrandunitsreportController extends Controller
|
|
{
|
|
private const DOMAIN = 'titans';
|
|
|
|
private const PARTITION = 'rapport-titans';
|
|
|
|
private const REPORT_START = 'April 2026';
|
|
|
|
private const BRAND_GROUPS = [
|
|
'Foracort G' => [
|
|
'Rotacaps' => '1783578705-ef9fb1c4-fd99-4fba-8cfc-6dd41a5121ad',
|
|
'Synchrobreathe' => '1783623548-9ad28a00-2311-40a4-a612-81cbb5e4a11f',
|
|
'Inhaler' => '1783623610-8fa0ce11-586f-4e73-ad74-b76a5b747014',
|
|
'Ciphaler' => '1783623678-d1c00c5a-9a81-4975-9157-452a7244a917',
|
|
],
|
|
'Glycohale F' => [
|
|
'Rotacaps' => '1783623967-96f5e880-229d-4727-a9ef-976ad03a84dd',
|
|
'Synchrobreathe' => '1783623989-e8042064-88ad-444e-8e42-8180fe1021d7',
|
|
'Inhaler' => '1783623943-ad4a354f-b3f0-430a-93ac-21b7efcaa1fb',
|
|
],
|
|
'Glycohale FB' => [
|
|
'Rotacaps' => '1783624207-51f0122a-61d7-43b2-9472-a543ef793f94',
|
|
'Synchrobreathe' => '1783624199-f359b6e4-ef93-4f22-a494-32250bb9e8dc',
|
|
'Inhaler' => '1783624218-3b9e4b8c-7ddf-41b1-b764-2f85673b435e',
|
|
],
|
|
];
|
|
|
|
private ?string $accessToken = null;
|
|
|
|
private ?object $tokenUser = null;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->_service = 'reports';
|
|
$this->_module = 'contact_metric_monthly';
|
|
$this->_viewname = 'titansbrandunitsreport';
|
|
$this->_title = 'Titans Brand Units Report';
|
|
$this->_base_url = '/titansbrandunitsreport';
|
|
}
|
|
|
|
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->assertTitansAccess($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);
|
|
|
|
return $this->view('report')->with([
|
|
'title' => $this->_title,
|
|
'base_url' => $this->_base_url,
|
|
'token' => $token,
|
|
'brandGroups' => $this->brandGroupsForView(),
|
|
'months' => $report->months ?? [],
|
|
'doctors' => $doctors,
|
|
'data' => $data,
|
|
'pagination' => $pagination,
|
|
'perPage' => $perPage,
|
|
'search' => $search,
|
|
]);
|
|
}
|
|
|
|
public function export(Request $request)
|
|
{
|
|
$this->bootstrapTokenSession($request);
|
|
$token = $this->assertTitansAccess($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.');
|
|
}
|
|
|
|
return $this->view('export')->with([
|
|
'title' => $this->_title . ' Export',
|
|
'base_url' => $this->_base_url,
|
|
'export_id' => $exportId,
|
|
'search' => $search,
|
|
'token' => $token,
|
|
]);
|
|
}
|
|
|
|
public function exportStatus(Request $request)
|
|
{
|
|
$token = $this->assertTitansAccess($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 brandGroupsForView(): array
|
|
{
|
|
$groups = [];
|
|
foreach (self::BRAND_GROUPS as $groupName => $subBrands) {
|
|
$brands = [];
|
|
foreach ($subBrands as $subBrandName => $metricId) {
|
|
$brands[] = (object) [
|
|
'name' => $subBrandName,
|
|
'metric_id' => $metricId,
|
|
];
|
|
}
|
|
$groups[] = (object) [
|
|
'name' => $groupName,
|
|
'brands' => $brands,
|
|
];
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
protected function flatMetrics(): array
|
|
{
|
|
$metrics = [];
|
|
foreach (self::BRAND_GROUPS as $groupName => $subBrands) {
|
|
foreach ($subBrands as $subBrandName => $metricId) {
|
|
$metrics[$metricId] = $groupName . ' - ' . $subBrandName;
|
|
}
|
|
}
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
protected function resolveReportPage(object $report, Request $request, int $perPage, string $search = ''): array
|
|
{
|
|
$doctors = $this->normalizeDoctorsList($report->doctors ?? null);
|
|
if ($search !== '') {
|
|
$doctors = $this->filterDoctorsBySearch($doctors, $search);
|
|
}
|
|
$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);
|
|
|
|
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 buildReportPayload(Request $request, bool $fetchAll = false, int $perPage = 50, string $search = ''): array
|
|
{
|
|
$metrics = $this->flatMetrics();
|
|
$metricLabels = [];
|
|
foreach ($metrics as $id => $label) {
|
|
$metricLabels[$id] = $label;
|
|
}
|
|
|
|
$payload = [
|
|
'partition' => self::PARTITION,
|
|
'metric_ids' => json_encode(array_keys($metrics)),
|
|
'metric_labels' => json_encode($metricLabels),
|
|
'start_month' => self::REPORT_START,
|
|
'column_layout' => 'metric_month',
|
|
'include_tagging_columns' => '0',
|
|
'doctor_source' => 'metric',
|
|
];
|
|
|
|
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('titans_brand_units_report_per_page', $perPage);
|
|
|
|
return $perPage;
|
|
}
|
|
|
|
if ($request->session()->has('titans_brand_units_report_per_page')) {
|
|
$perPage = (int) $request->session()->get('titans_brand_units_report_per_page');
|
|
if (in_array($perPage, $allowed, true)) {
|
|
return $perPage;
|
|
}
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
protected function assertTitansAccess(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->isTitansUser($user)) {
|
|
abort(403, 'Access denied. A valid titans 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->isTitansUser($user)) {
|
|
$payload = $this->decodeTokenPayload($token);
|
|
if (!$this->isTokenPayloadExpired($payload)) {
|
|
$this->persistAccessToken($request, $token);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function isTitansUser(object $user): bool
|
|
{
|
|
return strtolower($user->domain ?? '') === self::DOMAIN
|
|
|| strtolower($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->isTitansUser($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->isTitansUser($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('titans_brand_units_report_token', $token);
|
|
$this->tokenStorage()->put($this->tokenStoragePath($request), $token);
|
|
}
|
|
|
|
protected function loadPersistedToken(Request $request): string
|
|
{
|
|
if ($request->session()->has('titans_brand_units_report_token')) {
|
|
$sessionToken = $this->normalizeRawToken((string) $request->session()->get('titans_brand_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('titans_brand_units_report_token');
|
|
$path = $this->tokenStoragePath($request);
|
|
if ($this->tokenStorage()->exists($path)) {
|
|
$this->tokenStorage()->delete($path);
|
|
}
|
|
}
|
|
|
|
protected function tokenStoragePath(Request $request): string
|
|
{
|
|
return 'titans_brand_units_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([
|
|
'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;
|
|
}
|
|
}
|