*/ private array $scanAiDebug = []; /** * @var array{heading_style:string,line_gap:float,block_gap_mm:float} */ private array $opdPlaceholderRenderOptions = [ 'heading_style' => 'bold', 'line_gap' => 1.6, 'block_gap_mm' => 2.8, ]; private function configureOpdPlaceholderRenderOptionsFromRequest(): void { $headingStyle = strtolower(trim((string) $this->request->getGet('heading_style'))); if (!in_array($headingStyle, ['bold', 'highlight', 'plain', 'underline'], true)) { $headingStyle = 'bold'; } $lineGap = (float) $this->request->getGet('line_gap'); if (!is_finite($lineGap) || $lineGap <= 0) { $lineGap = 1.6; } $lineGap = max(1.0, min(3.0, $lineGap)); $blockGapMm = (float) $this->request->getGet('block_gap_mm'); if (!is_finite($blockGapMm) || $blockGapMm < 0) { $blockGapMm = 2.8; } $blockGapMm = max(0.0, min(10.0, $blockGapMm)); $this->opdPlaceholderRenderOptions = [ 'heading_style' => $headingStyle, 'line_gap' => round($lineGap, 2), 'block_gap_mm' => round($blockGapMm, 2), ]; } private function canAccessDoctorWorkPanel(): bool { $user = auth()->user(); if (! $user) { return false; } if (method_exists($user, 'can') && $user->can('opd.doctor-panel.access')) { return true; } return false; } private function canManageOpdPrintTemplates(): bool { $user = auth()->user(); if (! $user) { return false; } if (method_exists($user, 'can') && ($user->can('template.opd_print') || $user->can('billing.opd.edit'))) { return true; } if (method_exists($user, 'inGroup') && $user->inGroup('OPDEdit')) { return true; } return false; } public function print_template_builder() { if (! $this->canManageOpdPrintTemplates()) { return $this->response->setStatusCode(403)->setBody('Access denied: OPD print template builder is restricted.'); } $mode = strtolower(trim((string) $this->request->getGet('mode'))); if (!in_array($mode, ['list', 'edit'], true)) { $mode = 'list'; } $templateNames = $this->collectOpdTemplateNames(); $name = $this->normalizeOpdTemplateId((string) $this->request->getGet('name')); if ($mode === 'list') { return view('billing/opd_template_builder_list', [ 'template_names' => $templateNames, ]); } if ($name === '') { $name = !empty($templateNames) ? (string) $templateNames[0] : 'default'; } if (!in_array($name, $templateNames, true)) { $templateNames[] = $name; $templateNames = array_values(array_unique($templateNames)); sort($templateNames); } $content = $this->readOpdTemplateContentByName($name); $paperSettings = $this->getOpdPaperPrintSetting($name); // Backward compatibility: if paper settings are blank but full template content exists, // derive editable paper/header/footer blocks from the saved template HTML. if (trim((string) ($paperSettings['paper_html_content'] ?? '')) === '' && trim($content) !== '') { $derived = $this->derivePaperBlocksFromTemplateHtml($content); if (trim((string) ($paperSettings['header_html'] ?? '')) === '') { $paperSettings['header_html'] = (string) ($derived['header_html'] ?? ''); } if (trim((string) ($paperSettings['footer_html'] ?? '')) === '') { $paperSettings['footer_html'] = (string) ($derived['footer_html'] ?? ''); } if (trim((string) ($paperSettings['paper_html_content'] ?? '')) === '') { $paperSettings['paper_html_content'] = (string) ($derived['paper_html_content'] ?? ''); } } $placeholders = [ // Hospital 'hospital_name', 'hospital_address', 'hospital_phone', 'hospital_email', 'H_Name', 'H_address_1', 'H_address_2', 'H_phone_No', 'H_Email', 'H_logo', // Doctor 'doctor_name', 'doctor_full_name', 'doctor_title', 'doctor_reg_no', 'doctor_registration', 'doctor_phone', 'doctor_email', 'doctor_address', 'doctor_specialization', 'SpecName', 'short_description', 'doctor_short_description', 'doctor_sign_html', // Print metadata 'print_time', 'current_date', // Patient 'pName', 'pRelative', 'refer_by', 'age_sex', 'phoneno', 'p_address', 'uhid_no', 'opd_sr_no', 'opd_no', 'opd_date', 'exp_date', 'opd_fee_amount', 'opd_fee_desc', 'total_no_visit', 'last_opdvisit_date', 'str_opd_book_date', // General Examination (raw values) 'bp', 'bp_diastolic', 'pulse', 'temp', 'spo2', 'rr_min', 'height', 'weight', 'waist', 'pallor', 'icterus', 'cyanosis', 'clubbing', 'edema', // General Examination (formatted blocks) 'vital_content', // BP/Pulse/Temp/SpO2/RR/Ht/Wt/Waist formatted 'vital_content_extra', // Pallor/Icterus/Cyanosis/Clubbing/Edema 'vital_content_full', // All vitals + extra combined 'general_examination', // Alias of vital_content_full // Pain scale 'pain_value', // 0-4 'pain_label', // e.g. "Mild Pain" 'pain_scale', // Formatted block: Pain Scale : Mild Pain // Complication & Addiction 'complication', // Formatted block of checked flags 'addiction', // Formatted block of checked flags // Drug allergy / NABH 'drug_allergy_status', 'drug_allergy_details', 'adr_history', 'current_medications', 'drug_allergy_status_block', 'drug_allergy_details_block', 'adr_history_raw', 'current_medications_raw', 'drug_allergy_block', // Formatted: Drug Allergy : status — details 'adr_history_block', // Formatted: ADR History : ... 'current_medications_block', // Formatted: Current Medications : ... 'nabh_panel_block', // Formatted: single combined panel (status/details/ADR/current meds) 'allergy_adr_panel_block', // Alias of nabh_panel_block 'drug_allergy_panel_block', // Alias of nabh_panel_block // Women related 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems', 'women_block', // Formatted: Women Related : LMP | Last Baby | ... // Women specific patient history 'obstetric_history', 'menstrual_history', 'medical_surgical_history', 'family_history', 'allergic_history', 'vaccination_history', 'patient_history_block', // Co-morbidities 'morbidities', // Raw comma-separated list 'morbidities_block', // Formatted: Co-Morbidities : Diabetes, HTn, ... // Clinical content 'painscale_img', 'Complaint', 'diagnosis', 'Provisional_diagnosis', 'Finding_Examinations', 'complaint_onset', 'complaint_duration_days', 'complaint_severity', 'complaint_snomed_json', 'diagnosis_json', 'diagnosis_snomed_id', 'diagnosis_snomed_term', 'diagnosis_snomed_source', 'provisional_diagnosis_snomed_id', 'provisional_diagnosis_snomed_term', 'provisional_diagnosis_snomed_source', 'complaint_list', 'diagnosis_list', 'provisional_diagnosis_list', 'medical', 'investigation', 'Prescriber_Remarks', 'advice', 'next_visit', 'refer_to', // Composed blocks 'Rx', 'RxTable', 'RxFullBlock', 'VitalsBlock', 'ComplaintBlock', 'DiagnosisBlock', 'InvestigationBlock', 'RemarksBlock', 'AdviceBlock', 'NextVisitBlock', // Legacy layout sections 'hospital_section', 'patient_section', 'content_section', 'content', ]; return view('billing/opd_template_builder', [ 'selected_name' => $name, 'template_content' => $content, 'template_names' => $templateNames, 'placeholders' => $placeholders, 'paper_settings' => $paperSettings, ]); } /** * Extract header/footer/body editor blocks from a full OPD template HTML payload. * * @return array */ private function derivePaperBlocksFromTemplateHtml(string $templateHtml): array { $html = str_replace(["\r\n", "\r"], "\n", (string) $templateHtml); $headerHtml = ''; $footerHtml = ''; if (preg_match('/]*name\s*=\s*["\']myHeader["\'][^>]*>([\s\S]*?)<\/htmlpageheader>/i', $html, $m) === 1) { $headerHtml = trim((string) ($m[1] ?? '')); $html = str_replace((string) ($m[0] ?? ''), '', $html); } if (preg_match('/]*name\s*=\s*["\']myFooter["\'][^>]*>([\s\S]*?)<\/htmlpagefooter>/i', $html, $m) === 1) { $footerHtml = trim((string) ($m[1] ?? '')); $html = str_replace((string) ($m[0] ?? ''), '', $html); } // Remove style blocks that only define @page print settings. $html = (string) preg_replace('/]*>[\s\S]*?@page[\s\S]*?<\/style>/i', '', $html); // Remove standalone named header/footer references if present. $html = (string) preg_replace('/^\s*header\s*:\s*html_myHeader\s*;\s*$/im', '', $html); $html = (string) preg_replace('/^\s*footer\s*:\s*html_myFooter\s*;\s*$/im', '', $html); $bodyHtml = trim($html); return [ 'header_html' => $headerHtml, 'footer_html' => $footerHtml, 'paper_html_content' => $bodyHtml, ]; } /** * Keep OPD paper settings in sync with saved full template HTML. */ private function syncOpdPaperSettingsFromTemplateContent(string $templateName, string $templateHtml): bool { if (trim($templateName) === '') { return false; } $settings = $this->getOpdPaperPrintSetting($templateName); $derived = $this->derivePaperBlocksFromTemplateHtml($templateHtml); $settings['header_html'] = (string) ($derived['header_html'] ?? ''); $settings['footer_html'] = (string) ($derived['footer_html'] ?? ''); $settings['paper_html_content'] = (string) ($derived['paper_html_content'] ?? ''); return $this->saveOpdPaperPrintSetting($settings, $templateName); } /** * @return array */ private function collectOpdTemplateNames(): array { return $this->collectStoredOpdTemplateNames(); } private function readOpdTemplateContentByName(string $name): string { $name = $this->normalizeOpdTemplateId($name); if ($name === '') { return ''; } return $this->readStoredOpdTemplateContent($name); } public function print_template_save() { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']); } if (! $this->canManageOpdPrintTemplates()) { return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); } $allowedSections = ['full', 'layout', 'hospital', 'patient', 'content']; $section = strtolower(trim((string) $this->request->getPost('section'))); if (!in_array($section, $allowedSections, true)) { return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid section']); } $name = $this->normalizeOpdTemplateId((string) $this->request->getPost('name')); if ($name === '') { return $this->response->setJSON(['update' => 0, 'error_text' => 'Template name required']); } $content = (string) $this->request->getPost('content'); if (trim($content) === '') { return $this->response->setJSON(['update' => 0, 'error_text' => 'Template content required']); } if (! $this->saveStoredOpdTemplateContent($name, $content)) { return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to save template']); } $paperSynced = true; if ($section === 'full') { $paperSynced = $this->syncOpdPaperSettingsFromTemplateContent($name, $content); } $noticeText = 'Template saved'; if ($section === 'full' && ! $paperSynced) { $noticeText = 'Template saved (paper settings sync failed)'; } return $this->response->setJSON([ 'update' => 1, 'error_text' => $noticeText, 'section' => $section, 'name' => $name, 'paper_synced' => $paperSynced ? 1 : 0, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } public function print_template_rename() { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']); } if (! $this->canManageOpdPrintTemplates()) { return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); } $oldName = $this->normalizeOpdTemplateId((string) $this->request->getPost('old_name')); $newName = $this->normalizeOpdTemplateId((string) $this->request->getPost('new_name')); if ($oldName === '' || $newName === '') { return $this->response->setJSON(['update' => 0, 'error_text' => 'Both old and new template names are required']); } if ($oldName === $newName) { return $this->response->setJSON(['update' => 0, 'error_text' => 'New template name must be different']); } if (! $this->renameStoredOpdTemplateContent($oldName, $newName)) { return $this->response->setJSON(['update' => 0, 'error_text' => 'Template not found or rename failed']); } $this->renameOpdPaperSettingStorageKey($oldName, $newName); return $this->response->setJSON([ 'update' => 1, 'error_text' => 'Template renamed', 'old_name' => $oldName, 'new_name' => $newName, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } private function renameOpdPaperSettingStorageKey(string $oldName, string $newName): void { if (! $this->db->tableExists('hospital_setting')) { return; } $newKey = $this->getOpdPaperSettingStorageKey($newName); $oldKeys = $this->getOpdPaperSettingCandidateKeys($oldName); if (empty($oldKeys)) { return; } $existsNew = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $newKey) ->get(1) ->getRowArray(); // If target key exists, just clean old aliases. if (! empty($existsNew['id'])) { foreach ($oldKeys as $oldKey) { if ($oldKey === $newKey) { continue; } $this->db->table('hospital_setting')->where('s_name', $oldKey)->delete(); } return; } foreach ($oldKeys as $oldKey) { if ($oldKey === $newKey) { continue; } $row = $this->db->table('hospital_setting') ->select('id,s_value') ->where('s_name', $oldKey) ->get(1) ->getRowArray(); if (empty($row)) { continue; } $this->db->table('hospital_setting')->insert([ 's_name' => $newKey, 's_value' => (string) ($row['s_value'] ?? ''), ]); $this->db->table('hospital_setting')->where('id', (int) ($row['id'] ?? 0))->delete(); break; } } public function print_template_delete() { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']); } if (! $this->canManageOpdPrintTemplates()) { return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); } $templateName = $this->normalizeOpdTemplateId((string) $this->request->getPost('template_name')); if ($templateName === '') { return $this->response->setJSON(['update' => 0, 'error_text' => 'Template name is required']); } if (! $this->deleteStoredOpdTemplateContent($templateName)) { return $this->response->setJSON(['update' => 0, 'error_text' => 'Template not found or delete failed']); } $this->deleteOpdPaperSettingStorageKey($templateName); return $this->response->setJSON([ 'update' => 1, 'error_text' => 'Template deleted', 'template_name' => $templateName, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } private function deleteOpdPaperSettingStorageKey(string $templateName): void { if (! $this->db->tableExists('hospital_setting')) { return; } $keys = $this->getOpdPaperSettingCandidateKeys($templateName); foreach ($keys as $key) { $this->db->table('hospital_setting')->where('s_name', $key)->delete(); } } private function normalizeOpdTemplateId(string $templateId): string { $templateId = strtolower(trim($templateId)); $templateId = str_replace([' ', '.'], '_', $templateId); $templateId = preg_replace('/[^a-z0-9_\-]+/', '_', $templateId) ?? ''; $templateId = preg_replace('/_{2,}/', '_', $templateId) ?? $templateId; return trim($templateId, '_-'); } private function getOpdTemplateStorageKey(string $templateId): string { $templateId = $this->normalizeOpdTemplateId($templateId); if ($templateId === '') { return ''; } return self::OPD_TEMPLATE_KEY_PREFIX . $templateId; } private function readStoredOpdTemplateContent(string $templateId): string { $storageKey = $this->getOpdTemplateStorageKey($templateId); if ($storageKey === '' || ! $this->db->tableExists('hospital_setting')) { return ''; } $row = $this->db->table('hospital_setting') ->select('s_value') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); return trim((string) ($row['s_value'] ?? '')); } /** * @return array */ private function collectStoredOpdTemplateNames(): array { if (! $this->db->tableExists('hospital_setting')) { return []; } $rows = $this->db->table('hospital_setting') ->select('s_name') ->like('s_name', self::OPD_TEMPLATE_KEY_PREFIX, 'after') ->get() ->getResultArray(); $names = []; foreach ($rows as $row) { $storageKey = (string) ($row['s_name'] ?? ''); if (! str_starts_with($storageKey, self::OPD_TEMPLATE_KEY_PREFIX)) { continue; } $templateId = substr($storageKey, strlen(self::OPD_TEMPLATE_KEY_PREFIX)); $templateId = $this->normalizeOpdTemplateId((string) $templateId); if ($templateId !== '') { $names[] = $templateId; } } $names = array_values(array_unique($names)); sort($names); return $names; } private function saveStoredOpdTemplateContent(string $templateId, string $content): bool { $storageKey = $this->getOpdTemplateStorageKey($templateId); if ($storageKey === '' || ! $this->db->tableExists('hospital_setting')) { return false; } $this->ensureHospitalSettingValueCanStoreLargePayload(); $existing = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); if (! empty($existing['id'])) { return (bool) $this->db->table('hospital_setting') ->where('id', (int) $existing['id']) ->update(['s_value' => $content]); } return (bool) $this->db->table('hospital_setting')->insert([ 's_name' => $storageKey, 's_value' => $content, ]); } private function renameStoredOpdTemplateContent(string $oldTemplateId, string $newTemplateId): bool { $oldKey = $this->getOpdTemplateStorageKey($oldTemplateId); $newKey = $this->getOpdTemplateStorageKey($newTemplateId); if ($oldKey === '' || $newKey === '' || ! $this->db->tableExists('hospital_setting')) { return false; } $existingOld = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $oldKey) ->get(1) ->getRowArray(); if (empty($existingOld['id'])) { return false; } $existingNew = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $newKey) ->get(1) ->getRowArray(); if (! empty($existingNew['id'])) { return false; } return (bool) $this->db->table('hospital_setting') ->where('id', (int) $existingOld['id']) ->update(['s_name' => $newKey]); } private function deleteStoredOpdTemplateContent(string $templateId): bool { $storageKey = $this->getOpdTemplateStorageKey($templateId); if ($storageKey === '' || ! $this->db->tableExists('hospital_setting')) { return false; } $row = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); if (empty($row['id'])) { return false; } return (bool) $this->db->table('hospital_setting') ->where('id', (int) $row['id']) ->delete(); } public function paper_print_settings() { if (! $this->canManageOpdPrintTemplates()) { return $this->response->setStatusCode(403)->setBody('Access denied: OPD paper print settings are restricted.'); } $templateName = $this->normalizeOpdTemplateId((string) ($this->request->getPost('template_name') ?? $this->request->getGet('template_name') ?? '')); $notice = ''; $noticeType = 'success'; $settings = $this->getOpdPaperPrintSetting($templateName); if (strtolower($this->request->getMethod()) === 'post') { $isReset = (int) ($this->request->getPost('reset') ?? 0) === 1; if ($isReset) { $cleared = $this->clearOpdPaperPrintSetting($templateName); $settings = $this->getOpdPaperPrintSetting($templateName); $notice = $cleared ? 'OPD paper print settings reset.' : 'Unable to reset OPD paper print settings.'; $noticeType = $cleared ? 'success' : 'danger'; if ($this->request->isAJAX()) { return $this->response->setJSON([ 'status' => $cleared ? 'success' : 'error', 'notice' => $notice, 'notice_type' => $noticeType, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), 'settings' => $settings, ]); } } $pageSize = strtoupper(trim((string) ($this->request->getPost('page_size') ?? 'A4'))); if (! in_array($pageSize, ['A4', 'A4-L', 'A5', 'A6', 'LETTER', 'LEGAL', 'CUSTOM'], true)) { $pageSize = 'A4'; } $customWidthMm = (int) $this->normalizeOpdPrintMarginCm($this->request->getPost('custom_width_mm'), 210.0); $customHeightMm = (int) $this->normalizeOpdPrintMarginCm($this->request->getPost('custom_height_mm'), 297.0); if ($customWidthMm < 20) { $customWidthMm = 20; } if ($customWidthMm > 600) { $customWidthMm = 600; } if ($customHeightMm < 20) { $customHeightMm = 20; } if ($customHeightMm > 1000) { $customHeightMm = 1000; } $settings = [ 'page_size' => $pageSize, 'custom_width_mm' => $customWidthMm, 'custom_height_mm' => $customHeightMm, 'page_margin_top_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('page_margin_top_cm'), 6.1), 'page_margin_bottom_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('page_margin_bottom_cm'), 2.5), 'page_margin_left_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('page_margin_left_cm'), 0.7), 'page_margin_right_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('page_margin_right_cm'), 0.7), 'margin_header_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('margin_header_cm'), 0.5), 'margin_footer_cm' => $this->normalizeOpdPrintMarginCm($this->request->getPost('margin_footer_cm'), 1.5), 'paper_html_content' => (string) ($this->request->getPost('paper_html_content') ?? ''), 'header_html' => (string) ($this->request->getPost('header_html') ?? ''), 'footer_html' => (string) ($this->request->getPost('footer_html') ?? ''), ]; $saved = $this->saveOpdPaperPrintSetting($settings, $templateName); if ($saved) { $notice = 'OPD paper print settings saved.'; $noticeType = 'success'; } else { $notice = 'Unable to save OPD paper print settings.'; $noticeType = 'danger'; } if ($this->request->isAJAX()) { return $this->response->setJSON([ 'status' => $saved ? 'success' : 'error', 'notice' => $notice, 'notice_type' => $noticeType, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } } return view('billing/opd_paper_print_settings', [ 'settings' => $settings, 'notice' => $notice, 'notice_type' => $noticeType, ]); } private function resolveTemplateSectionDirectory(string $section): string { $section = strtolower(trim($section)); if ($section === 'full') { return APPPATH . 'Views/billing/opd_templates'; } return APPPATH . 'Views/billing/opd_parts/' . $section; } private function resetScanAiDebug(): void { $this->scanAiDebug = []; } private function addScanAiDebug(string $message): void { $message = trim($message); if ($message === '') { return; } $this->scanAiDebug[] = $message; } private function formatScanAiDebug(): string { if (empty($this->scanAiDebug)) { return ''; } return implode(' | ', array_slice($this->scanAiDebug, 0, 6)); } private function normalizeAzureEndpointBase(string $endpoint): string { $endpoint = trim($endpoint); if ($endpoint === '') { return ''; } if (! str_starts_with(strtolower($endpoint), 'http://') && ! str_starts_with(strtolower($endpoint), 'https://')) { $endpoint = 'https://' . ltrim($endpoint, '/'); } $parts = parse_url($endpoint); if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return rtrim($endpoint, '/'); } $base = strtolower((string) $parts['scheme']) . '://' . (string) $parts['host']; if (! empty($parts['port'])) { $base .= ':' . (int) $parts['port']; } return rtrim($base, '/'); } private function shouldAllowInsecureSslForEndpoint(string $endpoint): bool { $toggle = strtolower($this->readScanAiSettingValue( ['DIAGNOSIS_AI_ALLOW_INSECURE_SSL', 'AI_DIAGNOSIS_AI_ALLOW_INSECURE_SSL', 'APP_DIAGNOSIS_AI_ALLOW_INSECURE_SSL', 'H_DIAGNOSIS_AI_ALLOW_INSECURE_SSL'], ['DIAGNOSIS_AI_ALLOW_INSECURE_SSL'] )); if (in_array($toggle, ['1', 'true', 'yes', 'on'], true)) { return true; } $parts = parse_url($endpoint); $host = strtolower((string) ($parts['host'] ?? '')); if ($host === '') { return false; } if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { return true; } if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { if (preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/', $host) === 1) { return true; } } if (str_ends_with($host, '.local')) { return true; } return false; } private function isSslCertificateError(\Throwable $e): bool { $msg = strtolower($e->getMessage()); return str_contains($msg, 'ssl certificate problem') || str_contains($msg, 'unable to get local issuer certificate') || str_contains($msg, 'curl error 60') || str_contains($msg, 'error 60'); } /** * @return array */ private function buildAzureCurlOptions(string $endpoint, int $timeout, bool $forceInsecure = false): array { $options = [ 'timeout' => $timeout, 'http_errors' => false, ]; if ($forceInsecure || $this->shouldAllowInsecureSslForEndpoint($endpoint)) { $options['verify'] = false; $options['curl'] = [ CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, ]; $this->addScanAiDebug($forceInsecure ? 'ssl verify disabled after SSL error retry' : 'ssl verify disabled for intranet/local endpoint'); } return $options; } public function appointment(string $opdDate = '') { return $this->get_appointment_data($opdDate); } /** * Load OPD doctor dashboard for the selected date. */ public function get_appointment_data(string $opdDate = '') { if (! $this->canAccessDoctorWorkPanel()) { return $this->response->setStatusCode(403)->setBody('Access denied: OPD doctor work panel is restricted.'); } if ($opdDate === '') { $opdDate = date('Y-m-d'); } $opdDateSql = $this->db->escape($opdDate); $sql = "select d.id as doc_id, d.p_fname, coalesce(ds.Spec, '') as Spec, oa.No_opd, oa.count_booking, oa.count_wait, oa.count_visit, oa.count_cancel, MOD(d.id,5) as color_code from ( select o.doc_id, count(*) as No_opd, sum(case when o.opd_status = 1 and pr.id is null then 1 else 0 end) as count_booking, sum(case when o.opd_status = 1 and pr.id is not null then 1 else 0 end) as count_wait, sum(case when o.opd_status = 2 then 1 else 0 end) as count_visit, sum(case when o.opd_status = 3 then 1 else 0 end) as count_cancel from opd_master o left join opd_prescription pr on pr.opd_id = o.opd_id where date(o.apointment_date) = " . $opdDateSql . " group by o.doc_id ) oa join doctor_master d on d.id = oa.doc_id and d.active = 1 left join ( select s.doc_id, group_concat(DISTINCT m.SpecName) as Spec from doc_spec s join med_spec m on m.id = s.med_spec_id group by s.doc_id ) ds on ds.doc_id = d.id order by d.p_fname"; $query = $this->db->query($sql); $docMaster = $query->getResult(); $colorMap = [ '0' => 'bg-warning', '1' => 'bg-danger', '2' => 'bg-success', '3' => 'bg-secondary', '4' => 'bg-info', '5' => 'bg-primary', ]; return view('billing/opd_appointment_dashboard', [ 'opd_date' => $opdDate, 'doc_master' => $docMaster, 'color1' => $colorMap, ]); } /** * Load doctor-specific OPD queues split into booked, waiting, visited, and cancelled. */ public function get_appointment_list(int $docId, string $opdDate = '') { if (! $this->canAccessDoctorWorkPanel()) { return $this->response->setStatusCode(403)->setBody('Access denied: OPD doctor work panel is restricted.'); } if ($opdDate === '') { $opdDate = date('Y-m-d'); } $docId = (int) $docId; $opdDateSql = $this->db->escape($opdDate); $abdmSelect = '0 as is_abdm_source'; if ($this->db->tableExists('abdm_opd_tokens')) { $abdmSelect = "(case when exists(select 1 from abdm_opd_tokens t where t.opd_id = o.opd_id) then 1 else 0 end) as is_abdm_source"; } $sql = "select d.id as doc_id, d.p_fname, group_concat(DISTINCT m.SpecName) as Spec from doctor_master d left join doc_spec s on d.id=s.doc_id left join med_spec m on m.id=s.med_spec_id where d.id=" . $docId . " and d.active=1 group by d.id"; $query = $this->db->query($sql); $docMaster = $query->getResult(); if (empty($docMaster)) { return $this->response->setStatusCode(404)->setBody('Doctor not found for selected date.'); } $sql = "select o.opd_id,o.opd_code,o.opd_no,o.opd_status, p.id,p.p_code,p.p_fname as P_name,p.p_rname, p.gender,p.age,p.age_in_month,p.estimate_dob,p.dob, o.opd_fee_desc,o.opd_fee_amount,o.payment_status,o.opd_fee_type, coalesce(o.running_opd,0) as running_opd, o.confirm_pay_opd, (case when (coalesce(o.running_opd,0)=1 or o.opd_fee_type=3 or upper(coalesce(o.opd_fee_desc,'')) like '%RUNNING%') then 'Running' when o.payment_mode=4 then 'Credit to ECHS' when o.payment_status=1 and o.payment_mode=0 then 'No Cost' when o.payment_mode=1 then 'Cash' when o.payment_mode=2 then 'Bank Card' else 'Pending' end) as opd_type, if(pr.id is null, 0, 1) as has_prescription, if( coalesce(trim(pr.pulse),'')<>'' or coalesce(trim(pr.spo2),'')<>'' or coalesce(trim(pr.bp),'')<>'' or coalesce(trim(pr.diastolic),'')<>'' or coalesce(trim(pr.temp),'')<>'' or coalesce(trim(pr.rr_min),'')<>'' or coalesce(trim(pr.height),'')<>'' or coalesce(trim(pr.weight),'')<>'' or coalesce(trim(pr.waist),'')<>'', 1,0 ) as has_vitals, coalesce(pr.queue_no,0) as queue_no ," . $abdmSelect . " from opd_master o join patient_master p on o.p_id=p.id left join opd_prescription pr on o.opd_id=pr.opd_id where date(o.apointment_date)=" . $opdDateSql . " and o.doc_id=" . $docId; $query = $this->db->query($sql); $allRows = $query->getResult(); // Doctor-day display queue should be contiguous (1..N), even when stored queue_no // has gaps due to legacy/global sequencing from other doctors. $queueRows = array_values(array_filter($allRows, static function ($row): bool { return (int) ($row->queue_no ?? 0) > 0; })); usort($queueRows, static function ($left, $right): int { $leftQueue = (int) ($left->queue_no ?? 0); $rightQueue = (int) ($right->queue_no ?? 0); if ($leftQueue !== $rightQueue) { return $rightQueue <=> $leftQueue; } return ((int) ($left->opd_id ?? 0)) <=> ((int) ($right->opd_id ?? 0)); }); $displayQueueByOpdId = []; $displayQueue = 1; foreach ($queueRows as $queueRow) { $queueOpdId = (int) ($queueRow->opd_id ?? 0); if ($queueOpdId <= 0 || array_key_exists($queueOpdId, $displayQueueByOpdId)) { continue; } $displayQueueByOpdId[$queueOpdId] = $displayQueue; $displayQueue++; } foreach ($allRows as $row) { $row->display_queue_no = (int) ($displayQueueByOpdId[(int) ($row->opd_id ?? 0)] ?? 0); } $opdList0 = []; $opdList1 = []; $opdList2 = []; $opdList3 = []; foreach ($allRows as $row) { $status = (int) ($row->opd_status ?? 0); $hasPrescription = (int) ($row->has_prescription ?? 0) === 1; $paymentStatus = (int) ($row->payment_status ?? 0); $runningOpd = (int) ($row->running_opd ?? 0) === 1; $opdFeeType = (int) ($row->opd_fee_type ?? 0); $opdFeeAmount = (float) ($row->opd_fee_amount ?? 0); $opdFeeDesc = strtoupper(trim((string) ($row->opd_fee_desc ?? ''))); $isRunningVisit = $runningOpd || $opdFeeType === 3 || str_contains($opdFeeDesc, 'RUNNING'); $isConfirmedZeroAmount = $isRunningVisit && $opdFeeAmount <= 0; $isConfirmedForQueue = $paymentStatus === 1 || $isConfirmedZeroAmount || !empty($row->confirm_pay_opd); $row->is_confirmed_for_queue = $isConfirmedForQueue ? 1 : 0; if ($status === 1 && ! $hasPrescription && ! $isConfirmedForQueue) { $opdList0[] = $row; continue; } if ($status === 1 && ($hasPrescription || $isConfirmedForQueue)) { $opdList1[] = $row; continue; } if ($status === 2) { $opdList2[] = $row; continue; } if ($status === 3) { $opdList3[] = $row; } } usort($opdList0, static function ($left, $right): int { return strcmp((string) ($right->opd_code ?? ''), (string) ($left->opd_code ?? '')); }); usort($opdList1, static function ($left, $right): int { $leftHasPrescription = (int) ($left->has_prescription ?? 0); $rightHasPrescription = (int) ($right->has_prescription ?? 0); if ($leftHasPrescription !== $rightHasPrescription) { return $leftHasPrescription <=> $rightHasPrescription; } $leftVitals = (int) ($left->has_vitals ?? 0); $rightVitals = (int) ($right->has_vitals ?? 0); if ($leftVitals !== $rightVitals) { return $leftVitals <=> $rightVitals; } $leftQueue = (int) ($left->queue_no ?? 0); $rightQueue = (int) ($right->queue_no ?? 0); $leftQueueSort = $leftQueue > 0 ? $leftQueue : 999999; $rightQueueSort = $rightQueue > 0 ? $rightQueue : 999999; if ($leftQueueSort !== $rightQueueSort) { return $leftQueueSort <=> $rightQueueSort; } return ((int) ($left->opd_id ?? 0)) <=> ((int) ($right->opd_id ?? 0)); }); usort($opdList2, static function ($left, $right): int { return ((int) ($left->opd_id ?? 0)) <=> ((int) ($right->opd_id ?? 0)); }); usort($opdList3, static function ($left, $right): int { return ((int) ($left->opd_id ?? 0)) <=> ((int) ($right->opd_id ?? 0)); }); $docMaster[0]->No_opd = count($allRows); $docMaster[0]->count_booking = count($opdList0); $docMaster[0]->count_wait = count($opdList1); $docMaster[0]->count_visit = count($opdList2); $docMaster[0]->count_cancel = count($opdList3); return view('billing/opd_appointment_list', [ 'opd_date' => $opdDate, 'doc_id' => $docId, 'doc_master' => $docMaster, 'opd_list_0' => $opdList0, 'opd_list_1' => $opdList1, 'opd_list_2' => $opdList2, 'opd_list_3' => $opdList3, ]); } /** * Lightweight digest endpoint for live appointment screen refresh checks. */ public function get_appointment_digest(int $docId, string $opdDate = '') { if (! $this->canAccessDoctorWorkPanel()) { return $this->response->setStatusCode(403)->setJSON([ 'ok' => 0, 'message' => 'Access denied', ]); } if ($opdDate === '') { $opdDate = date('Y-m-d'); } $docId = (int) $docId; $opdDateSql = $this->db->escape($opdDate); $sql = "select count(*) as total_rows, sum(case when o.opd_status = 1 and pr.id is null then 1 else 0 end) as count_booking, sum(case when o.opd_status = 1 and pr.id is not null then 1 else 0 end) as count_wait, sum(case when o.opd_status = 2 then 1 else 0 end) as count_visit, sum(case when o.opd_status = 3 then 1 else 0 end) as count_cancel, coalesce( bit_xor( cast( crc32( concat_ws('|', o.opd_id, o.opd_status, if(pr.id is null, 0, 1), if( coalesce(trim(pr.pulse),'')<>'' or coalesce(trim(pr.spo2),'')<>'' or coalesce(trim(pr.bp),'')<>'' or coalesce(trim(pr.diastolic),'')<>'' or coalesce(trim(pr.temp),'')<>'' or coalesce(trim(pr.rr_min),'')<>'' or coalesce(trim(pr.height),'')<>'' or coalesce(trim(pr.weight),'')<>'' or coalesce(trim(pr.waist),'')<>'', 1,0 ), coalesce(pr.queue_no,0), coalesce(o.payment_status,0), coalesce(o.confirm_pay_opd,'') ) ) as unsigned ) ), 0 ) as digest_crc from opd_master o left join opd_prescription pr on o.opd_id = pr.opd_id where date(o.apointment_date) = " . $opdDateSql . " and o.doc_id = " . $docId; $row = $this->db->query($sql)->getRow(); $payload = [ 'doc_id' => $docId, 'opd_date' => $opdDate, 'total' => (int) ($row->total_rows ?? 0), 'booked' => (int) ($row->count_booking ?? 0), 'waiting' => (int) ($row->count_wait ?? 0), 'visited' => (int) ($row->count_visit ?? 0), 'cancelled' => (int) ($row->count_cancel ?? 0), 'crc' => (string) ($row->digest_crc ?? '0'), ]; $digest = sha1(json_encode($payload)); return $this->response->setJSON([ 'ok' => 1, 'digest' => $digest, 'payload' => $payload, 'server_time' => date('c'), ]); } /** * Live snapshot endpoint for in-place row merge on OPD appointment screen. */ public function get_appointment_live_rows(int $docId, string $opdDate = '') { if (! $this->canAccessDoctorWorkPanel()) { return $this->response->setStatusCode(403)->setJSON([ 'ok' => 0, 'message' => 'Access denied', ]); } if ($opdDate === '') { $opdDate = date('Y-m-d'); } $docId = (int) $docId; $opdDateSql = $this->db->escape($opdDate); $sql = "select o.opd_id, o.opd_status, o.payment_status, o.opd_fee_type, o.opd_fee_amount, o.opd_fee_desc, coalesce(o.running_opd,0) as running_opd, o.confirm_pay_opd, if(pr.id is null, 0, 1) as has_prescription, if( coalesce(trim(pr.pulse),'')<>'' or coalesce(trim(pr.spo2),'')<>'' or coalesce(trim(pr.bp),'')<>'' or coalesce(trim(pr.diastolic),'')<>'' or coalesce(trim(pr.temp),'')<>'' or coalesce(trim(pr.rr_min),'')<>'' or coalesce(trim(pr.height),'')<>'' or coalesce(trim(pr.weight),'')<>'' or coalesce(trim(pr.waist),'')<>'', 1,0 ) as has_vitals, coalesce(pr.queue_no,0) as queue_no from opd_master o left join opd_prescription pr on o.opd_id = pr.opd_id where date(o.apointment_date) = " . $opdDateSql . " and o.doc_id = " . $docId; $rows = $this->db->query($sql)->getResult(); $normalized = []; $counts = [ 'all' => 0, 'booked' => 0, 'waiting' => 0, 'visited' => 0, 'cancelled' => 0, ]; foreach ($rows as $row) { $status = (int) ($row->opd_status ?? 0); $hasPrescription = (int) ($row->has_prescription ?? 0) === 1; $paymentStatus = (int) ($row->payment_status ?? 0); $runningOpd = (int) ($row->running_opd ?? 0) === 1; $opdFeeType = (int) ($row->opd_fee_type ?? 0); $opdFeeAmount = (float) ($row->opd_fee_amount ?? 0); $opdFeeDesc = strtoupper(trim((string) ($row->opd_fee_desc ?? ''))); $isRunningVisit = $runningOpd || $opdFeeType === 3 || str_contains($opdFeeDesc, 'RUNNING'); $isConfirmedZeroAmount = $isRunningVisit && $opdFeeAmount <= 0; $isConfirmedForQueue = $paymentStatus === 1 || $isConfirmedZeroAmount || !empty($row->confirm_pay_opd); $statusLabel = 'booked'; if ($status === 1 && ($hasPrescription || $isConfirmedForQueue)) { $statusLabel = 'waiting'; } elseif ($status === 2) { $statusLabel = 'visited'; } elseif ($status === 3) { $statusLabel = 'cancelled'; } $normalized[] = [ 'opd_id' => (int) ($row->opd_id ?? 0), 'status' => $statusLabel, 'has_prescription' => $hasPrescription ? 1 : 0, 'has_vitals' => (int) ($row->has_vitals ?? 0), 'queue_no' => (int) ($row->queue_no ?? 0), 'is_confirmed_for_queue' => $isConfirmedForQueue ? 1 : 0, ]; $counts['all']++; if (array_key_exists($statusLabel, $counts)) { $counts[$statusLabel]++; } } // Normalize queue display numbers to contiguous doctor-day sequence. $queueRows = array_values(array_filter($normalized, static function (array $row): bool { return (int) ($row['queue_no'] ?? 0) > 0; })); usort($queueRows, static function (array $left, array $right): int { $leftQueue = (int) ($left['queue_no'] ?? 0); $rightQueue = (int) ($right['queue_no'] ?? 0); if ($leftQueue !== $rightQueue) { return $leftQueue <=> $rightQueue; } return ((int) ($left['opd_id'] ?? 0)) <=> ((int) ($right['opd_id'] ?? 0)); }); $displayQueueByOpdId = []; $displayQueue = 1; foreach ($queueRows as $row) { $queueOpdId = (int) ($row['opd_id'] ?? 0); if ($queueOpdId <= 0 || array_key_exists($queueOpdId, $displayQueueByOpdId)) { continue; } $displayQueueByOpdId[$queueOpdId] = $displayQueue; $displayQueue++; } foreach ($normalized as &$row) { $row['display_queue_no'] = (int) ($displayQueueByOpdId[(int) ($row['opd_id'] ?? 0)] ?? 0); } unset($row); usort($normalized, static function ($left, $right): int { return ((int) ($left['opd_id'] ?? 0)) <=> ((int) ($right['opd_id'] ?? 0)); }); $digest = sha1(json_encode([ 'doc_id' => $docId, 'opd_date' => $opdDate, 'counts' => $counts, 'rows' => $normalized, ])); return $this->response->setJSON([ 'ok' => 1, 'digest' => $digest, 'doc_id' => $docId, 'opd_date' => $opdDate, 'counts' => $counts, 'rows' => $normalized, 'server_time' => date('c'), ]); } /** * Update OPD status from queue actions. */ public function opd_status(int $opdId, int $opdStatus) { if (! $this->canAccessDoctorWorkPanel()) { return $this->response->setStatusCode(403)->setBody('Access denied: OPD doctor work panel is restricted.'); } $allowed = [1, 2, 3]; if (!in_array($opdStatus, $allowed, true)) { return $this->response->setStatusCode(400)->setBody('Invalid OPD status'); } $sql = "select * from opd_master where opd_id=" . (int) $opdId; $query = $this->db->query($sql); $opdMaster = $query->getResult(); if (empty($opdMaster)) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $user = auth()->user(); $userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User'; $userId = $user->id ?? 0; $statusRemark = 'Update By:' . $userLabel . '[' . $userId . '] T-' . date('d-m-Y H:i:s'); $this->db->table('opd_master') ->where('opd_id', (int) $opdId) ->update([ 'opd_status' => (string) $opdStatus, 'opd_status_remark' => $statusRemark, ]); $this->syncAbdmTokenAfterOpdStatus((int) $opdId, (int) $opdStatus, (int) $userId, (string) $userLabel); return $this->response->setJSON([ 'update' => 1, 'opd_id' => (int) $opdId, 'opd_status' => (int) $opdStatus, 'message' => 'Status updated', ]); } private function syncAbdmTokenAfterOpdStatus(int $opdId, int $opdStatus, int $actorId, string $actorLabel): void { if ($opdStatus !== 2 || ! $this->db->tableExists('abdm_opd_tokens')) { return; } $rows = $this->db->query( "SELECT id, gateway_token_id, status, queue_date FROM abdm_opd_tokens WHERE opd_id = " . (int) $opdId . " AND status <> 'COMPLETED'" )->getResultArray(); if (empty($rows)) { return; } $connector = null; try { $connector = AbdmConnectorFactory::make(); } catch (\Throwable $e) { $connector = null; } foreach ($rows as $row) { $localId = (int) ($row['id'] ?? 0); $gatewayTokenId = (int) ($row['gateway_token_id'] ?? 0); $oldStatus = strtoupper(trim((string) ($row['status'] ?? ''))); if ($localId <= 0 || $oldStatus === 'COMPLETED') { continue; } $gatewaySync = 'skipped'; $gatewayError = ''; if ($connector && $gatewayTokenId > 0) { try { $connector->opdTokenUpdateStatus($gatewayTokenId, 'COMPLETED'); $gatewaySync = 'ok'; } catch (\Throwable $e) { $gatewaySync = 'failed'; $gatewayError = $e->getMessage(); } } $this->db->table('abdm_opd_tokens') ->where('id', $localId) ->update([ 'status' => 'COMPLETED', 'updated_at' => date('Y-m-d H:i:s'), ]); $meta = [ 'source' => 'opd_status_visit_done', 'opd_id' => $opdId, 'queue_date' => (string) ($row['queue_date'] ?? ''), 'gateway_token_id' => $gatewayTokenId, 'gateway_sync' => $gatewaySync, 'gateway_error' => $gatewayError, 'actor' => $actorLabel, ]; $this->writeClinicalAuditTrail( 'abdm_opd_queue', $gatewayTokenId > 0 ? (string) $gatewayTokenId : (string) $localId, 'status', $oldStatus, 'COMPLETED | ' . json_encode($meta, JSON_UNESCAPED_SLASHES), (string) $actorId ); } } private function writeClinicalAuditTrail( string $module, string $recordId, string $fieldName, ?string $oldValue, ?string $newValue, string $userId ): void { try { if (! $this->db->tableExists('clinical_audit_trail')) { return; } $actionAt = date('Y-m-d H:i:s'); $hash = hash('sha256', implode('|', [ $module, $recordId, $fieldName, (string) $oldValue, (string) $newValue, $userId, $actionAt, microtime(true), random_int(1000, 999999), ])); (new ClinicalAuditTrailModel())->insert([ 'module' => $module, 'record_id' => $recordId, 'field_name' => $fieldName, 'old_value' => $oldValue, 'new_value' => $newValue, 'user_id' => $userId, 'action_at' => $actionAt, 'hash' => $hash, ]); } catch (\Throwable $e) { // fail-open: audit write errors must not block OPD flow } } public function addopd(int $pno, int $orgCaseId = 0) { $sql = "select *,if(gender=1,'Male','Female') as xgender from patient_master where id='" . (int) $pno . "'"; $query = $this->db->query($sql); $data['person_info'] = $query->getResult(); if (!empty($data['person_info'])) { $row = $data['person_info'][0]; $row->age = get_age_1($row->dob ?? null, $row->age ?? '', $row->age_in_month ?? '', $row->estimate_dob ?? ''); } $sql = "select d.id, d.p_fname, group_concat(m.SpecName) as SpecName from doctor_master d join doc_spec s join med_spec m on d.id = s.doc_id and s.med_spec_id = m.id where d.active = 1 group by d.id"; $query = $this->db->query($sql); $data['doc_spec_l'] = $query->getResult(); $data['org_case_id'] = $orgCaseId; if ($orgCaseId > 0) { $sql = "select * from organization_case_master where id=" . (int) $orgCaseId; $query = $this->db->query($sql); $data['org_case_master'] = $query->getResult(); } $defaultDocId = 0; $user = function_exists('auth') ? auth()->user() : null; if ($user && ! empty($user->id)) { $profile = (new \App\Models\UserProfileModel())->getProfile((int) $user->id); if (! empty($profile['doctor_id'])) { $defaultDocId = (int) $profile['doctor_id']; } } $data['default_doc_id'] = $defaultDocId; return view('billing/opd_appointment_V', $data); } public function showfee() { if (!$this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'content' => '', 'error_text' => 'Invalid request', ]); } $docId = (int) $this->request->getPost('doc_id'); $pId = (int) $this->request->getPost('pid'); $sql = "select * from doctor_master where id=" . $docId; $query = $this->db->query($sql); $doctorMaster = $query->getResult(); $doctorRow = $doctorMaster[0] ?? null; $noOpdDays = $this->resolveOpdValiditySpanDays($doctorRow); $sql = "select d.id, d.p_fname, group_concat(m.SpecName) as SpecName, if(d.gender=1,'Male','Female') as xGender from doctor_master d join doc_spec s join med_spec m on d.id = s.doc_id and s.med_spec_id = m.id where d.active = 1 and d.id=" . $docId . " group by d.id"; $query = $this->db->query($sql); $docInfo = $query->getResult(); $sql = "select * from opd_master where p_id=" . $pId . " and doc_id=" . $docId . " and apointment_date >= date_add(curdate(),interval -" . $noOpdDays . " day)" . " and opd_fee_type<>3 and coalesce(opd_fee_amount,0) > 0"; $query = $this->db->query($sql); $opdRunning = $query->getResult(); $sql = "select * from opd_master where p_id=" . $pId; $query = $this->db->query($sql); $opdOld = $query->getResult(); $feeTypeList = '4,0'; $feeTypeSelect = 1; if (count($opdOld) > 0) { if (count($opdRunning) > 0) { $feeTypeSelect = 3; $feeTypeList .= ',3'; } else { $feeTypeSelect = 2; $feeTypeList .= ',2'; } } else { $feeTypeSelect = 1; $feeTypeList .= ',1'; } $content = ''; $content .= '
Doctor Name : ' . esc($docInfo[0]->p_fname ?? '') . ' / Specialization: ' . esc($docInfo[0]->SpecName ?? '') . ' / Gender : ' . esc($docInfo[0]->xGender ?? '') . '
'; $content .= ''; $sql = "select d.*, if(d.doc_fee_desc='', t.fee_type, d.doc_fee_desc) as fee_desc from doc_opd_fee d join doc_fee_type t on d.doc_fee_type = t.id where doc_id=" . $docId . " and t.id in (" . $feeTypeList . ")"; $query = $this->db->query($sql); $docFeeA = $query->getResult(); $sql = "select d.*, if(d.doc_fee_desc='', t.fee_type, d.doc_fee_desc) as fee_desc from doc_opd_fee d join doc_fee_type t on d.doc_fee_type = t.id where doc_id=" . $docId . " and t.id not in (" . $feeTypeList . ")"; $query = $this->db->query($sql); $docFeeB = $query->getResult(); foreach ($docFeeA as $row) { $checked = ((int) $row->id === (int) $feeTypeSelect) ? 'checked' : ''; $content .= ''; } $content .= '
'; $otherContent = ''; foreach ($docFeeB as $row) { $checked = ((int) $row->id === (int) $feeTypeSelect) ? 'checked' : ''; $otherContent .= ''; } $content .= '
' . '
' . '

' . '' . '

' . '
' . '
' . $otherContent . '
' . '
' . '
' . '
'; return $this->response->setJSON([ 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), 'content' => $content, ]); } public function confirm_opd() { if (!$this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'insertid' => 0, 'error_text' => 'Invalid request', ]); } $docId = (int) $this->request->getPost('doc_id'); $feeId = (int) $this->request->getPost('fee_id'); $pid = (int) $this->request->getPost('pid'); $appointmentDate = (string) $this->request->getPost('datepicker_appointment'); $appointmentTime = (string) $this->request->getPost('appointment_time'); $abhaAddress = trim((string) $this->request->getPost('abha_address')); if ($docId <= 0 || $feeId <= 0 || $pid <= 0 || $appointmentDate === '') { return $this->response->setJSON([ 'insertid' => 0, 'error_text' => 'Missing required fields.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $sql = "select * from patient_master where id=" . $pid; $query = $this->db->query($sql); $personInfo = $query->getResult(); $sql = "select d.id, d.p_fname, group_concat(m.SpecName) as SpecName, if(d.gender=1,'Male','Female') as xGender from doctor_master d join doc_spec s join med_spec m on d.id = s.doc_id and s.med_spec_id = m.id where d.active=1 and d.id=" . $docId . " group by d.id"; $query = $this->db->query($sql); $docInfo = $query->getResult(); $sql = "select * from doc_opd_fee where id=" . $feeId; $query = $this->db->query($sql); $docFee = $query->getResult(); if (count($personInfo) === 0 || count($docInfo) === 0 || count($docFee) === 0) { return $this->response->setJSON([ 'insertid' => 0, 'error_text' => 'Invalid patient, doctor, or fee selection.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $sql = "select * from opd_master where p_id=" . $pid . " order by opd_id desc"; $query = $this->db->query($sql); $opdMaster = $query->getResult(); $user = auth()->user(); $userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User'; $userId = $user->id ?? ''; $userNameInfo = $userLabel . ' [' . date('d-m-Y H:i:s') . '] ' . $userId; // Combine appointment date and time into datetime $appointmentDateTime = str_to_MysqlDateTime($appointmentDate, $appointmentTime); $insert = [ 'p_id' => $pid, 'P_name' => strtoupper((string) $personInfo[0]->p_fname), 'doc_id' => $docId, 'insurance_id' => '0', 'opd_fee_id' => $feeId, 'opd_fee_amount' => $docFee[0]->amount, 'opd_fee_gross_amount' => $docFee[0]->amount, 'opd_fee_desc' => $docFee[0]->doc_fee_desc, 'doc_name' => $docInfo[0]->p_fname, 'opd_fee_type' => $docFee[0]->doc_fee_type, 'apointment_date' => $appointmentDateTime, 'doc_spec' => $docInfo[0]->SpecName, 'prepared_by' => $userNameInfo, ]; $isZeroValueRunning = ((int) ($docFee[0]->doc_fee_type ?? 0) === 3) || ((float) ($docFee[0]->amount ?? 0) <= 0); if ($isZeroValueRunning) { $sql = "select apointment_date from opd_master" . " where p_id=" . $pid . " and doc_id=" . $docId . " and opd_fee_type<>3" . " and coalesce(opd_fee_amount,0) > 0" . " order by opd_id desc limit 1"; $query = $this->db->query($sql); $previousPaid = $query->getResult(); if (!empty($previousPaid[0]->apointment_date ?? null)) { $insert['last_opdvisit_date'] = $previousPaid[0]->apointment_date; } elseif (count($opdMaster) > 0) { $insert['last_opdvisit_date'] = $opdMaster[0]->apointment_date; } } elseif (count($opdMaster) > 0) { $insert['last_opdvisit_date'] = $opdMaster[0]->apointment_date; } $opdModel = new OpdModel(); $insertId = $opdModel->insertOpd($insert); if ($insertId > 0) { $this->db->table('patient_master') ->where('id', $pid) ->update(['last_visit' => str_to_MysqlDate($appointmentDate)]); $this->auditClinicalUpdate('opd_master', 'created', $insertId, null, $insert); $abhaField = $this->resolvePatientAbhaField(); if ($abhaField !== null && $abhaAddress !== '') { $oldAbha = (string) ($personInfo[0]->{$abhaField} ?? ''); if ($oldAbha !== $abhaAddress) { $this->db->table('patient_master') ->where('id', $pid) ->update([$abhaField => $abhaAddress]); $this->auditClinicalUpdate('patient_master', $abhaField, $pid, $oldAbha, $abhaAddress); } } // Queue OPD consultation fee invoice for ABDM linking if ((float) ($docFee[0]->amount ?? 0) > 0 && class_exists('\App\Controllers\AbdmGateway')) { try { \App\Controllers\AbdmGateway::autoPushRecord('opd_invoice', $insertId, $pid); } catch (\Throwable $ie) { log_message('warning', '[insert_opd_patient] autoPushRecord opd_invoice error: ' . $ie->getMessage()); } } } return $this->response->setJSON([ 'insertid' => $insertId, 'error_text' => $insertId > 0 ? 'OPD Register' : 'OPD already exists for today.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } private function isValidAbhaAddress(string $value): bool { $validation = service('validation'); $validation->reset(); return $validation->setRules([ 'abha_address' => 'valid_abha_address', ])->run([ 'abha_address' => $value, ]); } private function resolvePatientAbhaField(): ?string { if (! $this->db->tableExists('patient_master')) { return null; } $fields = $this->db->getFieldNames('patient_master'); foreach (['abha_address', 'abha', 'abha_id', 'abha_no'] as $field) { if (in_array($field, $fields, true)) { return $field; } } return null; } public function invoice(int $opdId) { $sql = "select *,date_format(apointment_date,'%d-%m-%Y') as str_apointment_date, (case payment_status when 1 then 'Cash' when 2 then 'Bank Card' when 3 then 'Org. Credit' else 'Pending' end) as Payment_type_str from opd_master where opd_id=" . (int) $opdId; $query = $this->db->query($sql); $data['opd_master'] = $query->getResult(); if (empty($data['opd_master'])) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $opdRow = $data['opd_master'][0]; $sql = "select *,if(gender=1,'Male','Female') as xgender from patient_master where id='" . (int) $opdRow->p_id . "'"; $query = $this->db->query($sql); $data['patient_master'] = $query->getResult(); if (!empty($data['patient_master'])) { $pRow = $data['patient_master'][0]; $pRow->age = get_age_1($pRow->dob ?? null, $pRow->age ?? '', $pRow->age_in_month ?? '', $pRow->estimate_dob ?? '', $opdRow->apointment_date ?? null); } $sql = "select * from organization_case_master where status=0 and case_type=0 and p_id=" . (int) $opdRow->p_id; $query = $this->db->query($sql); $data['case_master'] = $query->getResult(); $sql = "select * from hc_insurance where id=" . (int) $opdRow->insurance_id; $query = $this->db->query($sql); $data['insurance'] = $query->getResult(); $sql = "select * from refund_order where refund_process=0 and refund_type=1 and refund_type_id=" . (int) $opdId; $query = $this->db->query($sql); $data['refund_order'] = $query->getResult(); $data['refund_status'] = count($data['refund_order']) > 0 ? 1 : 0; $sql = "select sum(if(credit_debit=0,amount,amount*-1)) as paid_amount from payment_history where payof_type=1 and payof_id=" . (int) $opdId; $query = $this->db->query($sql); $data['payment_history'] = $query->getResult(); $sql = "select id,payment_date,insert_time,payment_mode,credit_debit,amount,remark,card_tran_id,update_by, (case payment_mode when 0 then 'Zero Amount' when 1 then 'Cash' when 2 then 'Bank/Online' when 3 then 'Return Cash' when 4 then 'Bank Return' else 'Other' end) as payment_mode_str from payment_history where payof_type=1 and payof_id=" . (int) $opdId . " and not (payment_mode=0 and coalesce(amount,0)=0) order by id desc"; $query = $this->db->query($sql); $data['payment_history_rows'] = $query->getResult(); $paidAmount = 0; if (!empty($data['payment_history']) && $data['payment_history'][0]->paid_amount) { $paidAmount = (float) $data['payment_history'][0]->paid_amount; } $data['paid_amount'] = $paidAmount; $grossAmount = (float) $opdRow->opd_fee_amount; $data['pending_amount'] = ((int) $opdRow->payment_mode === 4) ? 0 : ($grossAmount - $paidAmount); $sql = "select d.id, d.p_fname, group_concat(m.SpecName) as SpecName from doctor_master d join doc_spec s join med_spec m on d.id = s.doc_id and s.med_spec_id = m.id where d.active=1 group by d.id"; $query = $this->db->query($sql); $data['doc_spec_l'] = $query->getResult(); $sql = "select s.id, s.pay_type, m.bank_name from hospital_bank m join hospital_bank_payment_source s on m.id=s.bank_id"; $query = $this->db->query($sql); $data['bank_data'] = $query->getResult(); return view('billing/opd_invoice_V', $data); } public function invoice_print(int $opdId) { $data = $this->buildOpdInvoicePrintData($opdId); if ($data === null) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } return view('billing/opd_invoice_print_v', $data); } public function invoice_print_pdf(int $opdId) { $data = $this->buildOpdInvoicePrintData($opdId); if ($data === null) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $html = view('billing/opd_invoice_pdf_v', $data); $mpdf = new Mpdf([ 'mode' => 'utf-8', 'format' => 'A4', 'orientation' => 'P', 'margin_left' => 10, 'margin_right' => 10, 'margin_top' => 10, 'margin_bottom' => 10, 'default_font' => 'freeserif', 'tempDir' => WRITEPATH . 'cache', ]); $opdCode = (string) ($data['opd_master'][0]->opd_code ?? ('OPD-' . $opdId)); $mpdf->SetTitle('OPD Invoice ' . $opdCode); $mpdf->WriteHTML($html); $fileName = 'OPD_Invoice_' . str_replace('/', '-', $opdCode) . '.pdf'; return $this->response ->setHeader('Content-Type', 'application/pdf') ->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"') ->setBody($mpdf->Output($fileName, 'S')); } private function buildOpdInvoicePrintData(int $opdId): ?array { $data = []; $sql = "select *,date_format(apointment_date,'%d-%m-%Y') as str_apointment_date, (case payment_status when 1 then 'Cash' when 2 then 'Bank Card' when 3 then 'Org. Credit' else 'Pending' end) as Payment_type_str from opd_master where opd_id=" . (int) $opdId; $query = $this->db->query($sql); $data['opd_master'] = $query->getResult(); if (empty($data['opd_master'])) { return null; } $opdRow = $data['opd_master'][0]; $sql = "select *,if(gender=1,'Male','Female') as xgender from patient_master where id='" . (int) $opdRow->p_id . "'"; $query = $this->db->query($sql); $data['patient_master'] = $query->getResult(); if (!empty($data['patient_master'])) { $pRow = $data['patient_master'][0]; $pRow->age = get_age_1($pRow->dob ?? null, $pRow->age ?? '', $pRow->age_in_month ?? '', $pRow->estimate_dob ?? '', $opdRow->apointment_date ?? null); } $sql = "select * from hc_insurance where id=" . (int) ($opdRow->insurance_id ?? 0); $query = $this->db->query($sql); $data['insurance'] = $query->getResult(); if ((int) ($opdRow->insurance_case_id ?? 0) > 0) { $sql = "select * from organization_case_master where id=" . (int) $opdRow->insurance_case_id; $query = $this->db->query($sql); $data['case_master'] = $query->getResult(); } else { $data['case_master'] = []; } return $data; } public function opd_lettre_print(int $opdId) { $sessionId = (int) $this->request->getGet('session_id'); $layoutMode = strtolower(trim((string) $this->request->getGet('layout'))); if ($layoutMode === '') { $layoutMode = 'full'; } $includeContent = in_array($layoutMode, ['full', 'meta_content', 'content_only'], true); $data = $this->buildOpdLetterPrintData($opdId, $sessionId, $includeContent); if ($data === null) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $data['print_layout_mode'] = $layoutMode; return view('billing/opd_letter_head', $data); } public function opd_lettre_pdf(int $opdId) { $this->configureOpdPlaceholderRenderOptionsFromRequest(); $sessionId = (int) $this->request->getGet('session_id'); $debugHtml = ((int) $this->request->getGet('debug_html')) === 1; $layoutMode = strtolower(trim((string) $this->request->getGet('layout'))); if ($layoutMode === '') { $layoutMode = 'full'; } $templateKey = $this->normalizeOpdTemplateId((string) $this->request->getGet('template')); $allowedModes = ['full', 'meta_content', 'content_only', 'header_meta', 'meta_only']; if (! in_array($layoutMode, $allowedModes, true)) { $layoutMode = 'full'; } // If template is explicitly requested, always render full layout so header/footer are applied. if ($templateKey !== '') { $layoutMode = 'full'; } $includeContent = in_array($layoutMode, ['full', 'meta_content', 'content_only'], true); $data = $this->buildOpdLetterPrintData($opdId, $sessionId, $includeContent); if ($data === null) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $data['print_layout_mode'] = $layoutMode; if ($includeContent) { try { $rxCtrl = new \App\Controllers\Opd_prescription(); $rxCtrl->finalizeOpdPrescriptionOnPrint($opdId, $sessionId); } catch (\Throwable $e) { log_message('warning', '[opd_lettre_pdf] finalizeOpdPrescriptionOnPrint error: ' . $e->getMessage()); } } $isComposedTemplate = $templateKey !== '' && str_starts_with($templateKey, 'compose_'); if ($isComposedTemplate) { $html = $this->renderComposedOpdPrint($data, $templateKey); } else { $customHtml = ''; if ($templateKey !== '') { // 1) Try DB-stored template first $customHtml = $this->readStoredOpdTemplateContent($templateKey); // 2) Fall back to filesystem HTML file in opd_templates/ if ($customHtml === '') { $fsPath = APPPATH . 'Views/billing/opd_templates/' . $templateKey . '.html'; if (is_file($fsPath)) { $customHtml = (string) file_get_contents($fsPath); } } } if ($customHtml !== '') { $legacyVars = $this->buildLegacyDashboardTemplateVars($data); $html = $this->renderCurlyTemplate($customHtml, $legacyVars); } else { $html = view('billing/opd_letter_head_pdf', $data); } } // SHORT-CIRCUIT: When DB template content is found, render it directly with token // substitution and skip all per-template paper settings entirely. if ($customHtml !== '') { $tokenVars = $this->buildOpdPdfTokenVars($data); $html = $this->applyOpdPdfTemplateTokens($customHtml, $tokenVars); $html = $this->sanitizeOpdNamedHeaderFooterReferences($html); $html = $this->applyOpdLongContentPaginationGuard($html); $html = mpdf_normalize_font_weight_css($html); if ($debugHtml) { return $this->response ->setHeader('Content-Type', 'text/html; charset=UTF-8') ->setBody($html); } try { $mpdf = new Mpdf([ 'mode' => 'utf-8', 'format' => 'A4', 'orientation' => 'P', 'margin_left' => 5, 'margin_right' => 5, 'margin_top' => 5, 'margin_bottom' => 5, 'margin_header' => 5, 'margin_footer' => 5, 'default_font' => 'freeserif', 'autoScriptToLang' => true, 'autoLangToFont' => true, 'tempDir' => WRITEPATH . 'cache', ]); $opdCode = (string) ($data['opd_master'][0]->opd_code ?? ('OPD-' . $opdId)); $mpdf->SetTitle('OPD Prescription ' . $opdCode); $mpdf->WriteHTML($html); $fileName = 'OPD_Prescription_' . str_replace('/', '-', $opdCode) . '.pdf'; return $this->response ->setHeader('Content-Type', 'application/pdf') ->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"') ->setBody($mpdf->Output($fileName, 'S')); } catch (\Throwable $e) { log_message('error', 'mPDF rendering failed: ' . $e->getMessage()); return $this->response ->setStatusCode(500) ->setBody('PDF generation failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } } $docPrintSetting = $this->getOpdPaperPrintSetting($templateKey); $tokenVars = $this->buildOpdPdfTokenVars($data); $paper = strtolower(trim((string) $this->request->getGet('paper'))); $format = strtoupper(trim((string) ($docPrintSetting['page_size'] ?? 'A4'))); if ($format === '') { $format = 'A4'; } if (in_array($paper, ['a5', 'a6', 'letter'], true)) { $format = strtoupper($paper); } $customWidthMm = (int) $this->normalizeOpdPrintMarginCm($docPrintSetting['custom_width_mm'] ?? null, 210.0); $customHeightMm = (int) $this->normalizeOpdPrintMarginCm($docPrintSetting['custom_height_mm'] ?? null, 297.0); if ($customWidthMm < 20) { $customWidthMm = 20; } if ($customWidthMm > 600) { $customWidthMm = 600; } if ($customHeightMm < 20) { $customHeightMm = 20; } if ($customHeightMm > 1000) { $customHeightMm = 1000; } $mpdfFormat = $format === 'CUSTOM' ? [$customWidthMm, $customHeightMm] : $format; $hasDocSetting = ! empty($docPrintSetting); if ($hasDocSetting) { $marginLeftCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['page_margin_left_cm'] ?? null, 0.7); $marginRightCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['page_margin_right_cm'] ?? null, 0.7); $marginTopCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['page_margin_top_cm'] ?? null, 6.1); $marginBottomCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['page_margin_bottom_cm'] ?? null, 2.5); $marginHeaderCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['margin_header_cm'] ?? null, 0.5); $marginFooterCm = $this->normalizeOpdPrintMarginCm($docPrintSetting['margin_footer_cm'] ?? null, 1.5); $marginLeft = $marginLeftCm * 10; $marginRight = $marginRightCm * 10; $marginTop = $marginTopCm * 10; $marginBottom = $marginBottomCm * 10; $marginHeader = $marginHeaderCm * 10; $marginFooter = $marginFooterCm * 10; } else { $marginLeftCm = 0.7; $marginRightCm = 0.7; $marginTopCm = 6.1; $marginBottomCm = 2.5; $marginHeaderCm = 0.5; $marginFooterCm = 1.5; $marginLeft = 8; $marginRight = 8; $marginTop = 8; $marginBottom = 8; $marginHeader = 5; $marginFooter = 10; } if ($format === 'A6') { $marginLeft = 6; $marginRight = 6; $marginTop = 6; $marginBottom = 6; $marginLeftCm = 0.6; $marginRightCm = 0.6; $marginTopCm = 0.6; $marginBottomCm = 0.6; } // Add margin variables to token replacement $tokenVars['MarginTop'] = (string) $marginTopCm; $tokenVars['MarginBottom'] = (string) $marginBottomCm; $tokenVars['MarginLeft'] = (string) $marginLeftCm; $tokenVars['MarginRight'] = (string) $marginRightCm; $tokenVars['MarginHeader'] = (string) $marginHeaderCm; $tokenVars['MarginFooter'] = (string) $marginFooterCm; // Aliases for alternate naming $tokenVars['page_margin_top_cm'] = (string) $marginTopCm; $tokenVars['page_margin_bottom_cm'] = (string) $marginBottomCm; $tokenVars['page_margin_left_cm'] = (string) $marginLeftCm; $tokenVars['page_margin_right_cm'] = (string) $marginRightCm; $tokenVars['margin_header_cm'] = (string) $marginHeaderCm; $tokenVars['margin_footer_cm'] = (string) $marginFooterCm; $mpdf = new Mpdf([ 'mode' => 'utf-8', 'format' => $mpdfFormat, 'orientation' => 'P', 'margin_left' => $marginLeft, 'margin_right' => $marginRight, 'margin_top' => $marginTop, 'margin_bottom' => $marginBottom, 'margin_header' => $marginHeader, 'margin_footer' => $marginFooter, 'default_font' => 'freeserif', 'autoScriptToLang' => true, 'autoLangToFont' => true, 'tempDir' => WRITEPATH . 'cache', ]); $rawHeaderHtml = trim((string) ($docPrintSetting['header_html'] ?? '')); $rawFooterHtml = trim((string) ($docPrintSetting['footer_html'] ?? '')); $rawPaperHtmlContent = trim((string) ($docPrintSetting['paper_html_content'] ?? '')); $prefixHtml = ''; // Build base @page CSS block from margin settings. $buildPageStyleBlock = static function (bool $useHeader, bool $useFooter) use ($marginTopCm, $marginBottomCm, $marginLeftCm, $marginRightCm, $marginHeaderCm, $marginFooterCm): string { $css = '' . "\n"; return $css; }; // Resolve body content first. if ($rawPaperHtmlContent !== '') { $hasContentPlaceholder = preg_match('/<\?=\s*\$content\s*\?>|<\?=\$content\?>|\{\{content\}\}|\{content\}/i', $rawPaperHtmlContent) === 1; $paperHtml = $this->applyOpdPdfTemplateTokens($rawPaperHtmlContent, $tokenVars); $paperHtml = str_replace(['', '', '{{content}}', '{content}'], $html, $paperHtml); if ($hasContentPlaceholder && strpos($paperHtml, $html) === false) { $paperHtml .= $html; } $html = $this->sanitizeOpdNamedHeaderFooterReferences($paperHtml); } // If separate header/footer fields are provided, always apply them around the resolved body. if ($rawHeaderHtml !== '' || $rawFooterHtml !== '') { $combinedHtml = $buildPageStyleBlock($rawHeaderHtml !== '', $rawFooterHtml !== ''); if ($rawHeaderHtml !== '') { $renderedHeaderHtml = $this->applyOpdPdfTemplateTokens($rawHeaderHtml, $tokenVars); if (!preg_match('/<\s*htmlpageheader\b/i', $renderedHeaderHtml)) { $combinedHtml .= '' . "\n" . $renderedHeaderHtml . "\n" . '' . "\n"; } else { $combinedHtml .= $renderedHeaderHtml . "\n"; } } if ($rawFooterHtml !== '') { $renderedFooterHtml = $this->applyOpdPdfTemplateTokens($rawFooterHtml, $tokenVars); if (!preg_match('/<\s*htmlpagefooter\b/i', $renderedFooterHtml)) { $combinedHtml .= '' . "\n" . $renderedFooterHtml . "\n" . '' . "\n"; } else { $combinedHtml .= $renderedFooterHtml . "\n"; } } $combinedHtml .= $html; $html = $this->sanitizeOpdNamedHeaderFooterReferences($combinedHtml); $rawHeaderHtml = ''; $rawFooterHtml = ''; } $html = $this->applyOpdLongContentPaginationGuard($html); $html = mpdf_normalize_font_weight_css($html); if ($rawHeaderHtml !== '') { $renderedHeaderHtml = $this->applyOpdPdfTemplateTokens($rawHeaderHtml, $tokenVars); $renderedHeaderHtml = mpdf_normalize_font_weight_css($renderedHeaderHtml); if (preg_match('/<\s*(htmlpageheader|sethtmlpageheader)\b/i', $renderedHeaderHtml) === 1) { $prefixHtml .= $renderedHeaderHtml; } else { $mpdf->SetHTMLHeader($renderedHeaderHtml, 'O'); $mpdf->SetHTMLHeader($renderedHeaderHtml, 'E'); } } if ($rawFooterHtml !== '') { $renderedFooterHtml = $this->applyOpdPdfTemplateTokens($rawFooterHtml, $tokenVars); $renderedFooterHtml = mpdf_normalize_font_weight_css($renderedFooterHtml); if (preg_match('/<\s*(htmlpagefooter|sethtmlpagefooter)\b/i', $renderedFooterHtml) === 1) { $prefixHtml .= $renderedFooterHtml; } else { $mpdf->SetHTMLFooter($renderedFooterHtml, 'O'); $mpdf->SetHTMLFooter($renderedFooterHtml, 'E'); } } if ($prefixHtml !== '') { $prefixHtml = mpdf_normalize_font_weight_css($prefixHtml); $mpdf->WriteHTML($prefixHtml, HTMLParserMode::HTML_HEADER_BUFFER); } if ($debugHtml) { $debugOutput = $prefixHtml !== '' ? ($prefixHtml . "\n" . $html) : $html; return $this->response ->setHeader('Content-Type', 'text/plain; charset=UTF-8') ->setBody($debugOutput); } $opdCode = (string) ($data['opd_master'][0]->opd_code ?? ('OPD-' . $opdId)); $mpdf->SetTitle('OPD Prescription ' . $opdCode); $mpdf->WriteHTML($html); $fileName = 'OPD_Prescription_' . str_replace('/', '-', $opdCode) . '.pdf'; return $this->response ->setHeader('Content-Type', 'application/pdf') ->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"') ->setBody($mpdf->Output($fileName, 'S')); } public function opd_lettre_html(int $opdId = 0) { if ($opdId <= 0) { $defaultHeading = esc((string) ($this->request->getGet('heading_style') ?: 'bold')); $defaultLineGap = esc((string) ($this->request->getGet('line_gap') ?: '1.6')); $defaultBlockGap = esc((string) ($this->request->getGet('block_gap_mm') ?: '2.8')); $html = 'OPD HTML Preview' . '' . '' . '

OPD Letter HTML Preview

' . '

Enter OPD ID and style options. This opens rendered HTML debug output for placeholder and CSS verification.

' . '
' . '' . '' . '' . '' . '' . '' . '' . '' . '
' . '
' . '

Direct URL example: ' . esc(base_url('Opd/opd_lettre_html/2047354?heading_style=highlight&line_gap=1.8&block_gap_mm=4')) . '

' . ''; return $this->response ->setHeader('Content-Type', 'text/html; charset=UTF-8') ->setBody($html); } $query = $this->request->getGet(); $query['debug_html'] = 1; $target = base_url('Opd/opd_lettre_pdf/' . (int) $opdId) . '?' . http_build_query($query); return redirect()->to($target); } public function opd_pdf_print(int $opdId) { $printConfig = $this->resolveDoctorPrintConfigByField($opdId, 'opd_blank_print', 'meta_only'); $url = base_url('Opd/opd_lettre_pdf/' . (int) $opdId); if ($printConfig['template'] !== '') { $url .= '?template=' . urlencode($printConfig['template']); } else { $url .= '?layout=' . urlencode($printConfig['layout']); } return redirect()->to($url); } public function opd_blank_print(int $opdId) { $printConfig = $this->resolveDoctorPrintConfigByField($opdId, 'opd_print_format', 'header_meta'); $url = base_url('Opd/opd_lettre_pdf/' . (int) $opdId); if ($printConfig['template'] !== '') { $url .= '?template=' . urlencode($printConfig['template']); } else { $url .= '?layout=' . urlencode($printConfig['layout']); } return redirect()->to($url); } public function opd_cont_print(int $opdId) { $printConfig = $this->resolveDoctorPrintConfigByField($opdId, 'opd_cont_paper_print', 'content_only'); $url = base_url('Opd/opd_lettre_pdf/' . (int) $opdId); if ($printConfig['template'] !== '') { $url .= '?template=' . urlencode($printConfig['template']); } else { $url .= '?layout=' . urlencode($printConfig['layout']); } return redirect()->to($url); } public function opd_day_care(int $opdId, int $mode = 0) { $printConfig = $this->resolveDoctorPrintConfigByField($opdId, 'opd_day_care_template', 'day_care_format'); $url = base_url('Opd/opd_lettre_pdf/' . (int) $opdId); if ($printConfig['template'] !== '') { $url .= '?template=' . urlencode($printConfig['template']); } else { $url .= '?template=day_care_format'; } return redirect()->to($url); } /** * @return array{layout:string,template:string} */ private function resolveDoctorPrintConfigByField(int $opdId, string $fieldName, string $fallback): array { $allowed = ['full', 'meta_content', 'content_only', 'header_meta', 'meta_only']; $fallbackLayout = strtolower(trim($fallback)); if (!in_array($fallbackLayout, $allowed, true)) { $fallbackLayout = 'full'; } if (! $this->db->tableExists('doctor_master') || ! $this->db->fieldExists($fieldName, 'doctor_master')) { return ['layout' => $fallbackLayout, 'template' => '']; } $query = $this->db->query( 'SELECT d.' . $fieldName . ' AS layout_field FROM opd_master o LEFT JOIN doctor_master d ON d.id = o.doc_id WHERE o.opd_id = ? LIMIT 1', [(int) $opdId] ); $row = $query->getRowArray(); $raw = strtolower(trim((string) ($row['layout_field'] ?? ''))); if ($raw === '') { return ['layout' => $fallbackLayout, 'template' => '']; } if (in_array($raw, $allowed, true)) { return ['layout' => $raw, 'template' => '']; } if ($raw === '0') { return ['layout' => 'content_only', 'template' => '']; } if ($raw === '1') { return ['layout' => 'meta_content', 'template' => '']; } if ($raw === '2') { return ['layout' => 'full', 'template' => '']; } $templateKey = $this->normalizeOpdTemplateId($raw); if ($templateKey !== '') { return ['layout' => $fallbackLayout, 'template' => $templateKey]; } return ['layout' => $fallbackLayout, 'template' => '']; } /** * @param array $data * @return array */ private function buildLegacyDashboardTemplateVars(array $data): array { $opd = $data['opd_master'][0] ?? null; $patient = $data['patient_master'][0] ?? null; $doctor = $data['doctor_master'][0] ?? null; $rx = is_array($data['opd_prescription'] ?? null) ? $data['opd_prescription'] : []; $pName = strtoupper(trim((string) (($patient->title ?? '') . ' ' . ($patient->p_fname ?? '')))); $pRelative = trim((string) (($patient->p_relative ?? '') . ' ' . ($patient->p_rname ?? ''))); $referBy = trim((string) ($patient->referby ?? '')); $ageSex = trim((string) (($patient->xgender ?? '') . ' / ' . ($patient->age ?? ''))); $phone = (string) ($patient->mphone1 ?? ''); $pAddress = trim((string) (($patient->add1 ?? '') . ', ' . ($patient->add2 ?? '') . ', ' . ($patient->city ?? '')), ' ,'); $uhidNo = trim((string) ($patient->p_code ?? '')); $oldUhid = trim((string) ($patient->old_uhid ?? '')); if ($oldUhid !== '') { $uhidNo .= ' / ' . $oldUhid; } $opdSrNo = (string) ($opd->opd_no ?? ''); $opdNo = (string) ($opd->opd_code ?? ''); $opdDate = (string) ($opd->str_apointment_date ?? ''); $expDate = trim((string) ($opd->opd_Exp_Date ?? '')); $isRunningOpdEntry = ((int) ($opd->running_opd ?? 0) === 1) || ((int) ($opd->opd_fee_type ?? 0) === 3); if ($isRunningOpdEntry && !empty($data['old_opd'][0] ?? null)) { $oldExpDate = trim((string) ($data['old_opd'][0]->opd_Exp_Date ?? '')); $oldStartDate = trim((string) ($data['old_opd'][0]->str_apointment_date ?? '')); $expDateText = ($oldStartDate !== '' ? 'OPD Start Date : ' . esc($oldStartDate) . '
' : '') . ($oldExpDate !== '' ? 'Valid Upto : ' . esc($oldExpDate) : ''); } else { $expDateText = $expDate !== '' ? ('Valid Upto : ' . esc($expDate)) : ''; } $specName = trim((string) ($doctor->SpecName ?? $opd->doc_spec ?? '')); $doctorShortDescription = trim((string) ($doctor->doc_sign ?? $opd->doc_sign ?? '')); $opdFeeAmount = trim((string) ($opd->opd_fee_amount ?? '')); $opdFeeDescription = trim((string) ($opd->opd_fee_desc ?? '')); $opdFeeDisplay = trim($opdFeeAmount . ' ' . $opdFeeDescription); $totalNoVisit = $this->resolveTotalOpdVisitCountForPrint($opd); $lastVisitDate = ''; $lastVisitText = ''; if (!empty($data['old_opd'][0]->str_apointment_date ?? '')) { $lastVisitDate = (string) $data['old_opd'][0]->str_apointment_date; $lastVisitText = 'Last Visit : ' . $lastVisitDate; } $bookTime = ''; if (!empty($opd->apointment_date)) { $dateTime = $opd->apointment_date; // Format datetime to "dd/mm/yyyy HH:MM" format if (preg_match('/^\d{4}-\d{2}-\d{2}/', $dateTime)) { try { $dt = new \DateTime($dateTime); $bookTime = $dt->format('d/m/Y H:i'); } catch (\Exception $e) { $bookTime = (string) $dateTime; } } else { $bookTime = (string) $dateTime; } } $rxMeds = is_array($data['rx_medicines'] ?? null) ? $data['rx_medicines'] : []; // ── Build Hindi lookup maps from opd_dose_* master tables ────────────── // opd_dose_frequency : dose_sign → dose_sign_hindi (day-type freq: Daily, Weekly…) // opd_dose_shed : dose_show_sign → dose_show_desc (schedule abbrev: QD, BID, AC…) // opd_dose_when : dose_sign → dose_sign_hindi (timing: EMPTY STOMACH, ONCE A DAY…) $doseFreqHindiMap = []; $doseShedHindiMap = []; $doseWhenHindiMap = []; if ($this->db->tableExists('opd_dose_frequency')) { $rows = $this->db->table('opd_dose_frequency') ->select('dose_sign, dose_sign_hindi')->get()->getResultArray(); foreach ($rows as $r) { $k = strtolower(trim((string) ($r['dose_sign'] ?? ''))); $v = trim((string) ($r['dose_sign_hindi'] ?? '')); if ($k !== '' && $v !== '') { $doseFreqHindiMap[$k] = $v; } } } if (empty($doseFreqHindiMap)) { $doseFreqHindiMap = [ 'daily' => 'रोज', 'alternate day' => 'वैकल्पिक दिन', 'weekly' => 'हर हफ़्ते', 'weekly twice' => 'हफ़्ते दो बार में', 'weekly thrice' => 'हफ़्ते में तीन बार', 'fort night' => '2 हफ़्ते में', 'monthly' => 'महीने के', 'mon' => 'सोमवार', 'tue' => 'मंगलवार', 'wed' => 'बुधवार', 'thu' => 'गुरुवार', 'fri' => 'शुक्रवार', 'sat' => 'शनिवार', 'sun' => 'रविवार', ]; } if ($this->db->tableExists('opd_dose_shed')) { $rows = $this->db->table('opd_dose_shed') ->select('dose_show_sign, dose_show_desc')->get()->getResultArray(); foreach ($rows as $r) { $k = strtolower(trim((string) ($r['dose_show_sign'] ?? ''))); $v = trim((string) ($r['dose_show_desc'] ?? '')); if ($k !== '' && $v !== '' && $v !== '-') { $doseShedHindiMap[$k] = $v; } } } if (empty($doseShedHindiMap)) { // Fallback mirrors opd_dose_shed table (dose_show_sign → dose_show_desc) $doseShedHindiMap = [ 'q (every)' => 'प्रत्येक', 'qd (everday)' => 'प्रतिदिन', 'qod (alternate day)' => 'हर एक दिन छोड़कर', 'qh (every hour)' => 'हर घंटे', 'sos (as when required)' => 'जरूरत पड़ने पर', 'ac (before meal)' => 'भोजन से पहले', 'pc (after meal)' => 'भोजन के बाद', 'bid (two time a day)' => 'दिन में दो बार', 'tid (three time a day)' => 'दिन में तीन बार', 'qid (four time a day)' => 'दिन में चार बार', 'od (one time a day)' => 'हर सुबह नाश्ते के बाद', 'bt (at sleep)' => 'सोते समय', 'bbf (before breakfast)' => 'नाश्ते से पहले', 'hs (after dinner)' => 'रात को भोजन के बाद', 'bl (before lunch)' => 'रात को भोजन से पहले', 'al (after lunch)' => 'रात को भोजन से पहले', 'tw (two time a week)' => 'हफ्ते में दो बार', 'qam (every morning)' => 'हर सुबह', 'qpm (every night)' => 'हर रात', 'q4h (every four hour)' => 'हर चार घंटों में', 'prn (as per need)' => 'जरूरत के मुताबिक', ]; } if ($this->db->tableExists('opd_dose_when')) { $rows = $this->db->table('opd_dose_when') ->select('dose_sign, dose_sign_hindi')->get()->getResultArray(); foreach ($rows as $r) { $k = strtolower(trim((string) ($r['dose_sign'] ?? ''))); $v = trim((string) ($r['dose_sign_hindi'] ?? '')); if ($k !== '' && $v !== '') { $doseWhenHindiMap[$k] = $v; } } } if (empty($doseWhenHindiMap)) { $doseWhenHindiMap = [ 'empty stomach' => 'सुबह खाली पेट', 'once a day' => 'दिन में एक बार', 'once a week' => 'सप्ताह मेँ एक बार', 'once a fiften day' => 'पन्द्रह दिन में एक बार', 'only now' => 'केवल अभी', 'morning 8 unit--- evening 5 unit' => 'सुबह 8 यूनिट------- शाम 5 यूनिट', ]; } // ─────────────────────────────────────────────────────────────────────── $medicalHtml = ''; if (!empty($rxMeds)) { $isCompactMeds = count($rxMeds) >= 8; $tableFontSize = $isCompactMeds ? '10px' : '12px'; $cellPadding = $isCompactMeds ? '2.5px 5px' : '6px 8px'; $tableMargin = $isCompactMeds ? '2px' : '5px'; $genericFontSize = $isCompactMeds ? '8.5px' : '10px'; $localFontSize = $isCompactMeds ? '8.5px' : '11px'; $localLineHeight = $isCompactMeds ? '1.2' : '1.4'; $medicalHtml .= ''; $medicalHtml .= '' . '' . '' . '' . ''; $whenCodeDescMap = [ 'BF' => 'BF (BEFORE FOOD)', 'BEFORE FOOD' => 'BF (BEFORE FOOD)', 'AF' => 'AF (AFTER FOOD)', 'AFTER FOOD' => 'AF (AFTER FOOD)', 'WF' => 'WF (WITH FOOD)', 'WITH FOOD' => 'WF (WITH FOOD)', 'ES' => 'ES (EMPTY STOMACH)', 'EMPTY STOMACH' => 'ES (EMPTY STOMACH)', 'MORNING EMPTY STOMACH' => 'ES (EMPTY STOMACH)', 'BBF' => 'BBF (BEFORE BREAKFAST)', 'BB' => 'BB (BEFORE BREAKFAST)', 'BEFORE BREAKFAST' => 'BBF (BEFORE BREAKFAST)', 'ABF' => 'ABF (AFTER BREAKFAST)', 'AB' => 'AB (AFTER BREAKFAST)', 'AFTER BREAKFAST' => 'ABF (AFTER BREAKFAST)', 'BL' => 'BL (BEFORE LUNCH)', 'BEFORE LUNCH' => 'BL (BEFORE LUNCH)', 'AL' => 'AL (AFTER LUNCH)', 'AFTER LUNCH' => 'AL (AFTER LUNCH)', 'BD' => 'BD (BEFORE DINNER)', 'BEFORE DINNER' => 'BD (BEFORE DINNER)', 'AD' => 'AD (AFTER DINNER)', 'AFTER DINNER' => 'AD (AFTER DINNER)', 'BT' => 'BT (BED TIME)', 'BED TIME' => 'BT (BED TIME)', 'WT' => 'WT (WITH TEA)', 'WITH TEA' => 'WT (WITH TEA)', 'BBATH'=> 'BEFORE BATH', 'ABATH'=> 'AFTER BATH', ]; $whenHindiMap = [ 'BF' => 'भोजन से पहले', 'BEFORE FOOD' => 'भोजन से पहले', 'AF' => 'भोजन के बाद', 'AFTER FOOD' => 'भोजन के बाद', 'WF' => 'भोजन के साथ', 'WITH FOOD' => 'भोजन के साथ', 'ES' => 'सुबह खाली पेट', 'EMPTY STOMACH' => 'सुबह खाली पेट', 'MORNING EMPTY STOMACH' => 'सुबह खाली पेट', 'BBF' => 'नाश्ते से पहले', 'BB' => 'नाश्ते से पहले', 'BEFORE BREAKFAST' => 'नाश्ते से पहले', 'ABF' => 'नाश्ते के बाद', 'AB' => 'नाश्ते के बाद', 'AFTER BREAKFAST' => 'नाश्ते के बाद', 'BL' => 'दोपहर के भोजन से पहले', 'BEFORE LUNCH' => 'दोपहर के भोजन से पहले', 'AL' => 'दोपहर के भोजन के बाद', 'AFTER LUNCH' => 'दोपहर के भोजन के बाद', 'BD' => 'रात के भोजन से पहले', 'BEFORE DINNER' => 'रात के भोजन से पहले', 'AD' => 'रात के भोजन के बाद', 'AFTER DINNER' => 'रात के भोजन के बाद', 'BT' => 'रात को सोते समय', 'BED TIME' => 'रात को सोते समय', 'WT' => 'चाय के साथ', 'WITH TEA' => 'चाय के साथ', 'BBATH'=> 'स्नान से पहले', 'ABATH'=> 'स्नान के बाद', ]; $freqHindiMap = [ 'OD' => 'दिन में एक बार (OD)', 'ONCE A DAY' => 'दिन में एक बार', 'ONCE DAILY' => 'दिन में एक बार', 'BD' => 'दिन में दो बार (BD)', 'BID' => 'दिन में दो बार (BID)', 'TWICE A DAY' => 'दिन में दो बार', 'TDS' => 'दिन में तीन बार (TDS)', 'TID' => 'दिन में तीन बार (TID)', 'THRICE A DAY' => 'दिन में तीन बार', 'QID' => 'दिन में चार बार (QID)', 'HS' => 'रात को सोते समय (HS)', 'SOS' => 'ज़रूरत पड़ने पर (SOS)', 'STAT' => 'तुरंत एक बार (STAT)', 'ALTERNATE DAY' => 'एक दिन छोड़कर', 'DAILY' => 'प्रतिदिन', 'WEEKLY' => 'हफ़्ते में एक बार', 'MONTHLY' => 'महीने में एक बार', ]; $remarkHindiMap = [ 'TAKE WITH MILK' => 'दूध के साथ लें', 'TAKE WITH WARM WATER' => 'गुनगुने पानी के साथ लें', 'AVOID SOUR FOOD AND DAIRY PRODUCTS' => 'खट्टा और डेयरी उत्पाद न लें', 'TAKE AFTER MEALS' => 'भोजन के बाद लें', 'TAKE ON AN EMPTY STOMACH EARLY MORNING' => 'सुबह खाली पेट लें', 'CHEW WELL BEFORE SWALLOWING' => 'चबाकर खाएं', 'DISSOLVE IN HALF GLASS OF WATER' => 'आधे गिलास पानी में घोलकर लें', 'APPLY LOCALLY TWICE DAILY' => 'दिन में दो बार लगाएं', 'STORE IN REFRIGERATOR (2-8°C)' => 'फ्रिज में रखें (2-8°C)', 'DO NOT CRUSH OR CHEW TABLET' => 'गोली को तोड़े या चबाएं नहीं', 'AVOID ALCOHOL WHILE TAKING THIS MEDICINE' => 'शराब का सेवन न करें', 'COMPLETE FULL COURSE OF ANTIBIOTICS' => 'एंटीबायोटिक का पूरा कोर्स लें', 'DRINK PLENTY OF FLUIDS / WATER' => 'प्रचुर मात्रा में पानी पिएं', ]; $formulationPrefixMap = [ 'tablet' => 'TAB', 'tab' => 'TAB', 'capsule' => 'CAP', 'cap' => 'CAP', 'syrup' => 'SYP', 'syp' => 'SYP', 'syr' => 'SYP', 'injection' => 'INJ', 'inj' => 'INJ', 'drop' => 'DROP', 'drops' => 'DROPS', 'eye drop' => 'EYE DROP', 'ear drop' => 'EAR DROP', 'cream' => 'CREAM', 'ointment' => 'OINT', 'gel' => 'GEL', 'lotion' => 'LOTION', 'powder' => 'POWDER', 'sachet' => 'SACHET', 'respules' => 'RESPULES', 'suppository' => 'SUPPOSITORY', 'patch' => 'PATCH', 'spray' => 'SPRAY', ]; $i = 0; foreach ($rxMeds as $med) { $i++; $rawName = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? '')); $formulationRaw = trim((string) ($med['med_type'] ?? $med['formulation'] ?? $med['dosage_form'] ?? $med['form'] ?? $med['medicine_form'] ?? '')); $formKey = strtolower($formulationRaw); $formType = $formulationPrefixMap[$formKey] ?? strtoupper($formulationRaw); if ($formType === '') { if (preg_match('/^(TAB|CAP|SYP|INJ|CREAM|OINT|GEL|DROPS|SPRAY)\b/i', $rawName, $m)) { $formType = strtoupper($m[1]); } else { $formType = 'TAB'; } } $generic = trim((string) ($med['genericname'] ?? $med['generic_name'] ?? '')); $dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? '')); $freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? '')); $when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? '')); $where = trim((string) ($med['dosage_where_label'] ?? $med['dosage_where'] ?? '')); $days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? '')); $remark = trim((string) ($med['remark'] ?? '')); // 1) Medicine Column: Prepend Type e.g. "CAP ACILOC" or "TAB A TO Z" $fullMedName = $rawName; if ($formType !== '' && preg_match('/^' . preg_quote($formType, '/') . '\b/i', $fullMedName) !== 1) { $fullMedName = $formType . ' ' . $fullMedName; } $nameHtml = '' . esc($fullMedName) . ''; if ($generic !== '') { // Show only generic data directly below medicine name (no "Salt/Generic:" heading label) $nameHtml .= '
' . esc($generic) . '
'; } // 2) English Directions & Local Language (Hindi) Directions // PATTERN ORDER: Frequency | Relation to Food (When) | Dose / Strength | Route (if non-oral) | Duration | Medicine Advice / Remarks $dirParts = []; $dirLocalParts = []; // --- SAFEGUARD: Clean up $when and $freq --- // Detect if $when contains frequency or schedule values from legacy/misconfigured master data (e.g. 'ONCE A DAY') $upperWhen = strtoupper($when); $isFreqPatternInWhen = in_array($upperWhen, [ 'ONCE A DAY', 'ONCE A WEEK', 'ONCE A FIFTEN DAY', 'ONLY NOW', 'DAILY', 'EVERYDAY', 'EVERY DAY', 'ALTERNATE DAY', 'TWICE A DAY', 'THREE TIMES A DAY', 'OD', 'BD', 'TDS', 'TID', 'QID', 'HS', 'SOS', 'STAT', ], true) || preg_match('/^(OD|BD|TDS|TID|QID|HS|SOS|STAT)\b/i', $upperWhen); if ($isFreqPatternInWhen) { if ($freq === '') { $freq = $when; } $when = ''; $upperWhen = ''; } elseif (is_numeric($when)) { $whenId = (int) $when; if ($whenId === 11) { if ($freq === '') { $freq = 'OD'; } $when = ''; } elseif ($whenId === 1) { $when = 'BF'; } elseif ($whenId === 2) { $when = 'AF'; } elseif ($whenId === 9) { $when = 'ES'; } elseif ($whenId === 12) { $when = 'BT'; } else { $when = ''; } $upperWhen = strtoupper($when); } // 1. FREQUENCY (e.g. BD, OD, TDS) if ($freq !== '') { $upperFreq = strtoupper($freq); $dirParts[] = $freq; if (isset($freqHindiMap[$upperFreq])) { $dirLocalParts[] = $freqHindiMap[$upperFreq]; } elseif (isset($doseFreqHindiMap[strtolower($freq)])) { $dirLocalParts[] = $doseFreqHindiMap[strtolower($freq)]; } elseif (isset($doseShedHindiMap[strtolower($freq)])) { $dirLocalParts[] = $doseShedHindiMap[strtolower($freq)]; } } // 2. RELATION TO FOOD / TIMING (When) (e.g. AF (AFTER FOOD), BF (BEFORE FOOD), ES (EMPTY STOMACH)) if ($when !== '') { if (isset($whenCodeDescMap[$upperWhen])) { $dirParts[] = $whenCodeDescMap[$upperWhen]; } else { $dirParts[] = $when; } if (isset($whenHindiMap[$upperWhen])) { $dirLocalParts[] = $whenHindiMap[$upperWhen]; } elseif (isset($doseWhenHindiMap[strtolower($when)])) { $dirLocalParts[] = $doseWhenHindiMap[strtolower($when)]; } } // 3. DOSE / STRENGTH (e.g. 1 Tab, 500mg, 5ml) if ($dose !== '') { $dirParts[] = $dose; if (isset($doseShedHindiMap[strtolower($dose)])) { $dirLocalParts[] = $doseShedHindiMap[strtolower($dose)]; } } // 4. ROUTE OF ADMINISTRATION (printed only if not default Oral) if ($where !== '' && strcasecmp($where, 'Oral') !== 0 && strcasecmp($where, 'Oral (PO)') !== 0) { $dirParts[] = $where; } // 5. DURATION (e.g. 5 Days, 7 Days, 1 Month) if ($days !== '') { $daysText = (is_numeric($days) ? $days . ' Days' : $days); $dirParts[] = $daysText; $daysNum = is_numeric($days) ? (int)$days : 0; if ($daysNum > 0) { $dirLocalParts[] = $daysNum . ' दिन'; } elseif (preg_match('/^(\d+)\s*day/i', $days, $dm)) { $dirLocalParts[] = $dm[1] . ' दिन'; } elseif (stripos($days, 'week') !== false) { $dirLocalParts[] = (preg_match('/^(\d+)/', $days, $wm) ? $wm[1] . ' हफ़्ता' : '1 हफ़्ता'); } elseif (stripos($days, 'month') !== false) { $dirLocalParts[] = (preg_match('/^(\d+)/', $days, $mm) ? $mm[1] . ' महीना' : '1 महीना'); } } // 6. MEDICINE ADVICE / REMARKS (e.g. 1/2 GLASS WATER, Take with warm water) $cleanRemark = trim((string) preg_replace('/^(edit\s*remove|remove\s*edit|edit|remove|delete)\s*$/i', '', $remark)); if ($cleanRemark !== '') { $upperRemark = strtoupper($cleanRemark); $dirParts[] = $upperRemark; if (isset($remarkHindiMap[$upperRemark])) { $dirLocalParts[] = $remarkHindiMap[$upperRemark]; } } $directionsText = !empty($dirParts) ? implode(' | ', $dirParts) : '-'; $localDirectionsText = !empty($dirLocalParts) ? implode(' | ', array_values(array_unique($dirLocalParts))) : ''; $medicalHtml .= '' . '' . '' . '' . ''; } $medicalHtml .= '
#MedicineDirections
' . $i . '' . $nameHtml . '' . '
' . esc($directionsText) . '
' . ($localDirectionsText !== '' ? ('
' . esc($localDirectionsText) . '
') : '') . '
'; } $rxRead = static function (array $row, array $keys): string { foreach ($keys as $key) { if (array_key_exists($key, $row)) { $value = trim((string) ($row[$key] ?? '')); if ($value !== '') { return $value; } } } return ''; }; $rxInvestigation = $rxRead($rx, ['investigation', 'Investigations', 'investigations', 'Investigation']); // Build test list from table (comma-separated) $rxInvestigationTestList = ''; $testNames = []; // Get tests from rx_investigations table if (!empty($data['rx_investigations']) && is_array($data['rx_investigations'])) { foreach ($data['rx_investigations'] as $inv) { $txt = trim((string) ($inv['investigation_name'] ?? $inv['investigation'] ?? '')); if ($txt !== '') { $testNames[] = $txt; } } } // Build comma-separated test list if (!empty($testNames)) { $rxInvestigationTestList = implode(', ', $testNames); } // Combine textarea content and test list into {{investigation}} placeholder $combinedInvestigation = []; if ($rxInvestigation !== '' && $rxInvestigation !== 'null') { $combinedInvestigation[] = $rxInvestigation; } if ($rxInvestigationTestList !== '') { $combinedInvestigation[] = $rxInvestigationTestList; } // Join with line break if both exist, otherwise use whichever is available if (count($combinedInvestigation) > 1) { $rxInvestigation = implode("\n", $combinedInvestigation); } elseif (!empty($combinedInvestigation)) { $rxInvestigation = $combinedInvestigation[0]; } $rxAdvice = $rxRead($rx, ['advice', 'Advice', 'prescription_advice', 'advice_notes', 'advice_note']); $rxAdviceLocal = ''; if (!empty($data['rx_advices']) && is_array($data['rx_advices'])) { $parts = []; $partsLocal = []; $partsPreferred = []; foreach ($data['rx_advices'] as $adv) { $txt = trim((string) ($adv['advice_txt'] ?? $adv['advice'] ?? '')); if ($txt !== '') { $parts[] = $txt; } $localRaw = (string) ($adv['advice_hindi'] ?? ($adv['advice_txt_hindi'] ?? '')); $localTxt = trim((string) preg_replace('/\r\n|\r|\n/', "\n", str_replace(['
', '
', '
'], "\n", strip_tags($localRaw)))); if ($localTxt !== '') { $partsLocal[] = $localTxt; } $preferredTxt = $localTxt !== '' ? $localTxt : $txt; if ($preferredTxt !== '') { $partsPreferred[] = $preferredTxt; } } // Build final printable advice as: typed advice + selected advice // (selected uses Hindi when available, else English), with de-duplication. $typedAdviceLines = []; $typedRaw = trim((string) $rxAdvice); if ($typedRaw !== '') { $typedRaw = str_replace(["\r\n", "\r"], "\n", $typedRaw); $typedAdviceLines = array_values(array_filter(array_map( static fn(string $v): string => trim($v), explode("\n", $typedRaw) ), static fn(string $v): bool => $v !== '')); } $selectedPreferred = !empty($partsPreferred) ? array_values(array_unique($partsPreferred)) : array_values(array_unique($parts)); $seen = []; $combined = []; foreach ($typedAdviceLines as $line) { $key = mb_strtolower(trim($line)); if ($key === '' || isset($seen[$key])) { continue; } $seen[$key] = true; $combined[] = $line; } foreach ($selectedPreferred as $line) { $line = trim((string) $line); $key = mb_strtolower($line); if ($key === '' || isset($seen[$key])) { continue; } $seen[$key] = true; $combined[] = $line; } if (!empty($combined)) { $rxAdvice = implode("\n", $combined); } if (!empty($partsLocal)) { $rxAdviceLocal = implode(' | ', array_values(array_unique($partsLocal))); } } $complaintText = $rxRead($rx, ['complaints', 'Complaint', 'complaint', 'chief_complaint', 'chief_complaints']); $complaintOnset = $rxRead($rx, ['complaint_onset']); $complaintDurationDays = $rxRead($rx, ['complaint_duration_days']); $complaintSeverity = $rxRead($rx, ['complaint_severity']); $complaintSnomedJson = $rxRead($rx, ['complaint_snomed_json']); if ($complaintText === '') { $complaintText = trim((string) ($opd->complaints ?? ($opd->complaint ?? ''))); } $provisionalDiagnosisText = $rxRead($rx, ['Provisional_diagnosis', 'provisional_diagnosis', 'provisional_diagnosis_text']); $diagnosisText = $rxRead($rx, ['diagnosis', 'Diagnosis', 'final_diagnosis', 'dx']); $diagnosisJson = $rxRead($rx, ['diagnosis_json']); $diagnosisSnomedId = $rxRead($rx, ['diagnosis_snomed_id']); $diagnosisSnomedTerm = $rxRead($rx, ['diagnosis_snomed_term']); $diagnosisSnomedSource = $rxRead($rx, ['diagnosis_snomed_source']); $provisionalDiagnosisSnomedId = $rxRead($rx, ['provisional_diagnosis_snomed_id']); $provisionalDiagnosisSnomedTerm = $rxRead($rx, ['provisional_diagnosis_snomed_term']); $provisionalDiagnosisSnomedSource = $rxRead($rx, ['provisional_diagnosis_snomed_source']); if ($diagnosisText === '') { $diagnosisText = implode(', ', is_array($data['selected_morbidities'] ?? null) ? ($data['selected_morbidities'] ?? []) : []); } $findingExaminationsText = $rxRead($rx, ['Finding_Examinations', 'finding_examinations', 'finding', 'findings', 'examination']); $prescriberRemarksText = $rxRead($rx, ['Prescriber_Remarks', 'prescriber_remarks', 'remarks', 'remark', 'doctor_note']); $prescriberRemarksText = $this->sanitizePrescriberRemarksForPrint($prescriberRemarksText); $nextVisitText = $rxRead($rx, ['next_visit', 'next_visit_date', 'follow_up', 'followup', 'review_after']); $complaintLocal = $this->translateToLocalPatientText($complaintText); $diagnosisLocal = $this->translateToLocalPatientText($diagnosisText); $investigationLocal = $this->translateToLocalPatientText($rxInvestigation); if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) { $complaintLocal = ''; } if (strtolower(trim($diagnosisLocal)) === strtolower(trim($diagnosisText))) { $diagnosisLocal = ''; } if (strtolower(trim($investigationLocal)) === strtolower(trim($rxInvestigation))) { $investigationLocal = ''; } if (strtolower(trim($rxAdviceLocal)) === strtolower(trim($rxAdvice))) { $rxAdviceLocal = ''; } $bp = trim((string) ($rx['bp'] ?? '')); $diastolic = trim((string) ($rx['diastolic'] ?? '')); $pulse = trim((string) ($rx['pulse'] ?? '')); $temp = trim((string) ($rx['temp'] ?? '')); $spo2 = trim((string) ($rx['spo2'] ?? '')); $rrMin = trim((string) ($rx['rr_min'] ?? '')); $height = trim((string) ($rx['height'] ?? '')); $weight = trim((string) ($rx['weight'] ?? '')); $waist = trim((string) ($rx['waist'] ?? '')); $pallor = trim((string) ($rx['pallor'] ?? '')); $icterus = trim((string) ($rx['icterus'] ?? $rx['Icterus'] ?? '')); $cyanosis = trim((string) ($rx['cyanosis'] ?? '')); $clubbing = trim((string) ($rx['clubbing'] ?? '')); $edema = trim((string) ($rx['edema'] ?? '')); $vitals = []; if ($bp !== '') { $vitals[] = 'BP: ' . $bp . ($diastolic !== '' ? ('/' . $diastolic) : ''); } if ($pulse !== '') { $vitals[] = 'Pulse: ' . $pulse; } if ($temp !== '') { $vitals[] = 'Temp: ' . $temp; } if ($spo2 !== '') { $vitals[] = 'SpO2: ' . $spo2; } if ($rrMin !== '') { $vitals[] = 'RR: ' . $rrMin . '/min'; } if ($height !== '') { $vitals[] = 'Ht: ' . $height; } if ($weight !== '') { $vitals[] = 'Wt: ' . $weight; } if ($waist !== '') { $vitals[] = 'Waist: ' . $waist; } // ── Pain scale ────────────────────────────────────────────────────── $painValue = trim((string) ($rx['pain_value'] ?? '')); $painLabels = ['0' => 'No Pain', '1' => 'Mild Pain', '2' => 'Moderate', '3' => 'Intense', '4' => 'Worst Pain Possible']; $painLabel = $painValue !== '' ? ($painLabels[$painValue] ?? '') : ''; // ── Complication flags ─────────────────────────────────────────────── $complicationFlags = []; if (!empty($rx['pregnancy'])) { $complicationFlags[] = 'Pregnancy'; } if (!empty($rx['lactation'])) { $complicationFlags[] = 'Lactation'; } if (!empty($rx['liver_insufficiency'])) { $complicationFlags[] = 'Liver Insufficiency'; } if (!empty($rx['renal_insufficiency'])) { $complicationFlags[] = 'Renal Insufficiency'; } if (!empty($rx['pulmonary_insufficiency'])) { $complicationFlags[] = 'Pulmonary Insufficiency'; } if (!empty($rx['corona_suspected'])) { $complicationFlags[] = 'Corona Suspected'; } if (!empty($rx['dengue'])) { $complicationFlags[] = 'Dengue'; } $complicationText = implode(', ', $complicationFlags); // ── Addiction flags (from patient_master or rx) ────────────────────── $pRowCopy = $data['patient_master'][0] ?? null; $addictionFlags = []; if (!empty($rx['is_smoking'] ?? $pRowCopy->is_smoking ?? 0)) { $addictionFlags[] = 'Smoking'; } if (!empty($rx['is_alcohol'] ?? $pRowCopy->is_alcohol ?? 0)) { $addictionFlags[] = 'Alcohol'; } if (!empty($rx['is_drug_abuse'] ?? $pRowCopy->is_drug_abuse ?? 0)) { $addictionFlags[] = 'Drug Abuse'; } $addictionText = implode(', ', $addictionFlags); // ── Drug allergy / NABH ────────────────────────────────────────────── $drugAllergyStatus = trim((string) ($rx['drug_allergy_status'] ?? '')); $drugAllergyDetails = trim((string) ($rx['drug_allergy_details'] ?? '')); $adrHistory = trim((string) ($rx['adr_history'] ?? '')); $currentMedications = trim((string) ($rx['current_medications'] ?? '')); // ── Women related ──────────────────────────────────────────────────── $womenLmp = trim((string) ($rx['women_lmp'] ?? '')); $womenLastBaby = trim((string) ($rx['women_last_baby'] ?? '')); $womenPregnancyRelated = trim((string) ($rx['women_pregnancy_related'] ?? '')); $womenRelatedProblems = trim((string) ($rx['women_related_problems'] ?? '')); // ── Women specific patient history ────────────────────────────────── $obstetricHistory = trim((string) ($rx['obstetric_history'] ?? '')); $menstrualHistory = trim((string) ($rx['menstrual_history'] ?? '')); $medicalSurgicalHistory = trim((string) ($rx['medical_surgical_history'] ?? '')); $familyHistory = trim((string) ($rx['family_history'] ?? '')); $allergicHistory = trim((string) ($rx['allergic_history'] ?? '')); $vaccinationHistory = trim((string) ($rx['vaccination_history'] ?? '')); $patientHistoryParts = []; if ($obstetricHistory !== '') { $patientHistoryParts[] = 'Obstetric: ' . $obstetricHistory; } if ($menstrualHistory !== '') { $patientHistoryParts[] = 'Menstrual: ' . $menstrualHistory; } if ($medicalSurgicalHistory !== '') { $patientHistoryParts[] = 'Medical/Surgical: ' . $medicalSurgicalHistory; } if ($familyHistory !== '') { $patientHistoryParts[] = 'Family: ' . $familyHistory; } if ($allergicHistory !== '') { $patientHistoryParts[] = 'Allergic: ' . $allergicHistory; } if ($vaccinationHistory !== '') { $patientHistoryParts[] = 'Vaccination: ' . $vaccinationHistory; } $patientHistoryBlock = implode(' | ', $patientHistoryParts); $printContent = '' . '' . '' . '' . '' . '' . '
' . 'Name : ' . esc($pName) . '
' . esc($pRelative) . '
' . 'Gender/Age : ' . esc($ageSex) . '
' . 'Mob :' . esc($phone) . '
' . 'Address :' . esc($pAddress) . '
' . 'UHID : ' . esc($uhidNo) . '
' . 'Sr No.: ' . esc($opdSrNo) . '
' . 'OPD No.: ' . esc($opdNo) . '
' . '
Date: ' . esc($opdDate) . '
' . $expDateText . '
' . 'DEPARTMENT :
' . esc($specName) . '
' . esc($opdFeeDisplay) . '
' . 'No. of Visit : ' . esc($totalNoVisit) . '
' . esc($lastVisitText) . '
' . 'Book Time : ' . esc($bookTime) . '
'; return [ 'opd_master' => $data['opd_master'] ?? [], 'patient_master' => $data['patient_master'] ?? [], 'doctor_master' => $data['doctor_master'] ?? [], 'old_opd' => $data['old_opd'] ?? [], 'insurance' => $data['insurance'] ?? [], 'case_master' => $data['case_master'] ?? [], 'content' => $printContent, 'content_2' => $printContent, 'content_3' => $printContent, 'content_4' => $printContent, 'pName' => $pName, 'pRelative' => $pRelative, 'refer_by' => $referBy, 'age_sex' => $ageSex, 'phoneno' => $phone, 'p_address' => $pAddress, 'uhid_no' => $uhidNo, 'opd_sr_no' => $opdSrNo, 'opd_no' => $opdNo, 'opd_date' => $opdDate, 'exp_date' => $expDateText, 'SpecName' => $specName, 'short_description' => $doctorShortDescription, 'doctor_short_description' => $doctorShortDescription, 'opd_fee_amount' => $opdFeeAmount, 'opd_fee_desc' => $opdFeeDescription, 'total_no_visit' => $totalNoVisit, 'last_opdvisit_date' => $lastVisitDate, 'str_opd_book_date' => $bookTime, 'Complaint' => $complaintText, 'complaint' => $complaintText, 'complaints' => $complaintText, 'complaint_onset' => $complaintOnset, 'complaint_duration_days' => $complaintDurationDays, 'complaint_severity' => $complaintSeverity, 'complaint_snomed_json' => $complaintSnomedJson, 'Complaint_local' => $complaintLocal, 'diagnosis' => $diagnosisText, 'Diagnosis' => $diagnosisText, 'diagnosis_json' => $diagnosisJson, 'diagnosis_snomed_id' => $diagnosisSnomedId, 'diagnosis_snomed_term' => $diagnosisSnomedTerm, 'diagnosis_snomed_source' => $diagnosisSnomedSource, 'diagnosis_local' => $diagnosisLocal, 'Provisional_diagnosis' => $provisionalDiagnosisText, 'provisional_diagnosis' => $provisionalDiagnosisText, 'provisional_diagnosis_snomed_id' => $provisionalDiagnosisSnomedId, 'provisional_diagnosis_snomed_term' => $provisionalDiagnosisSnomedTerm, 'provisional_diagnosis_snomed_source' => $provisionalDiagnosisSnomedSource, 'investigation' => $rxInvestigation, 'investigation_local' => $investigationLocal, 'Tadvise_test_list' => $rxInvestigationTestList, 'medical' => $medicalHtml, 'doctor' => '

Dr. ' . esc((string) ($opd->doc_name ?? '')) . '

', 'top_content' => '', 'vital_content' => implode(' | ', $vitals), 'vital_content_extra' => implode(' | ', array_filter([ $pallor !== '' ? 'Pallor: ' . $pallor : '', $icterus !== '' ? 'Icterus: ' . $icterus : '', $cyanosis !== '' ? 'Cyanosis: ' . $cyanosis : '', $clubbing !== '' ? 'Clubbing: ' . $clubbing : '', $edema !== '' ? 'Edema: ' . $edema : '', ], static fn ($v): bool => $v !== '')), 'height' => $height, 'weight' => $weight, 'waist' => $waist, 'rr_min' => $rrMin, 'pallor' => $pallor, 'icterus' => $icterus, 'cyanosis' => $cyanosis, 'clubbing' => $clubbing, 'edema' => $edema, 'bp' => $bp, 'bp_diastolic' => $diastolic, 'pulse' => $pulse, 'temp' => $temp, 'spo2' => $spo2, 'pain_value' => $painValue, 'pain_label' => $painLabel, 'Complication' => $complicationText, 'complication' => $complicationText, 'Addiction' => $addictionText, 'addiction' => $addictionText, 'drug_allergy_status' => $drugAllergyStatus, 'drug_allergy_details' => $drugAllergyDetails, 'adr_history' => $adrHistory, 'current_medications' => $currentMedications, 'women_lmp' => $womenLmp, 'women_last_baby' => $womenLastBaby, 'women_pregnancy_related' => $womenPregnancyRelated, 'women_related_problems' => $womenRelatedProblems, 'obstetric_history' => $obstetricHistory, 'menstrual_history' => $menstrualHistory, 'medical_surgical_history' => $medicalSurgicalHistory, 'family_history' => $familyHistory, 'allergic_history' => $allergicHistory, 'vaccination_history' => $vaccinationHistory, 'patient_history_block' => $patientHistoryBlock, 'Finding_Examinations' => $findingExaminationsText, 'finding_examinations' => $findingExaminationsText, 'Prescriber_Remarks' => $prescriberRemarksText, 'advice' => $rxAdvice, 'advice_local' => $rxAdviceLocal, 'next_visit' => $nextVisitText, 'refer_to' => (string) ($rx['refer_to'] ?? ''), 'painscale' => $painLabel, 'painscale_img' => '', 'morbidities' => implode(', ', is_array($data['selected_morbidities'] ?? null) ? ($data['selected_morbidities'] ?? []) : []), ]; } private function resolveTotalOpdVisitCountForPrint(?object $opdRow): string { if ($opdRow === null) { return ''; } $patientId = (int) ($opdRow->p_id ?? 0); if ($patientId <= 0 || ! $this->db->tableExists('opd_master')) { return (string) ($opdRow->no_visit ?? ''); } $builder = $this->db->table('opd_master') ->select('count(*) as total_visit', false) ->where('p_id', $patientId); $currentOpdId = (int) ($opdRow->opd_id ?? 0); $currentOpdDateTime = trim((string) ($opdRow->apointment_date ?? '')); $currentOpdDate = ''; if ($currentOpdDateTime !== '') { $ts = strtotime($currentOpdDateTime); if ($ts !== false) { $currentOpdDate = date('Y-m-d', $ts); } } if ($currentOpdDate !== '') { // Count visits up to the current OPD date; for same date, limit to current row id. $builder->groupStart() ->where('date(apointment_date) <', $currentOpdDate) ->orGroupStart() ->where('date(apointment_date)', $currentOpdDate); if ($currentOpdId > 0) { $builder->where('opd_id <=', $currentOpdId); } $builder->groupEnd() ->groupEnd(); } elseif ($currentOpdId > 0) { // Fallback for rows without appointment date. $builder->where('opd_id <=', $currentOpdId); } $row = $builder->get(1)->getRow(); $count = (int) ($row->total_visit ?? 0); if ($count <= 0) { return (string) ($opdRow->no_visit ?? ''); } return (string) $count; } private function translateToLocalPatientText(string $input): string { $text = trim($input); if ($text === '') { return ''; } $map = [ 'before food' => 'भोजन से पहले', 'after food' => 'भोजन के बाद', 'after breakfast' => 'नाश्ते के बाद', 'before breakfast' => 'नाश्ते से पहले', 'after lunch' => 'दोपहर के भोजन के बाद', 'before lunch' => 'दोपहर के भोजन से पहले', 'after dinner' => 'रात के भोजन के बाद', 'before dinner' => 'रात के भोजन से पहले', 'morning' => 'सुबह', 'afternoon' => 'दोपहर', 'evening' => 'शाम', 'night' => 'रात', // Dose schedules (longer phrases first for uksort) 'one time a day' => 'दिन में एक बार', 'two times a day' => 'दिन में दो बार', 'three times a day' => 'दिन में तीन बार', 'four times a day' => 'दिन में चार बार', 'twice a day' => 'दिन में दो बार', 'thrice a day' => 'दिन में तीन बार', 'once daily' => 'दिन में एक बार', 'twice daily' => 'दिन में दो बार', 'thrice daily' => 'दिन में तीन बार', 'at bed time' => 'सोते समय', 'at sleep' => 'सोते समय', 'at bedtime' => 'सोते समय', 'with food' => 'भोजन के साथ', 'with water' => 'पानी के साथ', 'with milk' => 'दूध के साथ', 'empty stomach' => 'खाली पेट', 'before meal' => 'भोजन से पहले', 'after meal' => 'भोजन के बाद', 'as required' => 'जरूरत पड़ने पर', 'as needed' => 'जरूरत पड़ने पर', 'when required' => 'जरूरत पड़ने पर', 'every 4 hours' => 'हर 4 घंटे', 'every 6 hours' => 'हर 6 घंटे', 'every 8 hours' => 'हर 8 घंटे', 'every 12 hours' => 'हर 12 घंटे', 'daily' => 'रोज', 'weekly' => 'साप्ताहिक', 'monthly' => 'मासिक', 'alternate day' => 'एक दिन छोड़कर', 'for' => 'के लिए', 'days' => 'दिन', 'day' => 'दिन', 'tab' => 'टैबलेट', 'cap' => 'कैप्सूल', 'syp' => 'सिरप', 'inj' => 'इंजेक्शन', 'apply' => 'लगाएं', 'continue' => 'जारी रखें', 'rest' => 'आराम करें', 'hydrate' => 'पर्याप्त पानी लें', 'avoid oily food' => 'तैलीय भोजन से बचें', 'avoid spicy food' => 'मसालेदार भोजन से बचें', 'follow up' => 'फॉलो-अप करें', 'review' => 'पुनः जांच', 'advice' => 'सलाह', 'investigation' => 'जांच', 'diagnosis' => 'निदान', 'fever' => 'बुखार', 'cough' => 'खांसी', 'cold' => 'जुकाम', 'headache' => 'सिर दर्द', 'abdominal pain' => 'पेट दर्द', ]; uksort($map, static function ($a, $b) { return strlen((string) $b) <=> strlen((string) $a); }); $translated = $text; foreach ($map as $en => $local) { $translated = preg_replace('/\b' . preg_quote($en, '/') . '\b/i', $local, $translated) ?? $translated; } $translated = preg_replace('/\s+/', ' ', trim($translated)) ?? trim($translated); return $translated; } /** * @param array $vars */ private function renderLegacyPhpTemplate(string $filePath, array $vars): string { ob_start(); extract($vars, EXTR_SKIP); include $filePath; return (string) ob_get_clean(); } /** * @param array $data */ private function renderComposedOpdPrint(array $data, string $templateKey): string { $vars = $this->buildLegacyDashboardTemplateVars($data); $hospitalName = defined('H_Name') ? (string) constant('H_Name') : 'Hospital'; $hospitalAddress1 = defined('H_address_1') ? (string) constant('H_address_1') : ''; $hospitalAddress2 = defined('H_address_2') ? (string) constant('H_address_2') : ''; $hospitalPhone = defined('H_phone_No') ? (string) constant('H_phone_No') : ''; $hospitalEmail = defined('H_Email') ? (string) constant('H_Email') : ''; $vars['hospital_name'] = esc($hospitalName); $vars['hospital_address'] = esc(trim($hospitalAddress1 . ', ' . $hospitalAddress2, ', ')); $vars['hospital_phone'] = esc($hospitalPhone); $vars['hospital_email'] = esc($hospitalEmail); $vars['print_time'] = esc(date('d-m-Y H:i:s')); $doctorName = trim((string) ($vars['doctor_name'] ?? '')); if ($doctorName === '') { $opd = $data['opd_master'][0] ?? null; $doctorName = (string) ($opd->doc_name ?? ''); } $vars['doctor_name'] = esc($doctorName); $themeKey = trim((string) substr($templateKey, strlen('compose_'))); if ($themeKey === '') { $themeKey = 'default'; } $hospitalPart = strtolower(trim((string) $this->request->getGet('hospital_part'))); if ($hospitalPart === '') { $hospitalPart = $themeKey; } $contentPart = strtolower(trim((string) $this->request->getGet('content_part'))); if ($contentPart === '') { $contentPart = $themeKey; } $patientView = strtolower(trim((string) $this->request->getGet('patient_view'))); if (!in_array($patientView, ['list', 'edit'], true)) { $patientView = 'list'; } $layoutHtml = $this->readComposedPartTemplate('layout', $themeKey); $hospitalHtml = $this->readComposedPartTemplate('hospital', $hospitalPart); $contentHtml = $this->readComposedPartTemplate('content', $contentPart); $patientHtml = $this->readComposedPartTemplate('patient', $patientView); $hospitalHtml = $this->renderCurlyTemplate($hospitalHtml, $vars); $patientHtml = $this->renderCurlyTemplate($patientHtml, $vars); $contentHtml = $this->renderCurlyTemplate($contentHtml, $vars); $vars['hospital_section'] = $hospitalHtml; $vars['patient_section'] = $patientHtml; $vars['content_section'] = $contentHtml; return $this->renderCurlyTemplate($layoutHtml, $vars); } private function readComposedPartTemplate(string $section, string $name): string { $safeSection = preg_replace('/[^a-z0-9_\-]/', '', strtolower(trim($section))) ?? ''; $safeName = preg_replace('/[^a-z0-9_\-]/', '', strtolower(trim($name))) ?? ''; if ($safeSection === '') { $safeSection = 'layout'; } if ($safeName === '') { $safeName = 'default'; } $baseDir = APPPATH . 'Views/billing/opd_parts/' . $safeSection . '/'; $target = $baseDir . $safeName . '.html'; $fallback = $baseDir . 'default.html'; if (is_file($target)) { return (string) file_get_contents($target); } if (is_file($fallback)) { return (string) file_get_contents($fallback); } return '{{content}}'; } /** * @param array $vars */ private function renderCurlyTemplate(string $html, array $vars): string { return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', static function (array $m) use ($vars) { $key = (string) ($m[1] ?? ''); $value = $vars[$key] ?? ''; if (is_array($value) || is_object($value)) { return ''; } return (string) $value; }, $html); } private function getOpdPaperSettingStorageKey(string $templateName = ''): string { $templateName = $this->normalizeOpdTemplateId($templateName); if ($templateName === '') { return self::OPD_PAPER_SETTING_KEY; } // Keep key short to avoid s_name length failures in legacy schemas. return self::OPD_PAPER_SETTING_KEY_PREFIX . substr(md5($templateName), 0, 20); } /** * @return array */ private function getOpdPaperSettingCandidateKeys(string $templateName = ''): array { $templateName = $this->normalizeOpdTemplateId($templateName); if ($templateName === '') { return [self::OPD_PAPER_SETTING_KEY]; } $newKey = $this->getOpdPaperSettingStorageKey($templateName); $legacyKey = self::OPD_PAPER_SETTING_KEY_PREFIX_LEGACY . $templateName; $keys = [$newKey, $legacyKey]; return array_values(array_unique($keys)); } private function readOpdPaperSettingPayloadByKey(string $storageKey): string { if (! $this->db->tableExists('hospital_setting')) { return ''; } $row = $this->db->table('hospital_setting') ->select('s_value') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); return trim((string) ($row['s_value'] ?? '')); } /** * @return array */ private function getOpdPaperPrintSetting(string $templateName = ''): array { $defaults = [ 'page_size' => 'A4', 'custom_width_mm' => 210, 'custom_height_mm' => 297, 'page_margin_top_cm' => 6.1, 'page_margin_bottom_cm' => 2.5, 'page_margin_left_cm' => 0.7, 'page_margin_right_cm' => 0.7, 'margin_header_cm' => 0.5, 'margin_footer_cm' => 1.5, 'paper_html_content' => '', 'header_html' => '', 'footer_html' => '', ]; $storageKey = $this->getOpdPaperSettingStorageKey($templateName); $raw = ''; $candidateKeys = $this->getOpdPaperSettingCandidateKeys($templateName); error_log('[OPD_PAPER_READ_DEBUG] Template: ' . $templateName . ' | CandidateKeys: ' . implode(', ', $candidateKeys)); foreach ($candidateKeys as $candidateKey) { $raw = $this->readOpdPaperSettingPayloadByKey($candidateKey); if ($raw !== '') { error_log('[OPD_PAPER_READ_DEBUG] Found data in key: ' . $candidateKey . ' | PayloadLen: ' . strlen($raw)); break; } } if ($raw === '' && $storageKey !== self::OPD_PAPER_SETTING_KEY) { // Backward compatibility with legacy global settings. $raw = $this->readOpdPaperSettingPayloadByKey(self::OPD_PAPER_SETTING_KEY); if ($raw !== '') { error_log('[OPD_PAPER_READ_DEBUG] Fell back to global key | PayloadLen: ' . strlen($raw)); } } if ($raw === '') { error_log('[OPD_PAPER_READ_DEBUG] No data found, using defaults'); return $defaults; } $decoded = json_decode($raw, true); if (! is_array($decoded)) { error_log('[OPD_PAPER_READ_DEBUG] JSON decode failed'); return $defaults; } return array_merge($defaults, $decoded); } /** * @param array $settings */ private function saveOpdPaperPrintSetting(array $settings, string $templateName = ''): bool { if (! $this->db->tableExists('hospital_setting')) { return false; } $payload = json_encode($settings, JSON_UNESCAPED_UNICODE); if ($payload === false) { return false; } $storageKey = $this->getOpdPaperSettingStorageKey($templateName); // Debug logging - to be removed after testing error_log('[OPD_PAPER_SAVE_DEBUG] Template: ' . $templateName . ' | StorageKey: ' . $storageKey . ' | PayloadLen: ' . strlen($payload)); $this->ensureHospitalSettingValueCanStoreLargePayload(); $existing = $this->db->table('hospital_setting') ->select('id') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); if (! empty($existing['id'])) { $ok = $this->db->table('hospital_setting') ->where('id', (int) $existing['id']) ->update(['s_value' => $payload]); if (! $ok) { error_log('[OPD_PAPER_SAVE_DEBUG] UPDATE failed for key: ' . $storageKey); return false; } $persisted = $this->isOpdPaperSettingPayloadPersisted($payload, $storageKey); error_log('[OPD_PAPER_SAVE_DEBUG] UPDATE done, persisted check: ' . ($persisted ? 'true' : 'false')); return $persisted; } $ok = $this->db->table('hospital_setting')->insert([ 's_name' => $storageKey, 's_value' => $payload, ]); if (! $ok) { error_log('[OPD_PAPER_SAVE_DEBUG] INSERT failed for key: ' . $storageKey); return false; } $persisted = $this->isOpdPaperSettingPayloadPersisted($payload, $storageKey); error_log('[OPD_PAPER_SAVE_DEBUG] INSERT done, persisted check: ' . ($persisted ? 'true' : 'false')); return $persisted; } private function ensureHospitalSettingValueCanStoreLargePayload(): void { try { $row = $this->db->query("SHOW COLUMNS FROM hospital_setting LIKE 's_value'")->getRowArray(); $type = strtolower(trim((string) ($row['Type'] ?? ''))); if ($type === '') { return; } // Legacy schema uses varchar(200), which truncates template HTML payloads. if (str_starts_with($type, 'varchar(')) { $this->db->query('ALTER TABLE hospital_setting MODIFY s_value LONGTEXT CHARACTER SET utf32 COLLATE utf32_unicode_ci DEFAULT NULL'); } } catch (\Throwable $e) { // Keep save flow non-fatal; caller validates persistence right after save. } } private function isOpdPaperSettingPayloadPersisted(string $payload, string $storageKey): bool { $row = $this->db->table('hospital_setting') ->select('s_value') ->where('s_name', $storageKey) ->get(1) ->getRowArray(); return ((string) ($row['s_value'] ?? '')) === $payload; } private function clearOpdPaperPrintSetting(string $templateName = ''): bool { if (! $this->db->tableExists('hospital_setting')) { return false; } $deleted = false; foreach ($this->getOpdPaperSettingCandidateKeys($templateName) as $storageKey) { $ok = (bool) $this->db->table('hospital_setting') ->where('s_name', $storageKey) ->delete(); $deleted = $deleted || $ok; } return $deleted; } private function normalizeOpdPrintMarginCm($value, float $default): float { if ($value === null || $value === '') { return $default; } $number = (float) $value; if (! is_finite($number)) { $number = $default; } $number = max(0.0, min(25.0, $number)); return round($number, 2); } /** * @param array $data * @return array */ private function buildOpdPdfTokenVars(array $data): array { $legacy = $this->buildLegacyDashboardTemplateVars($data); $tokens = []; foreach ($legacy as $key => $value) { if (is_scalar($value)) { $tokens[(string) $key] = (string) $value; } } // Keep OPD fee tokens explicit for PDF templates. $opdRow = $data['opd_master'][0] ?? null; $opdFeeAmount = trim((string) ($opdRow->opd_fee_amount ?? ($tokens['opd_fee_amount'] ?? ''))); $opdFeeDescription = trim((string) ($opdRow->opd_fee_desc ?? ($tokens['opd_fee_desc'] ?? ''))); $tokens['opd_fee_amount'] = $opdFeeAmount; $tokens['opd_fee_desc'] = $opdFeeDescription; $tokens['opd_fee_combined'] = trim($opdFeeAmount . ' ' . $opdFeeDescription); // Add case-insensitive token aliases for legacy template compatibility. foreach ($tokens as $key => $value) { $lower = strtolower((string) $key); if (! array_key_exists($lower, $tokens)) { $tokens[$lower] = $value; } $ucfirst = ucfirst($lower); if (! array_key_exists($ucfirst, $tokens)) { $tokens[$ucfirst] = $value; } } $rxMeds = is_array($data['rx_medicines'] ?? null) ? $data['rx_medicines'] : []; $isCompactMeds = count($rxMeds) >= 8; $headingStyleMode = (string) ($this->opdPlaceholderRenderOptions['heading_style'] ?? 'bold'); $lineGap = (float) ($this->opdPlaceholderRenderOptions['line_gap'] ?? ($isCompactMeds ? 1.35 : 1.6)); $blockGapMm = (float) ($this->opdPlaceholderRenderOptions['block_gap_mm'] ?? ($isCompactMeds ? 1.5 : 2.8)); $lineGapCss = number_format($lineGap, 2, '.', ''); $blockGapCss = number_format($blockGapMm, 2, '.', ''); // mPDF supports font-weight as bold|normal (not numeric weights like 700). $headingStyleCss = 'font-weight:bold;font-size:inherit;letter-spacing:0.04em;text-transform:uppercase;color:#1a1a1a;'; if ($headingStyleMode === 'plain') { $headingStyleCss = 'font-weight:normal;font-size:inherit;letter-spacing:0.04em;text-transform:uppercase;color:#1a1a1a;'; } elseif ($headingStyleMode === 'underline') { $headingStyleCss = 'font-weight:bold;font-size:inherit;letter-spacing:0.04em;text-transform:uppercase;color:#1a1a1a;text-decoration:underline;'; } elseif ($headingStyleMode === 'highlight') { $headingStyleCss = 'font-weight:bold;font-size:inherit;letter-spacing:0.04em;text-transform:uppercase;color:#1a1a1a;background-color:#fff3cd;border:0.3mm solid #f0d98a;border-radius:1mm;padding:0.2mm 1mm;display:inline-block;'; } // mPDF-safe vertical spacer. Using a fixed-height block in mm is more reliable // than margins inside absolute-positioned containers. $blockGapHtml = '
 
'; $formatBlock = function (string $label, string $value, bool $lineBreakAfterLabel = false) use ($blockGapHtml, $headingStyleCss, $lineGapCss): string { $value = trim($value); if ($value === '') { return ''; } // Heading: bold + uppercase, same font-size as surrounding content so // heading and value stay visually matched (no mismatch from hardcoded px). $headingHtml = '' . $label . ' :'; if ($lineBreakAfterLabel) { // List-style sections (Complaint, Diagnosis …): // heading on its own line, content block below with a small left indent. // explicit spacer block is appended for consistent separation in mPDF. return '
' . $headingHtml . '
' . $value . '
' . $blockGapHtml; } // Inline sections (Vitals, Investigation, Next Visit): // heading + value on the same line; explicit spacer appended after block. return '
' . $headingHtml . ' ' . $value . '
' . $blockGapHtml; }; $escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); $splitClinicalText = static function (string $value): array { $parts = preg_split('/[\r\n,;]+/', (string) $value) ?: []; $seen = []; $rows = []; foreach ($parts as $part) { $term = trim((string) $part); if ($term === '') { continue; } $key = strtoupper(preg_replace('/\s+/', ' ', $term) ?? $term); if (isset($seen[$key])) { continue; } $seen[$key] = true; $rows[] = ['term' => $term]; } return $rows; }; $parseClinicalJsonItems = static function (string $jsonRaw): array { $jsonRaw = trim($jsonRaw); if ($jsonRaw === '') { return []; } $decoded = json_decode($jsonRaw, true); if (! is_array($decoded)) { return []; } $items = []; $seen = []; foreach ($decoded as $row) { if (! is_array($row)) { continue; } $term = trim((string) ($row['term'] ?? $row['name'] ?? '')); if ($term === '') { continue; } $key = strtoupper(preg_replace('/\s+/', ' ', $term) ?? $term); if (isset($seen[$key])) { continue; } $seen[$key] = true; $items[] = [ 'term' => $term, 'frequency' => trim((string) ($row['frequency'] ?? '')), 'severity' => trim((string) ($row['severity'] ?? '')), 'duration' => trim((string) ($row['duration'] ?? '')), 'date' => trim((string) ($row['date'] ?? '')), 'concept_id' => trim((string) ($row['concept_id'] ?? '')), ]; } return $items; }; $renderClinicalList = static function (array $items, array $detailMap = []) use ($escape): string { if (empty($items)) { return ''; } $listHtml = '
    '; foreach ($items as $item) { $term = trim((string) ($item['term'] ?? '')); if ($term === '') { continue; } $details = []; foreach ($detailMap as $detailKey => $detailLabel) { $value = trim((string) ($item[$detailKey] ?? '')); if ($value !== '') { $details[] = $detailLabel . ': ' . $value; } } // Term in normal weight; detail metadata in muted italic at 90% size. $listHtml .= '
  • ' . $escape($term); if (! empty($details)) { $listHtml .= ' (' . $escape(implode(' | ', $details)) . ')'; } $listHtml .= '
  • '; } $listHtml .= '
'; return $listHtml === '
    ' ? '' : $listHtml; }; $tokens['vital_content_raw'] = (string) ($tokens['vital_content'] ?? ''); $tokens['Complaint_raw'] = (string) ($tokens['Complaint'] ?? ''); $tokens['diagnosis_raw'] = (string) ($tokens['diagnosis'] ?? ''); $tokens['Provisional_diagnosis_raw'] = (string) ($tokens['Provisional_diagnosis'] ?? ''); $tokens['Finding_Examinations_raw'] = (string) ($tokens['Finding_Examinations'] ?? ''); $tokens['investigation_raw'] = (string) ($tokens['investigation'] ?? ''); $tokens['Prescriber_Remarks_raw'] = (string) ($tokens['Prescriber_Remarks'] ?? ''); $tokens['advice_raw'] = (string) ($tokens['advice'] ?? ''); $tokens['next_visit_raw'] = (string) ($tokens['next_visit'] ?? ''); $complaintItems = $parseClinicalJsonItems((string) ($tokens['complaint_snomed_json'] ?? '')); if (empty($complaintItems)) { $complaintItems = $splitClinicalText((string) ($tokens['Complaint_raw'] ?? '')); } $diagnosisItems = $parseClinicalJsonItems((string) ($tokens['diagnosis_json'] ?? '')); if (empty($diagnosisItems)) { $diagnosisItems = $splitClinicalText((string) ($tokens['diagnosis_raw'] ?? '')); } $provisionalDiagnosisItems = $splitClinicalText((string) ($tokens['Provisional_diagnosis_raw'] ?? '')); $tokens['complaint_list'] = $renderClinicalList($complaintItems, [ 'frequency' => 'Freq', 'severity' => 'Severity', 'duration' => 'Duration', 'date' => 'Date', ]); $complaintTerms = []; foreach ($complaintItems as $item) { $term = trim((string) ($item['term'] ?? '')); if ($term !== '') { $complaintTerms[] = $term; } } $tokens['complaint_list_compact'] = implode(', ', array_values(array_unique($complaintTerms))); $tokens['diagnosis_list'] = $renderClinicalList($diagnosisItems, [ 'duration' => 'Duration', 'date' => 'Date', ]); $diagnosisTerms = []; foreach ($diagnosisItems as $item) { $term = trim((string) ($item['term'] ?? '')); if ($term !== '') { $diagnosisTerms[] = $term; } } $tokens['diagnosis_list_compact'] = implode(', ', array_values(array_unique($diagnosisTerms))); $tokens['provisional_diagnosis_list'] = $renderClinicalList($provisionalDiagnosisItems); $vitalCoreRaw = (string) ($tokens['vital_content_raw'] ?? ''); $tokens['vital_content'] = $vitalCoreRaw; $tokens['vital_content_block'] = $formatBlock('Vitals', $vitalCoreRaw, false); $complaintBlockValue = trim((string) ($tokens['complaint_list_compact'] ?? '')); if ($complaintBlockValue === '') { $complaintBlockValue = trim((string) ($tokens['Complaint_raw'] ?? '')); } $tokens['Complaint'] = $formatBlock('Complaint', $escape($complaintBlockValue), false); $tokens['complaint'] = $tokens['Complaint']; $diagnosisBlockValue = trim((string) ($tokens['diagnosis_list_compact'] ?? '')); if ($diagnosisBlockValue === '') { $diagnosisBlockValue = trim((string) ($tokens['diagnosis_raw'] ?? '')); } $tokens['diagnosis'] = $formatBlock('Diagnosis', $escape($diagnosisBlockValue), false); $tokens['Diagnosis'] = $tokens['diagnosis']; $renderMultilineText = static function (string $value) use ($escape): string { $value = trim(str_replace(["\r\n", "\r"], "\n", $value)); $value = str_replace('\\n', "\n", $value); if ($value === '') { return ''; } return nl2br($escape($value)); }; $provisionalBlockValue = (string) ($tokens['provisional_diagnosis_list'] ?? ''); if ($provisionalBlockValue === '') { $provisionalBlockValue = $renderMultilineText((string) ($tokens['Provisional_diagnosis_raw'] ?? '')); } $tokens['Provisional_diagnosis'] = $formatBlock('Provisional Diagnosis', $provisionalBlockValue, true); $tokens['provisional_diagnosis'] = $tokens['Provisional_diagnosis']; $findingRaw = trim((string) ($tokens['Finding_Examinations_raw'] ?? '')); $findingItems = $splitClinicalText($findingRaw); $findingTerms = []; foreach ($findingItems as $item) { $term = trim((string) ($item['term'] ?? '')); if ($term !== '') { $findingTerms[] = $term; } } $findingCompact = implode(', ', array_values(array_unique($findingTerms))); if (preg_match('/\R|\\n/', $findingRaw) === 1) { $findingCompact = $renderMultilineText($findingRaw); } elseif ($findingCompact === '') { $findingCompact = $renderMultilineText($findingRaw); } else { $findingCompact = $escape($findingCompact); } $tokens['Finding_Examinations'] = $formatBlock('Finding / Examination', $findingCompact, false); $tokens['finding_examinations'] = $tokens['Finding_Examinations']; $tokens['investigation'] = $formatBlock('Investigation Advised', $renderMultilineText((string) ($tokens['investigation_raw'] ?? ''))); $tokens['Prescriber_Remarks'] = $formatBlock('Remarks', $renderMultilineText((string) ($tokens['Prescriber_Remarks_raw'] ?? ''))); $adviceRaw = trim((string) ($tokens['advice_raw'] ?? '')); $adviceRaw = str_replace(["\r\n", "\r"], "\n", $adviceRaw); $adviceRaw = str_replace('\\n', "\n", $adviceRaw); // Print advice exactly as typed — no AI/Hindi conversion applied. if ($adviceRaw !== '') { $tokens['advice'] = nl2br(htmlspecialchars($adviceRaw, ENT_QUOTES, 'UTF-8')); } else { $tokens['advice'] = ''; } $tokens['next_visit'] = $formatBlock('Next Visit', (string) ($tokens['next_visit_raw'] ?? '')); // ── General Examination extras (formatted) ──────────────────────────── $vitalRaw = (string) ($tokens['vital_content_raw'] ?? ''); $vitalExtraRaw = trim((string) ($tokens['vital_content_extra'] ?? '')); $vitalFull = trim($vitalRaw . ($vitalExtraRaw !== '' ? ' | ' . $vitalExtraRaw : '')); $tokens['vital_content_full'] = $formatBlock('Vitals', $vitalFull); $tokens['general_examination'] = $tokens['vital_content_full']; // ── Pain scale (formatted) ──────────────────────────────────────────── $painValueRaw = trim((string) ($tokens['pain_value'] ?? '')); $painValue = is_numeric($painValueRaw) ? (int) $painValueRaw : null; $painLabel = trim((string) ($tokens['pain_label'] ?? '')); $isNoPain = $painLabel !== '' && strcasecmp($painLabel, 'No Pain') === 0; if ($painValue !== null && $painValue === 0) { $isNoPain = true; } // Do not print Pain Scale text block when value is explicitly No Pain. $tokens['pain_scale'] = ($painLabel !== '' && ! $isNoPain) ? $formatBlock('Pain Scale', $painLabel) : ''; $tokens['painscale'] = ! $isNoPain ? $painLabel : ''; // Legacy token {{painscale_img}}: use legacy PNG if available, else render // a simple inline visual scale so placeholder always displays meaningful UI. // IMPORTANT: Only render if painValue is explicitly set (not null/empty) $painIndex = ($painValue !== null && $painValue >= 0 && $painValue <= 4) ? $painValue : null; $painImageHtml = ''; // DISABLED: Static PNG images don't show which level is selected - use dynamic HTML instead // Only generate image if pain_value is actually set // if ($painIndex !== null) { // $imageCandidates = [ // FCPATH . 'assets/images/pains_scale_' . $painIndex . '.png', // FCPATH . 'assets/images/pains_scale.png', // ]; // // foreach ($imageCandidates as $imagePath) { // if (is_file($imagePath)) { // $painImageHtml = ''; // break; // } // } // } if ($painImageHtml === '' && $painIndex !== null) { $labels = ['No Pain', 'Mild', 'Moderate', 'Intense', 'Worst']; // Add debug info at top showing which pain level is selected $painImageHtml = '
    Pain Level: ' . $painIndex . ' - ' . esc($labels[$painIndex]) . '
    '; $painImageHtml .= ''; // Row 1: Arrow/checkmark indicators (PDF-safe symbols, visible in black & white) $painImageHtml .= ''; for ($i = 0; $i <= 4; $i++) { $active = ($painIndex === $i); $indicator = $active ? '▼' : ''; // Down-pointing triangle (▼) $fontSize = $active ? '12px' : '10px'; $painImageHtml .= ''; } $painImageHtml .= ''; // Row 2: Number boxes with bold border and shading for selected (visible in B&W) $painImageHtml .= ''; for ($i = 0; $i <= 4; $i++) { $active = ($painIndex === $i); $bg = $active ? '#cccccc' : '#ffffff'; // Medium gray for selected (visible in B&W) $borderColor = '#000000'; $borderWidth = $active ? '2px' : '1px'; $color = '#000000'; $painImageHtml .= ''; } $painImageHtml .= ''; // Row 3: Labels with bold for selected $painImageHtml .= ''; for ($i = 0; $i <= 4; $i++) { $active = ($painIndex === $i); $fontWeight = $active ? '700' : '400'; $fontSize = $active ? '7px' : '7px'; $painImageHtml .= ''; } $painImageHtml .= '
    ' . $indicator . '
    ' . $i . '
    ' . esc($labels[$i]) . '
    '; } $tokens['painscale_img'] = $painImageHtml; // ── Complication & Addiction (formatted) ────────────────────────────── $tokens['Complication'] = $formatBlock('Complication', (string) ($tokens['complication'] ?? '')); $tokens['complication'] = $tokens['Complication']; $tokens['Addiction'] = $formatBlock('Addiction', (string) ($tokens['addiction'] ?? '')); $tokens['addiction'] = $tokens['Addiction']; // ── Drug allergy / NABH (formatted) ────────────────────────────────── $allergyStatus = trim((string) ($tokens['drug_allergy_status'] ?? '')); $allergyDetails = trim((string) ($tokens['drug_allergy_details'] ?? '')); $adrHistory = trim((string) ($tokens['adr_history'] ?? '')); $currentMeds = trim((string) ($tokens['current_medications'] ?? '')); // Fallback extraction when ADR was stored only in free-text remarks. if ($adrHistory === '') { $remarksRaw = (string) ($tokens['Prescriber_Remarks_raw'] ?? ''); if (preg_match('/^\s*ADR\s*History\s*:\s*(.+)$/im', $remarksRaw, $m) === 1) { $adrHistory = trim((string) ($m[1] ?? '')); } } $allergyBlock = $allergyStatus !== '' ? esc($allergyStatus) : ''; if ($allergyDetails !== '') { $allergyBlock .= ($allergyBlock !== '' ? ' — ' : '') . esc($allergyDetails); } $allergicHistoryRaw = trim((string) ($tokens['allergic_history'] ?? '')); if ($allergicHistoryRaw === '' && $allergyDetails !== '') { $allergicHistoryRaw = $allergyDetails; } $tokens['drug_allergy_status_raw'] = $allergyStatus; $tokens['drug_allergy_details_raw'] = $allergyDetails; $tokens['adr_history_raw'] = $adrHistory; $tokens['current_medications_raw'] = $currentMeds; $tokens['drug_allergy_status_block'] = $allergyStatus !== '' ? $formatBlock('Drug Allergy Status', esc($allergyStatus)) : ''; $tokens['drug_allergy_details_block'] = $allergyDetails !== '' ? $formatBlock('Drug Allergy Details', esc($allergyDetails)) : ''; $tokens['drug_allergy_block'] = $allergyBlock !== '' ? $formatBlock('Drug Allergy', $allergyBlock) : ''; $tokens['adr_history_block'] = $adrHistory !== '' ? $formatBlock('ADR History', esc($adrHistory)) : ''; $tokens['current_medications_block'] = $currentMeds !== '' ? $formatBlock('Current Medications', esc($currentMeds)) : ''; $nabhPanelParts = []; if ($allergyStatus !== '') { $nabhPanelParts[] = 'Drug Allergy Status: ' . esc($allergyStatus); } if ($allergyDetails !== '') { $nabhPanelParts[] = 'Drug Allergy Details: ' . esc($allergyDetails); } if ($adrHistory !== '') { $nabhPanelParts[] = 'ADR History: ' . esc($adrHistory); } if ($currentMeds !== '') { $nabhPanelParts[] = 'Current Medications: ' . esc($currentMeds); } $nabhPanelHtml = implode('
    ', $nabhPanelParts); $tokens['nabh_panel_block'] = $nabhPanelHtml !== '' ? '
    ' . $nabhPanelHtml . '
    ' . $blockGapHtml : ''; $tokens['allergy_adr_panel_block'] = $tokens['nabh_panel_block']; $tokens['drug_allergy_panel_block'] = $tokens['nabh_panel_block']; // Keep both raw and formatted allergy placeholders for template compatibility. $tokens['allergic_history_raw'] = $allergicHistoryRaw; $tokens['allergic_history'] = $allergicHistoryRaw; $tokens['allergic_history_block'] = $allergicHistoryRaw !== '' ? $formatBlock('Allergic History', esc($allergicHistoryRaw)) : ''; $tokens['allergic_history_formatted'] = $tokens['allergic_history_block']; $tokens['adr_history'] = $adrHistory; // ── Women related (formatted) ───────────────────────────────────────── $womenParts = []; $womenLmpRaw = trim((string) ($tokens['women_lmp'] ?? '')); if ($womenLmpRaw !== '') { $womenParts[] = 'LMP: ' . esc($womenLmpRaw) . ' days ago'; } $womenLastBabyRaw = trim((string) ($tokens['women_last_baby'] ?? '')); if ($womenLastBabyRaw !== '') { $womenParts[] = 'Last Baby: ' . esc($womenLastBabyRaw); } $womenPregRaw = trim((string) ($tokens['women_pregnancy_related'] ?? '')); if ($womenPregRaw !== '') { $womenParts[] = esc($womenPregRaw); } $womenBlock = implode(' | ', $womenParts); $tokens['women_block'] = $womenBlock !== '' ? $formatBlock('Women Related', $womenBlock) : ''; // ── Women specific patient history (formatted) ───────────────────── $patientHistoryParts = []; $obstetric = trim((string) ($tokens['obstetric_history'] ?? '')); $menstrual = trim((string) ($tokens['menstrual_history'] ?? '')); $medicalSurgical = trim((string) ($tokens['medical_surgical_history'] ?? '')); $family = trim((string) ($tokens['family_history'] ?? '')); $allergic = trim((string) ($tokens['allergic_history_raw'] ?? $tokens['allergic_history'] ?? '')); $vaccination = trim((string) ($tokens['vaccination_history'] ?? '')); if ($obstetric !== '') { $patientHistoryParts[] = 'Obstetric: ' . esc($obstetric); } if ($menstrual !== '') { $patientHistoryParts[] = 'Menstrual: ' . esc($menstrual); } if ($medicalSurgical !== '') { $patientHistoryParts[] = 'Medical/Surgical: ' . esc($medicalSurgical); } if ($family !== '') { $patientHistoryParts[] = 'Family: ' . esc($family); } if ($allergic !== '') { $patientHistoryParts[] = 'Allergic: ' . esc($allergic); } if ($vaccination !== '') { $patientHistoryParts[] = 'Vaccination: ' . esc($vaccination); } $patientHistoryHtml = implode('
    ', $patientHistoryParts); $tokens['patient_history_block'] = $patientHistoryHtml !== '' ? $formatBlock('Patient History', $patientHistoryHtml, true) : ''; // ── Morbidities (formatted) ─────────────────────────────────────────── $tokens['morbidities_block'] = $formatBlock('Co-Morbidities', (string) ($tokens['morbidities'] ?? '')); $medicalHtml = trim((string) ($tokens['medical'] ?? '')); $rxTitleFontSize = $isCompactMeds ? '18px' : '24px'; $rxTitleMargin = $isCompactMeds ? '3px' : '8px'; $tokens['Rx'] = $medicalHtml !== '' ? $blockGapHtml . '
    Rx :
    ' . $medicalHtml . '
    ' . $blockGapHtml : ''; $tokens['rx'] = $tokens['Rx']; // {{medical}} used directly in templates: wrap with the same top/bottom margin. $tokens['medical'] = $medicalHtml !== '' ? $blockGapHtml . '
    ' . $medicalHtml . '
    ' . $blockGapHtml : ''; $tokens['VitalsBlock'] = (string) ($tokens['vital_content_full'] ?? $tokens['vital_content_block'] ?? ''); $tokens['ComplaintBlock'] = (string) ($tokens['Complaint'] ?? ''); $tokens['DiagnosisBlock'] = (string) ($tokens['diagnosis'] ?? ''); $tokens['ProvisionalDiagnosisBlock'] = (string) ($tokens['Provisional_diagnosis'] ?? ''); $tokens['FindingBlock'] = (string) ($tokens['Finding_Examinations'] ?? ''); $tokens['InvestigationBlock'] = (string) ($tokens['investigation'] ?? ''); $tokens['RemarksBlock'] = (string) ($tokens['Prescriber_Remarks'] ?? ''); $adviceBlockBody = trim((string) ($tokens['advice'] ?? '')); $adviceBlockLocal = trim((string) ($tokens['advice_local'] ?? '')); $adviceBlockPreferred = $adviceBlockBody !== '' ? $adviceBlockBody : $adviceBlockLocal; $tokens['AdviceBlock'] = $adviceBlockPreferred !== '' ? ('Advice : ' . $adviceBlockPreferred) : ''; $tokens['NextVisitBlock'] = (string) ($tokens['next_visit'] ?? ''); $tokens['RxTable'] = $medicalHtml; $tokens['rx_table'] = $medicalHtml; $fullBlocks = array_values(array_filter([ (string) ($tokens['VitalsBlock'] ?? ''), (string) ($tokens['ComplaintBlock'] ?? ''), (string) ($tokens['DiagnosisBlock'] ?? ''), (string) ($tokens['ProvisionalDiagnosisBlock'] ?? ''), (string) ($tokens['FindingBlock'] ?? ''), (string) ($tokens['Rx'] ?? ''), (string) ($tokens['InvestigationBlock'] ?? ''), (string) ($tokens['RemarksBlock'] ?? ''), (string) ($tokens['patient_history_block'] ?? ''), (string) ($tokens['AdviceBlock'] ?? ''), (string) ($tokens['NextVisitBlock'] ?? ''), ], static fn ($value): bool => trim((string) $value) !== '')); $tokens['RxFullBlock'] = implode("\n", $fullBlocks); $tokens['rx_full_block'] = $tokens['RxFullBlock']; // Load hospital settings from database $hName = $this->readSettingValueFromDb('H_Name'); $hAddress1 = $this->readSettingValueFromDb('H_address_1'); $hAddress2 = $this->readSettingValueFromDb('H_address_2'); $hPhone = $this->readSettingValueFromDb('H_phone_No'); $hEmail = $this->readSettingValueFromDb('H_Email'); $hLogo = $this->readSettingValueFromDb('H_logo'); // Try constants as fallback if database values are empty if ($hName === '') { $hName = defined('H_Name') ? (string) constant('H_Name') : ''; } if ($hAddress1 === '') { $hAddress1 = defined('H_address_1') ? (string) constant('H_address_1') : ''; } if ($hAddress2 === '') { $hAddress2 = defined('H_address_2') ? (string) constant('H_address_2') : ''; } if ($hPhone === '') { $hPhone = defined('H_phone_No') ? (string) constant('H_phone_No') : ''; } if ($hEmail === '') { $hEmail = defined('H_Email') ? (string) constant('H_Email') : ''; } if ($hLogo === '') { $hLogo = defined('H_logo') ? (string) constant('H_logo') : ''; } // Fallback to default logo if still empty if ($hLogo === '') { $hLogo = 'logo.png'; } $tokens['H_Name'] = $hName; $tokens['H_address_1'] = $hAddress1; $tokens['H_address_2'] = $hAddress2; $tokens['H_phone_No'] = $hPhone; $tokens['H_Email'] = $hEmail; $tokens['H_logo'] = $hLogo; $tokens['H_logo_abs'] = $hLogo !== '' ? FCPATH . 'assets/images/' . $hLogo : ''; $tokens['hospital_name'] = $hName; $tokens['hospital_address'] = trim($hAddress1 . ', ' . $hAddress2, ', '); $tokens['hospital_phone'] = $hPhone; $tokens['hospital_email'] = $hEmail; $tokens['print_time'] = date('d-m-Y H:i:s'); $tokens['current_date'] = date('d-m-Y'); $tokens['doctor_name'] = trim((string) ($tokens['doctor_name'] ?? (($data['opd_master'][0]->doc_name ?? '') ?? ''))); $shortDescriptionRaw = trim((string) ($tokens['short_description'] ?? '')); $shortDescriptionHtml = $shortDescriptionRaw !== '' ? nl2br(htmlspecialchars($shortDescriptionRaw, ENT_QUOTES, 'UTF-8')) : ''; $tokens['short_description_raw'] = $shortDescriptionRaw; $tokens['doctor_short_description_raw'] = $shortDescriptionRaw; $tokens['short_description'] = $shortDescriptionHtml; $tokens['doctor_short_description'] = $shortDescriptionHtml; $tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? '')); $tokens['content'] = (string) ($tokens['content'] ?? ''); $adviceLocal = trim((string) ($tokens['advice_local'] ?? '')); $adviceMain = trim((string) ($tokens['advice'] ?? '')); $tokens['advice_local_line'] = ($adviceMain === '' && $adviceLocal !== '') ? ('
    सलाह: ' . $adviceLocal) : ''; // ── Doctor master tokens ────────────────────────────────────────────── $docRow = $data['doctor_master'][0] ?? null; $docTitle = trim((string) ($docRow->p_title ?? 'Dr.')); $docFname = trim((string) ($docRow->p_fname ?? '')); $docMname = trim((string) ($docRow->p_mname ?? '')); $docLname = trim((string) ($docRow->p_lname ?? '')); $docFullName = trim(trim($docTitle . ' ' . $docFname . ' ' . $docMname) . ' ' . $docLname); $docRegNo = trim((string) ($docRow->doctor_reg_no ?? '')); $docPhone = trim((string) ($docRow->mphone1 ?? '')); $docEmail = trim((string) ($docRow->email1 ?? '')); $docSpec = trim((string) ($docRow->SpecName ?? $tokens['SpecName'] ?? '')); $docAdd1 = trim((string) ($docRow->add1 ?? '')); $docAdd2 = trim((string) ($docRow->add2 ?? '')); $docCity = trim((string) ($docRow->city ?? '')); $docAddress = trim(trim($docAdd1 . ', ' . $docAdd2, ', ') . (($docCity !== '') ? ', ' . $docCity : ''), ', '); $tokens['doctor_title'] = esc($docTitle); $tokens['doctor_full_name'] = esc($docFullName); $tokens['doctor_reg_no'] = esc($docRegNo); $tokens['doctor_registration'] = esc($docRegNo); $tokens['doctor_phone'] = esc($docPhone); $tokens['doctor_email'] = esc($docEmail); $tokens['doctor_specialization'] = esc($docSpec); $tokens['doctor_address'] = esc($docAddress); // ───────────────────────────────────────────────────────────────────── // ── Composed section tokens (available in flat/custom templates too) ─ // hospital_section : hospital name + address + phone/email block $hospitalSectionParts = array_filter([ $hName !== '' ? '' . esc($hName) . '' : '', trim($hAddress1 . ', ' . $hAddress2, ', ') !== '' ? esc(trim($hAddress1 . ', ' . $hAddress2, ', ')) : '', $hPhone !== '' ? 'Ph: ' . esc($hPhone) : '', $hEmail !== '' ? 'Email: ' . esc($hEmail) : '', ], static fn ($v): bool => $v !== ''); $tokens['hospital_section'] = implode('
    ', $hospitalSectionParts); // patient_section : reuse the existing patient info table ({{content}}) $tokens['patient_section'] = (string) ($tokens['content'] ?? ''); // content_section : all clinical blocks combined (same as {{RxFullBlock}}) $tokens['content_section'] = (string) ($tokens['RxFullBlock'] ?? ''); // ───────────────────────────────────────────────────────────────────── // Add case-insensitive aliases after all token mutations so newly built // placeholders also resolve in legacy templates. foreach ($tokens as $key => $value) { $key = (string) $key; $value = (string) $value; $lower = strtolower($key); if (! array_key_exists($lower, $tokens)) { $tokens[$lower] = $value; } $ucfirst = ucfirst($lower); if (! array_key_exists($ucfirst, $tokens)) { $tokens[$ucfirst] = $value; } $upper = strtoupper($key); if (! array_key_exists($upper, $tokens)) { $tokens[$upper] = $value; } } return $tokens; } /** * @param array $vars */ private function applyOpdPdfTemplateTokens(string $templateHtml, array $vars): string { if ($templateHtml === '') { return ''; } $templateHtml = $this->normalizeLegacyOpdPaperTemplate($templateHtml); $replace = []; foreach ($vars as $key => $value) { $replace['{{' . $key . '}}'] = $value; $replace['{' . $key . '}'] = $value; } return strtr($templateHtml, $replace); } private function normalizeLegacyOpdPaperTemplate(string $html): string { $out = str_replace(["\r\n", "\r"], "\n", $html); $mapPatterns = [ '/<\?=\s*H_Name\s*\?>/i' => '{{H_Name}}', '/<\?=\s*H_address_1\s*\?>/i' => '{{H_address_1}}', '/<\?=\s*H_address_2\s*\?>/i' => '{{H_address_2}}', '/<\?=\s*H_phone_No\s*\?>/i' => '{{H_phone_No}}', '/<\?=\s*H_Email\s*\?>/i' => '{{H_Email}}', '/<\?=\s*H_logo\s*\?>/i' => '{{H_logo}}', '/<\?=\s*\$opd_master\[0\]->doc_name\s*\?>/i' => '{{doctor_name}}', '/<\?=\s*nl2br\(\s*\$opd_master\[0\]->doc_sign\s*\)\s*\?>/i' => '{{doctor_sign_html}}', '/<\?=\s*date\(\s*[\"\']d-m-Y\s+H:i:s[\"\']\s*\)\s*\?>/i' => '{{print_time}}', '/<\?=\s*\$opd_master\[0\]->opd_fee_amount\s*\?>/i' => '{{opd_fee_amount}}', '/<\?=\s*\$opd_master\[0\]->opd_fee_desc\s*\?>/i' => '{{opd_fee_desc}}', '/<\?=\s*esc\(\s*\$opd_master\[0\]->opd_fee_amount\s*(?:\?\?\s*[\"\']\s*[\"\'])?\s*\)\s*\?>/i' => '{{opd_fee_amount}}', '/<\?=\s*esc\(\s*\$opd_master\[0\]->opd_fee_desc\s*(?:\?\?\s*[\"\']\s*[\"\'])?\s*\)\s*\?>/i' => '{{opd_fee_desc}}', '/<\?=\s*\$content\s*\?>/i' => '{{content}}', '/<\?=\s*\$content\s*;?\s*\?>/i' => '{{content}}', '/<\?=\s*\$\w+\s*\?>/i' => '', ]; foreach ($mapPatterns as $pattern => $replacement) { $out = (string) preg_replace($pattern, $replacement, $out); } // Legacy conditional phone block fallback. $out = (string) preg_replace( '/<\?php\s*if\s*\(\s*H_phone_No\s*!\s*=\s*[\"\']{0,1}\s*[\"\']{0,1}\s*\)\s*\{\s*echo\s*[\"\']Phone:\s*[\"\']\s*\.\s*H_phone_No\s*;\s*\}\s*\?>/is', 'Phone: {{H_phone_No}}', $out ); // Convert generic echo statements that match a known scalar token key. $out = (string) preg_replace('/<\?php\s+echo\s+\$content\s*;?\s*\?>/i', '{{content}}', $out); // Legacy templates often place {{advice}} on its own line (without label). // Upgrade only standalone advice placeholders to {{AdviceBlock}} so // existing templates that already print "Advice: {{advice}}" remain unchanged. $out = (string) preg_replace('/(^|\n)\s*\{\{\s*advice\s*\}\}\s*(?=\n|$)/i', '$1{{AdviceBlock}}', $out); $out = (string) preg_replace('/(^|\n)\s*\{\s*advice\s*\}\s*(?=\n|$)/i', '$1{{AdviceBlock}}', $out); // Remove remaining PHP blocks that cannot be interpreted safely. $out = (string) preg_replace('/<\?(?:php|=)[\s\S]*?\?>/i', '', $out); return trim($out); } private function sanitizeOpdNamedHeaderFooterReferences(string $html): string { if ($html === '') { return ''; } $hasMyHeaderBlock = preg_match('/<\s*htmlpageheader\b[^>]*\bname\s*=\s*["\']myHeader["\']/i', $html) === 1; $hasMyFooterBlock = preg_match('/<\s*htmlpagefooter\b[^>]*\bname\s*=\s*["\']myFooter["\']/i', $html) === 1; if (! $hasMyHeaderBlock) { $html = (string) preg_replace('/^\s*header\s*:\s*html_myHeader\s*;\s*$/im', '', $html); } if (! $hasMyFooterBlock) { $html = (string) preg_replace('/^\s*footer\s*:\s*html_myFooter\s*;\s*$/im', '', $html); } return $html; } private function applyOpdLongContentPaginationGuard(string $html): string { if ($html === '') { return ''; } $usesRxPlace = preg_match('/\.rxplace\b|class\s*=\s*["\'][^"\']*\brxplace\b/i', $html) === 1; if (! $usesRxPlace) { return $html; } $plainText = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $textLength = strlen((string) $plainText); $lineBreakCount = substr_count(strtolower($html), '= 8 || $lineBreakCount >= 18 || $textLength > 900; if (! $looksLong) { return $html; } $guardCss = ''; return $html . "\n" . $guardCss; } /** * Read a setting value from the hospital_setting table */ private function readSettingValueFromDb(string $name): string { if (! $this->db->tableExists('hospital_setting')) { return ''; } $row = $this->db->table('hospital_setting') ->select('s_value') ->where('s_name', $name) ->get(1) ->getRowArray(); return trim((string) ($row['s_value'] ?? '')); } private function resolveOpdValiditySpanDays(?object $doctorRow): int { $validDays = (int) ($doctorRow->opd_valid_no_days ?? 0); if ($validDays <= 0) { $validDays = $this->getHospitalDefaultOpdValidityDays(); } return max(0, $validDays - 1); } private function getHospitalDefaultOpdValidityDays(): int { $keys = [ 'OPD_VALID_NO_DAYS', 'OPD_VALIDITY_DAYS', 'DEFAULT_OPD_VALID_NO_DAYS', 'DEFAULT_OPD_VALIDITY_DAYS', ]; foreach ($keys as $key) { $value = (int) $this->readSettingValueFromDb($key); if ($value > 0) { return min(365, $value); } } return 5; } private function buildOpdLetterPrintData(int $opdId, int $sessionId = 0, bool $includeContent = true): ?array { $sql = "Select * from opd_master where opd_id=" . (int) $opdId; $query = $this->db->query($sql); $opdMaster = $query->getResult(); if (empty($opdMaster)) { return null; } $data = []; $docId = (int) ($opdMaster[0]->doc_id ?? 0); $noOpdDays = $this->resolveOpdValiditySpanDays(null); $data['doctor_master'] = []; if ($docId > 0) { $sql = "select d.*, group_concat(DISTINCT m.SpecName) as SpecName from doctor_master d left join doc_spec s on d.id=s.doc_id left join med_spec m on m.id=s.med_spec_id where d.id=" . $docId . " group by d.id"; $query = $this->db->query($sql); $data['doctor_master'] = $query->getResult(); $noOpdDays = $this->resolveOpdValiditySpanDays($data['doctor_master'][0] ?? null); } $sql = "select o.*,date_format(o.apointment_date,'%d-%m-%Y') as str_apointment_date, date_format(date_add( case when (coalesce(o.running_opd,0)=1 or coalesce(o.opd_fee_amount,0)<=0) and coalesce(o.running_opd_id,0)>0 then coalesce((select apointment_date from opd_master where opd_id=o.running_opd_id), o.apointment_date) else o.apointment_date end, interval " . $noOpdDays . " day),'%d-%m-%Y') as opd_Exp_Date, (case payment_status when 1 then 'Cash' when 2 then 'Bank Card' when 3 then 'Org. Credit' else 'Pending' end) as Payment_type_str from opd_master o where o.opd_id=" . (int) $opdId; $query = $this->db->query($sql); $data['opd_master'] = $query->getResult(); $opdRow = $data['opd_master'][0]; if (!isset($opdRow->doc_sign) || trim((string) $opdRow->doc_sign) === '') { $opdRow->doc_sign = (string) ($data['doctor_master'][0]->doc_sign ?? ''); } $sql = "select *,if(gender=1,'M','F') as xgender from patient_master where id='" . (int) $opdRow->p_id . "'"; $query = $this->db->query($sql); $data['patient_master'] = $query->getResult(); if (!empty($data['patient_master'])) { $pRow = $data['patient_master'][0]; $pRow->age = get_age_1($pRow->dob ?? null, $pRow->age ?? '', $pRow->age_in_month ?? '', $pRow->estimate_dob ?? '', $opdRow->apointment_date ?? null); } $data['old_opd'] = []; if (!empty($opdRow->last_opdvisit_date)) { $sql = "select *,date_format(apointment_date,'%d-%m-%Y') as str_apointment_date, date_format(date_add(apointment_date,interval " . $noOpdDays . " day),'%d-%m-%Y') as opd_Exp_Date from opd_master where p_id=" . (int) $opdRow->p_id . " and doc_id=" . (int) ($opdRow->doc_id ?? 0) . " and apointment_date=" . $this->db->escape((string) $opdRow->last_opdvisit_date) . " order by opd_id desc limit 1"; $query = $this->db->query($sql); $data['old_opd'] = $query->getResult(); } if (empty($data['old_opd'])) { $sql = "select *,date_format(apointment_date,'%d-%m-%Y') as str_apointment_date, date_format(date_add(apointment_date,interval " . $noOpdDays . " day),'%d-%m-%Y') as opd_Exp_Date from opd_master where p_id=" . (int) $opdRow->p_id . " and opd_id < " . (int) $opdId . " order by opd_id desc limit 1"; $query = $this->db->query($sql); $data['old_opd'] = $query->getResult(); } $sql = "select * from hc_insurance where id=" . (int) ($opdRow->insurance_id ?? 0); $query = $this->db->query($sql); $data['insurance'] = $query->getResult(); if ((int) ($opdRow->insurance_case_id ?? 0) > 0) { $sql = "select * from organization_case_master where id=" . (int) $opdRow->insurance_case_id; $query = $this->db->query($sql); $data['case_master'] = $query->getResult(); } else { $data['case_master'] = []; } $data['opd_prescription'] = null; if ($this->db->tableExists('opd_prescription')) { $rowBuilder = $this->db->table('opd_prescription') ->where('opd_id', (int) $opdId); if ($sessionId > 0) { $rowBuilder->where('id', $sessionId); } else { $rowBuilder->orderBy('id', 'DESC'); } $row = $rowBuilder->get(1)->getRowArray(); if (!empty($row)) { if (empty($row['women_related_problems'] ?? '')) { $row['women_related_problems'] = $this->extractWomenRelatedProblemsFromRemarks((string) ($row['Prescriber_Remarks'] ?? '')); } $womenStructured = $this->parseWomenStructuredDetails((string) ($row['women_related_problems'] ?? '')); $row['women_related_problems'] = (string) ($womenStructured['women_related_problems'] ?? ''); $row['women_lmp'] = trim((string) ( $row['women_lmp'] ?? $row['lmp'] ?? $row['lmp_date'] ?? ($womenStructured['women_lmp'] ?? '') )); $row['women_last_baby'] = trim((string) ( $row['women_last_baby'] ?? $row['last_baby'] ?? $row['last_baby_details'] ?? ($womenStructured['women_last_baby'] ?? '') )); $row['women_pregnancy_related'] = trim((string) ( $row['women_pregnancy_related'] ?? $row['pregnancy_related'] ?? $row['pregnancy_related_problem'] ?? ($womenStructured['women_pregnancy_related'] ?? '') )); $nabhInfo = $this->hydrateNabhPrintFields( $row, (string) ($row['Prescriber_Remarks'] ?? '') ); $row['drug_allergy_status'] = (string) ($nabhInfo['drug_allergy_status'] ?? ''); $row['drug_allergy_details'] = (string) ($nabhInfo['drug_allergy_details'] ?? ''); $row['adr_history'] = (string) ($nabhInfo['adr_history'] ?? ''); $row['current_medications'] = (string) ($nabhInfo['current_medications'] ?? ''); $historyInfo = $this->hydratePatientHistoryPrintFields( $row, (string) ($row['Prescriber_Remarks'] ?? '') ); $row['obstetric_history'] = (string) ($historyInfo['obstetric_history'] ?? ''); $row['menstrual_history'] = (string) ($historyInfo['menstrual_history'] ?? ''); $row['medical_surgical_history'] = (string) ($historyInfo['medical_surgical_history'] ?? ''); $row['family_history'] = (string) ($historyInfo['family_history'] ?? ''); $row['allergic_history'] = (string) ($historyInfo['allergic_history'] ?? ''); $row['vaccination_history'] = (string) ($historyInfo['vaccination_history'] ?? ''); $data['opd_prescription'] = $row; } } if (! $includeContent) { $data['selected_morbidities'] = []; $data['rx_medicines'] = []; $data['rx_investigations'] = []; $data['rx_advices'] = []; return $data; } $data['selected_morbidities'] = []; if ($this->db->tableExists('patient_morbidities') && $this->db->tableExists('morbidities_master')) { $pmFields = $this->db->getFieldNames('patient_morbidities'); $mmFields = $this->db->getFieldNames('morbidities_master'); $pmPatientField = $this->resolveFirstField($pmFields, ['p_id', 'patient_id']); $pmMorField = $this->resolveFirstField($pmFields, ['morbidities', 'mor_id', 'morbidity_id']); $mmMorField = $this->resolveFirstField($mmFields, ['mor_id', 'id']); $mmNameField = $this->resolveFirstField($mmFields, ['morbidities', 'name', 'title']); if ($pmPatientField !== null && $pmMorField !== null && $mmMorField !== null && $mmNameField !== null) { $rows = $this->db->table('patient_morbidities pm') ->select('m.' . $mmNameField . ' as morbidities') ->join('morbidities_master m', 'm.' . $mmMorField . '=pm.' . $pmMorField, 'inner') ->where('pm.' . $pmPatientField, (int) ($opdRow->p_id ?? 0)) ->orderBy('m.' . $mmNameField, 'ASC') ->get() ->getResultArray(); foreach ($rows as $morRow) { $name = trim((string) ($morRow['morbidities'] ?? '')); if ($name !== '') { $data['selected_morbidities'][] = $name; } } } } $data['rx_medicines'] = []; $data['rx_investigations'] = []; $data['rx_advices'] = []; $sessionId = (int) ($data['opd_prescription']['id'] ?? 0); $medTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']); if ($medTable !== null) { $medFields = $this->db->getFieldNames($medTable); $medBuilder = $this->db->table($medTable); $hasFilter = false; if (in_array('opd_pre_id', $medFields, true) && $sessionId > 0) { $medBuilder->where('opd_pre_id', $sessionId); $hasFilter = true; } elseif (in_array('opd_id', $medFields, true)) { $medBuilder->where('opd_id', (int) $opdId); $hasFilter = true; } if ($hasFilter) { if (in_array('id', $medFields, true)) { $medBuilder->orderBy('id', 'ASC'); } $data['rx_medicines'] = $medBuilder->get()->getResultArray(); if (!empty($data['rx_medicines'])) { $mapDose = []; $mapWhen = []; $mapFreq = []; $mapWhere = []; $mapDoseLocal = []; $mapWhenLocal = []; $mapFreqLocal = []; $mapWhereLocal = []; $medGenericMap = []; $medFormulationMap = []; $missingGenericMedIds = []; $missingFormulationMedIds = []; if ($this->db->tableExists('opd_dose_shed')) { $f = $this->db->getFieldNames('opd_dose_shed'); $idF = $this->resolveFirstField($f, ['dose_shed_id', 'id']); $labelF = $this->resolveFirstField($f, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']); $localLabelF = $this->resolveFirstField($f, ['dose_show_desc', 'dose_sign_hindi', 'dose_sign_desc']); if ($idF !== null && $labelF !== null) { $select = [$idF . ' as id', $labelF . ' as label']; if ($localLabelF !== null && $localLabelF !== $labelF) { $select[] = $localLabelF . ' as local_label'; } $rows = $this->db->table('opd_dose_shed')->select(implode(',', $select))->where($labelF . ' !=', '')->get()->getResultArray(); foreach ($rows as $r) { $id = trim((string) ($r['id'] ?? '')); $label = trim((string) ($r['label'] ?? '')); if ($id !== '' && $label !== '') { $mapDose[$id] = $label; } $localLabel = trim((string) ($r['local_label'] ?? '')); if ($id !== '' && $localLabel !== '') { $mapDoseLocal[$id] = $localLabel; } } } } if ($this->db->tableExists('opd_dose_when')) { $f = $this->db->getFieldNames('opd_dose_when'); $idF = $this->resolveFirstField($f, ['dose_when_id', 'id']); $labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']); $localLabelF = $this->resolveFirstField($f, ['dose_sign_hindi', 'dose_sign_desc']); if ($idF !== null && $labelF !== null) { $select = [$idF . ' as id', $labelF . ' as label']; if ($localLabelF !== null && $localLabelF !== $labelF) { $select[] = $localLabelF . ' as local_label'; } $rows = $this->db->table('opd_dose_when')->select(implode(',', $select))->where($labelF . ' !=', '')->get()->getResultArray(); foreach ($rows as $r) { $id = trim((string) ($r['id'] ?? '')); $label = trim((string) ($r['label'] ?? '')); if ($id !== '' && $label !== '') { $mapWhen[$id] = $label; } $localLabel = trim((string) ($r['local_label'] ?? '')); if ($id !== '' && $localLabel !== '') { $mapWhenLocal[$id] = $localLabel; } } } } if ($this->db->tableExists('opd_dose_frequency')) { $f = $this->db->getFieldNames('opd_dose_frequency'); $idF = $this->resolveFirstField($f, ['dose_freq_id', 'id']); $labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']); $localLabelF = $this->resolveFirstField($f, ['dose_sign_hindi', 'dose_sign_desc']); if ($idF !== null && $labelF !== null) { $select = [$idF . ' as id', $labelF . ' as label']; if ($localLabelF !== null && $localLabelF !== $labelF) { $select[] = $localLabelF . ' as local_label'; } $rows = $this->db->table('opd_dose_frequency')->select(implode(',', $select))->where($labelF . ' !=', '')->get()->getResultArray(); foreach ($rows as $r) { $id = trim((string) ($r['id'] ?? '')); $label = trim((string) ($r['label'] ?? '')); if ($id !== '' && $label !== '') { $mapFreq[$id] = $label; } $localLabel = trim((string) ($r['local_label'] ?? '')); if ($id !== '' && $localLabel !== '') { $mapFreqLocal[$id] = $localLabel; } } } } if ($this->db->tableExists('opd_dose_where')) { $f = $this->db->getFieldNames('opd_dose_where'); $idF = $this->resolveFirstField($f, ['dose_where_id', 'id']); $labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']); $localLabelF = $this->resolveFirstField($f, ['dose_sign_hindi', 'dose_sign_desc']); if ($idF !== null && $labelF !== null) { $select = [$idF . ' as id', $labelF . ' as label']; if ($localLabelF !== null && $localLabelF !== $labelF) { $select[] = $localLabelF . ' as local_label'; } $rows = $this->db->table('opd_dose_where')->select(implode(',', $select))->where($labelF . ' !=', '')->get()->getResultArray(); foreach ($rows as $r) { $id = trim((string) ($r['id'] ?? '')); $label = trim((string) ($r['label'] ?? '')); if ($id !== '' && $label !== '') { $mapWhere[$id] = $label; } $localLabel = trim((string) ($r['local_label'] ?? '')); if ($id !== '' && $localLabel !== '') { $mapWhereLocal[$id] = $localLabel; } } } } foreach ($data['rx_medicines'] as &$medRow) { $doseRaw = trim((string) ($medRow['dosage'] ?? '')); $whenRaw = trim((string) ($medRow['dosage_when'] ?? '')); $freqRaw = trim((string) ($medRow['dosage_freq'] ?? '')); $whereRaw = trim((string) ($medRow['dosage_where'] ?? '')); $medRow['dosage_label'] = ($doseRaw !== '' && $doseRaw !== '0' && isset($mapDose[$doseRaw])) ? $mapDose[$doseRaw] : ($doseRaw === '0' ? '' : $doseRaw); $medRow['dosage_when_label'] = ($whenRaw !== '' && $whenRaw !== '0' && isset($mapWhen[$whenRaw])) ? $mapWhen[$whenRaw] : ($whenRaw === '0' ? '' : $whenRaw); $medRow['dosage_freq_label'] = ($freqRaw !== '' && $freqRaw !== '0' && isset($mapFreq[$freqRaw])) ? $mapFreq[$freqRaw] : ($freqRaw === '0' ? '' : $freqRaw); $medRow['dosage_where_label'] = ($whereRaw !== '' && $whereRaw !== '0' && isset($mapWhere[$whereRaw])) ? $mapWhere[$whereRaw] : ($whereRaw === '0' ? '' : $whereRaw); $medRow['dosage_local_label'] = ($doseRaw !== '' && $doseRaw !== '0' && isset($mapDoseLocal[$doseRaw])) ? $mapDoseLocal[$doseRaw] : ''; $medRow['dosage_when_local_label'] = ($whenRaw !== '' && $whenRaw !== '0' && isset($mapWhenLocal[$whenRaw])) ? $mapWhenLocal[$whenRaw] : ''; $medRow['dosage_freq_local_label'] = ($freqRaw !== '' && $freqRaw !== '0' && isset($mapFreqLocal[$freqRaw])) ? $mapFreqLocal[$freqRaw] : ''; $medRow['dosage_where_local_label'] = ($whereRaw !== '' && $whereRaw !== '0' && isset($mapWhereLocal[$whereRaw])) ? $mapWhereLocal[$whereRaw] : ''; $genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? ''))); if ($genericInline === '') { $medId = (int) ($medRow['med_id'] ?? 0); if ($medId > 0) { $missingGenericMedIds[$medId] = true; } } $formulationInline = trim((string) ($medRow['formulation'] ?? ($medRow['dosage_form'] ?? ''))); if ($formulationInline === '') { $medId = (int) ($medRow['med_id'] ?? 0); if ($medId > 0) { $missingFormulationMedIds[$medId] = true; } } } unset($medRow); if ((!empty($missingGenericMedIds) || !empty($missingFormulationMedIds)) && $this->db->tableExists('opd_med_master')) { $masterFields = $this->db->getFieldNames('opd_med_master'); $masterIdField = $this->resolveFirstField($masterFields, ['id']); $masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']); $masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']); $masterFormulationField = $this->resolveFirstField($masterFields, ['formulation', 'dosage_form', 'form']); // Prefer genericname (Generic Name) over salt_name (Substitute) for "Salt/Generic" print label $labelField = $masterGenericField ?? $masterSaltField; $select = []; if ($masterIdField !== null) { $select[] = $masterIdField . ' as med_id'; } if ($labelField !== null) { $select[] = $labelField . ' as generic_fallback'; } if ($masterFormulationField !== null) { $select[] = $masterFormulationField . ' as formulation_fallback'; } if ($masterIdField !== null && !empty($select)) { $neededIds = array_unique(array_map('intval', array_merge(array_keys($missingGenericMedIds), array_keys($missingFormulationMedIds)))); $rows = $this->db->table('opd_med_master') ->select(implode(',', $select)) ->whereIn($masterIdField, $neededIds) ->get() ->getResultArray(); foreach ($rows as $r) { $id = (int) ($r['med_id'] ?? 0); $label = trim((string) ($r['generic_fallback'] ?? '')); if ($id > 0 && $label !== '') { $medGenericMap[$id] = $label; } $formulation = trim((string) ($r['formulation_fallback'] ?? '')); if ($id > 0 && $formulation !== '') { $medFormulationMap[$id] = $formulation; } } } } foreach ($data['rx_medicines'] as &$medRow) { // Only check genericname — do NOT fall back to salt_name (that is the Substitute field) $genericInline = trim((string) ($medRow['genericname'] ?? '')); if ($genericInline === '') { $fallback = $medGenericMap[(int) ($medRow['med_id'] ?? 0)] ?? ''; if ($fallback !== '') { $medRow['genericname'] = $fallback; } } $formulationInline = trim((string) ($medRow['formulation'] ?? ($medRow['dosage_form'] ?? ''))); if ($formulationInline === '') { $formulationFallback = $medFormulationMap[(int) ($medRow['med_id'] ?? 0)] ?? ''; if ($formulationFallback !== '') { $medRow['formulation'] = $formulationFallback; } } } unset($medRow); } } else { $data['rx_medicines'] = []; } } $invTable = $this->findExistingTable(['opd_prescription_investigation']); if ($invTable !== null) { $invFields = $this->db->getFieldNames($invTable); $invBuilder = $this->db->table($invTable); $hasFilter = false; if (in_array('opd_pre_id', $invFields, true) && $sessionId > 0) { $invBuilder->where('opd_pre_id', $sessionId); $hasFilter = true; } elseif (in_array('opd_id', $invFields, true)) { $invBuilder->where('opd_id', (int) $opdId); $hasFilter = true; } if ($hasFilter) { if (in_array('order_id', $invFields, true)) { $invBuilder->orderBy('CASE WHEN order_id IS NULL OR order_id = 0 THEN 1 ELSE 0 END', 'ASC', false); $invBuilder->orderBy('order_id', 'ASC'); if (in_array('id', $invFields, true)) { $invBuilder->orderBy('id', 'ASC'); } } elseif (in_array('id', $invFields, true)) { $invBuilder->orderBy('id', 'ASC'); } elseif (in_array('investigation_name', $invFields, true)) { $invBuilder->orderBy('investigation_name', 'ASC'); } $data['rx_investigations'] = $invBuilder->get()->getResultArray(); } else { $data['rx_investigations'] = []; } } $adviceTable = $this->findExistingTable(['opd_prescription_advice']); if ($adviceTable !== null) { $advFields = $this->db->getFieldNames($adviceTable); $advBuilder = $this->db->table($adviceTable); $hasFilter = false; if (in_array('opd_pre_id', $advFields, true) && $sessionId > 0) { $advBuilder->where('opd_pre_id', $sessionId); $hasFilter = true; } elseif (in_array('opd_id', $advFields, true)) { $advBuilder->where('opd_id', (int) $opdId); $hasFilter = true; } if ($hasFilter) { if (in_array('id', $advFields, true)) { $advBuilder->orderBy('id', 'ASC'); } $data['rx_advices'] = $advBuilder->get()->getResultArray(); } else { $data['rx_advices'] = []; } } if (!empty($data['rx_advices']) && $this->db->tableExists('opd_advice')) { $masterFields = $this->db->getFieldNames('opd_advice'); $masterIdField = $this->resolveFirstField($masterFields, ['id', 'advice_id']); $masterAdviceField = $this->resolveFirstField($masterFields, ['advice', 'advice_txt', 'advice_text']); $masterHindiField = $this->resolveFirstField($masterFields, ['advice_hindi', 'advice_local', 'hindi_advice', 'advice_hin']); if ($masterAdviceField !== null && $masterHindiField !== null) { $rxAdviceIds = []; $rxAdviceTexts = []; foreach ($data['rx_advices'] as $advRow) { $advIdVal = trim((string) ($advRow['advice_id'] ?? '')); if ($advIdVal !== '') { $rxAdviceIds[] = $advIdVal; } $advTextVal = trim((string) ($advRow['advice_txt'] ?? ($advRow['advice'] ?? ''))); if ($advTextVal !== '') { $rxAdviceTexts[] = $advTextVal; } } $rxAdviceIds = array_values(array_unique($rxAdviceIds)); $rxAdviceTexts = array_values(array_unique($rxAdviceTexts)); $selectParts = [ $masterAdviceField . ' as advice_text', $masterHindiField . ' as advice_hindi', ]; if ($masterIdField !== null) { $selectParts[] = $masterIdField . ' as advice_id'; } $masterBuilder = $this->db->table('opd_advice') ->select(implode(',', $selectParts)) ->where($masterAdviceField . ' !=', ''); $hasScopedFilter = false; if ($masterIdField !== null && !empty($rxAdviceIds)) { $masterBuilder->whereIn($masterIdField, $rxAdviceIds); $hasScopedFilter = true; } if (!empty($rxAdviceTexts)) { if ($hasScopedFilter) { $masterBuilder->orWhereIn($masterAdviceField, $rxAdviceTexts); } else { $masterBuilder->whereIn($masterAdviceField, $rxAdviceTexts); } $hasScopedFilter = true; } if (!$hasScopedFilter) { $masterRows = []; } else { $masterRows = $masterBuilder->limit(500)->get()->getResultArray(); } $byId = []; $byText = []; foreach ($masterRows as $mRow) { $rawHindi = (string) ($mRow['advice_hindi'] ?? ''); if ($rawHindi === '') { continue; } $hindi = trim((string) preg_replace('/\r\n|\r|\n/', "\n", str_replace(['
    ', '
    ', '
    '], "\n", strip_tags($rawHindi)))); if ($hindi === '') { continue; } $adviceText = trim((string) ($mRow['advice_text'] ?? '')); $textKey = mb_strtolower(preg_replace('/\s+/', ' ', $adviceText) ?? $adviceText); if ($textKey !== '') { $byText[$textKey] = $hindi; } $idKey = trim((string) ($mRow['advice_id'] ?? '')); if ($idKey !== '') { $byId[$idKey] = $hindi; } } foreach ($data['rx_advices'] as $idx => $advRow) { $currentHindi = trim((string) ($advRow['advice_hindi'] ?? ($advRow['advice_txt_hindi'] ?? ''))); if ($currentHindi !== '') { $data['rx_advices'][$idx]['advice_hindi'] = $currentHindi; continue; } $resolved = ''; $advIdKey = trim((string) ($advRow['advice_id'] ?? '')); if ($advIdKey !== '' && isset($byId[$advIdKey])) { $resolved = (string) $byId[$advIdKey]; } if ($resolved === '') { $advText = trim((string) ($advRow['advice_txt'] ?? ($advRow['advice'] ?? ''))); $advTextKey = mb_strtolower(preg_replace('/\s+/', ' ', $advText) ?? $advText); if ($advTextKey !== '' && isset($byText[$advTextKey])) { $resolved = (string) $byText[$advTextKey]; } } if ($resolved !== '') { $data['rx_advices'][$idx]['advice_hindi'] = $resolved; } } } } return $data; } public function confirm_payment() { if (!$this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'update' => 0, 'error_text' => 'Invalid request', ]); } $mode = (int) $this->request->getPost('mode'); $oid = (int) $this->request->getPost('oid'); $spid = (string) $this->request->getPost('spid'); if ($oid <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid OPD ID', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $sql = "select * from opd_master where opd_id='" . $oid . "'"; $query = $this->db->query($sql); $opdMaster = $query->getResult(); if (empty($opdMaster)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'OPD not found', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $sql = "select coalesce(sum(if(credit_debit=0,amount,amount*-1)),0) as opd_pay from payment_history where payof_id='" . $oid . "' and payof_type=1"; $query = $this->db->query($sql); $chkPayment = $query->getResult(); $paidAmount = (float) ($chkPayment[0]->opd_pay ?? 0); $pendingAmt = (float) $opdMaster[0]->opd_fee_amount - $paidAmount; $alreadyPaid = (int) ($opdMaster[0]->payment_status ?? 0) === 1; $payRemark = ''; if (!empty($opdMaster[0]->opd_discount) && (float) $opdMaster[0]->opd_discount > 0) { $payRemark = 'Dis.Amt.:' . $opdMaster[0]->opd_disc_remark . ' /Amount: ' . $opdMaster[0]->opd_discount . '/Update:' . $opdMaster[0]->opd_disc_update_by; } $user = auth()->user(); $userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User'; $userId = $user->id ?? 0; $userNameInfo = $userLabel . '[' . date('d-m-Y H:i:s') . ']'; $paymentModel = new PaymentModel(); $opdModel = new OpdModel(); if ($alreadyPaid && in_array($mode, [1, 2, 4], true)) { return $this->response->setJSON([ 'update' => 1, 'showcontent' => (string) ($opdMaster[0]->payment_mode_desc ?? 'Paid'), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($mode === 0) { if ($alreadyPaid && $pendingAmt <= 0) { return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'No Cost', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $data = [ 'payment_mode' => '0', 'payment_status' => '1', 'payment_mode_desc' => 'No Cost', 'confirm_pay_opd' => date('Y-m-d H:i:s'), 'prepared_by_id' => $userId, ]; $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); // Keep zero-amount confirm fast. return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Zero Cost', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($mode === 1 && $pendingAmt > 0) { $paydata = [ 'payment_mode' => '1', 'payof_type' => '1', 'payof_id' => $oid, 'payof_code' => $opdMaster[0]->opd_code, 'credit_debit' => '0', 'amount' => $opdMaster[0]->opd_fee_amount, 'payment_date' => date('Y-m-d H:i:s'), 'remark' => $payRemark, 'update_by' => $userNameInfo . ' [' . $userId . ']', 'update_by_id' => $userId, 'insert_code' => $spid, ]; $existingPayment = null; if ($spid !== '') { $existingPayment = $this->db->table('payment_history') ->select('id') ->where('payof_type', 1) ->where('payof_id', $oid) ->where('insert_code', $spid) ->orderBy('id', 'DESC') ->get(1) ->getRow(); } $insertId = (int) ($existingPayment->id ?? 0); if ($insertId <= 0) { $insertId = $paymentModel->insertPayment($paydata); } $data = [ 'payment_mode' => '1', 'payment_status' => '1', 'payment_mode_desc' => 'Cash', 'payment_id' => $insertId, 'confirm_pay_opd' => date('Y-m-d H:i:s'), 'prepared_by_id' => $userId, ]; $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'CASH', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($mode === 2 && $pendingAmt > 0) { $cardTran = trim((string) $this->request->getPost('input_card_tran')); if ($cardTran === '' || strlen($cardTran) < 3 || strlen($cardTran) > 15) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Card Transaction ID is required (3-15 chars).', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $paydata = [ 'payment_mode' => '2', 'payof_type' => '1', 'payof_id' => $oid, 'payof_code' => $opdMaster[0]->opd_code, 'credit_debit' => '0', 'amount' => $opdMaster[0]->opd_fee_amount, 'payment_date' => date('Y-m-d H:i:s'), 'remark' => $payRemark, 'update_by' => $userNameInfo . ' [' . $userId . ']', 'pay_bank_id' => (int) $this->request->getPost('cbo_pay_type'), 'card_tran_id' => $cardTran, 'update_by_id' => $userId, 'insert_code' => $spid, ]; $existingPayment = null; if ($spid !== '') { $existingPayment = $this->db->table('payment_history') ->select('id') ->where('payof_type', 1) ->where('payof_id', $oid) ->where('insert_code', $spid) ->orderBy('id', 'DESC') ->get(1) ->getRow(); } $insertId = (int) ($existingPayment->id ?? 0); if ($insertId <= 0) { $insertId = $paymentModel->insertPayment($paydata); } $data = [ 'payment_mode' => '2', 'payment_status' => '1', 'payment_mode_desc' => 'Bank/Online', 'confirm_pay_opd' => date('Y-m-d H:i:s'), 'payment_id' => $insertId, 'prepared_by_id' => $userId, ]; $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Bank Card', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($mode === 4) { $data = [ 'payment_mode' => '4', 'payment_status' => '1', 'payment_mode_desc' => 'Org.Case Credit', 'confirm_pay_opd' => date('Y-m-d H:i:s'), 'insurance_case_id' => (int) $this->request->getPost('case_id'), 'prepared_by_id' => $userId, ]; $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Org. Case Credit', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid payment request', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } private function autoCreateOpdQueue(int $opdId): void { if ($opdId <= 0 || ! $this->db->tableExists('opd_prescription')) { return; } // If a queue row already exists for this OPD, do nothing. $existing = $this->db->table('opd_prescription') ->where('opd_id', $opdId) ->get(1) ->getRow(); if ($existing) { return; } $opdRow = $this->db->table('opd_master') ->where('opd_id', $opdId) ->get(1) ->getRow(); if (! $opdRow) { return; } $fields = $this->db->getFieldNames('opd_prescription'); $insert = []; if (in_array('opd_id', $fields, true)) { $insert['opd_id'] = $opdId; } if (in_array('p_id', $fields, true)) { $insert['p_id'] = (int) ($opdRow->p_id ?? 0); } if (in_array('doc_id', $fields, true)) { $insert['doc_id'] = (int) ($opdRow->doc_id ?? 0); } if (in_array('date_opd_visit', $fields, true)) { $insert['date_opd_visit'] = date('Y-m-d'); } if (in_array('visit_status', $fields, true)) { $insert['visit_status'] = 0; } if (in_array('session_id', $fields, true)) { $insert['session_id'] = 0; } if (in_array('queue_no', $fields, true)) { // JOIN through opd_master to guarantee per-doctor isolation // (opd_prescription.doc_id may be stale on re-assignments) $maxQueue = $this->db->query( 'SELECT COALESCE(MAX(pr.queue_no), 0) AS max_queue' . ' FROM opd_prescription pr' . ' INNER JOIN opd_master om ON om.opd_id = pr.opd_id' . ' WHERE pr.date_opd_visit = ? AND om.doc_id = ?', [date('Y-m-d'), (int) ($opdRow->doc_id ?? 0)] )->getRow(); $insert['queue_no'] = (int) ($maxQueue->max_queue ?? 0) + 1; } if (! empty($insert)) { $this->db->table('opd_prescription')->insert($insert); } } private function syncOpdQueueAfterDoctorDateUpdate(int $opdId, int $doctorId, string $appointmentDate): void { if ($opdId <= 0 || $doctorId <= 0 || $appointmentDate === '' || ! $this->db->tableExists('opd_prescription')) { return; } $queueRow = $this->db->table('opd_prescription') ->where('opd_id', $opdId) ->get(1) ->getRowArray(); if (empty($queueRow)) { return; } $fields = $this->db->getFieldNames('opd_prescription'); $update = []; if (in_array('doc_id', $fields, true)) { $update['doc_id'] = $doctorId; } if (in_array('date_opd_visit', $fields, true)) { $update['date_opd_visit'] = $appointmentDate; } if (in_array('queue_no', $fields, true)) { $maxQueue = $this->db->query( 'SELECT COALESCE(MAX(pr.queue_no), 0) AS max_queue' . ' FROM opd_prescription pr' . ' INNER JOIN opd_master om ON om.opd_id = pr.opd_id' . ' WHERE pr.date_opd_visit = ? AND om.doc_id = ? AND pr.opd_id <> ?', [$appointmentDate, $doctorId, $opdId] )->getRow(); $update['queue_no'] = (int) ($maxQueue->max_queue ?? 0) + 1; } if (! empty($update)) { $this->db->table('opd_prescription')->where('opd_id', $opdId)->update($update); } } public function update_doc_date(int $opdId) { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'update' => 0, 'error_text' => 'Invalid request', ]); } $opdId = (int) ($this->request->getPost('oid') ?? $opdId); $doctorId = (int) $this->request->getPost('doc_name_id'); $grossAmount = (float) $this->request->getPost('opd_fee_amt'); $appointmentDateInput = trim((string) $this->request->getPost('datepicker_opddate')); if ($opdId <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid OPD ID', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($doctorId <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Please select a doctor.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($appointmentDateInput === '') { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Appointment date is required.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($grossAmount < 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'OPD fee cannot be negative.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $appointmentDate = str_to_MysqlDate($appointmentDateInput); if ($appointmentDate === '1900-01-01') { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid appointment date.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $opdRow = $this->db->table('opd_master')->where('opd_id', $opdId)->get(1)->getRowArray(); if (empty($opdRow)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'OPD not found', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $doctorInfo = $this->db->query( 'SELECT d.id, d.p_fname, COALESCE(GROUP_CONCAT(m.SpecName), "") AS SpecName' . ' FROM doctor_master d' . ' LEFT JOIN doc_spec s ON d.id = s.doc_id' . ' LEFT JOIN med_spec m ON s.med_spec_id = m.id' . ' WHERE d.active = 1 AND d.id = ?' . ' GROUP BY d.id, d.p_fname', [$doctorId] )->getRowArray(); if (empty($doctorInfo)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Selected doctor was not found.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $user = auth()->user(); $userLabel = (string) ($user->username ?? $user->email ?? 'User'); $userId = (int) ($user->id ?? 0); $userNameInfo = $userLabel . '[' . $userId . ']:T-' . date('d-m-Y H:i:s'); $discount = (float) ($opdRow['opd_discount'] ?? 0); $netAmount = max(0, $grossAmount - $discount); $dataUpdate = [ 'doc_id' => $doctorId, 'doc_name' => (string) ($doctorInfo['p_fname'] ?? ''), 'doc_spec' => (string) ($doctorInfo['SpecName'] ?? ''), 'apointment_date' => $appointmentDate, 'opd_fee_gross_amount' => $grossAmount, 'opd_fee_amount' => $netAmount, ]; $paymentModel = new PaymentModel(); $this->db->transStart(); $this->db->table('opd_master')->where('opd_id', $opdId)->update($dataUpdate); $linkedPaymentId = (int) ($opdRow['payment_id'] ?? 0); if ($linkedPaymentId > 0) { $paymentHistory = $this->db->table('payment_history') ->where('payof_type', 1) ->where('payof_id', $opdId) ->where('id', $linkedPaymentId) ->get(1) ->getRowArray(); if (! empty($paymentHistory)) { $amountDiff = (float) ($paymentHistory['amount'] ?? 0) - $netAmount; if (abs($amountDiff) > 0.00001) { $suffix = 'Update OPD Rate Difference ' . number_format($amountDiff, 2, '.', '') . ' :By ' . $userNameInfo; $remark = trim((string) ($paymentHistory['remark'] ?? '')); if ($remark !== '') { $remark .= ' /' . $suffix; } else { $remark = $suffix; } $paymentModel->updatePayment([ 'amount' => $netAmount, 'remark' => $remark, ], (int) $paymentHistory['id']); } } } $this->syncOpdQueueAfterDoctorDateUpdate($opdId, $doctorId, $appointmentDate); $this->db->transComplete(); if (! $this->db->transStatus()) { return $this->response->setStatusCode(500)->setJSON([ 'update' => 0, 'error_text' => 'Unable to update OPD invoice details.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Doctor/date updated', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } public function opd_discount_update(int $opdId) { if (!$this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'update' => 0, 'error_text' => 'Invalid request', ]); } $opdId = (int) $this->request->getPost('oid'); if ($opdId <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid OPD ID', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $discount = (float) $this->request->getPost('input_dis_amt'); $remark = (string) $this->request->getPost('input_dis_desc'); $user = auth()->user(); $userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User'; $userId = $user->id ?? 0; $userName = $userLabel . '[' . $userId . ']'; $sql = "select * from opd_master where opd_id=" . $opdId; $query = $this->db->query($sql); $opdMaster = $query->getResult(); if (empty($opdMaster)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'OPD not found', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $grossAmount = (float) ($opdMaster[0]->opd_fee_gross_amount ?? 0); $netAmount = $grossAmount - $discount; if ($netAmount < 0) { $netAmount = 0; } $dataUpdate = [ 'opd_discount' => $discount, 'opd_disc_remark' => $remark, 'opd_fee_amount' => $netAmount, 'opd_disc_update_by' => $userName, ]; $this->db->table('opd_master')->where('opd_id', $opdId)->update($dataUpdate); return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Discount updated', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } public function opd_cancel(int $opdId) { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'update' => 0, 'error_text' => 'Invalid request', ]); } $opdId = (int) ($this->request->getPost('oid') ?? $opdId); if ($opdId <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid OPD ID', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $opdRow = $this->db->table('opd_master')->where('opd_id', $opdId)->get(1)->getRowArray(); if (empty($opdRow)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'OPD not found', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $user = auth()->user(); $userId = (int) ($user->id ?? 0); $userName = (string) ($user->username ?? $user->email ?? 'User'); $userLabel = $userName . '[' . $userId . ']:T-' . date('d-m-Y H:i:s'); $remarkInput = trim((string) $this->request->getPost('input_remark')); $remarkPrefix = $remarkInput !== '' ? $remarkInput : 'Cancel OPD'; $statusRemark = $remarkPrefix . ' [Update By:' . $userLabel . ']'; $sql = "select coalesce(sum(if(credit_debit>0,amount*-1,amount)),0) as paid_amount from payment_history where payof_type=1 and payof_id=" . (int) $opdId; $query = $this->db->query($sql); $paymentHistory = $query->getResult(); $paidAmount = (float) ($paymentHistory[0]->paid_amount ?? 0); $refundPaymentId = 0; $this->db->transStart(); if ($paidAmount > 0 && (int) ($opdRow['opd_status'] ?? 0) !== 3) { $paymentModel = new PaymentModel(); $refundPaymentId = $paymentModel->insertPayment([ 'payment_mode' => '1', 'payof_type' => '1', 'payof_id' => $opdId, 'payof_code' => (string) ($opdRow['opd_code'] ?? ''), 'credit_debit' => '1', 'amount' => $paidAmount, 'payment_date' => date('Y-m-d H:i:s'), 'remark' => 'Refund: ' . $remarkPrefix, 'update_by' => $userLabel, 'update_by_id' => $userId, ]); } $this->db->table('opd_master') ->where('opd_id', $opdId) ->update([ 'opd_status' => '3', 'opd_status_remark' => $statusRemark, ]); $this->db->transComplete(); if (! $this->db->transStatus()) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Unable to cancel OPD. Please retry.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'OPD cancelled' . ($refundPaymentId > 0 ? ' and refund posted' : ''), 'refund_payment_id' => $refundPaymentId, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } /** * Load scan/upload modal context for a specific OPD record. */ public function opd_load_doc(int $opdid) { $sql = "select *,date_format(apointment_date,'%d-%m-%Y') as str_apointment_date, (case payment_status when 1 then 'Cash' when 2 then 'Bank Card' when 3 then 'Org. Credit' else 'Pending' end) as Payment_type_str from opd_master where opd_id=" . (int) $opdid; $query = $this->db->query($sql); $opdMaster = $query->getResult(); if (empty($opdMaster)) { $patientSql = "select *,if(gender=1,'Male','Female') as xgender from patient_master where id=" . (int) $opdid; $personInfo = $this->db->query($patientSql)->getResult(); if (empty($personInfo)) { return $this->response->setStatusCode(404)->setBody('Patient or OPD record not found'); } if (! empty($personInfo)) { $personInfo[0]->age = get_age_1($personInfo[0]->dob ?? null, $personInfo[0]->age ?? '', $personInfo[0]->age_in_month ?? '', $personInfo[0]->estimate_dob ?? '', date('Y-m-d')); } $opdMaster = [(object) [ 'opd_id' => 0, 'opd_code' => 'PT-' . $opdid, 'p_id' => $opdid, 'apointment_date' => date('Y-m-d'), 'Payment_type_str' => 'Self', ]]; } else { $opdRow = $opdMaster[0]; $sql = "select *,if(gender=1,'Male','Female') as xgender from patient_master where id='" . (int) $opdRow->p_id . "'"; $query = $this->db->query($sql); $personInfo = $query->getResult(); if (!empty($personInfo)) { $personInfo[0]->age = get_age_1($personInfo[0]->dob ?? null, $personInfo[0]->age ?? '', $personInfo[0]->age_in_month ?? '', $personInfo[0]->estimate_dob ?? '', $opdRow->apointment_date ?? null); } } $hospitalEnabled = hospital_setting_value('ALLOW_IMAGE_PREUPLOAD_EDIT', '0') === '1'; $user = function_exists('auth') ? auth()->user() : null; $userAllowed = $user && method_exists($user, 'can') && $user->can('media.image.preupload-edit'); return view('billing/opd_scan_modal_body', [ 'opdid' => $opdid, 'opd_master' => $opdMaster, 'person_info' => $personInfo, 'allow_image_preupload_edit' => ($hospitalEnabled && $userAllowed) ? 1 : 0, ]); } /** * Save uploaded or captured scan file and queue it for asynchronous AI analysis. */ public function save_image(int $opdid) { $patientId = 0; $opdRow = $this->db->table('opd_master')->where('opd_id', $opdid)->get(1)->getRowArray(); if (! empty($opdRow)) { $patientId = (int) ($opdRow['p_id'] ?? 0); } else { $patientRow = $this->db->table('patient_master')->where('id', $opdid)->get(1)->getRowArray(); if (! empty($patientRow)) { $patientId = (int) $patientRow['id']; $opdRow = [ 'opd_id' => 0, 'p_id' => $patientId, 'insurance_case_id' => 0, 'opd_status' => 2, ]; } else { log_message('warning', 'OPD scan upload failed: Patient or OPD not found (id={id})', ['id' => $opdid]); return $this->response->setStatusCode(404)->setJSON(['update' => 0, 'error_text' => 'Patient or OPD not found']); } } $uploadsDir = rtrim(FCPATH, '/\\') . '/uploads/' . date('Ymd'); if (!is_dir($uploadsDir)) { @mkdir($uploadsDir, 0777, true); } if (!is_dir($uploadsDir) || !is_writable($uploadsDir)) { log_message('error', 'OPD scan upload dir issue (opd_id={opd_id}, dir={dir}, exists={exists}, writable={writable})', [ 'opd_id' => $opdid, 'dir' => $uploadsDir, 'exists' => is_dir($uploadsDir) ? 'yes' : 'no', 'writable' => is_writable($uploadsDir) ? 'yes' : 'no', ]); } $baseFilename = 'OPD-' . $opdid . '-' . time(); $filename = $baseFilename . '.jpg'; $fullPath = $uploadsDir . '/' . $filename; $detectedMimeType = 'image/jpeg'; $isImageFile = 1; $detectedExt = '.jpg'; $saved = false; // Source 1: direct webcam file upload. $uploadedFile = $this->request->getFile('webcam'); if ($uploadedFile && $uploadedFile->isValid() && !$uploadedFile->hasMoved()) { $ext = strtolower((string) ($uploadedFile->getClientExtension() ?: $uploadedFile->guessExtension() ?: 'jpg')); if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { $ext = 'jpg'; } $filename = $baseFilename . '.' . $ext; $fullPath = $uploadsDir . '/' . $filename; try { $uploadedFile->move($uploadsDir, $filename); $saved = is_file($fullPath); $detectedMimeType = (string) ($uploadedFile->getClientMimeType() ?: 'image/jpeg'); $isImageFile = 1; $detectedExt = '.' . $ext; } catch (\Throwable $e) { log_message('error', 'OPD webcam upload move failed (opd_id={opd_id}, dir={dir}, error={error})', [ 'opd_id' => $opdid, 'dir' => $uploadsDir, 'error' => $e->getMessage(), ]); } } // Source 2: manual file upload (PDF/image). if (!$saved) { $uploadedFile = $this->request->getFile('userfile'); if ($uploadedFile && $uploadedFile->isValid() && !$uploadedFile->hasMoved()) { $ext = strtolower((string) ($uploadedFile->getClientExtension() ?: $uploadedFile->guessExtension() ?: pathinfo((string) $uploadedFile->getName(), PATHINFO_EXTENSION))); if (!in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'webp'], true)) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid file type. Upload PDF or image only.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $filename = $baseFilename . '.' . $ext; $fullPath = $uploadsDir . '/' . $filename; try { $uploadedFile->move($uploadsDir, $filename); $saved = is_file($fullPath); $detectedMimeType = (string) ($uploadedFile->getClientMimeType() ?: ($ext === 'pdf' ? 'application/pdf' : 'image/jpeg')); $isImageFile = $ext === 'pdf' ? 0 : 1; $detectedExt = '.' . $ext; } catch (\Throwable $e) { log_message('error', 'OPD userfile upload move failed (opd_id={opd_id}, ext={ext}, dir={dir}, error={error})', [ 'opd_id' => $opdid, 'ext' => $ext, 'dir' => $uploadsDir, 'error' => $e->getMessage(), ]); } } } // Source 3: base64 image data fallback from older capture flows. if (!$saved) { $dataUri = (string) ($this->request->getPost('webcam_data') ?? $this->request->getPost('image') ?? ''); if ($dataUri !== '' && preg_match('#^data:image/\w+;base64,#i', $dataUri) === 1) { preg_match('#^data:image/(\w+);base64,#i', $dataUri, $match); $imgExt = strtolower((string) ($match[1] ?? 'jpg')); if ($imgExt === 'jpeg') { $imgExt = 'jpg'; } if (!in_array($imgExt, ['jpg', 'png', 'webp'], true)) { $imgExt = 'jpg'; } $filename = $baseFilename . '.' . $imgExt; $fullPath = $uploadsDir . '/' . $filename; $raw = base64_decode((string) preg_replace('#^data:image/\w+;base64,#i', '', $dataUri)); if ($raw !== false && $raw !== '') { try { file_put_contents($fullPath, $raw); $saved = is_file($fullPath); $detectedMimeType = 'image/' . ($imgExt === 'jpg' ? 'jpeg' : $imgExt); $isImageFile = 1; $detectedExt = '.' . $imgExt; } catch (\Throwable $e) { log_message('error', 'OPD base64 upload save failed (opd_id={opd_id}, ext={ext}, file={file}, error={error})', [ 'opd_id' => $opdid, 'ext' => $imgExt, 'file' => $fullPath, 'error' => $e->getMessage(), ]); } } } } if (!$saved) { log_message('error', 'OPD scan save failed: no source persisted (opd_id={opd_id}, dir={dir}, has_webcam={has_webcam}, has_userfile={has_userfile}, has_data_uri={has_data_uri})', [ 'opd_id' => $opdid, 'dir' => $uploadsDir, 'has_webcam' => ($this->request->getFile('webcam') !== null) ? 1 : 0, 'has_userfile' => ($this->request->getFile('userfile') !== null) ? 1 : 0, 'has_data_uri' => ((string) ($this->request->getPost('webcam_data') ?? $this->request->getPost('image') ?? '') !== '') ? 1 : 0, ]); return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Unable to capture image. Please allow camera and retry.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $user = auth()->user(); $userId = (int) ($user->id ?? 0); $userName = (string) ($user->username ?? $user->email ?? 'User'); $publicPath = '/uploads/' . date('Ymd') . '/' . $filename; $insertId = $this->insertOpdFileUploadRecord( $fullPath, $publicPath, (int) ($opdRow['p_id'] ?? 0), $opdid, (int) ($opdRow['insurance_case_id'] ?? 0), $userName, $userId, $detectedMimeType, $isImageFile, $detectedExt ); if ($insertId <= 0) { log_message('error', 'OPD scan upload saved but DB insert failed (opd_id={opd_id}, file={file}, public_path={public_path})', [ 'opd_id' => $opdid, 'file' => $fullPath, 'public_path' => $publicPath, ]); } $scanType = $this->detectScanType((string) $filename); $docType = trim((string) ($this->request->getPost('document_type') ?? 'Paper Document')); if (empty($docType)) { $docType = 'Paper Document'; } if ($insertId > 0 && $this->db->tableExists('file_upload_data')) { $fields = $this->db->getFieldNames('file_upload_data'); $update = []; if (in_array('scan_type', $fields, true)) { $update['scan_type'] = $scanType; } if (in_array('document_type', $fields, true)) { $update['document_type'] = $docType; } if (in_array('content_description', $fields, true)) { $update['content_description'] = 'Queued for AI analysis'; } if (in_array('ai_status', $fields, true)) { $update['ai_status'] = 'pending'; } if (in_array('ai_alert_flag', $fields, true)) { $update['ai_alert_flag'] = 0; } if (!empty($update)) { $this->db->table('file_upload_data')->where('id', $insertId)->update($update); } try { $patientController = new \App\Controllers\Patient(); $patientController->enqueueHealthDocumentFhirForFileUpload($insertId); } catch (\Throwable $e) { log_message('warning', 'Unable to enqueue ABDM HealthDocumentRecord FHIR sync for OPD file {id}: {msg}', [ 'id' => $insertId, 'msg' => $e->getMessage(), ]); } } $opdStatusUpdated = 0; if ($insertId > 0 && $this->db->tableExists('opd_master')) { $opdFields = $this->db->getFieldNames('opd_master') ?? []; $currentStatus = (int) ($opdRow['opd_status'] ?? 0); if ($currentStatus !== 2 && $currentStatus !== 3) { $statusUpdate = []; if (in_array('opd_status', $opdFields, true)) { $statusUpdate['opd_status'] = 2; } if (in_array('opd_status_remark', $opdFields, true)) { $statusUpdate['opd_status_remark'] = 'Auto Visit Done after scan upload'; } if (! empty($statusUpdate)) { $this->db->table('opd_master')->where('opd_id', $opdid)->update($statusUpdate); $opdStatusUpdated = 1; } } } return $this->response->setJSON([ 'update' => 1, 'filename' => $filename, 'file_path' => $publicPath, 'file_id' => $insertId, 'scan_type' => $scanType, 'extracted_text' => '', 'auto_saved_to_prescription' => 0, 'opd_session_id' => 0, 'target_field' => '', 'ai_status' => 'pending', 'opd_status' => 2, 'opd_status_updated' => $opdStatusUpdated, 'error_text' => 'File uploaded successfully', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } public function opd_file_last_list(int $opdid = 0) { if (!$this->db->tableExists('file_upload_data')) { log_message('error', 'OPD scan list failed: file_upload_data table missing (opd_id={opd_id})', ['opd_id' => $opdid]); return $this->response->setBody('
    No scan table found.
    '); } $fields = $this->db->getFieldNames('file_upload_data'); $hasShowType = in_array('show_type', $fields, true); $safeSelect = [ 'id', 'full_path', 'file_ext', 'insert_date', 'pid', 'opd_id', ]; if ($hasShowType) { $safeSelect[] = 'show_type'; } if (in_array('public_path', $fields, true)) { $safeSelect[] = 'public_path'; } foreach (['scan_type', 'document_type', 'content_description', 'ai_status', 'ai_alert_flag', 'ai_alert_text'] as $optional) { if (in_array($optional, $fields, true)) { $safeSelect[] = $optional; } } if (in_array('ai_provider', $fields, true)) { $safeSelect[] = 'ai_provider'; } $builder = $this->db->table('file_upload_data') ->select(implode(',', $safeSelect)) ->orderBy('id', 'DESC') ->limit(10); if ($hasShowType) { $builder->where('show_type', 0); } if ($opdid > 0) { $builder->where('opd_id', $opdid); } $rows = $builder->get()->getResultArray(); if ($opdid > 0 && empty($rows)) { log_message('warning', 'OPD scan list returned no rows (opd_id={opd_id})', ['opd_id' => $opdid]); } $list = []; foreach ($rows as $row) { $raw = str_replace('\\', '/', (string) ($row['full_path'] ?? '')); $publicPath = trim((string) ($row['public_path'] ?? '')); if ($publicPath === '') { $publicPath = $raw; $pos = strpos($raw, '/uploads/'); if ($pos !== false) { $publicPath = substr($raw, $pos); } } $publicPath = str_replace('\\', '/', $publicPath); if (strpos($publicPath, '/uploads/') !== 0) { $trimmedPath = ltrim($publicPath, '/'); if (stripos($trimmedPath, 'uploads/') === 0) { $publicPath = '/' . $trimmedPath; } } $ext = strtolower((string) ($row['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION))); if ($ext !== '' && $ext[0] !== '.') { $ext = '.' . $ext; } $insertDateRaw = (string) ($row['insert_date'] ?? ''); $insertTs = $insertDateRaw !== '' ? strtotime($insertDateRaw) : false; $nowTs = time(); $isWithin24Hours = ($insertTs !== false) && (($nowTs - $insertTs) <= 86400); $isSameDate = ($insertTs !== false) && (date('Y-m-d', $insertTs) === date('Y-m-d', $nowTs)); $canDeleteLimited = $isWithin24Hours || $isSameDate; $list[] = [ 'id' => (int) ($row['id'] ?? 0), 'path' => $publicPath, 'is_pdf' => $ext === '.pdf', 'insert_date' => !empty($row['insert_date']) ? date('d/m/Y-H:i', strtotime((string) $row['insert_date'])) : '', 'can_delete_limited' => $canDeleteLimited ? 1 : 0, 'scan_type' => (string) ($row['scan_type'] ?? ''), 'document_type' => (string) ($row['document_type'] ?? ''), 'content_description' => (string) ($row['content_description'] ?? ''), 'ai_status' => (string) ($row['ai_status'] ?? ''), 'ai_alert_flag' => (int) ($row['ai_alert_flag'] ?? 0), 'ai_alert_text' => (string) ($row['ai_alert_text'] ?? ''), 'ai_provider' => (string) ($row['ai_provider'] ?? ''), ]; } return view('billing/opd_scan_last_list', ['opd_file_list' => $list]); } public function opd_file_list(int $opdid) { if (! $this->db->tableExists('file_upload_data')) { log_message('error', 'OPD scan carousel failed: file_upload_data table missing (opd_id={opd_id})', ['opd_id' => $opdid]); return $this->response->setBody('
    No scan table found.
    '); } $fields = $this->db->getFieldNames('file_upload_data'); $safeSelect = ['id', 'full_path', 'file_ext', 'insert_date']; $hasShowType = in_array('show_type', $fields, true); if ($hasShowType) { $safeSelect[] = 'show_type'; } if (in_array('public_path', $fields, true)) { $safeSelect[] = 'public_path'; } $builder = $this->db->table('file_upload_data') ->select(implode(',', $safeSelect)) ->where('opd_id', (int) $opdid) ->orderBy('id', 'ASC'); if ($hasShowType) { $builder->where('show_type', 0); } $rows = $builder->get()->getResultArray(); if (empty($rows)) { log_message('warning', 'OPD scan carousel returned no rows (opd_id={opd_id})', ['opd_id' => $opdid]); } $slides = []; foreach ($rows as $row) { $normalizedPath = str_replace('\\', '/', (string) ($row['full_path'] ?? '')); $publicPath = trim((string) ($row['public_path'] ?? '')); if ($publicPath === '') { $publicPath = $normalizedPath; } $uploadPos = strpos($normalizedPath, '/uploads/'); if ($uploadPos !== false && strpos($publicPath, '/uploads/') !== 0) { $publicPath = substr($normalizedPath, $uploadPos); } $publicPath = str_replace('\\', '/', $publicPath); if (strpos($publicPath, '/uploads/') !== 0) { $trimmedPath = ltrim($publicPath, '/'); if (stripos($trimmedPath, 'uploads/') === 0) { $publicPath = '/' . $trimmedPath; } } $ext = strtolower((string) ($row['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION))); if ($ext !== '' && $ext[0] === '.') { $ext = substr($ext, 1); } $insertDate = (string) ($row['insert_date'] ?? ''); $insertTs = $insertDate !== '' ? strtotime($insertDate) : false; $nowTs = time(); $isWithin24Hours = ($insertTs !== false) && (($nowTs - $insertTs) <= 86400); $isSameDate = ($insertTs !== false) && (date('Y-m-d', $insertTs) === date('Y-m-d', $nowTs)); $canDeleteLimited = $isWithin24Hours || $isSameDate; $slides[] = [ 'id' => (int) ($row['id'] ?? 0), 'path' => $publicPath, 'is_pdf' => $ext === 'pdf', 'insert_date' => $insertDate !== '' ? date('d-m-Y H:i', strtotime($insertDate)) : '', 'can_delete_today' => $canDeleteLimited ? 1 : 0, 'can_delete_limited' => $canDeleteLimited ? 1 : 0, ]; } return view('billing/opd_scan_carousel_list', [ 'opdid' => (int) $opdid, 'slides' => $slides, ]); } public function scan_ai_assist() { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON([ 'update' => 0, 'error_text' => 'Invalid request', ]); } $opdid = (int) $this->request->getPost('opd_id'); $scanType = trim((string) $this->request->getPost('scan_type')); if ($scanType === '') { $scanType = 'general'; } $text = trim((string) $this->request->getPost('extracted_text')); if ($text === '' || strlen($text) < 20) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Extracted report text is too short for AI diagnosis assist.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $analysis = $this->analyzeScanForDiagnosisSupport($text, $scanType); if ($analysis === null) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'AI diagnosis assist unavailable right now.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } $applyToOpd = (int) $this->request->getPost('apply_to_opd') === 1; $applied = false; $sessionId = 0; $updatedFields = []; if ($applyToOpd && $opdid > 0) { $applyResult = $this->applyAiScanAssistToPrescription($opdid, $analysis); $applied = (bool) ($applyResult['applied'] ?? false); $sessionId = (int) ($applyResult['session_id'] ?? 0); $updatedFields = is_array($applyResult['updated_fields'] ?? null) ? $applyResult['updated_fields'] : []; } return $this->response->setJSON([ 'update' => 1, 'analysis' => $analysis, 'applied' => $applied ? 1 : 0, 'opd_session_id' => $sessionId, 'updated_fields' => $updatedFields, 'error_text' => $applied ? 'AI diagnosis assist prepared and added to consult fields.' : 'AI diagnosis assist prepared.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } /** * Process one uploaded scan file through AI and update per-file metadata. */ public function scan_ai_process_file() { try { $fileId = (int) $this->request->getPost('file_id'); if ($fileId <= 0) { return $this->response->setJSON([ 'update' => 0, 'error_text' => 'Invalid file ID.', 'csrfHash' => csrf_hash(), ]); } $applyToOpd = (int) $this->request->getPost('apply_to_opd') === 1; $result = $this->processAiForUploadedFile($fileId, $applyToOpd); return $this->response->setJSON([ 'update' => (int) ($result['update'] ?? 0), 'file_id' => $fileId, 'ai_status' => (string) ($result['ai_status'] ?? 'failed'), 'document_type' => (string) ($result['document_type'] ?? ''), 'content_description' => (string) ($result['content_description'] ?? ''), 'ai_alert_flag' => (int) ($result['ai_alert_flag'] ?? 0), 'ai_alert_text' => (string) ($result['ai_alert_text'] ?? ''), 'error_text' => (string) ($result['error_text'] ?? ''), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } catch (\Throwable $e) { log_message('error', 'scan_ai_process_file failed: {message}', ['message' => $e->getMessage()]); return $this->response->setStatusCode(200)->setJSON([ 'update' => 0, 'ai_status' => 'failed', 'error_text' => 'Background AI processing failed.', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } } public function opd_file_hide(int $fileId) { if (!$this->db->tableExists('file_upload_data')) { return $this->response->setStatusCode(404)->setBody('File table not found'); } $row = $this->db->table('file_upload_data')->select('id,opd_id')->where('id', $fileId)->get(1)->getRowArray(); if (empty($row)) { return $this->response->setStatusCode(404)->setBody('File not found'); } $user = auth()->user(); $userId = (int) ($user->id ?? 0); $fields = $this->db->getFieldNames('file_upload_data'); $update = ['show_type' => 1]; if (in_array('upload_by_id', $fields, true)) { $update['upload_by_id'] = $userId; } $this->db->table('file_upload_data')->where('id', $fileId)->update($update); return $this->opd_file_last_list((int) ($row['opd_id'] ?? 0)); } public function opd_file_delete(int $fileId) { $isAjax = $this->request->isAJAX(); $requestedOpdId = (int) ($this->request->getGetPost('opd_id') ?? 0); $respondDelete = function (int $update, string $message, int $opdId = 0) use ($isAjax, $requestedOpdId) { $effectiveOpdId = $opdId > 0 ? $opdId : $requestedOpdId; if ($isAjax) { return $this->response->setJSON([ 'update' => $update, 'opd_id' => $effectiveOpdId, 'error_text' => $message, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } if ($effectiveOpdId > 0) { $target = base_url('Opd/opd_file_list/' . $effectiveOpdId); return redirect()->to($target) ->with($update === 1 ? 'success' : 'error', $message); } return redirect()->to(base_url('Opd')) ->with($update === 1 ? 'success' : 'error', $message); }; if (! $this->db->tableExists('file_upload_data')) { return $respondDelete(0, 'File table not found'); } $row = $this->db->table('file_upload_data') ->select('id,opd_id,full_path,insert_date') ->where('id', $fileId) ->get(1) ->getRowArray(); if (empty($row)) { return $respondDelete(0, 'File not found'); } $insertDate = (string) ($row['insert_date'] ?? ''); $insertTs = $insertDate !== '' ? strtotime($insertDate) : false; $nowTs = time(); $isWithin24Hours = ($insertTs !== false) && (($nowTs - $insertTs) <= 86400); $isSameDate = ($insertTs !== false) && (date('Y-m-d', $insertTs) === date('Y-m-d', $nowTs)); if (! ($isWithin24Hours || $isSameDate)) { return $respondDelete(0, 'Delete allowed only within 24 hours or same current date uploads', (int) ($row['opd_id'] ?? 0)); } $fullPath = (string) ($row['full_path'] ?? ''); if ($fullPath !== '' && is_file($fullPath)) { @unlink($fullPath); } $this->db->table('file_upload_data')->where('id', (int) $fileId)->delete(); return $respondDelete(1, 'Scan deleted', (int) ($row['opd_id'] ?? 0)); } private function insertOpdFileUploadRecord(string $fullPath, string $publicPath, int $pid, int $opdid, int $caseId, string $uploadBy, int $uploadById, string $mimeType = 'image/jpeg', int $isImageFile = 1, string $fileExt = '.jpg'): int { if (!$this->db->tableExists('file_upload_data')) { return 0; } $fields = $this->db->getFieldNames('file_upload_data'); $ext = $fileExt !== '' ? $fileExt : ('.' . strtolower((string) pathinfo($fullPath, PATHINFO_EXTENSION))); $mimeType = trim($mimeType) !== '' ? $mimeType : 'application/octet-stream'; $isImageFile = $isImageFile > 0 ? 1 : 0; $insert = []; $fieldMap = [ 'name' => basename($fullPath), 'file_name' => basename($fullPath), 'orig_name' => basename($fullPath), 'client_name' => basename($fullPath), 'file_ext' => $ext, 'file_type' => $mimeType, 'full_path' => str_replace('\\', '/', $fullPath), 'file_path' => str_replace('\\', '/', dirname($fullPath)) . '/', 'raw_name' => pathinfo($fullPath, PATHINFO_FILENAME), 'file_size' => @filesize($fullPath) ? round((float) filesize($fullPath) / 1024, 2) : 0, 'is_image' => $isImageFile, 'image_width' => 0, 'image_height' => 0, 'image_type' => ltrim($ext, '.'), 'image_size_str' => '', 'insert_date' => Time::now('Asia/Kolkata')->toDateTimeString(), 'upload_by' => $uploadBy, 'upload_by_id' => $uploadById, 'pid' => $pid, 'opd_id' => $opdid, 'ipd_id' => 0, 'case_id' => $caseId, 'show_type' => 0, 'public_path' => $publicPath, ]; foreach ($fieldMap as $key => $value) { if (in_array($key, $fields, true)) { $insert[$key] = $value; } } $this->db->table('file_upload_data')->insert($insert); return (int) $this->db->insertID(); } private function detectScanType(string $filename): string { $low = strtolower($filename); if (str_contains($low, 'ecg')) { return 'ecg'; } if (str_contains($low, 'lab') || str_contains($low, 'report')) { return 'lab'; } return 'general'; } /** * Run the full AI pipeline for one uploaded scan file and persist per-file AI metadata. * * @return array */ private function processAiForUploadedFile(int $fileId, bool $applyToOpd = false): array { $this->resetScanAiDebug(); if (! $this->db->tableExists('file_upload_data')) { return ['update' => 0, 'ai_status' => 'failed', 'error_text' => 'File table not found']; } $fields = $this->db->getFieldNames('file_upload_data'); $select = ['id']; foreach (['opd_id', 'full_path', 'file_ext', 'file_type', 'name', 'file_name', 'scan_type'] as $col) { if (in_array($col, $fields, true)) { $select[] = $col; } } $row = $this->db->table('file_upload_data') ->select(implode(',', $select)) ->where('id', $fileId) ->get(1) ->getRowArray(); if (empty($row)) { return ['update' => 0, 'ai_status' => 'failed', 'error_text' => 'Scan file not found']; } // Mark row as processing so UI can reflect asynchronous progress. if (in_array('ai_status', $fields, true)) { $this->db->table('file_upload_data')->where('id', $fileId)->update(['ai_status' => 'processing']); } $fullPath = $this->resolveScanFilePath($row); if ($fullPath === '' || !is_file($fullPath)) { $this->updateScanAiColumns($fileId, [ 'ai_status' => 'failed', 'content_description' => 'Scan file not accessible for AI analysis', ], $fields); return ['update' => 0, 'ai_status' => 'failed', 'error_text' => 'Scan file not accessible']; } $filename = (string) ($row['name'] ?? $row['file_name'] ?? basename($fullPath)); $scanType = trim((string) ($row['scan_type'] ?? '')); if ($scanType === '') { $scanType = $this->detectScanType($filename); } $mimeType = $this->guessMimeTypeFromPath($fullPath, (string) ($row['file_type'] ?? '')); $readiness = $this->getScanAiReadiness(); $this->addScanAiDebug('readiness: ' . $readiness['summary']); // Fail early when neither AI server diagnosis nor OCR endpoints are configured. if (! $readiness['ai_server'] && ! $readiness['ocr_server']) { $msg = 'AI not configured. Set DIAGNOSIS_AI_SERVER_URL and DIAGNOSIS_AI_OCR_ENDPOINT in AI Settings.'; $this->updateScanAiColumns($fileId, [ 'scan_type' => $scanType, 'document_type' => $this->classifyDocumentType('', $scanType, $filename), 'content_description' => $msg, 'ai_status' => 'failed', 'ai_alert_flag' => 0, 'ai_alert_text' => '', ], $fields); return [ 'update' => 0, 'ai_status' => 'failed', 'document_type' => $this->classifyDocumentType('', $scanType, $filename), 'content_description' => $msg, 'ai_alert_flag' => 0, 'ai_alert_text' => '', 'error_text' => $msg, ]; } // First try visual interpretation and OCR extraction in parallel-friendly sequence. $visual = $this->analyzeScanVisualSupport($fullPath, $scanType, $mimeType); $text = $this->extractTextFromScanFile($fullPath, $scanType, $mimeType); if ($text === null || trim($text) === '') { // If OCR text is weak, keep visual result as a safe fallback summary. if (is_array($visual) && !empty($visual)) { $documentType = trim((string) ($visual['document_type'] ?? '')); if ($documentType === '') { $documentType = $this->classifyDocumentType('', $scanType, $filename); } $contentDescription = trim((string) ($visual['content_description'] ?? '')); $risk = strtolower(trim((string) ($visual['risk_level'] ?? ''))); $aiAlertText = trim((string) ($visual['alert_text'] ?? '')); $detailLines = []; if ($contentDescription !== '') { $detailLines[] = 'Summary: ' . $contentDescription; } if ($risk !== '') { $detailLines[] = 'Risk level: ' . ucfirst($risk); } if ($aiAlertText !== '') { $detailLines[] = 'Alert: ' . $aiAlertText; } if (empty($detailLines)) { $detailLines[] = 'AI visual review completed.'; } $contentDescription = mb_substr(implode("\n", $detailLines), 0, 2000); $aiAlertFlag = in_array($risk, ['high', 'critical'], true) ? 1 : 0; if ($aiAlertFlag === 1 && $aiAlertText === '') { $aiAlertText = 'AI flagged possible abnormal finding'; } $this->updateScanAiColumns($fileId, [ 'scan_type' => $scanType, 'document_type' => $documentType, 'content_description' => $contentDescription, 'ai_status' => 'completed', 'ai_alert_flag' => $aiAlertFlag, 'ai_alert_text' => $aiAlertText, ], $fields); return [ 'update' => 1, 'ai_status' => 'completed', 'document_type' => $documentType, 'content_description' => $contentDescription, 'ai_alert_flag' => $aiAlertFlag, 'ai_alert_text' => $aiAlertText, 'error_text' => 'AI visual analysis completed', ]; } $documentType = $this->classifyDocumentType('', $scanType, $filename); $debugInfo = $this->formatScanAiDebug(); $failMsg = 'AI server could not read usable text from this file. ' . $readiness['summary'] . '. Ensure OCR endpoint and token settings are valid.'; if ($debugInfo !== '') { $failMsg .= ' Debug: ' . $debugInfo; } log_message('warning', 'scan_ai_process_file extraction failed fileId={id}; {summary}', [ 'id' => $fileId, 'summary' => $readiness['summary'], ]); $this->updateScanAiColumns($fileId, [ 'scan_type' => $scanType, 'document_type' => $documentType, 'content_description' => $failMsg, 'ai_status' => 'failed', 'ai_alert_flag' => 0, 'ai_alert_text' => '', ], $fields); return [ 'update' => 0, 'ai_status' => 'failed', 'document_type' => $documentType, 'content_description' => $failMsg, 'ai_alert_flag' => 0, 'ai_alert_text' => '', 'error_text' => $failMsg, ]; } // Build structured diagnosis-support output from extracted text. $analysis = $this->analyzeScanForDiagnosisSupport($text, $scanType); $documentType = trim((string) ($visual['document_type'] ?? '')); if ($documentType === '') { $documentType = $this->classifyDocumentType($text, $scanType, $filename); } $visualSummary = trim((string) ($visual['content_description'] ?? '')); $contentDescription = $this->buildScanContentDescription($analysis, $text, $documentType); if ($visualSummary !== '') { $contentDescription = $contentDescription !== '' ? mb_substr($visualSummary . "\n" . $contentDescription, 0, 2000) : mb_substr($visualSummary, 0, 2000); } if (strtoupper($documentType) === 'ECG') { $contentDescription = $this->buildEcgDetailedDescription($text, $analysis, $contentDescription); } [$aiAlertFlag, $aiAlertText] = $this->buildScanAlert($analysis, $text); $visualRisk = strtolower(trim((string) ($visual['risk_level'] ?? ''))); if (in_array($visualRisk, ['high', 'critical'], true)) { $aiAlertFlag = 1; $visualAlertText = trim((string) ($visual['alert_text'] ?? '')); if ($visualAlertText !== '') { $aiAlertText = $visualAlertText; } } // Optionally append AI support to OPD prescription notes. if ($applyToOpd && !empty($analysis) && ((string) ($analysis['diagnosis_suitable'] ?? 'no')) === 'yes') { $this->applyAiScanAssistToPrescription((int) ($row['opd_id'] ?? 0), $analysis); } $this->updateScanAiColumns($fileId, [ 'scan_type' => $scanType, 'document_type' => $documentType, 'content_description' => $contentDescription, 'extract_text' => $text, 'extracted_text' => $text, 'ai_status' => 'completed', 'ai_alert_flag' => $aiAlertFlag, 'ai_alert_text' => $aiAlertText, ], $fields); return [ 'update' => 1, 'ai_status' => 'completed', 'document_type' => $documentType, 'content_description' => $contentDescription, 'ai_alert_flag' => $aiAlertFlag, 'ai_alert_text' => $aiAlertText, 'error_text' => 'AI analysis completed', ]; } /** * @param array $row */ private function resolveScanFilePath(array $row): string { // Accept both stored absolute path and recovered path under /public/uploads. $fullPath = str_replace('\\', '/', (string) ($row['full_path'] ?? '')); if ($fullPath !== '' && is_file($fullPath)) { return $fullPath; } if ($fullPath !== '') { $uploadsPos = strpos($fullPath, '/uploads/'); if ($uploadsPos !== false) { $relative = substr($fullPath, $uploadsPos + 1); $candidate = rtrim(FCPATH, '/\\') . '/' . str_replace('\\', '/', (string) $relative); if (is_file($candidate)) { return $candidate; } } } return ''; } /** * Infer MIME type from saved extension when upload metadata is missing. */ private function guessMimeTypeFromPath(string $path, string $existingMime = ''): string { $existingMime = trim($existingMime); if ($existingMime !== '') { return $existingMime; } $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); if ($ext === 'pdf') { return 'application/pdf'; } if ($ext === 'png') { return 'image/png'; } if ($ext === 'webp') { return 'image/webp'; } return 'image/jpeg'; } /** * @param array|null $analysis */ private function buildScanContentDescription(?array $analysis, string $text, string $documentType): string { if (!empty($analysis)) { $summary = trim((string) ($analysis['clinical_summary'] ?? '')); $dx = trim((string) ($analysis['probable_diagnosis'] ?? '')); $reason = trim((string) ($analysis['suitability_reason'] ?? '')); $keyFindings = $this->sanitizeAiStringList($analysis['key_findings'] ?? []); $suggestedTests = $this->sanitizeAiStringList($analysis['suggested_tests'] ?? []); $redFlags = $this->sanitizeAiStringList($analysis['red_flags'] ?? []); $confidence = trim((string) ($analysis['confidence_note'] ?? '')); $parts = []; if ($summary !== '') { $parts[] = 'Summary: ' . $summary; } if ($dx !== '') { $parts[] = 'Impression: ' . $dx; } if (!empty($keyFindings)) { $parts[] = 'Findings: ' . implode('; ', array_slice($keyFindings, 0, 6)); } if (!empty($redFlags)) { $parts[] = 'Red flags: ' . implode('; ', array_slice($redFlags, 0, 4)); } if (!empty($suggestedTests)) { $parts[] = 'Suggested tests: ' . implode('; ', array_slice($suggestedTests, 0, 5)); } if ($reason !== '') { $parts[] = 'Suitability: ' . $reason; } if ($confidence !== '') { $parts[] = 'Confidence: ' . $confidence; } if (!empty($parts)) { return mb_substr(implode("\n", $parts), 0, 2000); } } $plain = trim((string) preg_replace('/\s+/', ' ', $text)); if ($plain === '') { return $documentType . ' report uploaded.'; } return mb_substr($plain, 0, 500); } /** * @param array|null $analysis */ private function buildEcgDetailedDescription(string $text, ?array $analysis, string $baseSummary): string { $flat = trim((string) preg_replace('/\s+/', ' ', $text)); $metrics = []; $patterns = [ 'Ventricular rate' => '/(?:vent\.?\s*rate|heart\s*rate|hr)\s*[:=]?\s*(\d{2,3})\s*(?:bpm)?/i', 'PR interval' => '/pr\s*(?:int|interval)?\s*[:=]?\s*(\d{2,4})\s*(?:ms)?/i', 'QRS duration' => '/qrs\s*(?:int|duration)?\s*[:=]?\s*(\d{2,4})\s*(?:ms)?/i', 'QT/QTc' => '/qt\/?qtc\s*(?:int)?\s*[:=]?\s*([\d\.\-\s\/]{3,20})/i', 'Axis' => '/axis\s*(?:deg)?\s*[:=]?\s*([\d\-\+\s\/]{1,20})/i', ]; foreach ($patterns as $label => $regex) { if (preg_match($regex, $flat, $match) === 1) { $value = trim((string) ($match[1] ?? '')); if ($value !== '') { $metrics[] = $label . ': ' . $value; } } } $findings = []; if (is_array($analysis)) { $summary = trim((string) ($analysis['clinical_summary'] ?? '')); $dx = trim((string) ($analysis['probable_diagnosis'] ?? '')); $keyFindings = $this->sanitizeAiStringList($analysis['key_findings'] ?? []); if ($summary !== '') { $findings[] = $summary; } if ($dx !== '') { $findings[] = 'Impression: ' . $dx; } if (!empty($keyFindings)) { $findings[] = 'Findings: ' . implode('; ', array_slice($keyFindings, 0, 4)); } } if (empty($findings) && trim($baseSummary) !== '') { $findings[] = trim($baseSummary); } $chunks = []; if (!empty($metrics)) { $chunks[] = implode(' | ', array_slice($metrics, 0, 5)); } if (!empty($findings)) { $chunks[] = implode(' | ', array_slice($findings, 0, 3)); } if (empty($chunks)) { return 'ECG uploaded. AI review completed.'; } return mb_substr(implode("\n", $chunks), 0, 1000); } /** * @param array|null $analysis * @return array{0:int,1:string} */ private function buildScanAlert(?array $analysis, string $text): array { if (!empty($analysis)) { $redFlags = $this->sanitizeAiStringList($analysis['red_flags'] ?? []); if (!empty($redFlags)) { return [1, mb_substr(implode('; ', $redFlags), 0, 250)]; } $probableDx = strtolower(trim((string) ($analysis['probable_diagnosis'] ?? ''))); foreach (['abnormal', 'acute', 'critical', 'infarct', 'ischemia', 'stroke', 'sepsis', 'hemorrhage', 'emergency'] as $keyword) { if ($probableDx !== '' && str_contains($probableDx, $keyword)) { return [1, 'AI flagged possible ' . $keyword . ' risk']; } } } $txt = strtolower($text); foreach (['critical', 'urgent', 'abnormal', 'positive for', 'high risk'] as $keyword) { if (str_contains($txt, $keyword)) { return [1, 'Report text suggests ' . $keyword]; } } return [0, '']; } /** * Classify broad document type using scan hints, filename and text keywords. */ private function classifyDocumentType(string $text, string $scanType, string $filename): string { $pool = strtolower($scanType . ' ' . $filename . ' ' . $text); $map = [ 'ECG' => ['ecg', 'electrocardio', 'st segment', 'qrs'], 'Pathology' => ['pathology', 'haemoglobin', 'hemoglobin', 'cbc', 'blood', 'urine', 'biochemistry', 'platelet'], 'XRay' => ['xray', 'x-ray', 'radiograph', 'chest pa'], 'CT' => ['ct scan', 'computed tomography'], 'MRI' => ['mri', 'magnetic resonance'], 'Ultrasound' => ['usg', 'ultrasound', 'sonography'], ]; foreach ($map as $type => $keywords) { foreach ($keywords as $keyword) { if (str_contains($pool, $keyword)) { return $type; } } } return strtoupper($scanType) !== 'GENERAL' ? strtoupper($scanType) : 'General'; } /** * @return array|null */ private function analyzeScanVisualSupport(string $filePath, string $scanType, string $mimeType): ?array { if (!is_file($filePath)) { return null; } $raw = @file_get_contents($filePath); if ($raw === false || $raw === '') { return null; } $maxBytes = $mimeType === 'application/pdf' ? (12 * 1024 * 1024) : (8 * 1024 * 1024); if (strlen($raw) > $maxBytes) { return null; } $prompt = "You are a clinical assistant. Analyze this medical report image/document visually (even if OCR text is unclear).\n" . "Return ONLY valid JSON object with keys: document_type, content_description, risk_level, alert_text.\n" . "Rules:\n" . "- document_type must be one of: ECG, Pathology, XRay, CT, MRI, Ultrasound, General.\n" . "- risk_level must be one of: low, moderate, high, critical.\n" . "- content_description should be 1 short cautious summary line.\n" . "- alert_text should be short and only when risk is high/critical; else empty string.\n" . "- If uncertain, set document_type based on visible format pattern and keep conservative language.\n" . "Scan type hint: " . strtoupper($scanType); // Ask vision-capable model for a conservative JSON summary. $azureRaw = $this->callAzureOpenAiVision($filePath, $mimeType, $prompt, 500, true); $azureParsed = $this->parseJsonFromAiRaw($azureRaw); if (is_array($azureParsed)) { $documentType = trim((string) ($azureParsed['document_type'] ?? 'General')); if (!in_array($documentType, ['ECG', 'Pathology', 'XRay', 'CT', 'MRI', 'Ultrasound', 'General'], true)) { $documentType = 'General'; } $riskLevel = strtolower(trim((string) ($azureParsed['risk_level'] ?? 'low'))); if (!in_array($riskLevel, ['low', 'moderate', 'high', 'critical'], true)) { $riskLevel = 'low'; } return [ 'document_type' => $documentType, 'content_description' => trim((string) ($azureParsed['content_description'] ?? '')), 'risk_level' => $riskLevel, 'alert_text' => trim((string) ($azureParsed['alert_text'] ?? '')), ]; } return null; } /** * @param array $payload * @param array $fields */ private function updateScanAiColumns(int $fileId, array $payload, array $fields): void { $update = []; foreach ($payload as $key => $value) { if (in_array($key, $fields, true)) { $update[$key] = $value; } } if (!empty($update)) { $this->db->table('file_upload_data')->where('id', $fileId)->update($update); } } /** * @return array|null */ private function analyzeScanForDiagnosisSupport(string $extractedText, string $scanType): ?array { // No text means there is nothing reliable to assess. if (trim($extractedText) === '') { return null; } $prompt = "You are a clinical decision-support assistant for OPD doctors.\n" . "Input is OCR text from scanned/uploaded patient reports (lab/xray/ecg/general).\n" . "Determine if this report text is suitable for diagnosis support.\n" . "Return ONLY valid JSON object with keys:\n" . "diagnosis_suitable (yes/no), suitability_reason, probable_diagnosis, clinical_summary, key_findings, suggested_tests, red_flags, confidence_note.\n" . "Rules:\n" . "- Keep content concise, medically cautious, non-final.\n" . "- key_findings/suggested_tests/red_flags must be JSON arrays of short strings.\n" . "- Do not invent values not present in text.\n" . "Scan Type: " . strtoupper($scanType) . "\n" . "Report Text:\n" . $extractedText; $azureRaw = $this->callAzureOpenAiText($prompt, 900, true); $azureParsed = $this->parseJsonFromAiRaw($azureRaw); if (is_array($azureParsed)) { return [ 'diagnosis_suitable' => strtolower(trim((string) ($azureParsed['diagnosis_suitable'] ?? 'no'))) === 'yes' ? 'yes' : 'no', 'suitability_reason' => trim((string) ($azureParsed['suitability_reason'] ?? '')), 'probable_diagnosis' => trim((string) ($azureParsed['probable_diagnosis'] ?? '')), 'clinical_summary' => trim((string) ($azureParsed['clinical_summary'] ?? '')), 'key_findings' => $this->sanitizeAiStringList($azureParsed['key_findings'] ?? []), 'suggested_tests' => $this->sanitizeAiStringList($azureParsed['suggested_tests'] ?? []), 'red_flags' => $this->sanitizeAiStringList($azureParsed['red_flags'] ?? []), 'confidence_note' => trim((string) ($azureParsed['confidence_note'] ?? '')), ]; } return null; } /** * @return array{applied:bool,session_id:int,updated_fields:array} */ private function applyAiScanAssistToPrescription(int $opdid, array $analysis): array { if (! $this->db->tableExists('opd_prescription')) { return ['applied' => false, 'session_id' => 0, 'updated_fields' => []]; } $fields = $this->db->getFieldNames('opd_prescription'); $diagnosisField = $this->resolveFirstField($fields, ['diagnosis', 'provisional_diagnosis']); $findingField = $this->resolveFirstField($fields, ['Finding_Examinations', 'finding_examinations']); $investigationField = $this->resolveFirstField($fields, ['investigation']); $row = $this->db->table('opd_prescription') ->where('opd_id', $opdid) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (empty($row)) { $opdMaster = $this->db->table('opd_master')->where('opd_id', $opdid)->get(1)->getRowArray(); if (empty($opdMaster)) { return ['applied' => false, 'session_id' => 0, 'updated_fields' => []]; } $insert = []; foreach ([ 'opd_id' => $opdid, 'p_id' => (int) ($opdMaster['p_id'] ?? 0), 'doc_id' => (int) ($opdMaster['doc_id'] ?? 0), 'date_opd_visit' => date('Y-m-d'), 'visit_status' => 0, 'session_id' => 0, ] as $k => $v) { if (in_array($k, $fields, true)) { $insert[$k] = $v; } } $this->db->table('opd_prescription')->insert($insert); $newId = (int) $this->db->insertID(); $row = $this->db->table('opd_prescription')->where('id', $newId)->get(1)->getRowArray(); } if (empty($row)) { return ['applied' => false, 'session_id' => 0, 'updated_fields' => []]; } $sessionId = (int) ($row['id'] ?? 0); $prefix = '[AI-SCAN ' . date('d-m-Y H:i') . ']'; $summary = trim((string) ($analysis['clinical_summary'] ?? '')); $probableDx = trim((string) ($analysis['probable_diagnosis'] ?? '')); $confidence = trim((string) ($analysis['confidence_note'] ?? '')); $findings = $this->sanitizeAiStringList($analysis['key_findings'] ?? []); $tests = $this->sanitizeAiStringList($analysis['suggested_tests'] ?? []); $redFlags = $this->sanitizeAiStringList($analysis['red_flags'] ?? []); $updateData = []; $updatedFields = []; if ($diagnosisField !== null && $probableDx !== '') { $chunk = $prefix . ' Probable diagnosis support: ' . $probableDx; if ($confidence !== '') { $chunk .= ' (' . $confidence . ')'; } $old = trim((string) ($row[$diagnosisField] ?? '')); if (! str_contains($old, $chunk)) { $updateData[$diagnosisField] = $old === '' ? $chunk : ($old . "\n" . $chunk); $updatedFields[] = $diagnosisField; } } if ($findingField !== null) { $parts = []; if ($summary !== '') { $parts[] = 'Summary: ' . $summary; } if (! empty($findings)) { $parts[] = 'Findings: ' . implode('; ', $findings); } if (! empty($redFlags)) { $parts[] = 'Red flags: ' . implode('; ', $redFlags); } if (! empty($parts)) { $chunk = $prefix . ' ' . implode(' | ', $parts); $old = trim((string) ($row[$findingField] ?? '')); if (! str_contains($old, $chunk)) { $updateData[$findingField] = $old === '' ? $chunk : ($old . "\n" . $chunk); $updatedFields[] = $findingField; } } } if ($investigationField !== null && ! empty($tests)) { $chunk = $prefix . ' Suggested tests: ' . implode('; ', $tests); $old = trim((string) ($row[$investigationField] ?? '')); if (! str_contains($old, $chunk)) { $updateData[$investigationField] = $old === '' ? $chunk : ($old . "\n" . $chunk); $updatedFields[] = $investigationField; } } if (! empty($updateData)) { $this->db->table('opd_prescription')->where('id', $sessionId)->update($updateData); } return [ 'applied' => ! empty($updateData), 'session_id' => $sessionId, 'updated_fields' => $updatedFields, ]; } /** * @param mixed $raw * @return array */ private function sanitizeAiStringList($raw): array { // Normalize AI arrays into unique, non-empty strings. if (! is_array($raw)) { return []; } $out = []; foreach ($raw as $item) { $txt = trim((string) $item); if ($txt !== '') { $out[] = $txt; } } return array_values(array_unique($out)); } /** * @return array|null */ private function parseJsonFromAiRaw(string $raw): ?array { $text = trim($raw); if ($text === '') { return null; } // First attempt: raw response is already valid JSON. $decoded = json_decode($text, true); if (is_array($decoded)) { return $decoded; } // Second attempt: extract JSON object embedded in model prose. if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $match) === 1) { $decoded = json_decode((string) ($match[0] ?? ''), true); if (is_array($decoded)) { return $decoded; } } return null; } /** * @param array $constCandidates * @param array $settingCandidates */ private function readScanAiSettingValue(array $constCandidates, array $settingCandidates): string { foreach ($constCandidates as $constName) { if (! defined($constName)) { continue; } $value = trim((string) constant($constName)); if ($value !== '') { return $value; } } if ($this->db->tableExists('hospital_setting') && ! empty($settingCandidates)) { $row = $this->db->table('hospital_setting') ->select('s_value') ->whereIn('s_name', $settingCandidates) ->where('s_value !=', '') ->orderBy('s_name', 'ASC') ->get(1) ->getRowArray(); if (! empty($row['s_value'])) { return trim((string) $row['s_value']); } } return ''; } /** * @return array{ai_server:bool,ocr_server:bool,summary:string} */ private function getScanAiReadiness(): array { $base = $this->resolveAiServerBaseUrlForScan(); $aiServer = $base !== ''; $ocrEndpoint = rtrim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_OCR_ENDPOINT']), '/'); $ocrReady = $ocrEndpoint !== ''; $parts = []; $parts[] = 'AIServer=' . ($aiServer ? 'set' : 'missing base URL'); $parts[] = 'OCR=' . ($ocrReady ? 'set' : 'missing endpoint'); return [ 'ai_server' => $aiServer, 'ocr_server' => $ocrReady, 'summary' => implode(', ', $parts), ]; } private function callAzureOpenAiVision(string $filePath, string $mimeType, string $prompt, int $maxTokens = 1200, bool $jsonObject = false): string { unset($filePath, $mimeType, $prompt, $maxTokens, $jsonObject); $this->addScanAiDebug('visual interpretation via Azure is disabled; using AI server OCR/diagnosis flow'); return ''; } private function callAzureOpenAiText(string $prompt, int $maxTokens = 1200, bool $jsonObject = false): string { unset($prompt, $maxTokens, $jsonObject); $this->addScanAiDebug('text generation via Azure is disabled; using AI server flow'); return ''; } private function extractTextWithAzureDocumentIntelligence(string $filePath): string { $endpoint = rtrim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_OCR_ENDPOINT']), '/'); $token = trim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_PARSE_TOKEN'])); if ($endpoint === '' || !is_file($filePath)) { $this->addScanAiDebug('ai server OCR skipped: endpoint/file missing'); return ''; } try { $headers = ['Accept' => 'application/json']; if ($token !== '') { $headers['Authorization'] = 'Bearer ' . $token; } $client = service('curlrequest', $this->buildAzureCurlOptions($endpoint, 40, false)); $mime = $this->guessMimeTypeFromPath($filePath); if (! str_starts_with($mime, 'image/')) { return ''; } $curlFile = new \CURLFile($filePath, $mime, basename($filePath)); $response = $client->post($endpoint, [ 'headers' => $headers, 'multipart' => [ 'file' => $curlFile, ], ]); if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { return ''; } $decoded = json_decode((string) $response->getBody(), true); if (! is_array($decoded)) { return ''; } foreach (['ocr_text', 'text', 'extracted_text'] as $key) { $text = trim((string) ($decoded[$key] ?? '')); if ($text !== '') { return $text; } } } catch (\Throwable $e) { return ''; } return ''; } private function resolveAiServerBaseUrlForScan(): string { $base = rtrim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_SERVER_URL']), '/'); if ($base !== '') { return $base; } $candidates = [ trim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_PARSE_ENDPOINT'])), trim($this->readScanAiSettingValue([], ['DIAGNOSIS_AI_OCR_ENDPOINT'])), ]; foreach ($candidates as $url) { if ($url === '') { continue; } $parts = @parse_url($url); if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { continue; } $base = (string) $parts['scheme'] . '://' . (string) $parts['host']; if (! empty($parts['port'])) { $base .= ':' . (int) $parts['port']; } return rtrim($base, '/'); } return ''; } private function extractTextFromDocIntelPayload(array $payload): string { // Prefer native concatenated content when present. $content = trim((string) ($payload['analyzeResult']['content'] ?? '')); if ($content !== '') { return $content; } // Fallback: rebuild text from line entries. $lines = []; $pages = $payload['analyzeResult']['pages'] ?? []; foreach ($pages as $page) { $pageLines = $page['lines'] ?? []; foreach ($pageLines as $line) { $txt = trim((string) ($line['content'] ?? '')); if ($txt !== '') { $lines[] = $txt; } } } return trim(implode("\n", $lines)); } private function extractTextFromScanFile(string $filePath, string $scanType, ?string $mimeType = null): ?string { if (!is_file($filePath)) { return null; } $raw = @file_get_contents($filePath); if ($raw === false || $raw === '') { return null; } $mimeType = trim((string) ($mimeType ?? '')); if ($mimeType === '') { $mimeType = $this->guessMimeTypeFromPath($filePath); } $maxBytes = $mimeType === 'application/pdf' ? (12 * 1024 * 1024) : (8 * 1024 * 1024); if (strlen($raw) > $maxBytes) { return null; } // OCR priority: Document Intelligence first, then vision model fallback. $prompt = 'Extract readable clinical text from this ' . strtoupper($scanType) . ' medical document. Return plain text with sections: Findings, Impression, Values. Do not invent data.'; $docIntelText = $this->extractTextWithAzureDocumentIntelligence($filePath); if ($docIntelText !== '') { $clean = $this->sanitizeExtractedScanText($docIntelText, $scanType); return $clean !== '' ? $clean : (preg_replace('/\s+/', ' ', trim($docIntelText)) ?: trim($docIntelText)); } $azurePrompt = 'Read and extract all visible clinical text from this ' . strtoupper($scanType) . ' medical image. Return plain text only. If unclear, return best readable lines.'; $azureText = $this->callAzureOpenAiVision($filePath, $mimeType, $azurePrompt, 1800, false); if ($azureText !== '') { $clean = $this->sanitizeExtractedScanText($azureText, $scanType); return $clean !== '' ? $clean : (preg_replace('/\s+/', ' ', trim($azureText)) ?: trim($azureText)); } return null; } /** * Clean OCR output so OPD UI stores readable clinical text only. */ private function sanitizeExtractedScanText(string $text, string $scanType): string { $text = trim($text); if ($text === '') { return ''; } // Remove control and non-ASCII characters that usually come from OCR noise. $text = preg_replace('/[^\x09\x0A\x0D\x20-\x7E]/', ' ', $text) ?? $text; // Drop repeated-char junk words like "ffffff". $text = preg_replace('/\b([A-Za-z])\1{4,}\b/', ' ', $text) ?? $text; // Collapse very long repeated words into one token. $text = preg_replace('/\b([A-Za-z]{2,})\b(?:\s+\1\b){2,}/i', '$1', $text) ?? $text; $isEcg = strtolower(trim($scanType)) === 'ecg' || stripos($text, 'ecg') !== false; if ($isEcg) { // Common OCR fixes for ECG templates. $replacements = [ '/\bKECG\b/i' => 'ECG', '/\bUniconfirmed\b/i' => 'Unconfirmed', '/\bavt\b/i' => 'aVL', '/\baVR\b/i' => 'aVR', '/\baVF\b/i' => 'aVF', '/\baVL\b/i' => 'aVL', '/\bVT\b/' => 'V1', ]; foreach ($replacements as $pattern => $replacement) { $text = preg_replace($pattern, $replacement, $text) ?? $text; } } // Normalize whitespace for compact storage/response. $text = preg_replace('/\s+/', ' ', trim($text)) ?? trim($text); return $text; } /** * @return array{updated:bool,session_id:int,target_field:string} */ private function appendExtractedTextToOpdPrescription(int $opdid, string $scanType, string $text): array { if (!$this->db->tableExists('opd_prescription')) { return ['updated' => false, 'session_id' => 0, 'target_field' => '']; } $fields = $this->db->getFieldNames('opd_prescription'); $targetField = in_array('Finding_Examinations', $fields, true) ? 'Finding_Examinations' : ''; if (in_array($scanType, ['ecg', 'lab'], true) && in_array('investigation', $fields, true)) { $targetField = 'investigation'; } elseif ($targetField === '' && in_array('diagnosis', $fields, true)) { $targetField = 'diagnosis'; } if ($targetField === '') { return ['updated' => false, 'session_id' => 0, 'target_field' => '']; } $row = $this->db->table('opd_prescription') ->where('opd_id', $opdid) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (empty($row)) { $opdMaster = $this->db->table('opd_master')->where('opd_id', $opdid)->get(1)->getRowArray(); if (empty($opdMaster)) { return ['updated' => false, 'session_id' => 0, 'target_field' => '']; } $insert = []; foreach ([ 'opd_id' => $opdid, 'p_id' => (int) ($opdMaster['p_id'] ?? 0), 'doc_id' => (int) ($opdMaster['doc_id'] ?? 0), 'date_opd_visit' => date('Y-m-d'), 'visit_status' => 0, 'session_id' => 0, ] as $k => $v) { if (in_array($k, $fields, true)) { $insert[$k] = $v; } } $this->db->table('opd_prescription')->insert($insert); $newId = (int) $this->db->insertID(); $row = $this->db->table('opd_prescription')->where('id', $newId)->get(1)->getRowArray(); } if (empty($row)) { return ['updated' => false, 'session_id' => 0, 'target_field' => '']; } $sessionId = (int) ($row['id'] ?? 0); $oldValue = trim((string) ($row[$targetField] ?? '')); $prefix = '[SCAN-' . strtoupper($scanType) . ' ' . date('d-m-Y H:i') . ']'; $newChunk = $prefix . ' ' . trim($text); if ($oldValue !== '' && str_contains($oldValue, $newChunk)) { return ['updated' => true, 'session_id' => $sessionId, 'target_field' => $targetField]; } $newValue = $oldValue === '' ? $newChunk : ($oldValue . "\n" . $newChunk); $this->db->table('opd_prescription')->where('id', $sessionId)->update([$targetField => $newValue]); return ['updated' => true, 'session_id' => $sessionId, 'target_field' => $targetField]; } private function resolveFirstField(array $fields, array $candidates): ?string { foreach ($candidates as $candidate) { if (in_array($candidate, $fields, true)) { return $candidate; } } return null; } private function findExistingTable(array $candidates): ?string { foreach ($candidates as $table) { if ($this->db->tableExists($table)) { return $table; } } return null; } private function hydrateNabhPrintFields(array $row, string $remarks): array { $pick = function (array $candidates) use ($row): string { foreach ($candidates as $field) { $value = trim((string) ($row[$field] ?? '')); if ($value !== '') { return $value; } } return ''; }; $data = [ 'drug_allergy_status' => $pick(['drug_allergy_status', 'allergy_status', 'drug_allergy']), 'drug_allergy_details' => $pick(['drug_allergy_details', 'allergy_details', 'drug_allergy_note', 'allergy_note']), 'adr_history' => $pick(['adr_history', 'adverse_drug_reaction', 'adr_details', 'adverse_reaction_history']), 'current_medications' => $pick(['current_medications', 'current_medication', 'current_medication_history', 'ongoing_medications']), ]; $parsed = $this->extractNabhFieldsFromRemarks($remarks); foreach ($data as $key => $value) { if ($value === '' && !empty($parsed[$key])) { $data[$key] = (string) $parsed[$key]; } } return $data; } private function hydratePatientHistoryPrintFields(array $row, string $remarks): array { $pick = function (array $candidates) use ($row): string { foreach ($candidates as $field) { $value = trim((string) ($row[$field] ?? '')); if ($value !== '') { return $value; } } return ''; }; $data = [ 'obstetric_history' => $pick(['obstetric_history', 'obstetric_hist', 'obstetric_details']), 'menstrual_history' => $pick(['menstrual_history', 'menstrual_hist', 'menstrual_details']), 'medical_surgical_history' => $pick(['medical_surgical_history', 'medical_surgical_hist', 'past_medical_surgical_history', 'medical_history', 'surgical_history']), 'family_history' => $pick(['family_history', 'family_hist']), 'allergic_history' => $pick(['allergic_history', 'allergy_history', 'allergy_hist']), 'vaccination_history' => $pick(['vaccination_history', 'vaccination_hist', 'immunization_history']), ]; $parsed = $this->extractPatientHistoryPrintFieldsFromRemarks($remarks); foreach ($data as $key => $value) { if ($value === '' && ! empty($parsed[$key])) { $data[$key] = (string) $parsed[$key]; } } return $data; } private function extractPatientHistoryPrintFieldsFromRemarks(string $remarks): array { $extract = static function (string $pattern, string $source): string { if (preg_match($pattern, $source, $m) !== 1) { return ''; } return trim((string) ($m[1] ?? '')); }; return [ 'obstetric_history' => $extract('/^\s*OBSTETRIC\s*HISTORY\s*:\s*(.+)$/im', $remarks), 'menstrual_history' => $extract('/^\s*MENSTRUAL\s*HISTORY\s*:\s*(.+)$/im', $remarks), 'medical_surgical_history' => $extract('/^\s*MEDICAL\s*\/\s*SURGICAL\s*HISTORY\s*:\s*(.+)$/im', $remarks), 'family_history' => $extract('/^\s*FAMILY\s*HISTORY\s*:\s*(.+)$/im', $remarks), 'allergic_history' => $extract('/^\s*ALLERGIC\s*HISTORY\s*:\s*(.+)$/im', $remarks), 'vaccination_history' => $extract('/^\s*VACCINATION\s*:\s*(.+)$/im', $remarks), ]; } private function extractNabhFieldsFromRemarks(string $remarks): array { $extract = static function (string $pattern, string $source): string { if (preg_match($pattern, $source, $m) !== 1) { return ''; } return trim((string) ($m[1] ?? '')); }; return [ 'drug_allergy_status' => $extract('/^\s*Drug\s*Allergy\s*Status\s*:\s*(.+)$/im', $remarks), 'drug_allergy_details' => $extract('/^\s*Drug\s*Allergy\s*Details\s*:\s*(.+)$/im', $remarks), 'adr_history' => $extract('/^\s*ADR\s*History\s*:\s*(.+)$/im', $remarks), 'current_medications' => $extract('/^\s*Current\s*Medications\s*:\s*(.+)$/im', $remarks), ]; } private function sanitizePrescriberRemarksForPrint(string $remarks): string { $remarks = trim($remarks); if ($remarks === '') { return ''; } $lines = preg_split('/\r\n|\r|\n/', $remarks) ?: []; $cleaned = []; foreach ($lines as $line) { $trimmed = trim((string) $line); if ( preg_match('/^Drug\s*Allergy\s*Status\s*:/i', $trimmed) === 1 || preg_match('/^Drug\s*Allergy\s*Details\s*:/i', $trimmed) === 1 || preg_match('/^ADR\s*History\s*:/i', $trimmed) === 1 || preg_match('/^Current\s*Medications\s*:/i', $trimmed) === 1 ) { continue; } $cleaned[] = $line; } return trim(implode("\n", $cleaned)); } private function extractWomenRelatedProblemsFromRemarks(string $remarks): string { if ($remarks === '') { return ''; } $lines = preg_split('/\r\n|\r|\n/', $remarks) ?: []; $capture = []; $started = false; foreach ($lines as $line) { $trimmed = trim((string) $line); if ($trimmed === '') { if ($started) { break; } continue; } if (preg_match('/^Women Related Problems\s*:/i', $trimmed) === 1) { $started = true; $capture[] = $trimmed; continue; } if ($started && preg_match('/^(LMP(?:\s*Days\s*Before)?|Last Baby|Pregnancy Related)\s*:/i', $trimmed) === 1) { $capture[] = $trimmed; continue; } if ($started) { break; } } if (!empty($capture)) { return trim(implode("\n", $capture)); } return ''; } /** * @return array{women_related_problems:string,women_lmp:string,women_last_baby:string,women_pregnancy_related:string} */ private function parseWomenStructuredDetails(string $text): array { $remaining = trim($text); $extract = static function (string $pattern, string &$haystack): string { $value = ''; if (preg_match($pattern, $haystack, $match) === 1) { $value = trim((string) ($match[1] ?? '')); } $haystack = trim((string) (preg_replace($pattern, '', $haystack) ?? $haystack)); return $value; }; $womenProblems = $extract('/^\s*Women Related Problems\s*:\s*(.+)$/im', $remaining); $lmp = $extract('/^\s*LMP(?:\s*Days\s*Before)?\s*:\s*(.+)$/im', $remaining); $lastBaby = $extract('/^\s*Last Baby\s*:\s*(.+)$/im', $remaining); $pregnancyRelated = $extract('/^\s*Pregnancy Related\s*:\s*(.+)$/im', $remaining); if ($womenProblems === '') { $womenProblems = trim($remaining); } return [ 'women_related_problems' => $womenProblems, 'women_lmp' => $lmp, 'women_last_baby' => $lastBaby, 'women_pregnancy_related' => $pregnancyRelated, ]; } /** * Fallback for legacy CI3 uploads that live in the project-root uploads/ directory * instead of public/uploads/. Apache routes the request here when the file is not * found as a real file in public/uploads/. * * URL pattern: /uploads/ */ public function serve_legacy_upload(string $subpath = ''): \CodeIgniter\HTTP\Response { // Sanitise: reject any path-traversal attempts. $subpath = ltrim(str_replace('\\', '/', $subpath), '/'); if ( $subpath === '' || strpos($subpath, '..') !== false || strpos($subpath, "\0") !== false ) { return $this->response->setStatusCode(404)->setBody('Not Found'); } // 1. public/uploads/ — CI4 standard location (covers servers that route all // requests through index.php, e.g. Nginx without try_files static bypass). $publicUploadsPath = rtrim(FCPATH, '/\\') . '/uploads/' . $subpath; // 2. Project-root uploads/ — CI3 legacy location. $legacyPath = rtrim(ROOTPATH, '/\\') . '/uploads/' . $subpath; // 3. writable/uploads/ — fallback. $writablePath = rtrim(WRITEPATH, '/\\') . '/uploads/' . $subpath; $filePath = ''; if (is_file($publicUploadsPath)) { $filePath = $publicUploadsPath; } elseif (is_file($legacyPath)) { $filePath = $legacyPath; } elseif (is_file($writablePath)) { $filePath = $writablePath; } if ($filePath === '') { return $this->response->setStatusCode(404)->setBody('Not Found'); } $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $mimeMap = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'bmp' => 'image/bmp', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', ]; if (!array_key_exists($ext, $mimeMap)) { return $this->response->setStatusCode(403)->setBody('Forbidden'); } $mime = $mimeMap[$ext]; $size = filesize($filePath); $etag = md5($filePath . (string) filemtime($filePath)); // Basic cache headers. $this->response ->setHeader('Content-Type', $mime) ->setHeader('Content-Length', (string) $size) ->setHeader('ETag', $etag) ->setHeader('Cache-Control', 'public, max-age=86400') ->setHeader('X-Legacy-Upload', '1'); // @phpstan-ignore-next-line return $this->response->setBody((string) file_get_contents($filePath)); } }