devsofttech_hms_ci4/app/Controllers/Opd.php

7349 lines
305 KiB
PHP

<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Libraries\Abdm\AbdmConnectorFactory;
use App\Libraries\HealthplixService;
use App\Models\ClinicalAuditTrailModel;
use App\Models\OpdModel;
use App\Models\PaymentModel;
use CodeIgniter\I18n\Time;
use Mpdf\HTMLParserMode;
use Mpdf\Mpdf;
class Opd extends BaseController
{
private const OPD_PAPER_SETTING_KEY = 'OPD_PAPER_PRINT_SETTINGS';
private const OPD_PAPER_SETTING_KEY_PREFIX = 'OPD_PAPER_PS__';
private const OPD_PAPER_SETTING_KEY_PREFIX_LEGACY = 'OPD_PAPER_PRINT_SETTINGS__';
private const OPD_TEMPLATE_KEY_PREFIX = 'OPD_PRINT_TEMPLATE__';
/**
* @var array<int, string>
*/
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', 'complication', // Formatted block of checked flags
'Addiction', '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',
'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<string,string>
*/
private function derivePaperBlocksFromTemplateHtml(string $templateHtml): array
{
$html = str_replace(["\r\n", "\r"], "\n", (string) $templateHtml);
$headerHtml = '';
$footerHtml = '';
if (preg_match('/<htmlpageheader\b[^>]*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('/<htmlpagefooter\b[^>]*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('/<style\b[^>]*>[\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<int,string>
*/
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<int, string>
*/
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<string, mixed>
*/
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 .= '<h5><strong>Doctor Name :</strong> ' . esc($docInfo[0]->p_fname ?? '') .
' <strong>/ Specialization:</strong> ' . esc($docInfo[0]->SpecName ?? '') .
' <strong>/ Gender :</strong> ' . esc($docInfo[0]->xGender ?? '') . ' </h5>';
$content .= '<input type="hidden" name="doc_id" id="doc_id" value="' . esc($docInfo[0]->id ?? 0) . '" />';
$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 .= '<label class="d-block">';
$content .= '<input type="radio" name="fee_id" id="fee_id" class="form-check-input me-1" ' . $checked . ' value="' . (int) $row->id . '"> ';
$content .= 'Rs. ' . esc($row->amount) . ' [<i>' . esc($row->fee_desc) . '</i>]';
$content .= '</label>';
}
$content .= '<hr />';
$otherContent = '';
foreach ($docFeeB as $row) {
$checked = ((int) $row->id === (int) $feeTypeSelect) ? 'checked' : '';
$otherContent .= '<label class="d-block">';
$otherContent .= '<input type="radio" name="fee_id" id="fee_id" class="form-check-input me-1" ' . $checked . ' value="' . (int) $row->id . '"> ';
$otherContent .= 'Rs. ' . esc($row->amount) . ' [<i>' . esc($row->fee_desc) . '</i>]';
$otherContent .= '</label>';
}
$content .= '<div class="accordion" id="opdFeeAccordion">'
. '<div class="accordion-item">'
. '<h2 class="accordion-header" id="headingOne">'
. '<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">'
. 'Other Option'
. '</button>'
. '</h2>'
. '<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#opdFeeAccordion">'
. '<div class="accordion-body">'
. $otherContent
. '</div>'
. '</div>'
. '</div>'
. '</div>';
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');
$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);
$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 = '<style>@page {' . "\n"
. 'margin-top: ' . $marginTopCm . 'cm;' . "\n"
. 'margin-bottom: ' . $marginBottomCm . 'cm;' . "\n"
. 'margin-left: ' . $marginLeftCm . 'cm;' . "\n"
. 'margin-right: ' . $marginRightCm . 'cm;' . "\n"
. 'margin-header: ' . $marginHeaderCm . 'cm;' . "\n"
. 'margin-footer: ' . $marginFooterCm . 'cm;' . "\n";
if ($useHeader) {
$css .= 'header: html_myHeader;' . "\n";
}
if ($useFooter) {
$css .= 'footer: html_myFooter;' . "\n";
}
$css .= '}' . "\n" . '</style>' . "\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?>', '{{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 .= '<htmlpageheader name="myHeader">' . "\n" . $renderedHeaderHtml . "\n" . '</htmlpageheader>' . "\n";
} else {
$combinedHtml .= $renderedHeaderHtml . "\n";
}
}
if ($rawFooterHtml !== '') {
$renderedFooterHtml = $this->applyOpdPdfTemplateTokens($rawFooterHtml, $tokenVars);
if (!preg_match('/<\s*htmlpagefooter\b/i', $renderedFooterHtml)) {
$combinedHtml .= '<htmlpagefooter name="myFooter">' . "\n" . $renderedFooterHtml . "\n" . '</htmlpagefooter>' . "\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);
}
$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<string, mixed> $data
* @return array<string, mixed>
*/
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) . '<br>' : '')
. ($oldExpDate !== '' ? '<b>Valid Upto : </b>' . esc($oldExpDate) : '');
} else {
$expDateText = $expDate !== '' ? ('<b>Valid Upto : </b>' . 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 .= '<table width="100%" style="border-collapse:collapse;font-size:12px;">';
$medicalHtml .= '<tr><th style="text-align:left;width:5%;">#</th><th style="text-align:left;width:43%;">Medicine</th><th style="text-align:left;width:32%;">Dose/Freq</th><th style="text-align:left;width:10%;">Qty</th><th style="text-align:left;width:10%;">Day</th></tr>';
$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 .= '<div style="font-size:11px;color:#444;">Salt/Generic: ' . esc($generic) . '</div>';
}
$hasInstruction = ($inst !== '' || $instLocal !== '');
$baseRowCellStyle = $hasInstruction ? ' style="border-bottom:none;"' : '';
$medicalHtml .= '<tr>'
. '<td' . $baseRowCellStyle . '>' . $i . '</td>'
. '<td' . $baseRowCellStyle . '>' . $nameHtml . '</td>'
. '<td' . $baseRowCellStyle . '>' . esc($doseFreqText)
. ($doseFreqLocal !== '' ? ('<div style="font-size:11px;color:#444;line-height:1.4;" lang="hi">' . esc($doseFreqLocal) . '</div>') : '')
. '</td>'
. '<td' . $baseRowCellStyle . '>' . esc($qty) . '</td>'
. '<td' . $baseRowCellStyle . '>' . esc($days) . '</td>'
. '</tr>';
if ($hasInstruction) {
$medicalHtml .= '<tr>'
. '<td></td>'
. '<td colspan="4" style="padding-top:2px;padding-bottom:4px;">'
. '<div>' . esc($inst !== '' ? $inst : '-') . '</div>'
. ($instLocal !== '' ? ('<div style="font-size:11px;color:#444;line-height:1.4;" lang="hi">' . esc($instLocal) . '</div>') : '')
. '</td>'
. '</tr>';
}
}
$medicalHtml .= '</table>';
}
$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']);
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']);
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 = '<table width="100%" border="0" style="font-size:10pt;">'
. '<tr>'
. '<td width="33.3%" valign="top">'
. 'Name : <strong>' . esc($pName) . '</strong><br>'
. esc($pRelative) . '<br>'
. 'Gender/Age : <b>' . esc($ageSex) . '</b><br>'
. 'Mob :' . esc($phone) . '<br>'
. 'Address :' . esc($pAddress)
. '</td>'
. '<td width="33.3%" valign="top">'
. 'UHID : ' . esc($uhidNo) . '<br>'
. 'Sr No.: <b>' . esc($opdSrNo) . '</b><br>'
. 'OPD No.: <b>' . esc($opdNo) . '</b><br>'
. '<br><b>Date: ' . esc($opdDate) . '</b><br>' . $expDateText
. '</td>'
. '<td width="33.3%" valign="top">'
. '<b>DEPARTMENT :</b><br>' . esc($specName) . '<br>'
. esc($opdFeeDesc) . '<br>'
. '<b>No. of Visit</b> : ' . esc($totalNoVisit) . '<br>'
. esc($lastVisitText) . '<br>'
. 'Book Time : ' . esc($bookTime)
. '</td>'
. '</tr>'
. '</table>';
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_local' => $complaintLocal,
'diagnosis' => $diagnosisText,
'Diagnosis' => $diagnosisText,
'diagnosis_local' => $diagnosisLocal,
'Provisional_diagnosis' => $provisionalDiagnosisText,
'provisional_diagnosis' => $provisionalDiagnosisText,
'investigation' => $rxInvestigation,
'investigation_local' => $investigationLocal,
'medical' => $medicalHtml,
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
'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<string, mixed> $vars
*/
private function renderLegacyPhpTemplate(string $filePath, array $vars): string
{
ob_start();
extract($vars, EXTR_SKIP);
include $filePath;
return (string) ob_get_clean();
}
/**
* @param array<string, mixed> $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<string, mixed> $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<int, string>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $data
* @return array<string, string>
*/
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;
}
}
$formatBlock = static function (string $label, string $value, bool $lineBreakAfterLabel = false): string {
$value = trim($value);
if ($value === '') {
return '';
}
$headingHtml = '<span style="font-weight:700;font-size:13px;line-height:1.35;">' . $label . ' :</span>';
if ($lineBreakAfterLabel) {
return '<div style="margin-bottom:4px;">' . $headingHtml . '<br/>' . $value . '</div>';
}
return '<div style="margin-bottom:4px;">' . $headingHtml . ' ' . $value . '</div>';
};
$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'] ?? '');
$tokens['vital_content'] = $formatBlock('Vitals', (string) ($tokens['vital_content'] ?? ''), false);
$tokens['Complaint'] = $formatBlock('Complaint', (string) ($tokens['Complaint_raw'] ?? ''));
$tokens['complaint'] = $tokens['Complaint'];
$tokens['diagnosis'] = $formatBlock('Diagnosis', (string) ($tokens['diagnosis_raw'] ?? ''));
$tokens['Diagnosis'] = $tokens['diagnosis'];
$tokens['Provisional_diagnosis'] = $formatBlock('Provisional Diagnosis', (string) ($tokens['Provisional_diagnosis_raw'] ?? ''));
$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) ────────────────────────────────────────────
$painLabel = trim((string) ($tokens['pain_label'] ?? ''));
$tokens['pain_scale'] = $painLabel !== '' ? $formatBlock('Pain Scale', $painLabel) : '';
// ── 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'] ?? ''));
$allergyBlock = $allergyStatus !== '' ? esc($allergyStatus) : '';
if ($allergyDetails !== '') {
$allergyBlock .= ($allergyBlock !== '' ? ' — ' : '') . esc($allergyDetails);
}
$tokens['drug_allergy_block'] = $allergyBlock !== '' ? $formatBlock('Drug Allergy', $allergyBlock) : '';
$tokens['adr_history_block'] = $adrHistory !== '' ? $formatBlock('ADR History', esc($adrHistory)) : '';
$tokens['current_medications_block'] = $currentMeds !== '' ? $formatBlock('Current Medications', esc($currentMeds)) : '';
// ── 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'] ?? ''));
$vaccination = trim((string) ($tokens['vaccination_history'] ?? ''));
if ($obstetric !== '') { $patientHistoryParts[] = '<strong>Obstetric:</strong> ' . esc($obstetric); }
if ($menstrual !== '') { $patientHistoryParts[] = '<strong>Menstrual:</strong> ' . esc($menstrual); }
if ($medicalSurgical !== '') { $patientHistoryParts[] = '<strong>Medical/Surgical:</strong> ' . esc($medicalSurgical); }
if ($family !== '') { $patientHistoryParts[] = '<strong>Family:</strong> ' . esc($family); }
if ($allergic !== '') { $patientHistoryParts[] = '<strong>Allergic:</strong> ' . esc($allergic); }
if ($vaccination !== '') { $patientHistoryParts[] = '<strong>Vaccination:</strong> ' . esc($vaccination); }
$patientHistoryHtml = implode('<br/>', $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 !== ''
? '<div style="margin:8px 0 6px 0;"><div style="font-weight:700;font-size:24px;line-height:1.2;margin-bottom:8px;">Rx :</div>' . $medicalHtml . '</div>'
: '';
$tokens['rx'] = $tokens['Rx'];
$tokens['VitalsBlock'] = (string) ($tokens['vital_content'] ?? '');
$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 !== ''
? ('<br><strong>सलाह:</strong> ' . $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 !== '' ? '<strong>' . esc($hName) . '</strong>' : '',
trim($hAddress1 . ', ' . $hAddress2, ', ') !== '' ? esc(trim($hAddress1 . ', ' . $hAddress2, ', ')) : '',
$hPhone !== '' ? 'Ph: ' . esc($hPhone) : '',
$hEmail !== '' ? 'Email: ' . esc($hEmail) : '',
], static fn ($v): bool => $v !== '');
$tokens['hospital_section'] = implode('<br>', $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'] ?? '');
// ─────────────────────────────────────────────────────────────────────
return $tokens;
}
/**
* @param array<string, string> $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(['<br/>', '<br />', '<br>'], "\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<string, mixed> $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('<div class="text-muted">No scan table found.</div>');
}
$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('<div class="text-muted">No scan table found.</div>');
}
$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<string, mixed>
*/
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<string, mixed> $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<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, string>|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<string, mixed> $payload
* @param array<int, string> $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<string, mixed>|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<int,string>}
*/
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<int, string>
*/
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<string, mixed>|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<int, string> $constCandidates
* @param array<int, string> $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/<subpath>
*/
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));
}
}