*/ private array $scanAiDebug = []; 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', 'age_sex', 'phoneno', 'p_address', 'uhid_no', 'opd_sr_no', 'opd_no', 'opd_date', 'exp_date', '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_block', // Formatted: Drug Allergy : status — details 'adr_history_block', // Formatted: ADR History : ... 'current_medications_block', // Formatted: Current Medications : ... // 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(); } 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(), ]); } if ($abhaAddress !== '' && ! $this->isValidAbhaAddress($abhaAddress)) { return $this->response->setJSON([ 'insertid' => 0, 'error_text' => 'Invalid ABHA Address format.', '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); } } } 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(); // ── HealthPlix sync status for invoice button ───────────────────────── $data['healthplix_sync_applicable'] = false; $data['healthplix_sync_done'] = false; $data['healthplix_log'] = null; $healthplixEnabled = $this->readHealthplixSetting('HEALTHPLIX_ENABLED') === '1'; if ($healthplixEnabled && (int) ($opdRow->payment_status ?? 0) === 1) { $docId = (int) ($opdRow->doc_id ?? 0); $doctorIdentifier = ''; if ($docId > 0 && $this->db->tableExists('doctor_master')) { $identifierField = $this->resolveDoctorHealthplixIdentifierField(); if ($identifierField !== null) { $docRow2 = $this->db->table('doctor_master') ->select($identifierField) ->where('id', $docId) ->get(1) ->getRowArray(); $doctorIdentifier = trim((string) ($docRow2[$identifierField] ?? '')); } } if ($doctorIdentifier !== '') { $data['healthplix_sync_applicable'] = true; if ($this->db->tableExists('abdm_api_logs')) { $hpLog = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('event_type', 'healthplix.appointment.book') ->where('entity_type', 'opd') ->where('entity_id', (string) $opdId) ->where('status', 'success') ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (! empty($hpLog)) { $data['healthplix_sync_done'] = true; $data['healthplix_log'] = $hpLog; } } } } // OPD-level sync ID stored in table after successful appointment push. $data['healthplix_opd_sync_id'] = trim((string) ($opdRow->healthplix_sync_id ?? '')); // ───────────────────────────────────────────────────────────────────── return view('billing/opd_invoice_V', $data); } public function healthplix_push(int $opdId) { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'message' => 'Invalid request']); } $opdId = (int) $opdId; if ($opdId <= 0) { return $this->response->setJSON([ 'ok' => 0, 'message' => 'Invalid OPD ID', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } // If a success log already exists, just return it. if ($this->db->tableExists('abdm_api_logs')) { $existing = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('event_type', 'healthplix.appointment.book') ->where('entity_type', 'opd') ->where('entity_id', (string) $opdId) ->where('status', 'success') ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); if (! empty($existing)) { return $this->response->setJSON([ 'ok' => 1, 'already_synced' => true, 'message' => 'Already synced to HealthPlix.', 'log' => $existing, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } } $this->syncPaidOpdToHealthplix($opdId); // Check result $synced = false; $log = null; $errorMsg = 'Sync failed. Check HealthPlix settings or logs.'; if ($this->db->tableExists('abdm_api_logs')) { $log = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('event_type', 'healthplix.appointment.book') ->where('entity_type', 'opd') ->where('entity_id', (string) $opdId) ->where('status', 'success') ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); $synced = ! empty($log); if (! $synced) { $errLog = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('status', 'error') ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); // If no OPD-level error, look for any recent healthplix error (token/patient) if (empty($errLog)) { $errLog = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('status', 'error') ->where('created_at >=', date('Y-m-d H:i:s', time() - 30)) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); } if (! empty($errLog)) { $errDetail = trim((string) ($errLog['error_message'] ?? '')); $errCode = (int) ($errLog['response_code'] ?? 0); $errEvent = trim((string) ($errLog['event_type'] ?? '')); if ($errCode > 0 && $errDetail !== '') { $errorMsg = '[' . $errEvent . '] HTTP ' . $errCode . ': ' . $errDetail; } elseif ($errCode > 0) { $errorMsg = '[' . $errEvent . '] HealthPlix API returned HTTP ' . $errCode . '.'; } elseif ($errDetail !== '') { $errorMsg = '[' . $errEvent . '] ' . $errDetail; } } else { // No API log at all — likely a gate/config issue. $healthplixEnabled = $this->readHealthplixSetting('HEALTHPLIX_ENABLED') === '1'; if (! $healthplixEnabled) { $errorMsg = 'HealthPlix integration is disabled in settings.'; } else { $identifierField = $this->resolveDoctorHealthplixIdentifierField(); $hasDoctorId = false; if ($identifierField !== null) { $opdRow2 = $this->db->table('opd_master')->select('doc_id')->where('opd_id', $opdId)->get(1)->getRowArray(); $docId2 = (int) ($opdRow2['doc_id'] ?? 0); if ($docId2 > 0) { $docRow2 = $this->db->table('doctor_master')->select($identifierField)->where('id', $docId2)->get(1)->getRowArray(); $hasDoctorId = trim((string) ($docRow2[$identifierField] ?? '')) !== ''; } } if (! $hasDoctorId) { $errorMsg = 'Doctor does not have a HealthPlix Doctor Identifier set. Add it in Doctor Master.'; } } } } } return $this->response->setJSON([ 'ok' => $synced ? 1 : 0, 'synced' => $synced, 'message' => $synced ? 'Appointment registered in HealthPlix.' : $errorMsg, 'log' => $log, 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } 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' => 'dejavusans', '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) { $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; $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); if ($debugHtml) { return $this->response ->setHeader('Content-Type', 'text/plain; charset=UTF-8') ->setBody($html); } $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' => 'dejavusans', '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')); } $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' => 'dejavusans', '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 = ''; } if ($rawHeaderHtml !== '') { $renderedHeaderHtml = $this->applyOpdPdfTemplateTokens($rawHeaderHtml, $tokenVars); 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); if (preg_match('/<\s*(htmlpagefooter|sethtmlpagefooter)\b/i', $renderedFooterHtml) === 1) { $prefixHtml .= $renderedFooterHtml; } else { $mpdf->SetHTMLFooter($renderedFooterHtml, 'O'); $mpdf->SetHTMLFooter($renderedFooterHtml, 'E'); } } if ($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_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); } /** * @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'; } $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 ?? ''))); $ageSex = trim((string) (($patient->xgender ?? '') . ' / ' . ($patient->age ?? ''))); $phone = (string) ($patient->mphone1 ?? ''); $pAddress = trim((string) (($patient->add1 ?? '') . ', ' . ($patient->add2 ?? '') . ', ' . ($patient->city ?? '')), ' ,'); $uhidNo = (string) ($patient->p_code ?? ''); $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 ?? '')); $opdFeeDesc = trim((string) (($opd->opd_fee_amount ?? '') . ' ' . ($opd->opd_fee_desc ?? ''))); $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)) { $medicalHtml .= ''; $medicalHtml .= ''; $i = 0; foreach ($rxMeds as $med) { $i++; $name = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? '')); $formulationRaw = trim((string) ($med['formulation'] ?? $med['dosage_form'] ?? $med['form'] ?? $med['medicine_form'] ?? '')); $formulationPrefixMap = [ 'tablet' => 'TAB', 'tab' => 'TAB', 'capsule' => 'CAP', 'cap' => 'CAP', 'syrup' => 'SYP', 'syp' => 'SYP', 'injection' => 'INJ', 'inj' => 'INJ', 'drop' => 'DROP', 'drops' => 'DROPS', 'cream' => 'CREAM', 'ointment' => 'OINT', 'gel' => 'GEL', 'lotion' => 'LOTION', 'powder' => 'POWDER', 'sachet' => 'SACHET', ]; $formulationKey = strtolower($formulationRaw); $formulationPrefix = $formulationPrefixMap[$formulationKey] ?? $formulationRaw; $formulationPrefix = strtoupper(trim((string) $formulationPrefix)); if ($formulationPrefix !== '' && $name !== '' && preg_match('/^' . preg_quote($formulationPrefix, '/') . '\b/i', $name) !== 1) { $name = trim($formulationPrefix . ' ' . $name); } // salt_name is Substitute (other brand names), not Generic Name — do not use it as generic fallback $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'] ?? '')); $doseLocalRaw = trim((string) ($med['dosage_local_label'] ?? $med['dosage_local'] ?? '')); $freqLocalRaw = trim((string) ($med['dosage_freq_local_label'] ?? $med['dosage_freq_local'] ?? '')); $qtyRaw = trim((string) ($med['qty'] ?? $med['quantity'] ?? $med['drug_qty'] ?? '')); $qty = ($qtyRaw === '0') ? '' : $qtyRaw; $days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? '')); $when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? '')); $remark = trim((string) ($med['remark'] ?? '')); $inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : '')); $doseFreqText = trim($dose . ' ' . $freq); // Hindi for dose + frequency: // Prefer pre-resolved local labels from buildOpdLetterPrintData(), then resolve via maps. $doseLocal = $doseLocalRaw; if ($doseLocal === '' && $dose !== '') { $doseKey = strtolower(trim($dose)); $doseLocal = $doseShedHindiMap[$doseKey] ?? ''; if ($doseLocal === '' && preg_match('/^(.+?\))/u', $dose, $_m) === 1) { $doseSignKey = strtolower(trim($_m[1])); $doseLocal = $doseShedHindiMap[$doseSignKey] ?? ''; } if ($doseLocal === '') { $doseFallback = $this->translateToLocalPatientText($dose); if (strtolower($doseFallback) !== strtolower($dose)) { $doseLocal = $doseFallback; } } } $freqLocal = $freqLocalRaw; if ($freqLocal === '' && $freq !== '') { $freqKey = strtolower(trim($freq)); $freqLocal = $doseShedHindiMap[$freqKey] ?? $doseFreqHindiMap[$freqKey] ?? ''; if ($freqLocal === '' && preg_match('/^(.+?\))/u', $freq, $_m) === 1) { $signKey = strtolower(trim($_m[1])); $freqLocal = $doseShedHindiMap[$signKey] ?? ''; } if ($freqLocal === '') { $freqFallback = $this->translateToLocalPatientText($freq); if (strtolower($freqFallback) !== strtolower($freq)) { $freqLocal = $freqFallback; } } } $doseFreqLocal = trim($doseLocal . (($doseLocal !== '' && $freqLocal !== '') ? ' ' : '') . $freqLocal); if ($doseFreqLocal === '' && $doseFreqText !== '') { $fallback = $this->translateToLocalPatientText($doseFreqText); if (strtolower($fallback) !== strtolower($doseFreqText)) { $doseFreqLocal = $fallback; } } // Hindi for when/timing (opd_dose_when → opd_dose_shed → fallback) $whenKey = strtolower(trim($when)); $instLocal = $doseWhenHindiMap[$whenKey] ?? $doseShedHindiMap[$whenKey] ?? ''; if ($instLocal === '' && preg_match('/^(.+?\))/u', $when, $_m) === 1) { $signKey = strtolower(trim($_m[1])); $instLocal = $doseWhenHindiMap[$signKey] ?? $doseShedHindiMap[$signKey] ?? ''; } if ($instLocal === '' && $when !== '') { $fallback = $this->translateToLocalPatientText($when); if (strtolower($fallback) !== strtolower($when)) { $instLocal = $fallback; } } $nameHtml = esc($name); if ($generic !== '') { $nameHtml .= '
Salt/Generic: ' . esc($generic) . '
'; } $hasInstruction = ($inst !== '' || $instLocal !== ''); $baseRowCellStyle = $hasInstruction ? ' style="border-bottom:none;"' : ''; $medicalHtml .= '' . '' . $i . '' . '' . $nameHtml . '' . '' . esc($doseFreqText) . ($doseFreqLocal !== '' ? ('
' . esc($doseFreqLocal) . '
') : '') . '' . '' . esc($qty) . '' . '' . esc($days) . '' . '
'; if ($hasInstruction) { $medicalHtml .= '' . '' . '' . ''; } } $medicalHtml .= '
#MedicineDose/FreqQtyDay
' . '
' . esc($inst !== '' ? $inst : '-') . '
' . ($instLocal !== '' ? ('
' . esc($instLocal) . '
') : '') . '
'; } $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']); if ($rxInvestigation === '' && !empty($data['rx_investigations']) && is_array($data['rx_investigations'])) { $parts = []; foreach ($data['rx_investigations'] as $inv) { $txt = trim((string) ($inv['investigation_name'] ?? $inv['investigation'] ?? '')); if ($txt !== '') { $parts[] = $txt; } } $rxInvestigation = implode(', ', $parts); } $rxAdvice = $rxRead($rx, ['advice', 'Advice', 'prescription_advice', 'advice_notes', 'advice_note']); $rxAdviceLocal = ''; if ($rxAdvice === '' && !empty($data['rx_advices']) && is_array($data['rx_advices'])) { $parts = []; $partsLocal = []; foreach ($data['rx_advices'] as $adv) { $txt = trim((string) ($adv['advice_txt'] ?? $adv['advice'] ?? '')); if ($txt !== '') { $parts[] = $txt; } $localTxt = trim((string) ($adv['advice_hindi'] ?? ($adv['advice_txt_hindi'] ?? ''))); if ($localTxt !== '') { $partsLocal[] = $localTxt; } } $rxAdvice = implode(', ', $parts); if (!empty($partsLocal)) { $rxAdviceLocal = implode(' | ', $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 ($rxAdviceLocal === '') { $rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice); } 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($opdFeeDesc) . '
' . '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, '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_desc' => $opdFeeDesc, '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, '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; } } // 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; } } // mPDF-safe vertical spacer. Using a fixed-height block in mm is more reliable // than margins inside absolute-positioned containers. $blockGapHtml = '
 
'; $formatBlock = static function (string $label, string $value, bool $lineBreakAfterLabel = false) use ($blockGapHtml): 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', ]); $tokens['diagnosis_list'] = $renderClinicalList($diagnosisItems, [ 'duration' => 'Duration', 'date' => 'Date', ]); $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 = (string) ($tokens['complaint_list'] ?? ''); if ($complaintBlockValue === '') { $complaintBlockValue = nl2br($escape((string) ($tokens['Complaint_raw'] ?? ''))); } $tokens['Complaint'] = $formatBlock('Complaint', $complaintBlockValue, true); $tokens['complaint'] = $tokens['Complaint']; $diagnosisBlockValue = (string) ($tokens['diagnosis_list'] ?? ''); if ($diagnosisBlockValue === '') { $diagnosisBlockValue = nl2br($escape((string) ($tokens['diagnosis_raw'] ?? ''))); } $tokens['diagnosis'] = $formatBlock('Diagnosis', $diagnosisBlockValue, true); $tokens['Diagnosis'] = $tokens['diagnosis']; $provisionalBlockValue = (string) ($tokens['provisional_diagnosis_list'] ?? ''); if ($provisionalBlockValue === '') { $provisionalBlockValue = nl2br($escape((string) ($tokens['Provisional_diagnosis_raw'] ?? ''))); } $tokens['Provisional_diagnosis'] = $formatBlock('Provisional Diagnosis', $provisionalBlockValue, true); $tokens['provisional_diagnosis'] = $tokens['Provisional_diagnosis']; $tokens['Finding_Examinations'] = $formatBlock('Finding / Examination', (string) ($tokens['Finding_Examinations_raw'] ?? '')); $tokens['finding_examinations'] = $tokens['Finding_Examinations']; $tokens['investigation'] = $formatBlock('Investigation Advised', (string) ($tokens['investigation_raw'] ?? '')); $tokens['Prescriber_Remarks'] = $formatBlock('Remarks', (string) ($tokens['Prescriber_Remarks_raw'] ?? '')); $adviceRaw = trim((string) ($tokens['advice_raw'] ?? '')); // 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. $painIndex = ($painValue !== null && $painValue >= 0 && $painValue <= 4) ? $painValue : null; $painImageHtml = ''; 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']; $painImageHtml = '' . ''; for ($i = 0; $i <= 4; $i++) { $active = $painIndex === $i; $bg = $active ? '#f59e0b' : '#e5e7eb'; $color = $active ? '#111' : '#4b5563'; $painImageHtml .= ''; } $painImageHtml .= ''; for ($i = 0; $i <= 4; $i++) { $painImageHtml .= ''; } $painImageHtml .= '
    ' . $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_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)) : ''; // 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'] ?? '')); $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'] ?? ''); $tokens['AdviceBlock'] = (string) ($tokens['advice'] ?? ''); $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') : ''; } $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'] ?? '')); $tokens['advice_local_line'] = $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*\$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); // 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; } /** * 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('investigation_name', $invFields, true)) { $invBuilder->orderBy('investigation_name', 'ASC'); } elseif (in_array('id', $invFields, true)) { $invBuilder->orderBy('id', '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; user can trigger HealthPlix sync manually from invoice. 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); $this->syncPaidOpdToHealthplix($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); $this->syncPaidOpdToHealthplix($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); $this->syncPaidOpdToHealthplix($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 syncPaidOpdToHealthplix(int $opdId): void { if ($opdId <= 0 || ! $this->db->tableExists('opd_master')) { return; } // Skip duplicate push if one success log already exists for this OPD. if ($this->db->tableExists('abdm_api_logs')) { $existing = $this->db->table('abdm_api_logs') ->where('channel', 'healthplix') ->where('event_type', 'healthplix.appointment.book') ->where('entity_type', 'opd') ->where('entity_id', (string) $opdId) ->where('status', 'success') ->get(1) ->getRowArray(); if (! empty($existing)) { return; } } try { $opd = $this->db->table('opd_master') ->where('opd_id', $opdId) ->get(1) ->getRowArray(); if (! is_array($opd) || empty($opd)) { return; } $patientId = (int) ($opd['p_id'] ?? 0); $patient = []; if ($patientId > 0 && $this->db->tableExists('patient_master')) { $patient = $this->db->table('patient_master') ->where('id', $patientId) ->get(1) ->getRowArray() ?? []; } $doctorRow = []; $doctorId = (int) ($opd['doc_id'] ?? 0); if ($doctorId > 0 && $this->db->tableExists('doctor_master')) { $doctorRow = $this->db->table('doctor_master') ->where('id', $doctorId) ->get(1) ->getRowArray() ?? []; } $doctorIdentifier = $this->readHealthplixSetting('HEALTHPLIX_DOCTOR_IDENTIFIER'); $doctorEmail = $this->readHealthplixSetting('HEALTHPLIX_DOCTOR_EMAIL'); $serviceIdentifier = $this->readHealthplixSetting('HEALTHPLIX_SERVICE_IDENTIFIER'); $serviceName = $this->readHealthplixSetting('HEALTHPLIX_SERVICE_NAME'); if (! empty($doctorRow)) { $doctorIdentifierField = $this->resolveDoctorHealthplixIdentifierField(); if ($doctorIdentifierField !== null) { $rowDoctorIdentifier = trim((string) ($doctorRow[$doctorIdentifierField] ?? '')); if ($rowDoctorIdentifier !== '') { $doctorIdentifier = $rowDoctorIdentifier; } } $rowDoctorEmail = trim((string) ($doctorRow['email1'] ?? '')); if ($rowDoctorEmail !== '') { $doctorEmail = $rowDoctorEmail; } } // Appointment booking should use a valid HealthPlix doctor UUID, not local doctor id. if ($doctorIdentifier === '') { return; } $service = new HealthplixService(); $patientPayload = [ 'external_patient_id' => (string) $patientId, 'patient_code' => trim((string) ($patient['p_code'] ?? $patientId)), 'full_name' => trim((string) ($patient['p_fname'] ?? ($opd['P_name'] ?? ''))), 'mobile' => trim((string) ($patient['mphone1'] ?? '')), 'email' => trim((string) ($patient['email1'] ?? '')), 'gender' => $this->mapPatientGenderToHealthplix((string) ($patient['gender'] ?? '')), 'dob' => trim((string) ($patient['dob'] ?? '')), 'estimate_dob' => trim((string) ($patient['estimate_dob'] ?? '')), 'age_years' => trim((string) ($patient['age'] ?? '')), 'age_months' => trim((string) ($patient['age_in_month'] ?? '')), 'address_line1' => trim((string) ($patient['add1'] ?? '')), 'city' => trim((string) ($patient['city'] ?? '')), 'district' => trim((string) ($patient['district'] ?? '')), 'state' => trim((string) ($patient['state'] ?? '')), 'zip' => trim((string) ($patient['zip'] ?? '')), 'aadhar' => trim((string) ($patient['udai'] ?? '')), 'abha_id' => '', 'trigger' => 'opd.payment.confirmed', ]; $patientResult = $service->registerPatient($patientPayload); $patientStatus = (int) ($patientResult['status'] ?? 0); $patientAlreadyExists = $patientStatus === 409; if (($patientResult['ok'] ?? false) !== true && ! $patientAlreadyExists) { return; } // Persist patient sync id when HealthPlix accepted/recognized this patient. $patientSyncFallback = trim((string) ($patient['p_code'] ?? $patientId)); $patientSyncId = $this->extractHealthplixSyncId($patientResult, $patientSyncFallback); $this->persistHealthplixSyncId('patient_master', 'id', $patientId, $patientSyncId); $payload = [ 'external_appointment_id' => (string) $opdId, 'appointment_id' => (string) ($opd['opd_code'] ?? ''), 'appointment_datetime' => trim((string) ($opd['apointment_date'] ?? '')), 'patient_identifier' => (string) ($patient['p_code'] ?? $patientId), 'patient_name' => trim((string) ($patient['p_fname'] ?? ($opd['P_name'] ?? ''))), 'patient_mobile' => trim((string) ($patient['mphone1'] ?? '')), 'doctor_identifier' => $doctorIdentifier, 'doctor_name' => trim((string) ($opd['doc_name'] ?? '')), 'doctor_email' => $doctorEmail, 'service_identifier' => $serviceIdentifier, 'service_name' => $serviceName !== '' ? $serviceName : trim((string) ($opd['opd_fee_desc'] ?? 'Consultation')), 'price' => (float) ($opd['opd_fee_amount'] ?? 0), 'local_trigger' => 'opd.payment.confirmed', ]; $appointmentResult = $service->bookAppointment($payload); if (($appointmentResult['ok'] ?? false) === true) { $appointmentSyncFallback = trim((string) ($opd['opd_code'] ?? $opdId)); $appointmentSyncId = $this->extractHealthplixSyncId($appointmentResult, $appointmentSyncFallback); $this->persistHealthplixSyncId('opd_master', 'opd_id', $opdId, $appointmentSyncId); } } catch (\Throwable $e) { // Never block OPD payment flow due to external integration errors. } } /** * @param array $result */ private function extractHealthplixSyncId(array $result, string $fallback = ''): string { $data = (array) ($result['data'] ?? []); $candidates = [ $data['sync_id'] ?? null, $data['reference_id'] ?? null, $data['referenceId'] ?? null, $data['request_id'] ?? null, $data['appointment_id'] ?? null, $data['appointment_identifier'] ?? null, $data['patient_id'] ?? null, $data['patient_identifier'] ?? null, $data['id'] ?? null, $data['uuid'] ?? null, $result['sync_id'] ?? null, ]; foreach ($candidates as $candidate) { $value = trim((string) $candidate); if ($value !== '') { return $value; } } return trim($fallback); } private function persistHealthplixSyncId(string $table, string $idField, int $entityId, string $syncId): void { if ($entityId <= 0 || $syncId === '') { return; } if (! $this->db->tableExists($table)) { return; } $fields = array_map( static fn (object $field): string => (string) ($field->name ?? ''), $this->db->getFieldData($table) ); if (! in_array($idField, $fields, true) || ! in_array('healthplix_sync_id', $fields, true)) { return; } $this->db->table($table) ->where($idField, $entityId) ->update(['healthplix_sync_id' => $syncId]); } private function mapPatientGenderToHealthplix(string $gender): string { $gender = trim(strtolower($gender)); if ($gender === '1' || $gender === 'male' || $gender === 'm') { return 'male'; } if ($gender === '2' || $gender === 'female' || $gender === 'f') { return 'female'; } return 'other'; } private function readHealthplixSetting(string $name): string { $value = trim((string) env($name, '')); if ($value !== '') { return $value; } if (defined($name)) { $defined = trim((string) constant($name)); if ($defined !== '') { return $defined; } } 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 resolveDoctorHealthplixIdentifierField(): ?string { if (! $this->db->tableExists('doctor_master')) { return null; } $fields = $this->db->getFieldNames('doctor_master') ?? []; foreach (['healthplix_doctor_identifier', 'healthplix_identifier'] as $field) { if (in_array($field, $fields, true)) { return $field; } } return null; } 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)) { return $this->response->setStatusCode(404)->setBody('OPD not found'); } $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); } return view('billing/opd_scan_modal_body', [ 'opdid' => $opdid, 'opd_master' => $opdMaster, 'person_info' => $personInfo, ]); } /** * Save uploaded or captured scan file and queue it for asynchronous AI analysis. */ public function save_image(int $opdid) { $opdRow = $this->db->table('opd_master')->where('opd_id', $opdid)->get(1)->getRowArray(); if (empty($opdRow)) { return $this->response->setStatusCode(404)->setJSON(['update' => 0, 'error_text' => 'OPD not found']); } $uploadsDir = rtrim(FCPATH, '/\\') . '/uploads/' . date('Ymd'); if (!is_dir($uploadsDir)) { @mkdir($uploadsDir, 0777, true); } $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; $uploadedFile->move($uploadsDir, $filename); $saved = is_file($fullPath); $detectedMimeType = (string) ($uploadedFile->getClientMimeType() ?: 'image/jpeg'); $isImageFile = 1; $detectedExt = '.' . $ext; } // 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; $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; } } // 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 !== '') { file_put_contents($fullPath, $raw); $saved = is_file($fullPath); $detectedMimeType = 'image/' . ($imgExt === 'jpg' ? 'jpeg' : $imgExt); $isImageFile = 1; $detectedExt = '.' . $imgExt; } } } if (!$saved) { 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 ); $scanType = $this->detectScanType((string) $filename); 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'] = ''; } 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); } } $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')) { return $this->response->setBody('
    No scan table found.
    '); } $fields = $this->db->getFieldNames('file_upload_data'); $safeSelect = [ 'id', 'full_path', 'file_ext', 'insert_date', 'pid', 'opd_id', 'show_type', ]; 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)) ->where('show_type', 0) ->orderBy('id', 'DESC') ->limit(10); if ($opdid > 0) { $builder->where('opd_id', $opdid); } $rows = $builder->get()->getResultArray(); $list = []; foreach ($rows as $row) { $raw = (string) ($row['full_path'] ?? ''); $publicPath = $raw; $pos = strpos(str_replace('\\', '/', $raw), '/uploads/'); if ($pos !== false) { $publicPath = substr(str_replace('\\', '/', $raw), $pos); } $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')) { return $this->response->setBody('
    No scan table found.
    '); } $fields = $this->db->getFieldNames('file_upload_data'); $safeSelect = ['id', 'full_path', 'file_ext', 'insert_date']; if (in_array('show_type', $fields, true)) { $safeSelect[] = 'show_type'; } $rows = $this->db->table('file_upload_data') ->select(implode(',', $safeSelect)) ->where('opd_id', (int) $opdid) ->where('show_type', 0) ->orderBy('id', 'ASC') ->get() ->getResultArray(); $slides = []; foreach ($rows as $row) { $rawPath = (string) ($row['full_path'] ?? ''); $normalizedPath = str_replace('\\', '/', $rawPath); $publicPath = $normalizedPath; $uploadPos = strpos($normalizedPath, '/uploads/'); if ($uploadPos !== false) { $publicPath = substr($normalizedPath, $uploadPos); } $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)); } }