devsofttech_hms_ci4/app/Controllers/Opd_prescription.php
DevSoftTech Server 0628f8f813 feat: ABDM OPD Queue + ABHA Register flow + patient ABHA search fix
ABDM OPD Queue (today):
- New EAtriaBridgeConnector: opdQueueFetch/opdTokenCreate/opdTokenUpdateStatus + patch() helper
- New AbdmOpdQueue controller: index/list/fetchQueue/createToken/updateTokenStatus/processScannedToken
  - fetchQueue syncs gateway tokens into abdm_opd_tokens (local DB) and enriches with HMS patient data
  - processScannedToken auto-finds/creates patient_master record from ABHA scan data, marks token CALLED
- New migration: abdm_opd_tokens table (gateway_token_id, source, status, patient_id, opd_id, processed_at)
- New view: abdm/opd_queue.php - reception screen with auto-refresh 30s, HMS Patient column (green row when OPD registered)
- Sidebar: ABDM OPD Queue nav link added
- Routes: AbdmOpdQueue with abdmPermFilter

ABHA Register tab (earlier this session):
- New Abha controller: initiate/verifyOtp/communication/verifyCommOtp/validateAbha/card + autoCreateOrFindPatient
- New views: abha/card.php, partials/abha_create_panel.php, partials/abha_otp_modal.php
- Patient_V.php: ABHA Register tab with 4-method wizard (ABHA number, QR scan, facility QR, mobile OTP)

Patient ABHA search fix:
- Patient::search() now detects ABHA column (abha_id/abha_no/abha/abha_address) and searches by it

ABDM Bridge infrastructure:
- EAtriaBridgeConnector: new connector for e-atria.in ABDM bridge gateway
- AbdmConnectorFactory/Interface updated
- AbdmGatewaySettings controller + view
- SNOMED/CSNOTK terminology service skeleton
- Finance payout OPD consult views
- Migration: AddAbdmPushFieldsToOpdAndPatient, CreateSnomedCoreTables
- Docs: abdm/ API specs, architecture, gateway guides
2026-05-16 04:21:31 +05:30

10812 lines
423 KiB
PHP

<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Libraries\AbdmWorkTaskService;
use App\Libraries\BridgeSyncService;
use App\Libraries\CsnotkTerminologyService;
use App\Models\OpdMedicineModel;
use CodeIgniter\I18n\Time;
class Opd_prescription extends BaseController
{
private string $lastAiProvider = 'none';
private string $lastAiServerError = '';
private function canAccessSnomedAdmin(): bool
{
$user = auth()->user();
if (! $user) {
return false;
}
if (method_exists($user, 'can') && (
$user->can('admin.access')
|| $user->can('admin.settings')
|| $user->can('abdm.access')
)) {
return true;
}
return false;
}
private function canAccessPrescription(): bool
{
$user = auth()->user();
if (! $user) {
return false;
}
if (method_exists($user, 'can') && $user->can('opd.doctor-panel.access')) {
return true;
}
return false;
}
public function Prescription(int $opdId)
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setBody('Access denied: OPD Prescription is restricted to doctor-authorized users.');
}
$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');
}
$opdRow = $opdMaster[0];
$patientId = (int) ($opdRow->p_id ?? 0);
$sql = "select *,if(gender=1,'Male','Female') as xgender
from patient_master where id='" . (int) $opdRow->p_id . "'";
$query = $this->db->query($sql);
$patient = $query->getResult();
$patientProfileFilePath = '/assets/images/no_image.svg';
if (!empty($patient)) {
$profilePicturePath = '';
if ($this->db->fieldExists('profile_picture', 'patient_master')) {
$profilePicturePath = trim((string) ($patient[0]->profile_picture ?? ''));
}
$profileFileId = (int) ($patient[0]->profile_file_id ?? 0);
if ($profileFileId > 0 && $this->db->tableExists('file_upload_data')) {
$fileRow = $this->db->table('file_upload_data')
->select('full_path')
->where('id', $profileFileId)
->get(1)
->getRow();
$fullPath = trim((string) ($fileRow->full_path ?? ''));
if ($fullPath !== '') {
$pos = strpos($fullPath, '/uploads/', 1);
$patientProfileFilePath = $pos !== false ? substr($fullPath, $pos) : $fullPath;
}
} elseif ($profilePicturePath !== '') {
$pos = strpos($profilePicturePath, '/uploads/', 1);
$patientProfileFilePath = $pos !== false ? substr($profilePicturePath, $pos) : $profilePicturePath;
}
}
if (!empty($patient)) {
$patient[0]->str_age = get_age_1($patient[0]->dob ?? null, $patient[0]->age ?? '', $patient[0]->age_in_month ?? '', $patient[0]->estimate_dob ?? '', $opdRow->apointment_date ?? null);
}
$prescription = [];
if ($this->db->tableExists('opd_prescription')) {
$prescription = $this->db->table('opd_prescription')
->where('opd_id', $opdId)
->orderBy('id', 'DESC')
->get(1)
->getResult();
if (!empty($prescription)) {
$first = $prescription[0];
$firstRow = (array) $first;
$womenText = '';
if (isset($first->women_related_problems)) {
$womenText = trim((string) $first->women_related_problems);
}
if ($womenText === '') {
$womenText = $this->extractWomenRelatedProblemsFromRemarks((string) ($first->Prescriber_Remarks ?? ''));
}
$womenStructured = $this->parseWomenStructuredDetails($womenText);
$first->women_related_problems = (string) ($womenStructured['women_related_problems'] ?? '');
$first->women_lmp = trim((string) (
$firstRow['women_lmp']
?? $firstRow['lmp']
?? $firstRow['lmp_date']
?? ($womenStructured['women_lmp'] ?? '')
));
$first->women_last_baby = trim((string) (
$firstRow['women_last_baby']
?? $firstRow['last_baby']
?? $firstRow['last_baby_details']
?? ($womenStructured['women_last_baby'] ?? '')
));
$first->women_pregnancy_related = trim((string) (
$firstRow['women_pregnancy_related']
?? $firstRow['pregnancy_related']
?? $firstRow['pregnancy_related_problem']
?? ($womenStructured['women_pregnancy_related'] ?? '')
));
$nabhInfo = $this->hydrateNabhPrescriptionFields(
$firstRow,
(string) ($first->Prescriber_Remarks ?? '')
);
$first->Prescriber_Remarks = $this->stripNabhFieldsFromRemarks((string) ($first->Prescriber_Remarks ?? ''));
$first->drug_allergy_status = (string) ($nabhInfo['drug_allergy_status'] ?? '');
$first->drug_allergy_details = (string) ($nabhInfo['drug_allergy_details'] ?? '');
$first->adr_history = (string) ($nabhInfo['adr_history'] ?? '');
$first->current_medications = (string) ($nabhInfo['current_medications'] ?? '');
$historyInfo = $this->hydratePatientHistoryFields(
$firstRow,
(string) ($first->Prescriber_Remarks ?? '')
);
$first->obstetric_history = (string) ($historyInfo['obstetric_history'] ?? '');
$first->menstrual_history = (string) ($historyInfo['menstrual_history'] ?? '');
$first->medical_surgical_history = (string) ($historyInfo['medical_surgical_history'] ?? '');
$first->family_history = (string) ($historyInfo['family_history'] ?? '');
$first->allergic_history = (string) ($historyInfo['allergic_history'] ?? '');
$first->vaccination_history = (string) ($historyInfo['vaccination_history'] ?? '');
$prescription[0] = $first;
}
}
return view('billing/opd_prescription_basic', [
'opd_id' => $opdId,
'opd_master' => $opdMaster,
'patient_master' => $patient,
'patient_profile_file_path' => $patientProfileFilePath,
'opd_prescription' => $prescription,
'next_visit_options' => $this->getNextVisitOptions(date('Y-m-d')),
'addiction_flags' => $this->getPatientAddictionFlags($patientId),
'co_morbidities' => $this->getCoMorbiditiesWithSelection($patientId),
'co_morbidities_prefilled_from_history' => $this->hasHistoricalCoMorbiditySelection($patientId),
'history_alerts' => $this->buildPatientHistoryAlerts((int) ($opdRow->p_id ?? 0), $opdId),
'left_remarks' => $this->fetchPatientRemarks((int) ($opdRow->p_id ?? 0), 8),
]);
}
/**
* @return array<int, array{desc:string,days:int,date:string,value:string}>
*/
private function getNextVisitOptions(string $baseDate): array
{
$fallbackDays = [3, 4, 5, 7, 10, 15, 20, 30, 60];
$fallbackDesc = [
3 => '3 Days',
4 => '4 Days',
5 => '5 Days',
7 => '1 Week',
10 => '10 Days',
15 => '15 Days',
20 => '20 days',
30 => '1 Month',
60 => '2 Months',
];
$baseTs = strtotime($baseDate);
if ($baseTs === false) {
$baseTs = strtotime(date('Y-m-d'));
}
$rows = [];
if ($this->db->tableExists('opd_nextvisit')) {
$fields = $this->db->getFieldNames('opd_nextvisit') ?? [];
$descField = $this->resolveFirstField($fields, ['next_visit_desc', 'visit_desc', 'description', 'nextvisit_desc', 'name']);
$daysField = $this->resolveFirstField($fields, ['no_of_days', 'days', 'day_count']);
if ($descField !== null && $daysField !== null) {
$builder = $this->db->table('opd_nextvisit')
->select($descField . ' as visit_desc,' . $daysField . ' as no_of_days');
if (in_array('status', $fields, true)) {
$builder->where('status', 1);
}
$rows = $builder
->orderBy($daysField, 'ASC')
->get()
->getResultArray();
}
}
$options = [];
if (! empty($rows)) {
foreach ($rows as $row) {
$days = (int) ($row['no_of_days'] ?? 0);
$desc = trim((string) ($row['visit_desc'] ?? ''));
if ($days <= 0 || $desc === '') {
continue;
}
$visitDate = date('d-m-Y', strtotime('+' . $days . ' day', $baseTs));
$value = $desc . ' (' . $visitDate . ')';
$options[] = [
'desc' => $desc,
'days' => $days,
'date' => $visitDate,
'value' => $value,
];
}
}
if (empty($options)) {
foreach ($fallbackDays as $days) {
$desc = (string) ($fallbackDesc[$days] ?? ($days . ' Days'));
$visitDate = date('d-m-Y', strtotime('+' . $days . ' day', $baseTs));
$value = $desc . ' (' . $visitDate . ')';
$options[] = [
'desc' => $desc,
'days' => $days,
'date' => $visitDate,
'value' => $value,
];
}
}
return $options;
}
public function vitals_get(int $opdId)
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
}
if ($opdId <= 0 || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'OPD prescription table not found']);
}
$row = $this->db->table('opd_prescription')
->where('opd_id', (int) $opdId)
->orderBy('id', 'DESC')
->get(1)
->getRowArray() ?? [];
$vitals = [
'pulse' => (string) ($row['pulse'] ?? ''),
'spo2' => (string) ($row['spo2'] ?? ''),
'bp' => (string) ($row['bp'] ?? ''),
'diastolic' => (string) ($row['diastolic'] ?? ''),
'temp' => (string) ($row['temp'] ?? ''),
'rr_min' => (string) ($row['rr_min'] ?? ''),
'height' => (string) ($row['height'] ?? ''),
'weight' => (string) ($row['weight'] ?? ''),
'waist' => (string) ($row['waist'] ?? ''),
];
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => (int) ($row['id'] ?? 0),
'vitals' => $vitals,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function vitals_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
if ($opdId <= 0 || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'OPD prescription table not found']);
}
$sessionId = $this->ensurePrescriptionSession($opdId, $sessionId);
if ($sessionId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to create prescription session']);
}
$fields = $this->db->getFieldNames('opd_prescription');
$input = [
'pulse' => trim((string) $this->request->getPost('pulse')),
'spo2' => trim((string) $this->request->getPost('spo2')),
'bp' => trim((string) $this->request->getPost('bp')),
'diastolic' => trim((string) $this->request->getPost('diastolic')),
'temp' => trim((string) $this->request->getPost('temp')),
'rr_min' => trim((string) $this->request->getPost('rr_min')),
'height' => trim((string) $this->request->getPost('height')),
'weight' => trim((string) $this->request->getPost('weight')),
'waist' => trim((string) $this->request->getPost('waist')),
];
$update = [];
foreach ($input as $field => $value) {
if (in_array($field, $fields, true)) {
$update[$field] = $value;
}
}
if (empty($update)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No vital fields available']);
}
$this->db->table('opd_prescription')->where('id', $sessionId)->update($update);
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Vitals saved successfully',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_prescription_save()
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON([
'update' => 0,
'error_text' => 'Invalid request',
]);
}
if (!$this->db->tableExists('opd_prescription')) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Table opd_prescription not found.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$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(),
]);
}
$opdRow = $opdMaster[0];
$fields = $this->db->getFieldNames('opd_prescription');
$patientRow = $this->db->table('patient_master')->where('id', (int) ($opdRow->p_id ?? 0))->get(1)->getRowArray() ?? [];
$abhaAddress = trim((string) $this->request->getPost('abha_address'));
if ($abhaAddress !== '' && ! $this->isValidAbhaAddress($abhaAddress)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Invalid ABHA Address format.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$abhaField = $this->resolvePatientAbhaField();
if ($abhaField !== null && $abhaAddress !== '') {
$currentAbha = trim((string) ($patientRow[$abhaField] ?? ''));
if ($currentAbha !== $abhaAddress) {
$this->db->table('patient_master')->where('id', (int) ($opdRow->p_id ?? 0))->update([$abhaField => $abhaAddress]);
$this->auditClinicalUpdate('patient_master', $abhaField, (int) ($opdRow->p_id ?? 0), $currentAbha, $abhaAddress);
$patientRow[$abhaField] = $abhaAddress;
}
}
$payload = [
'complaints' => trim((string) $this->request->getPost('complaints')),
'complaint_onset' => trim((string) $this->request->getPost('complaint_onset')),
'complaint_duration_days' => trim((string) $this->request->getPost('complaint_duration_days')),
'complaint_severity' => trim((string) $this->request->getPost('complaint_severity')),
'diagnosis' => trim((string) $this->request->getPost('diagnosis')),
'diagnosis_snomed_id' => trim((string) $this->request->getPost('diagnosis_snomed_id')),
'diagnosis_snomed_term' => trim((string) $this->request->getPost('diagnosis_snomed_term')),
'diagnosis_snomed_source' => trim((string) $this->request->getPost('diagnosis_snomed_source')),
'Provisional_diagnosis' => trim((string) $this->request->getPost('provisional_diagnosis')),
'provisional_diagnosis_snomed_id' => trim((string) $this->request->getPost('provisional_diagnosis_snomed_id')),
'provisional_diagnosis_snomed_term' => trim((string) $this->request->getPost('provisional_diagnosis_snomed_term')),
'provisional_diagnosis_snomed_source' => trim((string) $this->request->getPost('provisional_diagnosis_snomed_source')),
'Finding_Examinations' => trim((string) $this->request->getPost('finding_examinations')),
'Prescriber_Remarks' => trim((string) $this->request->getPost('prescriber_remarks')),
'women_related_problems' => trim((string) $this->request->getPost('women_related_problems')),
'women_lmp' => trim((string) $this->request->getPost('women_lmp')),
'women_last_baby' => trim((string) $this->request->getPost('women_last_baby')),
'women_pregnancy_related' => trim((string) $this->request->getPost('women_pregnancy_related')),
'obstetric_history' => trim((string) $this->request->getPost('obstetric_history')),
'menstrual_history' => trim((string) $this->request->getPost('menstrual_history')),
'medical_surgical_history' => trim((string) $this->request->getPost('medical_surgical_history')),
'family_history' => trim((string) $this->request->getPost('family_history')),
'allergic_history' => trim((string) $this->request->getPost('allergic_history')),
'vaccination_history' => trim((string) $this->request->getPost('vaccination_history')),
'drug_allergy_status' => trim((string) $this->request->getPost('drug_allergy_status')),
'drug_allergy_details' => trim((string) $this->request->getPost('drug_allergy_details')),
'adr_history' => trim((string) $this->request->getPost('adr_history')),
'current_medications' => trim((string) $this->request->getPost('current_medications')),
'investigation' => trim((string) $this->request->getPost('investigation')),
'advice' => trim((string) $this->request->getPost('advice')),
'next_visit' => trim((string) $this->request->getPost('next_visit')),
'refer_to' => trim((string) $this->request->getPost('refer_to')),
'bp' => trim((string) $this->request->getPost('bp')),
'diastolic' => trim((string) $this->request->getPost('diastolic')),
'pulse' => trim((string) $this->request->getPost('pulse')),
'temp' => trim((string) $this->request->getPost('temp')),
'spo2' => trim((string) $this->request->getPost('spo2')),
'rr_min' => trim((string) $this->request->getPost('rr_min')),
'height' => trim((string) $this->request->getPost('height')),
'weight' => trim((string) $this->request->getPost('weight')),
'waist' => trim((string) $this->request->getPost('waist')),
'pallor' => trim((string) $this->request->getPost('pallor')),
'Icterus' => trim((string) $this->request->getPost('icterus')),
'cyanosis' => trim((string) $this->request->getPost('cyanosis')),
'clubbing' => trim((string) $this->request->getPost('clubbing')),
'edema' => trim((string) $this->request->getPost('edema')),
'pain_value' => trim((string) $this->request->getPost('pain_value')),
'pregnancy' => (int) $this->request->getPost('pregnancy'),
'lactation' => (int) $this->request->getPost('lactation'),
'liver_insufficiency' => (int) $this->request->getPost('liver_insufficiency'),
'renal_insufficiency' => (int) $this->request->getPost('renal_insufficiency'),
'pulmonary_insufficiency' => (int) $this->request->getPost('pulmonary_insufficiency'),
'corona_suspected' => (int) $this->request->getPost('corona_suspected'),
'dengue' => (int) $this->request->getPost('dengue'),
];
$allergyStatusRaw = trim((string) ($payload['drug_allergy_status'] ?? ''));
$allergyStatusNorm = mb_strtolower(preg_replace('/\s+/', ' ', $allergyStatusRaw) ?? $allergyStatusRaw);
$allergyDetails = trim((string) ($payload['drug_allergy_details'] ?? ''));
if ($allergyStatusRaw === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Drug Allergy Status is required as per NABH documentation.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if (in_array($allergyStatusNorm, ['known', 'yes', 'present'], true) && $allergyDetails === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Drug Allergy Details are required when Drug Allergy Status is Known.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$womenStructuredInput = [
'women_lmp' => (string) ($payload['women_lmp'] ?? ''),
'women_last_baby' => (string) ($payload['women_last_baby'] ?? ''),
'women_pregnancy_related' => (string) ($payload['women_pregnancy_related'] ?? ''),
];
$payload['women_related_problems'] = $this->buildWomenCombinedText(
(string) ($payload['women_related_problems'] ?? ''),
$womenStructuredInput
);
$provisionalText = trim((string) ($payload['Provisional_diagnosis'] ?? ''));
$postedSnomedId = trim((string) ($payload['provisional_diagnosis_snomed_id'] ?? ''));
$postedSnomedTerm = trim((string) ($payload['provisional_diagnosis_snomed_term'] ?? ''));
$postedSnomedSource = trim((string) ($payload['provisional_diagnosis_snomed_source'] ?? ''));
$diagnosisText = trim((string) ($payload['diagnosis'] ?? ''));
$postedDiagnosisSnomedId = trim((string) ($payload['diagnosis_snomed_id'] ?? ''));
$postedDiagnosisSnomedTerm = trim((string) ($payload['diagnosis_snomed_term'] ?? ''));
$postedDiagnosisSnomedSource = trim((string) ($payload['diagnosis_snomed_source'] ?? ''));
if ($postedDiagnosisSnomedId !== '') {
$payload['diagnosis_snomed_id'] = $postedDiagnosisSnomedId;
$payload['diagnosis_snomed_term'] = $postedDiagnosisSnomedTerm !== '' ? $postedDiagnosisSnomedTerm : $diagnosisText;
$payload['diagnosis_snomed_source'] = $postedDiagnosisSnomedSource !== '' ? $postedDiagnosisSnomedSource : 'snomed';
} else {
$payload['diagnosis_snomed_id'] = '';
$payload['diagnosis_snomed_term'] = '';
$payload['diagnosis_snomed_source'] = '';
if ($diagnosisText !== '' && strpos($diagnosisText, ',') === false) {
$resolvedDiagnosis = $this->resolveProvisionalDiagnosisSnomedMatch($diagnosisText);
if (! empty($resolvedDiagnosis['concept_id'])) {
$payload['diagnosis_snomed_id'] = (string) $resolvedDiagnosis['concept_id'];
$payload['diagnosis_snomed_term'] = (string) ($resolvedDiagnosis['term'] ?? $diagnosisText);
$payload['diagnosis_snomed_source'] = (string) ($resolvedDiagnosis['source'] ?? 'snomed-exact');
}
}
}
if ($postedSnomedId !== '') {
$payload['provisional_diagnosis_snomed_id'] = $postedSnomedId;
$payload['provisional_diagnosis_snomed_term'] = $postedSnomedTerm !== '' ? $postedSnomedTerm : $provisionalText;
$payload['provisional_diagnosis_snomed_source'] = $postedSnomedSource !== '' ? $postedSnomedSource : 'snomed';
} else {
$payload['provisional_diagnosis_snomed_id'] = '';
$payload['provisional_diagnosis_snomed_term'] = '';
$payload['provisional_diagnosis_snomed_source'] = '';
if ($provisionalText !== '' && strpos($provisionalText, ',') === false) {
$resolved = $this->resolveProvisionalDiagnosisSnomedMatch($provisionalText);
if (! empty($resolved['concept_id'])) {
$payload['provisional_diagnosis_snomed_id'] = (string) $resolved['concept_id'];
$payload['provisional_diagnosis_snomed_term'] = (string) ($resolved['term'] ?? $provisionalText);
$payload['provisional_diagnosis_snomed_source'] = (string) ($resolved['source'] ?? 'snomed-exact');
}
}
}
$this->mapWomenStructuredToExistingColumns($payload, $fields, $womenStructuredInput);
$complaintStructuredInput = [
'complaint_onset' => (string) ($payload['complaint_onset'] ?? ''),
'complaint_duration_days' => (string) ($payload['complaint_duration_days'] ?? ''),
'complaint_severity' => (string) ($payload['complaint_severity'] ?? ''),
];
$this->mapComplaintStructuredToExistingColumns($payload, $fields, $complaintStructuredInput);
$payload['complaints'] = $this->upsertComplaintStructuredIntoComplaints(
(string) ($payload['complaints'] ?? ''),
$complaintStructuredInput
);
$diagnosisCodingInput = [
'diagnosis_snomed_id' => (string) ($payload['diagnosis_snomed_id'] ?? ''),
'diagnosis_snomed_term' => (string) ($payload['diagnosis_snomed_term'] ?? ''),
'diagnosis_snomed_source' => (string) ($payload['diagnosis_snomed_source'] ?? ''),
];
$unmappedDiagnosisCoding = $this->mapDiagnosisCodingToExistingColumns($payload, $fields, $diagnosisCodingInput);
$nabhInput = [
'drug_allergy_status' => (string) ($payload['drug_allergy_status'] ?? ''),
'drug_allergy_details' => (string) ($payload['drug_allergy_details'] ?? ''),
'adr_history' => (string) ($payload['adr_history'] ?? ''),
'current_medications' => (string) ($payload['current_medications'] ?? ''),
];
$this->mapNabhFieldsToExistingColumns($payload, $fields, $nabhInput);
$patientHistoryInput = [
'obstetric_history' => (string) ($payload['obstetric_history'] ?? ''),
'menstrual_history' => (string) ($payload['menstrual_history'] ?? ''),
'medical_surgical_history' => (string) ($payload['medical_surgical_history'] ?? ''),
'family_history' => (string) ($payload['family_history'] ?? ''),
'allergic_history' => (string) ($payload['allergic_history'] ?? ''),
'vaccination_history' => (string) ($payload['vaccination_history'] ?? ''),
];
$unmappedPatientHistory = $this->mapPatientHistoryFieldsToExistingColumns($payload, $fields, $patientHistoryInput);
// Keep prescription remarks user-controlled; do not auto-inject structured NABH lines.
$payload['Prescriber_Remarks'] = $this->stripNabhFieldsFromRemarks((string) ($payload['Prescriber_Remarks'] ?? ''));
$payload['Prescriber_Remarks'] = $this->stripPatientHistoryFieldsFromRemarks((string) ($payload['Prescriber_Remarks'] ?? ''));
if ($this->hasAnyPatientHistoryField($unmappedPatientHistory)) {
$payload['Prescriber_Remarks'] = $this->upsertPatientHistoryFieldsIntoRemarks(
(string) ($payload['Prescriber_Remarks'] ?? ''),
$unmappedPatientHistory
);
}
if ($this->hasAnyPatientHistoryField($unmappedDiagnosisCoding)) {
$payload['Prescriber_Remarks'] = $this->upsertDiagnosisCodingIntoRemarks(
(string) ($payload['Prescriber_Remarks'] ?? ''),
$unmappedDiagnosisCoding
);
}
if (!in_array('women_related_problems', $fields, true)) {
$womenText = trim((string) ($payload['women_related_problems'] ?? ''));
if ($womenText !== '') {
$payload['Prescriber_Remarks'] = $this->upsertWomenRelatedProblemsIntoRemarks(
(string) ($payload['Prescriber_Remarks'] ?? ''),
$womenText
);
}
}
$abdmValidationErrors = $this->validateAbdmReadinessForConsult($payload, $patientRow, (array) $opdRow);
if (! empty($abdmValidationErrors)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => implode(' ', $abdmValidationErrors),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
foreach ($payload as $value) {
if (mb_strlen($value) > 4000) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Each section allows up to 4000 characters.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
$insertBase = [
'opd_id' => $opdId,
'p_id' => (int) ($opdRow->p_id ?? 0),
'doc_id' => (int) ($opdRow->doc_id ?? 0),
'date_opd_visit' => date('Y-m-d'),
'visit_status' => 0,
'session_id' => 0,
];
$allowedData = [];
foreach (array_merge($insertBase, $payload) as $key => $value) {
if (in_array($key, $fields, true)) {
$allowedData[$key] = $value;
}
}
if (in_array('queue_no', $fields, true) && !isset($allowedData['queue_no'])) {
// JOIN through opd_master to guarantee per-doctor isolation
$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();
$allowedData['queue_no'] = (int) ($maxQueue->max_queue ?? 0) + 1;
}
$table = $this->db->table('opd_prescription');
$beforeRow = [];
if ($sessionId > 0) {
$beforeRow = $table->where('id', $sessionId)->where('opd_id', $opdId)->get(1)->getRowArray() ?? [];
$table->where('id', $sessionId)->where('opd_id', $opdId)->update($allowedData);
$recordId = $sessionId;
} else {
$table->insert($allowedData);
$recordId = (int) $this->db->insertID();
}
$afterRow = $this->db->table('opd_prescription')->where('id', $recordId)->where('opd_id', $opdId)->get(1)->getRowArray() ?? [];
if (!empty($afterRow)) {
$this->clinicalAuditTrail->logChangedFields('opd_prescription', $recordId, $beforeRow, $afterRow, $this->getCurrentUserId());
}
$fhirStored = $this->storePrescriptionFhirBundle((int) $opdId, (int) $recordId, $patientRow, (array) $opdRow);
$this->syncPatientAddictionFlags((int) ($opdRow->p_id ?? 0), [
'is_smoking' => (int) $this->request->getPost('is_smoking'),
'is_alcohol' => (int) $this->request->getPost('is_alcohol'),
'is_drug_abuse' => (int) $this->request->getPost('is_drug_abuse'),
]);
$morbidityIds = $this->parseNumericList((string) $this->request->getPost('morbidities_list'));
$this->syncPatientMorbidities((int) ($opdRow->p_id ?? 0), $morbidityIds);
if (in_array('Prescriber_Remarks', $fields, true)) {
$coMorbiditiesText = trim((string) $this->request->getPost('morbidities_text'));
$remarksNow = (string) ($allowedData['Prescriber_Remarks'] ?? ($afterRow['Prescriber_Remarks'] ?? ''));
$updatedRemarks = $this->upsertCoMorbiditiesIntoRemarks($remarksNow, $coMorbiditiesText);
if ($updatedRemarks !== $remarksNow) {
$this->db->table('opd_prescription')
->where('id', $recordId)
->where('opd_id', $opdId)
->update(['Prescriber_Remarks' => $updatedRemarks]);
}
}
$this->markOpdVisitedOnConsultCompletion((int) $opdId);
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $recordId,
'error_text' => 'Prescription saved',
'fhir_stored' => $fhirStored ? 1 : 0,
'saved_at' => date('d-m-Y H:i:s'),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function clinical_autotype()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$text = trim((string) $this->request->getPost('text'));
$mode = trim((string) $this->request->getPost('mode'));
if ($mode === '') {
$mode = 'autotype';
}
if ($text === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Please enter text first.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$prompt = $this->buildClinicalPrompt($mode, $text);
$this->lastAiProvider = 'none';
$draft = $this->generateClinicalTextWithAi($prompt);
if ($draft !== null && trim($draft) !== '') {
$provider = $this->lastAiProvider === 'ai-server' ? 'ai-server' : 'ai';
$providerLabel = $provider === 'ai-server' ? 'AI Server' : 'AI';
return $this->response->setJSON([
'update' => 1,
'draft_text' => trim($draft),
'error_text' => 'AI text prepared using ' . $providerLabel,
'ai_mode' => $provider,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$fallback = $text;
$fallback = $this->buildClinicalAutotypeLocalFallback($mode, $text);
$fallbackMessage = 'External AI unavailable. Local rewrite applied.';
if (trim($fallback) === trim($text)) {
$fallbackMessage = 'External AI unavailable. Original text kept.';
}
return $this->response->setJSON([
'update' => 1,
'draft_text' => $fallback,
'error_text' => $fallbackMessage,
'ai_mode' => 'fallback',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function ai_full_draft()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$input = [
'complaints' => trim((string) $this->request->getPost('complaints')),
'finding_examinations' => trim((string) $this->request->getPost('finding_examinations')),
'diagnosis' => trim((string) $this->request->getPost('diagnosis')),
'provisional_diagnosis' => trim((string) $this->request->getPost('provisional_diagnosis')),
'prescriber_remarks' => trim((string) $this->request->getPost('prescriber_remarks')),
'investigation' => trim((string) $this->request->getPost('investigation')),
'advice' => trim((string) $this->request->getPost('advice')),
'next_visit' => trim((string) $this->request->getPost('next_visit')),
'refer_to' => trim((string) $this->request->getPost('refer_to')),
'bp' => trim((string) $this->request->getPost('bp')),
'diastolic' => trim((string) $this->request->getPost('diastolic')),
'pulse' => trim((string) $this->request->getPost('pulse')),
'temp' => trim((string) $this->request->getPost('temp')),
'spo2' => trim((string) $this->request->getPost('spo2')),
'rr_min' => trim((string) $this->request->getPost('rr_min')),
'height' => trim((string) $this->request->getPost('height')),
'weight' => trim((string) $this->request->getPost('weight')),
'waist' => trim((string) $this->request->getPost('waist')),
'pallor' => trim((string) $this->request->getPost('pallor')),
'icterus' => trim((string) $this->request->getPost('icterus')),
'cyanosis' => trim((string) $this->request->getPost('cyanosis')),
'clubbing' => trim((string) $this->request->getPost('clubbing')),
'edema' => trim((string) $this->request->getPost('edema')),
'pain_value' => trim((string) $this->request->getPost('pain_value')),
'pregnancy' => (int) $this->request->getPost('pregnancy'),
'lactation' => (int) $this->request->getPost('lactation'),
'liver_insufficiency' => (int) $this->request->getPost('liver_insufficiency'),
'renal_insufficiency' => (int) $this->request->getPost('renal_insufficiency'),
'pulmonary_insufficiency' => (int) $this->request->getPost('pulmonary_insufficiency'),
'corona_suspected' => (int) $this->request->getPost('corona_suspected'),
'dengue' => (int) $this->request->getPost('dengue'),
'is_smoking' => (int) $this->request->getPost('is_smoking'),
'is_alcohol' => (int) $this->request->getPost('is_alcohol'),
'is_drug_abuse' => (int) $this->request->getPost('is_drug_abuse'),
'women_related_problems' => trim((string) $this->request->getPost('women_related_problems')),
'women_lmp' => trim((string) $this->request->getPost('women_lmp')),
'women_last_baby' => trim((string) $this->request->getPost('women_last_baby')),
'women_pregnancy_related' => trim((string) $this->request->getPost('women_pregnancy_related')),
];
if ($input['complaints'] === '' && $input['finding_examinations'] === '' && $input['diagnosis'] === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Add complaints/finding/diagnosis first, then use AI Draft.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$prompt = $this->buildFullClinicalDraftPrompt($input);
$this->lastAiProvider = 'none';
$draftText = $this->generateClinicalTextWithAi($prompt);
$drafts = $this->extractFullDraftJson($draftText);
if ($drafts === null) {
$drafts = $this->buildFullDraftFallback($input);
$aiMode = 'fallback';
$message = 'AI unavailable. Template draft prepared from entered data.';
} else {
$aiMode = $this->lastAiProvider === 'ai-server' ? 'ai-server' : 'ai';
$message = 'Full clinical draft prepared using ' . ($aiMode === 'ai-server' ? 'AI Server' : 'AI') . '.';
}
return $this->response->setJSON([
'update' => 1,
'drafts' => $drafts,
'ai_mode' => $aiMode,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function local_clinical_assist()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$input = [
'complaints' => trim((string) $this->request->getPost('complaints')),
'finding_examinations' => trim((string) $this->request->getPost('finding_examinations')),
'diagnosis' => trim((string) $this->request->getPost('diagnosis')),
'investigation' => trim((string) $this->request->getPost('investigation')),
'advice' => trim((string) $this->request->getPost('advice')),
'bp' => trim((string) $this->request->getPost('bp')),
'diastolic' => trim((string) $this->request->getPost('diastolic')),
'pulse' => trim((string) $this->request->getPost('pulse')),
'temp' => trim((string) $this->request->getPost('temp')),
'spo2' => trim((string) $this->request->getPost('spo2')),
'rr_min' => trim((string) $this->request->getPost('rr_min')),
'pain_value' => trim((string) $this->request->getPost('pain_value')),
'pregnancy' => (int) $this->request->getPost('pregnancy'),
'lactation' => (int) $this->request->getPost('lactation'),
'dengue' => (int) $this->request->getPost('dengue'),
'corona_suspected' => (int) $this->request->getPost('corona_suspected'),
];
if ($input['complaints'] === '' && $input['finding_examinations'] === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Enter complaints or findings first for clinical assist.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$assist = $this->buildLocalClinicalAssist($input);
return $this->response->setJSON([
'update' => 1,
'assist' => $assist,
'error_text' => 'Local clinical assist prepared. Final decision remains with doctor.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function section_template_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$section = trim((string) $this->request->getPost('section'));
$templateName = trim((string) $this->request->getPost('template_name'));
$templateText = trim((string) $this->request->getPost('template_text'));
$templateScope = strtolower(trim((string) $this->request->getPost('template_scope')));
$templateDocId = (int) $this->request->getPost('template_doc_id');
if ($templateName === '' || $templateText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Template name and text are required']);
}
if (! $this->isTemplateSectionAllowed($section)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported section']);
}
if (! $this->ensureClinicalTemplateTable()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to access template storage']);
}
$docId = $this->getCurrentUserId();
if ($templateScope === 'master') {
$docId = 0;
} elseif ($templateDocId > 0) {
$docId = $templateDocId;
} elseif ($docId < 0) {
$docId = 0;
}
$table = $this->db->table('opd_clinical_templates');
$existing = $table
->where('doc_id', $docId)
->where('section_key', $section)
->where('template_name', $templateName)
->where('is_active', 1)
->get(1)
->getRowArray();
$now = date('Y-m-d H:i:s');
if (! empty($existing)) {
$table->where('id', (int) ($existing['id'] ?? 0))->update([
'template_text' => $templateText,
'updated_at' => $now,
]);
} else {
$table->insert([
'doc_id' => $docId,
'section_key' => $section,
'template_name' => $templateName,
'template_text' => $templateText,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => ($docId === 0 ? 'Master template saved' : 'My template saved'),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function section_template_list()
{
$section = trim((string) $this->request->getGet('section'));
if (! $this->isTemplateSectionAllowed($section)) {
return $this->response->setJSON(['rows' => []]);
}
if (! $this->ensureClinicalTemplateTable()) {
return $this->response->setJSON(['rows' => []]);
}
$docId = $this->getCurrentUserId();
$rows = $this->db->table('opd_clinical_templates')
->select('id,template_name,template_text,doc_id,section_key')
->where('section_key', $section)
->where('is_active', 1)
->groupStart()
->where('doc_id', $docId)
->orWhere('doc_id', 0)
->groupEnd()
->orderBy('doc_id', 'DESC')
->orderBy('template_name', 'ASC')
->limit(100)
->get()
->getResultArray();
$usageByTemplateId = [];
if (! empty($rows) && $this->ensureClinicalTemplateUsageTable() && $docId !== null) {
$templateIds = array_map(static fn($row) => (int) ($row['id'] ?? 0), $rows);
$templateIds = array_values(array_filter($templateIds, static fn($id) => $id > 0));
if (! empty($templateIds)) {
$usageRows = $this->db->table('opd_clinical_template_usage')
->select('template_id,use_count,last_used_at')
->where('doc_id', (int) $docId)
->where('section_key', $section)
->whereIn('template_id', $templateIds)
->get()
->getResultArray();
foreach ($usageRows as $usageRow) {
$templateId = (int) ($usageRow['template_id'] ?? 0);
if ($templateId <= 0) {
continue;
}
$usageByTemplateId[$templateId] = [
'use_count' => (int) ($usageRow['use_count'] ?? 0),
'last_used_at' => (string) ($usageRow['last_used_at'] ?? ''),
];
}
}
}
foreach ($rows as &$row) {
$templateId = (int) ($row['id'] ?? 0);
$usage = $usageByTemplateId[$templateId] ?? ['use_count' => 0, 'last_used_at' => ''];
$row['use_count'] = (int) ($usage['use_count'] ?? 0);
$row['last_used_at'] = (string) ($usage['last_used_at'] ?? '');
}
unset($row);
usort($rows, static function (array $a, array $b): int {
$aUse = (int) ($a['use_count'] ?? 0);
$bUse = (int) ($b['use_count'] ?? 0);
if ($aUse !== $bUse) {
return $bUse <=> $aUse;
}
$aDoc = (int) ($a['doc_id'] ?? 0);
$bDoc = (int) ($b['doc_id'] ?? 0);
if ($aDoc !== $bDoc) {
return $bDoc <=> $aDoc;
}
$aLast = trim((string) ($a['last_used_at'] ?? ''));
$bLast = trim((string) ($b['last_used_at'] ?? ''));
if ($aLast !== $bLast) {
return strcmp($bLast, $aLast);
}
return strcasecmp((string) ($a['template_name'] ?? ''), (string) ($b['template_name'] ?? ''));
});
return $this->response->setJSON(['rows' => $rows]);
}
public function section_template_track_usage()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$section = trim((string) $this->request->getPost('section'));
$templateId = (int) $this->request->getPost('template_id');
if ($templateId <= 0 || ! $this->isTemplateSectionAllowed($section)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Invalid template usage payload',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if (! $this->ensureClinicalTemplateTable() || ! $this->ensureClinicalTemplateUsageTable()) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Usage storage unavailable',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$docId = $this->getCurrentUserId();
if ($docId === null) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'User not found',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$templateRow = $this->db->table('opd_clinical_templates')
->select('id,section_key,is_active')
->where('id', $templateId)
->where('is_active', 1)
->get(1)
->getRowArray();
if (empty($templateRow)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Template not found',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
if (trim((string) ($templateRow['section_key'] ?? '')) !== $section) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Section mismatch for template',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$usageTable = $this->db->table('opd_clinical_template_usage');
$existing = $usageTable
->where('doc_id', (int) $docId)
->where('template_id', $templateId)
->where('section_key', $section)
->get(1)
->getRowArray();
$now = date('Y-m-d H:i:s');
if (! empty($existing)) {
$usageTable->where('id', (int) ($existing['id'] ?? 0))->update([
'use_count' => ((int) ($existing['use_count'] ?? 0)) + 1,
'last_used_at' => $now,
'updated_at' => $now,
]);
} else {
$usageTable->insert([
'doc_id' => (int) $docId,
'template_id' => $templateId,
'section_key' => $section,
'use_count' => 1,
'last_used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Template usage tracked',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function section_past_data()
{
$section = trim((string) $this->request->getGet('section'));
$opdId = (int) $this->request->getGet('opd_id');
if ($opdId <= 0 || ! $this->isTemplateSectionAllowed($section) || ! $this->db->tableExists('opd_master') || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Past data not found']);
}
$field = $this->resolveSectionColumn($section);
if ($field === null) {
return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Unsupported section']);
}
$fields = $this->db->getFieldNames('opd_prescription');
if (! in_array($field, $fields, true)) {
return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Past data column missing']);
}
$opdRow = $this->db->table('opd_master')->select('p_id')->where('opd_id', $opdId)->get(1)->getRowArray();
$patientId = (int) ($opdRow['p_id'] ?? 0);
if ($patientId <= 0) {
return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'Patient not found']);
}
$row = $this->db->table('opd_prescription')
->select($field . ' as section_text,opd_id,id')
->where('p_id', $patientId)
->where('opd_id !=', $opdId)
->where($field . ' IS NOT NULL', null, false)
->where($field . ' !=', '')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if (empty($row)) {
return $this->response->setJSON(['update' => 0, 'past_text' => '', 'error_text' => 'No past data found']);
}
return $this->response->setJSON([
'update' => 1,
'past_text' => (string) ($row['section_text'] ?? ''),
'past_opd_id' => (int) ($row['opd_id'] ?? 0),
'error_text' => 'Past data loaded',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function section_recent_entries()
{
$opdId = (int) $this->request->getGet('opd_id');
if ($opdId <= 0 || ! $this->db->tableExists('opd_master') || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON([
'update' => 0,
'sections' => [],
'error_text' => 'Recent entries not available',
]);
}
$opdRow = $this->db->table('opd_master')->select('p_id')->where('opd_id', $opdId)->get(1)->getRowArray();
$patientId = (int) ($opdRow['p_id'] ?? 0);
if ($patientId <= 0) {
return $this->response->setJSON([
'update' => 0,
'sections' => [],
'error_text' => 'Patient not found',
]);
}
$sections = [
'complaints' => $this->collectRecentPrescriptionEntries($patientId, $opdId, ['complaints']),
'diagnosis' => $this->collectRecentPrescriptionEntries($patientId, $opdId, ['diagnosis']),
'provisional_diagnosis' => $this->collectRecentPrescriptionEntries($patientId, $opdId, ['Provisional_diagnosis', 'provisional_diagnosis']),
'investigation' => $this->collectRecentInvestigationEntries($patientId, $opdId),
];
return $this->response->setJSON([
'update' => 1,
'sections' => $sections,
'error_text' => 'Recent entries loaded',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function template_workspace()
{
return view('billing/opd_template_workspace');
}
public function snomed_dashboard()
{
if (! $this->canAccessSnomedAdmin()) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('billing/snomed_dashboard');
}
public function snomed_release_status()
{
if (! $this->canAccessSnomedAdmin()) {
return $this->response->setStatusCode(403)->setJSON(['error_text' => 'Access denied']);
}
$latest = [];
if ($this->db->tableExists('snomed_release_log')) {
$latest = $this->db->table('snomed_release_log')
->select('id,package_name,release_effective_time,rf2_md5,release_notes_md5,status,row_counts_json,error_text,imported_at,updated_at')
->orderBy('id', 'DESC')
->get(1)
->getRowArray() ?? [];
}
$tableCounts = [
'concept' => $this->countTableRows('snomed_concept'),
'description' => $this->countTableRows('snomed_description'),
'language_refset' => $this->countTableRows('snomed_language_refset'),
'simple_map' => $this->countTableRows('snomed_map_simple'),
'extended_map' => $this->countTableRows('snomed_map_extended'),
];
return $this->response->setJSON([
'latest_release' => $latest,
'table_counts' => $tableCounts,
]);
}
public function snomed_map_lookup()
{
if (! $this->canAccessPrescription() && ! $this->canAccessSnomedAdmin()) {
return $this->response->setStatusCode(403)->setJSON(['rows' => [], 'error_text' => 'Access denied']);
}
$conceptId = trim((string) $this->request->getGet('concept_id'));
$q = trim((string) $this->request->getGet('q'));
if ($conceptId === '' && $q === '') {
return $this->response->setJSON(['rows' => []]);
}
if ($conceptId === '') {
$matched = $this->resolveProvisionalDiagnosisSnomedMatch($q);
if (! empty($matched['concept_id'])) {
$conceptId = (string) $matched['concept_id'];
}
}
if ($conceptId === '' || ! preg_match('/^[0-9]{6,20}$/', $conceptId)) {
return $this->response->setJSON(['rows' => []]);
}
$conceptTerm = $this->getSnomedPreferredTermByConcept($conceptId);
$rows = $this->fetchSnomedIcdMapRows($conceptId);
return $this->response->setJSON([
'concept_id' => $conceptId,
'concept_term' => $conceptTerm,
'rows' => $rows,
]);
}
public function clinical_template_monitor()
{
if (! $this->ensureClinicalTemplateTable()) {
return $this->response->setJSON(['rows' => []]);
}
$section = trim((string) $this->request->getGet('section'));
$scope = strtolower(trim((string) $this->request->getGet('scope')));
$doctorId = (int) $this->request->getGet('doctor_id');
$docId = $this->getCurrentUserId();
$builder = $this->db->table('opd_clinical_templates')
->select('id,doc_id,section_key,template_name,template_text,is_active,created_at,updated_at')
->where('is_active', 1);
if ($this->isTemplateSectionAllowed($section)) {
$builder->where('section_key', $section);
}
if ($scope === 'master') {
$builder->where('doc_id', 0);
} elseif ($scope === 'doctor') {
if ($doctorId > 0) {
$builder->where('doc_id', $doctorId);
} else {
$builder->where('doc_id', $docId);
}
} else {
$builder->groupStart()
->where('doc_id', 0)
->orWhere('doc_id', $docId)
->groupEnd();
}
$rows = $builder
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(300)
->get()
->getResultArray();
$doctorNameById = [];
if ($this->db->tableExists('doctor_master')) {
$doctorRows = $this->db->table('doctor_master')->select('id,p_fname')->get()->getResultArray();
foreach ($doctorRows as $doctorRow) {
$doctorNameById[(int) ($doctorRow['id'] ?? 0)] = (string) ($doctorRow['p_fname'] ?? '');
}
}
foreach ($rows as &$row) {
$row['scope_label'] = ((int) ($row['doc_id'] ?? -1) === 0) ? 'Master' : 'Doctor';
$row['doctor_name'] = ((int) ($row['doc_id'] ?? 0) > 0) ? ($doctorNameById[(int) ($row['doc_id'] ?? 0)] ?? '') : '';
}
unset($row);
return $this->response->setJSON(['rows' => $rows]);
}
public function clinical_template_update()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->ensureClinicalTemplateTable()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Template storage unavailable']);
}
$id = (int) $this->request->getPost('id');
$section = trim((string) $this->request->getPost('section'));
$templateName = trim((string) $this->request->getPost('template_name'));
$templateText = trim((string) $this->request->getPost('template_text'));
$templateScope = strtolower(trim((string) $this->request->getPost('template_scope')));
$templateDocId = (int) $this->request->getPost('template_doc_id');
if ($id <= 0 || ! $this->isTemplateSectionAllowed($section) || $templateName === '' || $templateText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Template id, section, name and text are required']);
}
$targetDocId = $templateScope === 'master' ? 0 : ($templateDocId > 0 ? $templateDocId : $this->getCurrentUserId());
if ($targetDocId < 0) {
$targetDocId = 0;
}
$current = $this->db->table('opd_clinical_templates')->where('id', $id)->where('is_active', 1)->get(1)->getRowArray();
if (empty($current)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Template not found']);
}
$currentDocId = (int) ($current['doc_id'] ?? -1);
$myDocId = $this->getCurrentUserId();
if ($currentDocId !== 0 && $currentDocId !== $myDocId) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'You can edit only your own or master templates']);
}
$existing = $this->db->table('opd_clinical_templates')
->where('id !=', $id)
->where('doc_id', $targetDocId)
->where('section_key', $section)
->where('template_name', $templateName)
->where('is_active', 1)
->get(1)
->getRowArray();
if (!empty($existing)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Another template with same name already exists in selected scope']);
}
$this->db->table('opd_clinical_templates')->where('id', $id)->update([
'doc_id' => $targetDocId,
'section_key' => $section,
'template_name' => $templateName,
'template_text' => $templateText,
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Template updated',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function clinical_template_doctors()
{
if (! $this->db->tableExists('doctor_master')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('doctor_master');
$idField = $this->resolveFirstField($fields, ['id', 'doc_id']);
$nameField = $this->resolveFirstField($fields, ['p_fname', 'name', 'doc_name']);
if ($idField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table('doctor_master')
->select($idField . ' as id,' . $nameField . ' as name');
if (in_array('active', $fields, true)) {
$builder->where('active', 1);
}
$rows = $builder->orderBy($nameField, 'ASC')->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function clinical_template_usage_analytics()
{
if (! $this->ensureClinicalTemplateTable() || ! $this->ensureClinicalTemplateUsageTable()) {
return $this->response->setJSON([
'top_rows' => [],
'section_rows' => [],
'summary' => ['total_uses' => 0, 'used_templates' => 0],
]);
}
$section = trim((string) $this->request->getGet('section'));
$scope = strtolower(trim((string) $this->request->getGet('scope')));
$doctorId = (int) $this->request->getGet('doctor_id');
$currentDocId = (int) ($this->getCurrentUserId() ?? 0);
$targetDocId = $scope === 'doctor' && $doctorId > 0 ? $doctorId : $currentDocId;
$builder = $this->db->table('opd_clinical_template_usage u')
->select('u.template_id,u.section_key,u.use_count,u.last_used_at,t.template_name,t.doc_id')
->join('opd_clinical_templates t', 't.id = u.template_id', 'inner')
->where('u.doc_id', $targetDocId)
->where('t.is_active', 1);
if ($this->isTemplateSectionAllowed($section)) {
$builder->where('u.section_key', $section);
}
if ($scope === 'master') {
$builder->where('t.doc_id', 0);
} elseif ($scope === 'doctor') {
$builder->where('t.doc_id', $targetDocId);
} else {
$builder->groupStart()
->where('t.doc_id', 0)
->orWhere('t.doc_id', $targetDocId)
->groupEnd();
}
$usageRows = $builder->get()->getResultArray();
if (empty($usageRows)) {
return $this->response->setJSON([
'top_rows' => [],
'section_rows' => [],
'summary' => ['total_uses' => 0, 'used_templates' => 0],
]);
}
usort($usageRows, static function (array $a, array $b): int {
$aUse = (int) ($a['use_count'] ?? 0);
$bUse = (int) ($b['use_count'] ?? 0);
if ($aUse !== $bUse) {
return $bUse <=> $aUse;
}
return strcmp((string) ($b['last_used_at'] ?? ''), (string) ($a['last_used_at'] ?? ''));
});
$totalUses = 0;
$usedTemplates = [];
$sectionAgg = [];
foreach ($usageRows as $row) {
$use = (int) ($row['use_count'] ?? 0);
$sec = trim((string) ($row['section_key'] ?? ''));
$totalUses += $use;
$templateId = (int) ($row['template_id'] ?? 0);
if ($templateId > 0) {
$usedTemplates[$templateId] = true;
}
if ($sec === '') {
continue;
}
if (! isset($sectionAgg[$sec])) {
$sectionAgg[$sec] = ['section_key' => $sec, 'total_uses' => 0, 'template_count' => 0, 'template_ids' => []];
}
$sectionAgg[$sec]['total_uses'] += $use;
if ($templateId > 0 && ! isset($sectionAgg[$sec]['template_ids'][$templateId])) {
$sectionAgg[$sec]['template_ids'][$templateId] = true;
$sectionAgg[$sec]['template_count']++;
}
}
$sectionRows = array_values(array_map(static function (array $row): array {
unset($row['template_ids']);
return $row;
}, $sectionAgg));
usort($sectionRows, static function (array $a, array $b): int {
$aUse = (int) ($a['total_uses'] ?? 0);
$bUse = (int) ($b['total_uses'] ?? 0);
if ($aUse !== $bUse) {
return $bUse <=> $aUse;
}
return strcasecmp((string) ($a['section_key'] ?? ''), (string) ($b['section_key'] ?? ''));
});
return $this->response->setJSON([
'top_rows' => array_slice($usageRows, 0, 15),
'section_rows' => $sectionRows,
'summary' => [
'total_uses' => $totalUses,
'used_templates' => count($usedTemplates),
],
]);
}
public function rx_group_panel()
{
return view('billing/opd_rx_group_workspace', ['initial_rx_id' => 0]);
}
public function save_rx_group_edit(int $rxId = 0)
{
return view('billing/opd_rx_group_workspace', ['initial_rx_id' => max(0, $rxId)]);
}
public function rx_group_data()
{
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('opd_prescription_template');
$idField = $this->resolveFirstField($fields, ['id']);
$nameField = $this->resolveFirstField($fields, ['rx_group_name']);
if ($idField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$userId = max(0, $this->getCurrentUserId());
$hasDocId = in_array('doc_id', $fields, true);
$builder = $this->db->table('opd_prescription_template')
->select('MAX(' . $idField . ') as id,' . $nameField . ' as rx_group_name,COUNT(*) as row_count')
->where($nameField . ' <>', '');
if ($hasDocId) {
$builder->select('SUM(CASE WHEN doc_id = 0 THEN 1 ELSE 0 END) as global_count', false);
$builder->select('SUM(CASE WHEN doc_id = ' . (int) $userId . ' THEN 1 ELSE 0 END) as doctor_count', false);
}
if ($hasDocId) {
$builder->groupStart()
->where('doc_id', $userId)
->orWhere('doc_id', 0)
->groupEnd();
}
$rows = $builder
->groupBy($nameField)
->orderBy($nameField, 'ASC')
->limit(500)
->get()
->getResultArray();
foreach ($rows as &$row) {
if (! $hasDocId) {
$row['access_label'] = 'Global';
continue;
}
$globalCount = (int) ($row['global_count'] ?? 0);
$doctorCount = (int) ($row['doctor_count'] ?? 0);
if ($globalCount > 0 && $doctorCount > 0) {
$row['access_label'] = 'Global + Doctor';
} elseif ($globalCount > 0) {
$row['access_label'] = 'Global';
} elseif ($doctorCount > 0) {
$row['access_label'] = 'Doctor';
} else {
$row['access_label'] = 'Specific User';
}
}
unset($row);
return $this->response->setJSON(['rows' => $rows]);
}
public function rx_group_apply_to_session()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$rxGroupId = (int) $this->request->getPost('rx_group_id');
if ($opdId <= 0 || $rxGroupId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid OPD or Rx-Group']);
}
$templateTable = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($templateTable === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx-Group medicine template not found']);
}
$sessionTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($sessionTable === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$templateFields = $this->db->getFieldNames($templateTable);
if (! in_array('rx_group_id', $templateFields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid Rx-Group template structure']);
}
$templateRows = $this->db->table($templateTable)
->where('rx_group_id', $rxGroupId)
->orderBy('id', 'ASC')
->get()
->getResultArray();
if (empty($templateRows)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No medicines found in selected Rx-Group']);
}
$sessionId = $this->ensurePrescriptionSession($opdId, $sessionId);
if ($sessionId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to create prescription session']);
}
$sessionFields = $this->db->getFieldNames($sessionTable);
$user = auth()->user();
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
$updateByText = $userLabel . '[' . ($user->id ?? 0) . ']:' . date('d-m-Y H:i:s');
$inserted = 0;
$skipped = 0;
foreach ($templateRows as $row) {
$insert = [];
if (in_array('opd_pre_id', $sessionFields, true)) {
$insert['opd_pre_id'] = $sessionId;
}
if (in_array('opd_id', $sessionFields, true)) {
$insert['opd_id'] = $opdId;
}
foreach (['med_id', 'med_name', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'dosage_where', 'no_of_days', 'qty', 'remark', 'genericname'] as $field) {
if (! in_array($field, $sessionFields, true) || ! array_key_exists($field, $row)) {
continue;
}
$value = $row[$field];
if (in_array($field, ['med_name', 'remark'], true)) {
$value = strtoupper((string) $value);
}
$insert[$field] = $value;
}
if (in_array('update_by', $sessionFields, true)) {
$insert['update_by'] = $updateByText;
}
if (! empty($insert)) {
if ($this->isDuplicateMedicineRow($sessionTable, $sessionFields, $sessionId, $insert)) {
$skipped++;
continue;
}
$this->db->table($sessionTable)->insert($insert);
$inserted++;
$medId = (int) ($insert['med_id'] ?? 0);
if ($medId > 0) {
$this->trackMedicineUsage($medId);
}
}
}
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'inserted_count' => $inserted,
'skipped_count' => $skipped,
'error_text' => $inserted > 0
? ($inserted . ' medicine(s) added from Rx-Group.' . ($skipped > 0 ? (' ' . $skipped . ' duplicate(s) skipped.') : ''))
: ($skipped > 0 ? ($skipped . ' duplicate medicine(s) already present.') : 'No medicine inserted from Rx-Group.'),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function rx_group_get(int $rxId)
{
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group table not found']);
}
$fields = $this->db->getFieldNames('opd_prescription_template');
$builder = $this->db->table('opd_prescription_template')->where('id', (int) $rxId);
if (in_array('doc_id', $fields, true)) {
$userId = max(0, $this->getCurrentUserId());
$builder->groupStart()
->where('doc_id', $userId)
->orWhere('doc_id', 0)
->groupEnd();
}
$row = $builder->get(1)->getRowArray() ?? [];
if (empty($row)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group not found']);
}
return $this->response->setJSON(['update' => 1, 'row' => $row]);
}
public function rx_group_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group table not found']);
}
$id = (int) $this->request->getPost('id');
$name = trim((string) $this->request->getPost('rx_group_name'));
if ($name === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx-Group Name is required']);
}
$fields = $this->db->getFieldNames('opd_prescription_template');
$data = [];
if (in_array('rx_group_name', $fields, true)) {
$data['rx_group_name'] = $name;
}
if (in_array('complaints', $fields, true)) {
$data['complaints'] = trim((string) $this->request->getPost('complaints'));
}
if (in_array('diagnosis', $fields, true)) {
$data['diagnosis'] = trim((string) $this->request->getPost('diagnosis'));
}
if (in_array('Finding_Examinations', $fields, true)) {
$data['Finding_Examinations'] = trim((string) $this->request->getPost('finding_examinations'));
}
if (in_array('investigation', $fields, true)) {
$data['investigation'] = trim((string) $this->request->getPost('investigation'));
}
$userId = max(0, $this->getCurrentUserId());
if (in_array('rx_group_name', $fields, true)) {
$dupBuilder = $this->db->table('opd_prescription_template')
->select('id')
->where('rx_group_name', $name);
if (in_array('doc_id', $fields, true)) {
$dupBuilder->groupStart()
->where('doc_id', $userId)
->orWhere('doc_id', 0)
->groupEnd();
}
if ($id > 0) {
$dupBuilder->where('id <>', $id);
}
$exists = $dupBuilder->orderBy('id', 'DESC')->get(1)->getRowArray();
if (! empty($exists)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Rx-Group name already exists. Please use Edit for existing group.',
'existing_id' => (int) ($exists['id'] ?? 0),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
if (in_array('doc_id', $fields, true) && $id <= 0) {
$data['doc_id'] = $userId;
}
$user = auth()->user();
if (in_array('update_by', $fields, true)) {
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
$data['update_by'] = $userLabel . '[' . ($user->id ?? 0) . ']:' . date('d-m-Y H:i:s');
}
if ($id > 0) {
$this->db->table('opd_prescription_template')->where('id', $id)->update($data);
$savedId = $id;
$message = 'Rx-Group updated';
} else {
$this->db->table('opd_prescription_template')->insert($data);
$savedId = (int) $this->db->insertID();
$message = 'Rx-Group saved';
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedId,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function rx_group_medicine(int $rxId)
{
$rxGroupId = max(0, $rxId);
$rxGroupName = '';
if ($rxGroupId > 0 && $this->db->tableExists('opd_prescription_template')) {
$templateFields = $this->db->getFieldNames('opd_prescription_template');
$nameField = $this->resolveFirstField($templateFields, ['rx_group_name']);
if ($nameField !== null) {
$row = $this->db->table('opd_prescription_template')
->select($nameField . ' as rx_group_name')
->where('id', $rxGroupId)
->get(1)
->getRowArray();
$rxGroupName = trim((string) ($row['rx_group_name'] ?? ''));
}
}
return view('billing/rx_group_medicine_workspace', [
'rx_group_id' => $rxGroupId,
'rx_group_name' => $rxGroupName,
]);
}
public function rx_group_medicine_list(int $rxId)
{
$table = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($table === null) {
return $this->response->setJSON(['rows' => [], 'rx_group_name' => '']);
}
$fields = $this->db->getFieldNames($table);
if (! in_array('rx_group_id', $fields, true)) {
return $this->response->setJSON(['rows' => [], 'rx_group_name' => '']);
}
$rxGroupName = '';
if ($this->db->tableExists('opd_prescription_template')) {
$templateFields = $this->db->getFieldNames('opd_prescription_template');
$nameField = $this->resolveFirstField($templateFields, ['rx_group_name']);
if ($nameField !== null) {
$groupRow = $this->db->table('opd_prescription_template')
->select($nameField . ' as rx_group_name')
->where('id', (int) $rxId)
->get(1)
->getRowArray();
$rxGroupName = trim((string) ($groupRow['rx_group_name'] ?? ''));
}
}
$builder = $this->db->table($table)->where('rx_group_id', (int) $rxId);
if (in_array('id', $fields, true)) {
$builder->orderBy('id', 'DESC');
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return $this->response->setJSON(['rows' => [], 'rx_group_name' => $rxGroupName]);
}
$medGenericMap = [];
$missingGenericMedIds = [];
foreach ($rows as $row) {
$existingGeneric = trim((string) ($row['genericname'] ?? ''));
$medId = (int) ($row['med_id'] ?? 0);
if ($existingGeneric === '' && $medId > 0) {
$missingGenericMedIds[$medId] = true;
}
}
if (! empty($missingGenericMedIds)) {
$masterTable = $this->findExistingTable(['opd_med_master']);
if ($masterTable !== null) {
$masterFields = $this->db->getFieldNames($masterTable);
$masterIdField = $this->resolveFirstField($masterFields, ['id']);
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
$masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']);
// Prefer genericname (Generic Name) over salt_name (Substitute)
$labelField = $masterGenericField ?? $masterSaltField;
if ($masterIdField !== null && $labelField !== null) {
$medRows = $this->db->table($masterTable)
->select($masterIdField . ' as med_id,' . $labelField . ' as generic_fallback')
->whereIn($masterIdField, array_map('intval', array_keys($missingGenericMedIds)))
->get()
->getResultArray();
foreach ($medRows as $medRow) {
$id = (int) ($medRow['med_id'] ?? 0);
$label = trim((string) ($medRow['generic_fallback'] ?? ''));
if ($id > 0 && $label !== '') {
$medGenericMap[$id] = $label;
}
}
}
}
}
$doseMap = $this->getDoseMasterLabelMap('opd_dose_shed', ['dose_shed_id', 'id'], ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
$whenMap = $this->getDoseMasterLabelMap('opd_dose_when', ['dose_when_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$freqMap = $this->getDoseMasterLabelMap('opd_dose_frequency', ['dose_freq_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$whereMap = $this->getDoseMasterLabelMap('opd_dose_where', ['dose_where_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
foreach ($rows as &$row) {
if (trim((string) ($row['genericname'] ?? '')) === '') {
// Do NOT fall back to salt_name — that field stores Substitute (other brand names),
// not the generic/salt composition. Use master lookup only.
$fallback = $medGenericMap[(int) ($row['med_id'] ?? 0)] ?? '';
if ($fallback !== '') {
$row['genericname'] = $fallback;
}
}
$row['dosage_label'] = $this->resolveDoseMasterLabel($row['dosage'] ?? '', $doseMap);
$row['dosage_when_label'] = $this->resolveDoseMasterLabel($row['dosage_when'] ?? '', $whenMap);
$row['dosage_freq_label'] = $this->resolveDoseMasterLabel($row['dosage_freq'] ?? '', $freqMap);
$row['dosage_where_label'] = $this->resolveDoseMasterLabel($row['dosage_where'] ?? '', $whereMap);
}
unset($row);
return $this->response->setJSON(['rows' => $rows, 'rx_group_name' => $rxGroupName]);
}
public function rx_group_medicine_get(int $itemId)
{
$table = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx medicine table not found']);
}
$row = $this->db->table($table)->where('id', (int) $itemId)->get(1)->getRowArray() ?? [];
if (empty($row)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx medicine not found']);
}
return $this->response->setJSON(['update' => 1, 'row' => $row]);
}
public function rx_group_medicine_save(int $rxId)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group table not found']);
}
$rxExists = $this->db->table('opd_prescription_template')->where('id', (int) $rxId)->get(1)->getRowArray();
if (empty($rxExists)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group not found']);
}
$table = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx medicine table not found']);
}
$itemId = (int) $this->request->getPost('item_id');
$medId = (int) $this->request->getPost('med_id');
$medName = trim((string) $this->request->getPost('med_name'));
if ($medName === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine name is required']);
}
$genericName = trim((string) $this->request->getPost('genericname'));
$medType = trim((string) $this->request->getPost('med_type'));
$masterTable = $this->findExistingTable(['opd_med_master']);
if ($masterTable !== null) {
$masterFields = $this->db->getFieldNames($masterTable);
$masterNameField = $this->resolveFirstField($masterFields, ['item_name', 'med_name']);
$masterTypeField = $this->resolveFirstField($masterFields, ['formulation']);
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
$masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']);
if ($medId <= 0 && $masterNameField !== null) {
$existingMaster = $this->db->table($masterTable)->where($masterNameField, $medName)->get(1)->getRowArray();
if (! empty($existingMaster)) {
$medId = (int) ($existingMaster['id'] ?? 0);
}
}
if ($medId > 0) {
$updateMaster = [];
if ($masterTypeField !== null && $medType !== '') {
$updateMaster[$masterTypeField] = $medType;
}
if ($masterGenericField !== null && $genericName !== '') {
$updateMaster[$masterGenericField] = $genericName;
}
if ($masterSaltField !== null && $genericName !== '') {
$updateMaster[$masterSaltField] = $genericName;
}
if (! empty($updateMaster)) {
$this->db->table($masterTable)->where('id', $medId)->update($updateMaster);
}
} elseif ($masterNameField !== null) {
$insertMaster = [$masterNameField => $medName];
if ($masterTypeField !== null) {
$insertMaster[$masterTypeField] = $medType;
}
if ($masterGenericField !== null) {
$insertMaster[$masterGenericField] = $genericName;
}
if ($masterSaltField !== null) {
$insertMaster[$masterSaltField] = $genericName;
}
$this->db->table($masterTable)->insert($insertMaster);
$medId = (int) $this->db->insertID();
}
}
$fields = $this->db->getFieldNames($table);
$data = [];
if (in_array('rx_group_id', $fields, true)) {
$data['rx_group_id'] = (int) $rxId;
}
if (in_array('med_id', $fields, true)) {
$data['med_id'] = $medId;
}
if (in_array('med_name', $fields, true)) {
$data['med_name'] = strtoupper($medName);
}
if (in_array('med_type', $fields, true)) {
$data['med_type'] = $medType;
}
if (in_array('dosage', $fields, true)) {
$data['dosage'] = trim((string) $this->request->getPost('dosage'));
}
if (in_array('dosage_when', $fields, true)) {
$data['dosage_when'] = trim((string) $this->request->getPost('dosage_when'));
}
if (in_array('dosage_freq', $fields, true)) {
$data['dosage_freq'] = trim((string) $this->request->getPost('dosage_freq'));
}
if (in_array('dosage_where', $fields, true)) {
$data['dosage_where'] = trim((string) $this->request->getPost('dosage_where'));
}
if (in_array('no_of_days', $fields, true)) {
$data['no_of_days'] = trim((string) $this->request->getPost('no_of_days'));
}
if (in_array('qty', $fields, true)) {
$data['qty'] = trim((string) $this->request->getPost('qty'));
}
if (in_array('remark', $fields, true)) {
$data['remark'] = strtoupper(trim((string) $this->request->getPost('remark')));
}
if (in_array('genericname', $fields, true)) {
$data['genericname'] = $genericName;
}
$user = auth()->user();
if (in_array('update_by', $fields, true)) {
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
$data['update_by'] = $userLabel . '[' . ($user->id ?? 0) . ']:' . date('d-m-Y H:i:s');
}
if ($itemId > 0) {
$this->db->table($table)->where('id', $itemId)->update($data);
$savedId = $itemId;
$message = 'Rx group medicine updated';
} else {
$this->db->table($table)->insert($data);
$savedId = (int) $this->db->insertID();
$message = 'Rx group medicine added';
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedId,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function rx_group_medicine_remove(int $itemId)
{
$table = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx medicine table not found']);
}
$this->db->table($table)->where('id', (int) $itemId)->delete();
return $this->response->setJSON(['update' => 1, 'error_text' => 'Rx group medicine removed']);
}
public function rx_group_delete(int $rxId)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$rxId = max(0, $rxId);
if ($rxId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid Rx-Group ID']);
}
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Rx group table not found']);
}
// Delete linked medicines first
$medTable = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
if ($medTable !== null) {
$medFields = $this->db->getFieldNames($medTable);
$fkField = $this->resolveFirstField($medFields, ['opd_prescription_template_id', 'rx_group_id', 'template_id']);
if ($fkField !== null) {
$this->db->table($medTable)->where($fkField, $rxId)->delete();
}
}
$this->db->table('opd_prescription_template')->where('id', $rxId)->delete();
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Rx-Group deleted',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function rx_group_medicine_suggest()
{
$q = trim((string) $this->request->getGet('q'));
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null || $q === '') {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames($table);
$idField = $this->resolveFirstField($fields, ['id']);
$nameField = $this->resolveFirstField($fields, ['item_name', 'med_name']);
$typeField = $this->resolveFirstField($fields, ['formulation']);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$companyField = $this->resolveFirstField($fields, ['company_name', 'company']);
if ($idField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$select = $idField . ' as med_id,' . $nameField . ' as med_name';
if ($typeField !== null) {
$select .= ',' . $typeField . ' as med_type';
}
if ($genericField !== null) {
$select .= ',' . $genericField . ' as genericname';
}
if ($saltField !== null) {
$select .= ',' . $saltField . ' as salt_name';
}
if ($companyField !== null) {
$select .= ',' . $companyField . ' as company_name';
}
$builder = $this->db->table($table)->select($select)->groupStart()->like($nameField, $q);
if ($genericField !== null) {
$builder->orLike($genericField, $q);
}
if ($saltField !== null) {
$builder->orLike($saltField, $q);
}
$rows = $builder->groupEnd()->orderBy($nameField, 'ASC')->limit(30)->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function rx_group_generic_suggest()
{
$q = trim((string) $this->request->getGet('q'));
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames($table);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
if ($genericField === null && $saltField === null) {
return $this->response->setJSON(['rows' => []]);
}
$values = [];
$builder = $this->db->table($table);
if ($genericField !== null) {
$gBuilder = clone $builder;
$gBuilder->select($genericField . ' as value')->where($genericField . ' !=', '');
if ($q !== '') {
$gBuilder->like($genericField, $q);
}
foreach ($gBuilder->orderBy($genericField, 'ASC')->limit(40)->get()->getResultArray() as $row) {
$value = trim((string) ($row['value'] ?? ''));
if ($value !== '') {
$values[$value] = ['value' => $value];
}
}
}
if ($saltField !== null) {
$sBuilder = clone $builder;
$sBuilder->select($saltField . ' as value')->where($saltField . ' !=', '');
if ($q !== '') {
$sBuilder->like($saltField, $q);
}
foreach ($sBuilder->orderBy($saltField, 'ASC')->limit(40)->get()->getResultArray() as $row) {
$value = trim((string) ($row['value'] ?? ''));
if ($value !== '') {
$values[$value] = ['value' => $value];
}
}
}
return $this->response->setJSON(['rows' => array_values($values)]);
}
public function rx_group_dose_masters()
{
return $this->response->setJSON([
'dose' => $this->getDoseMasterRows('opd_dose_shed', ['dose_shed_id', 'id'], ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']),
'when' => $this->getDoseMasterRows('opd_dose_when', ['dose_when_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']),
'freq' => $this->getDoseMasterRows('opd_dose_frequency', ['dose_freq_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']),
'where' => $this->getDoseMasterRows('opd_dose_where', ['dose_where_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']),
]);
}
public function opd_medicince()
{
return view('billing/opd_medicince_workspace', ['initial_med_id' => 0]);
}
public function opd_medicince_edit(int $medId)
{
return view('billing/opd_medicince_workspace', ['initial_med_id' => max(0, $medId)]);
}
public function opd_medicince_data()
{
$filter = trim((string) $this->request->getGet('filter'));
$scope = strtolower(trim((string) $this->request->getGet('scope')));
$showAll = (int) $this->request->getGet('show_all') === 1;
if (! in_array($scope, ['all', 'favorite', 'active'], true)) {
$scope = 'all';
}
$query = $this->request->getGet();
$isDataTableRequest = is_array($query) && array_key_exists('draw', $query);
if ($isDataTableRequest) {
$draw = max(1, (int) ($query['draw'] ?? 1));
$start = max(0, (int) ($query['start'] ?? 0));
$length = (int) ($query['length'] ?? 25);
if ($length < 1 || $length > 500) {
$length = 25;
}
$searchValue = '';
if (isset($query['search']) && is_array($query['search'])) {
$searchValue = trim((string) ($query['search']['value'] ?? ''));
}
$orderField = 'use_count';
$orderDir = 'desc';
$allowedOrderFields = ['id', 'item_name', 'use_count', 'last_used_at', 'genericname', 'company_name'];
if (isset($query['order'][0]) && is_array($query['order'][0])) {
$orderColIndex = (int) ($query['order'][0]['column'] ?? -1);
$orderDirCandidate = strtolower(trim((string) ($query['order'][0]['dir'] ?? 'desc')));
if (in_array($orderDirCandidate, ['asc', 'desc'], true)) {
$orderDir = $orderDirCandidate;
}
if ($orderColIndex >= 0 && isset($query['columns'][$orderColIndex]) && is_array($query['columns'][$orderColIndex])) {
$candidate = trim((string) ($query['columns'][$orderColIndex]['data'] ?? ''));
if (in_array($candidate, $allowedOrderFields, true)) {
$orderField = $candidate;
}
}
}
$rows = $this->getOpdMedicineRows($filter, $scope, $showAll);
$recordsTotal = count($rows);
if ($searchValue !== '') {
$needle = mb_strtolower($searchValue);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
$haystack = [
(string) ($row['id'] ?? ''),
(string) ($row['item_name'] ?? ''),
(string) ($row['genericname'] ?? ''),
(string) ($row['company_name'] ?? ''),
(string) ($row['last_used_at'] ?? ''),
];
foreach ($haystack as $value) {
if (mb_stripos($value, $needle) !== false) {
return true;
}
}
return false;
}));
}
$recordsFiltered = count($rows);
usort($rows, static function (array $a, array $b) use ($orderField, $orderDir): int {
$numericFields = ['id', 'use_count'];
if (in_array($orderField, $numericFields, true)) {
$cmp = ((int) ($a[$orderField] ?? 0)) <=> ((int) ($b[$orderField] ?? 0));
} else {
$cmp = strcasecmp((string) ($a[$orderField] ?? ''), (string) ($b[$orderField] ?? ''));
}
if ($cmp === 0) {
$cmp = strcasecmp((string) ($a['item_name'] ?? ''), (string) ($b['item_name'] ?? ''));
}
return $orderDir === 'desc' ? -$cmp : $cmp;
});
$pageRows = array_slice($rows, $start, $length);
return $this->response->setJSON([
'draw' => $draw,
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $pageRows,
'active_filter' => $filter,
]);
}
return $this->response->setJSON([
'rows' => $this->getOpdMedicineRows($filter, $scope, $showAll),
'active_filter' => $filter,
'active_scope' => $scope,
'show_all' => $showAll ? 1 : 0,
]);
}
public function opd_medicince_export()
{
$filter = trim((string) $this->request->getGet('filter'));
$rows = $this->getOpdMedicineRows($filter);
$output = fopen('php://temp', 'r+');
if ($output === false) {
return $this->response->setStatusCode(500)->setBody('Unable to generate export file.');
}
fputcsv($output, ['ID', 'Medicine Name', 'Formulation', 'Generic Name', 'Salt Name', 'Company Name']);
foreach ($rows as $row) {
fputcsv($output, [
(int) ($row['id'] ?? 0),
(string) ($row['item_name'] ?? ''),
(string) ($row['formulation'] ?? ''),
(string) ($row['genericname'] ?? ''),
(string) ($row['salt_name'] ?? ''),
(string) ($row['company_name'] ?? ''),
]);
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
if ($csv === false) {
return $this->response->setStatusCode(500)->setBody('Unable to generate export file.');
}
$suffix = $filter !== '' ? preg_replace('/[^a-z0-9_\-]/i', '_', strtolower($filter)) : 'all';
$filename = 'opd_medicine_' . $suffix . '_' . date('Ymd_His') . '.csv';
return $this->response
->setHeader('Content-Type', 'text/csv; charset=UTF-8')
->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
->setBody($csv);
}
public function opd_medicince_coding_summary()
{
$rows = $this->getOpdMedicineRows('all', 'all', true);
$summary = [
'total' => 0,
'coding_missing' => 0,
'snomed_missing' => 0,
'atc_missing' => 0,
'both_missing' => 0,
];
foreach ($rows as $row) {
$summary['total']++;
$snomed = strtoupper(trim((string) ($row['snomed_code'] ?? '')));
$atc = strtoupper(trim((string) ($row['atc_code'] ?? '')));
$isSnomedMissing = ($snomed === '');
$isAtcMissing = ($atc === '');
if ($isSnomedMissing) {
$summary['snomed_missing']++;
}
if ($isAtcMissing) {
$summary['atc_missing']++;
}
if ($isSnomedMissing || $isAtcMissing) {
$summary['coding_missing']++;
}
if ($isSnomedMissing && $isAtcMissing) {
$summary['both_missing']++;
}
}
return $this->response->setJSON([
'update' => 1,
'summary' => $summary,
]);
}
private function getOpdMedicineRows(string $filter = '', string $scope = 'all', bool $showAll = false): array
{
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return [];
}
$fields = $this->db->getFieldNames($table);
$idField = $this->resolveFirstField($fields, ['id']);
$nameField = $this->resolveFirstField($fields, ['item_name', 'med_name']);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$companyField = $this->resolveFirstField($fields, ['company_name', 'company']);
$restrictionField = $this->resolveFirstField($fields, ['dosage_restriction', 'dose_restriction', 'restriction_note', 'restriction']);
$snomedField = $this->resolveFirstField($fields, ['snomed_code', 'medicine_snomed_code', 'snomed_ct_code', 'sctid']);
$atcField = $this->resolveFirstField($fields, ['atc_code', 'who_atc_code']);
if ($idField === null || $nameField === null) {
return [];
}
$select = $idField . ' as id,' . $nameField . ' as item_name';
if (in_array('formulation', $fields, true)) {
$select .= ',formulation';
}
if ($genericField !== null) {
$select .= ',' . $genericField . ' as genericname';
}
if ($saltField !== null) {
$select .= ',' . $saltField . ' as salt_name';
}
if ($companyField !== null) {
$select .= ',' . $companyField . ' as company_name';
}
if ($restrictionField !== null) {
$select .= ',' . $restrictionField . ' as dosage_restriction';
}
if ($snomedField !== null) {
$select .= ',' . $snomedField . ' as snomed_code';
}
if ($atcField !== null) {
$select .= ',' . $atcField . ' as atc_code';
}
$rows = $this->db->table($table)
->select($select)
->orderBy($nameField, 'ASC')
->limit(5000)
->get()
->getResultArray();
$unclearTokens = ['na', 'n/a', 'same', '-', '--', 'unknown', '?'];
$normalize = static function ($value): string {
$text = trim((string) $value);
if ($text === '') {
return '';
}
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
return mb_strtolower($text);
};
if ($filter === 'generic_issue') {
$rows = array_values(array_filter($rows, static function (array $row) use ($normalize, $unclearTokens): bool {
$name = $normalize($row['item_name'] ?? '');
$generic = $normalize($row['genericname'] ?? '');
$salt = $normalize($row['salt_name'] ?? '');
$genericUnclear = ($generic === '' || in_array($generic, $unclearTokens, true) || ($name !== '' && $generic === $name));
$saltUnclear = ($salt === '' || in_array($salt, $unclearTokens, true) || ($name !== '' && $salt === $name));
return $genericUnclear && $saltUnclear;
}));
} elseif ($filter === 'generic_same_name') {
$rows = array_values(array_filter($rows, static function (array $row) use ($normalize): bool {
$name = $normalize($row['item_name'] ?? '');
$generic = $normalize($row['genericname'] ?? '');
$salt = $normalize($row['salt_name'] ?? '');
if ($name === '') {
return false;
}
return ($generic !== '' && $generic === $name) || ($salt !== '' && $salt === $name);
}));
} elseif ($filter === 'company_blank') {
$rows = array_values(array_filter($rows, static function (array $row) use ($normalize, $unclearTokens): bool {
$company = $normalize($row['company_name'] ?? '');
return $company === '' || in_array($company, $unclearTokens, true);
}));
} elseif ($filter === 'coding_missing') {
$rows = array_values(array_filter($rows, static function (array $row): bool {
$snomed = strtoupper(trim((string) ($row['snomed_code'] ?? '')));
$atc = strtoupper(trim((string) ($row['atc_code'] ?? '')));
return $snomed === '' || $atc === '';
}));
} elseif ($filter === 'snomed_missing') {
$rows = array_values(array_filter($rows, static function (array $row): bool {
$snomed = strtoupper(trim((string) ($row['snomed_code'] ?? '')));
return $snomed === '';
}));
} elseif ($filter === 'atc_missing') {
$rows = array_values(array_filter($rows, static function (array $row): bool {
$atc = strtoupper(trim((string) ($row['atc_code'] ?? '')));
return $atc === '';
}));
}
foreach ($rows as &$row) {
$generic = trim((string) ($row['genericname'] ?? ''));
if ($generic === '') {
$salt = trim((string) ($row['salt_name'] ?? ''));
if ($salt !== '') {
$row['genericname'] = $salt;
}
}
}
unset($row);
$userId = (int) ($this->getCurrentUserId() ?? 0);
$candidateIds = [];
foreach ($rows as $row) {
$medId = (int) ($row['id'] ?? 0);
if ($medId > 0) {
$candidateIds[] = $medId;
}
}
$candidateIds = array_values(array_unique($candidateIds));
$favoriteMap = $userId > 0 ? $this->getUserFavoriteMedicineIds($userId, $candidateIds) : [];
$usageMap = $userId > 0 ? $this->getUserMedicineUsageMap($userId, $candidateIds) : [];
foreach ($rows as &$row) {
$medId = (int) ($row['id'] ?? 0);
$row['is_favorite'] = isset($favoriteMap[$medId]) ? 1 : 0;
$row['use_count'] = (int) ($usageMap[$medId]['use_count'] ?? 0);
$row['last_used_at'] = (string) ($usageMap[$medId]['last_used_at'] ?? '');
}
unset($row);
if (! $showAll) {
if ($scope === 'favorite') {
$rows = array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['is_favorite'] ?? 0) === 1));
} elseif ($scope === 'active') {
$rows = array_values(array_filter($rows, static fn(array $row): bool => ((int) ($row['is_favorite'] ?? 0) === 1) || ((int) ($row['use_count'] ?? 0) > 0)));
}
}
return $rows;
}
public function opd_medicince_company_suggest()
{
if (! $this->db->tableExists('med_company')) {
return $this->response->setJSON(['rows' => []]);
}
$term = trim((string) $this->request->getGet('term'));
$fields = $this->db->getFieldNames('med_company');
$nameField = $this->resolveFirstField($fields, ['company_name', 'name', 'short_name']);
if ($nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table('med_company')->select($nameField . ' as company_name')->orderBy($nameField, 'ASC');
if ($term !== '') {
$builder->like($nameField, $term);
}
$rows = $builder->limit(200)->get()->getResultArray();
$names = [];
foreach ($rows as $row) {
$name = trim((string) ($row['company_name'] ?? ''));
if ($name === '') {
continue;
}
$names[$name] = ['company_name' => $name];
}
return $this->response->setJSON(['rows' => array_values($names)]);
}
public function opd_medicince_get(int $medId)
{
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$row = $this->db->table($table)->where('id', (int) $medId)->get(1)->getRowArray() ?? [];
if (empty($row)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine not found']);
}
return $this->response->setJSON(['update' => 1, 'row' => $row]);
}
public function opd_medicince_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$id = (int) $this->request->getPost('id');
$itemName = trim((string) $this->request->getPost('item_name'));
$formulation = trim((string) $this->request->getPost('formulation'));
if ($itemName === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine name is required']);
}
$medicineModel = new OpdMedicineModel($this->db);
$duplicate = $medicineModel->findDuplicateByName($itemName, $formulation, $id);
if (! empty($duplicate)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Duplicate medicine name with same formulation found (ID: ' . (int) ($duplicate['id'] ?? 0) . '). Please use existing entry or merge duplicates.',
'duplicate_id' => (int) ($duplicate['id'] ?? 0),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$fields = $this->db->getFieldNames($table);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$companyField = $this->resolveFirstField($fields, ['company_name', 'company']);
$restrictionField = $this->resolveFirstField($fields, ['dosage_restriction', 'dose_restriction', 'restriction_note', 'restriction']);
$snomedField = $this->resolveFirstField($fields, ['snomed_code', 'medicine_snomed_code', 'snomed_ct_code', 'sctid']);
$atcField = $this->resolveFirstField($fields, ['atc_code', 'who_atc_code']);
$data = [];
if (in_array('item_name', $fields, true)) {
$data['item_name'] = $itemName;
}
if (in_array('formulation', $fields, true)) {
$data['formulation'] = $formulation;
}
if ($genericField !== null) {
$data[$genericField] = trim((string) $this->request->getPost('genericname'));
}
if ($saltField !== null) {
$data[$saltField] = trim((string) $this->request->getPost('salt_name'));
}
if ($companyField !== null) {
$data[$companyField] = trim((string) $this->request->getPost('company_name'));
}
if ($restrictionField !== null) {
$data[$restrictionField] = trim((string) $this->request->getPost('dosage_restriction'));
}
if ($snomedField !== null) {
$data[$snomedField] = trim((string) $this->request->getPost('snomed_code'));
}
if ($atcField !== null) {
$data[$atcField] = strtoupper(trim((string) $this->request->getPost('atc_code')));
}
foreach (['dosage', 'dosage_when', 'dosage_freq', 'dosage_where', 'no_of_days', 'qty', 'remark'] as $dosageField) {
if (in_array($dosageField, $fields, true)) {
$data[$dosageField] = trim((string) $this->request->getPost($dosageField));
}
}
if ($id > 0) {
$this->db->table($table)->where('id', $id)->update($data);
$savedId = $id;
$message = 'Medicine updated';
} else {
$this->db->table($table)->insert($data);
$savedId = (int) $this->db->insertID();
$message = 'Medicine added';
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedId,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_medicince_ai_details()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$name = trim((string) $this->request->getPost('item_name'));
$formulation = trim((string) $this->request->getPost('formulation'));
if ($name === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine name is required']);
}
$local = $this->lookupMedicineDetailsFromMaster($name, $formulation);
if (($local['genericname'] ?? '') !== '' || ($local['salt_name'] ?? '') !== '') {
return $this->response->setJSON([
'update' => 1,
'provider' => 'local-master',
'match_confidence' => (string) ($local['match_confidence'] ?? 'partial'),
'matched_item_name' => (string) ($local['matched_item_name'] ?? ''),
'details' => [
'genericname' => trim((string) ($local['genericname'] ?? '')),
'salt_name' => trim((string) ($local['salt_name'] ?? '')),
'dosage_restriction' => trim((string) ($local['dosage_restriction'] ?? '')),
'caution_note' => trim((string) ($local['caution_note'] ?? '')),
],
'error_text' => 'Medicine details found from local master data',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$serverDetails = $this->fetchMedicineDetailsFromAiServer($name, $formulation);
if (($serverDetails['genericname'] ?? '') !== '' || ($serverDetails['salt_name'] ?? '') !== '') {
return $this->response->setJSON([
'update' => 1,
'provider' => 'ai-server',
'match_confidence' => 'ai-server',
'matched_item_name' => '',
'details' => [
'genericname' => trim((string) ($serverDetails['genericname'] ?? '')),
'salt_name' => trim((string) ($serverDetails['salt_name'] ?? '')),
'dosage_restriction' => trim((string) ($serverDetails['dosage_restriction'] ?? '')),
'caution_note' => trim((string) ($serverDetails['caution_note'] ?? '')),
],
'error_text' => 'Medicine details prepared from AI server',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$prompt = "You are a medicine data enrichment assistant for OPD master data in India.\n"
. "For this medicine, provide structured details based on public medical references and standard labeling guidance.\n"
. "Medicine Name: {$name}\n"
. "Formulation: {$formulation}\n"
. "Return ONLY valid JSON object with keys: genericname, salt_name, dosage_restriction, caution_note.\n"
. "Use short plain text values. If unsure for a field, keep empty string.";
$this->lastAiProvider = 'none';
$aiText = $this->generateClinicalTextWithAi($prompt, 280);
if ($aiText === null || trim($aiText) === '') {
$fallback = $this->inferMedicineDetailsFromBrandName($name, $formulation);
if (($fallback['genericname'] ?? '') !== '' || ($fallback['salt_name'] ?? '') !== '') {
return $this->response->setJSON([
'update' => 1,
'provider' => 'local-brand-fallback',
'match_confidence' => 'brand-fallback',
'matched_item_name' => '',
'details' => [
'genericname' => trim((string) ($fallback['genericname'] ?? '')),
'salt_name' => trim((string) ($fallback['salt_name'] ?? '')),
'dosage_restriction' => trim((string) ($fallback['dosage_restriction'] ?? '')),
'caution_note' => trim((string) ($fallback['caution_note'] ?? '')),
],
'error_text' => 'AI unavailable. Fallback generic/salt suggestion prepared.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$aiServerHint = $this->lastAiServerError !== ''
? (' AI-server attempts: ' . $this->lastAiServerError)
: '';
return $this->response->setJSON([
'update' => 0,
'error_text' => 'AI unavailable and no local match found. Check AI settings (DIAGNOSIS_AI_SERVER_URL or DIAGNOSIS_AI_MEDICINE_ENDPOINT).' . $aiServerHint,
]);
}
$parsed = $this->parseJsonFromAiResponse($aiText);
if ($parsed === null) {
$fallback = $this->inferMedicineDetailsFromBrandName($name, $formulation);
if (($fallback['genericname'] ?? '') !== '' || ($fallback['salt_name'] ?? '') !== '') {
return $this->response->setJSON([
'update' => 1,
'provider' => 'local-brand-fallback',
'match_confidence' => 'brand-fallback',
'matched_item_name' => '',
'details' => [
'genericname' => trim((string) ($fallback['genericname'] ?? '')),
'salt_name' => trim((string) ($fallback['salt_name'] ?? '')),
'dosage_restriction' => trim((string) ($fallback['dosage_restriction'] ?? '')),
'caution_note' => trim((string) ($fallback['caution_note'] ?? '')),
],
'error_text' => 'AI parse failed. Fallback generic/salt suggestion prepared.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
return $this->response->setJSON(['update' => 0, 'error_text' => 'AI response parse failed. Try again.']);
}
return $this->response->setJSON([
'update' => 1,
'provider' => $this->lastAiProvider,
'match_confidence' => 'ai',
'matched_item_name' => '',
'details' => [
'genericname' => trim((string) ($parsed['genericname'] ?? '')),
'salt_name' => trim((string) ($parsed['salt_name'] ?? '')),
'dosage_restriction' => trim((string) ($parsed['dosage_restriction'] ?? '')),
'caution_note' => trim((string) ($parsed['caution_note'] ?? '')),
],
'error_text' => 'AI details prepared',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
/**
* @return array<string, string>
*/
private function fetchMedicineDetailsFromAiServer(string $itemName, string $formulation): array
{
$this->lastAiServerError = '';
$itemName = trim($itemName);
if ($itemName === '') {
return [];
}
$configuredEndpointsRaw = trim($this->readAiSettingValue([
'DIAGNOSIS_AI_MEDICINE_ENDPOINT',
'DIAGNOSIS_AI_MEDICINE_DETAILS_ENDPOINT',
'AI_SERVER_MEDICINE_ENDPOINT',
]));
$token = trim($this->readAiSettingValue(['DIAGNOSIS_AI_PARSE_TOKEN', 'AI_SERVER_TOKEN']));
if ($token === '') {
$token = trim((string) getenv('DIAGNOSIS_AI_PARSE_TOKEN'));
}
if ($token === '') {
$token = trim((string) getenv('AI_SERVER_TOKEN'));
}
$payload = [
'item_name' => $itemName,
'medicine_name' => $itemName,
'name' => $itemName,
'formulation' => trim($formulation),
'mode' => 'medicine-details',
];
$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$baseCandidates = $this->resolveAiServerBaseCandidates();
$defaultPaths = [
'/medicine-details',
'/medicine/details',
'/medicine/enrich',
'/medicine/lookup',
'/api/medicine/details',
'/api/medicine/enrich',
'/api/medicine/lookup',
'/api/v1/medicine/details',
'/api/v1/medicine/enrich',
'/api/v1/medicine/lookup',
'/v1/medicine/details',
'/v1/medicine/enrich',
'/v1/medicine/lookup',
];
$endpointCandidates = [];
if ($configuredEndpointsRaw !== '') {
$parts = preg_split('/[\r\n,]+/', $configuredEndpointsRaw) ?: [];
foreach ($parts as $part) {
$candidate = trim((string) $part);
if ($candidate === '') {
continue;
}
// Normalize accidental Windows-style slashes from settings values.
$candidate = str_replace('\\', '/', $candidate);
if (preg_match('#^https?://#i', $candidate) === 1) {
$endpointCandidates[] = rtrim($candidate, '/');
continue;
}
$path = '/' . ltrim($candidate, '/');
foreach ($baseCandidates as $base) {
$endpointCandidates[] = rtrim($base, '/') . $path;
}
}
}
foreach ($baseCandidates as $base) {
foreach ($defaultPaths as $path) {
$endpointCandidates[] = rtrim($base, '/') . $path;
}
}
$endpointCandidates = array_values(array_unique($endpointCandidates));
$endpointCandidates = array_values(array_filter($endpointCandidates, static function ($url): bool {
$text = (string) $url;
if ($text === '') {
return false;
}
$lower = strtolower($text);
if (strpos($text, '\\') !== false || strpos($lower, '%5c') !== false) {
return false;
}
return true;
}));
$statusTrail = [];
foreach ($endpointCandidates as $endpointUrl) {
if (strpos($endpointUrl, '\\') !== false || strpos(strtolower($endpointUrl), '%5c') !== false) {
$statusTrail[] = 'skipped-malformed @ ' . $endpointUrl;
continue;
}
try {
$options = [
'timeout' => 20,
'http_errors' => false,
];
if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) {
$options['verify'] = false;
}
$client = service('curlrequest', $options);
$response = $client->post($endpointUrl, [
'headers' => $headers,
'json' => $payload,
]);
$status = (int) $response->getStatusCode();
$statusTrail[] = $status . ' @ ' . $endpointUrl;
if ($status < 200 || $status >= 300) {
continue;
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded)) {
continue;
}
$details = is_array($decoded['details'] ?? null) ? $decoded['details'] : $decoded;
$data = is_array($decoded['data'] ?? null) ? $decoded['data'] : [];
$generic = trim((string) (
$details['genericname']
?? $details['generic_name']
?? $data['genericname']
?? $data['generic_name']
?? $data['medicine_name']
?? ''
));
$salt = trim((string) (
$details['salt_name']
?? $details['salt']
?? $data['salt_name']
?? $data['salt']
?? ''
));
if ($salt === '' && is_array($data['brand_names'] ?? null)) {
$salt = trim(implode(', ', array_slice(array_map('strval', $data['brand_names']), 0, 3)));
}
$dosage = trim((string) (
$details['dosage_restriction']
?? $details['restriction']
?? $data['dosage_restriction']
?? $data['restriction']
?? ((is_array($data['dosage'] ?? null) ? ($data['dosage']['adult_dose'] ?? '') : ''))
?? ''
));
$caution = trim((string) (
$details['caution_note']
?? $details['caution']
?? $data['caution_note']
?? $data['caution']
?? ''
));
if ($caution === '') {
$pieces = [];
if (is_array($data['contraindications'] ?? null) && !empty($data['contraindications'])) {
$pieces[] = 'Contra: ' . implode(', ', array_slice(array_map('strval', $data['contraindications']), 0, 2));
}
if (is_array($data['precautions'] ?? null) && !empty($data['precautions'])) {
$pieces[] = 'Prec: ' . implode(', ', array_slice(array_map('strval', $data['precautions']), 0, 2));
}
if (is_array($data['drug_interactions'] ?? null) && !empty($data['drug_interactions'])) {
$pieces[] = 'Interacts: ' . implode(', ', array_slice(array_map('strval', $data['drug_interactions']), 0, 2));
}
$caution = trim(implode(' | ', $pieces));
}
$out = [
'genericname' => $generic,
'salt_name' => $salt,
'dosage_restriction' => $this->formatMedicineNote($dosage, 420),
'caution_note' => $this->formatMedicineNote($caution, 520),
];
if ($out['genericname'] !== '' || $out['salt_name'] !== '') {
$this->lastAiProvider = 'ai-server';
$this->lastAiServerError = '';
return $out;
}
$statusTrail[] = 'empty-payload @ ' . $endpointUrl;
} catch (\Throwable $e) {
$statusTrail[] = 'error @ ' . $endpointUrl . ' :: ' . $e->getMessage();
}
}
if (! empty($statusTrail)) {
$this->lastAiServerError = implode(' | ', array_slice($statusTrail, 0, 5));
}
return [];
}
private function formatMedicineNote(string $value, int $maxLen = 500): string
{
$text = trim($value);
if ($text === '') {
return '';
}
// Normalize separators for UI readability while preserving meaning.
$text = str_replace(["\r\n", "\n", "\r"], ' | ', $text);
$text = preg_replace('/\s*\|\s*/', ' | ', $text) ?? $text;
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$text = trim($text, " \t\n\r\0\x0B|");
if ($maxLen < 80) {
$maxLen = 80;
}
if (mb_strlen($text) <= $maxLen) {
return $text;
}
$short = mb_substr($text, 0, $maxLen);
$lastSep = mb_strrpos($short, ' | ');
if ($lastSep !== false && $lastSep > (int) ($maxLen * 0.6)) {
$short = mb_substr($short, 0, $lastSep);
}
return rtrim($short, " ,;|.") . '...';
}
/**
* @return array<string, string>
*/
private function lookupMedicineDetailsFromMaster(string $name, string $formulation = ''): array
{
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return [];
}
$fields = $this->db->getFieldNames($table);
$nameField = $this->resolveFirstField($fields, ['item_name', 'med_name']);
$formField = $this->resolveFirstField($fields, ['formulation', 'med_type']);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$restrictionField = $this->resolveFirstField($fields, ['dosage_restriction', 'dose_restriction', 'restriction_note', 'restriction']);
if ($nameField === null || ($genericField === null && $saltField === null)) {
return [];
}
$name = trim($name);
if ($name === '') {
return [];
}
$exact = $this->db->table($table)
->where('UPPER(' . $nameField . ')', strtoupper($name));
if ($formField !== null && trim($formulation) !== '') {
$exact->where('UPPER(' . $formField . ')', strtoupper(trim($formulation)));
}
$row = $exact->get(1)->getRowArray() ?? [];
$matchConfidence = 'partial';
if (empty($row)) {
$builder = $this->db->table($table);
$builder->groupStart()
->like($nameField, $name)
->orLike($nameField, preg_replace('/\s+/', ' ', $name) ?? $name)
->groupEnd();
if ($formField !== null && trim($formulation) !== '') {
$builder->like($formField, trim($formulation));
}
$candidates = $builder->limit(15)->get()->getResultArray();
if (! empty($candidates)) {
$target = strtoupper($name);
usort($candidates, static function (array $a, array $b) use ($nameField, $target): int {
$aName = strtoupper(trim((string) ($a[$nameField] ?? '')));
$bName = strtoupper(trim((string) ($b[$nameField] ?? '')));
$aScore = 0;
$bScore = 0;
if ($aName === $target) {
$aScore += 1000;
} elseif (str_starts_with($aName, $target)) {
$aScore += 600;
} elseif (str_contains($aName, $target)) {
$aScore += 400;
}
if ($bName === $target) {
$bScore += 1000;
} elseif (str_starts_with($bName, $target)) {
$bScore += 600;
} elseif (str_contains($bName, $target)) {
$bScore += 400;
}
if ($aScore !== $bScore) {
return $bScore <=> $aScore;
}
return strlen($aName) <=> strlen($bName);
});
$row = $candidates[0] ?? [];
$picked = strtoupper(trim((string) ($row[$nameField] ?? '')));
$target = strtoupper($name);
if ($picked === $target) {
$matchConfidence = 'exact';
} elseif (str_starts_with($picked, $target) || str_contains($picked, $target)) {
$matchConfidence = 'close';
}
}
} else {
$matchConfidence = 'exact';
}
if (empty($row)) {
return [];
}
return [
'genericname' => $genericField !== null ? trim((string) ($row[$genericField] ?? '')) : '',
'salt_name' => $saltField !== null ? trim((string) ($row[$saltField] ?? '')) : '',
'dosage_restriction' => $restrictionField !== null ? trim((string) ($row[$restrictionField] ?? '')) : '',
'caution_note' => '',
'match_confidence' => $matchConfidence,
'matched_item_name' => trim((string) ($row[$nameField] ?? '')),
];
}
/**
* @return array<string, string>
*/
private function inferMedicineDetailsFromBrandName(string $name, string $formulation = ''): array
{
$text = strtoupper(trim($name));
if ($text === '') {
return [];
}
preg_match('/(\d{2,4})\s*MG/i', $name, $m);
$strength = isset($m[1]) ? trim((string) $m[1]) : '';
$map = [
'DOLO' => 'Paracetamol',
'CROCIN' => 'Paracetamol',
'CALPOL' => 'Paracetamol',
'PARACIP' => 'Paracetamol',
'PANTOCID' => 'Pantoprazole',
'PAN' => 'Pantoprazole',
'AZEE' => 'Azithromycin',
'AZTOR' => 'Atorvastatin',
'MONTAIR' => 'Montelukast',
'LEVOCET' => 'Levocetirizine',
'AUGMENTIN' => 'Amoxicillin + Clavulanic Acid',
];
$generic = '';
foreach ($map as $brand => $gen) {
if (str_contains($text, $brand)) {
$generic = $gen;
break;
}
}
if ($generic === '') {
return [];
}
$salt = $generic;
if ($strength !== '') {
if ($generic === 'Amoxicillin + Clavulanic Acid' && $strength === '625') {
$salt = 'Amoxicillin 500 mg + Clavulanic Acid 125 mg';
} else {
$salt = $generic . ' ' . $strength . ' mg';
}
}
$doseRestriction = '';
if ($generic === 'Paracetamol') {
$doseRestriction = 'Adults: usually max 4 g/day total from all paracetamol-containing products.';
} elseif ($generic === 'Pantoprazole') {
$doseRestriction = 'Usually once daily before food unless specifically advised otherwise.';
} elseif ($generic === 'Azithromycin') {
$doseRestriction = 'Follow fixed daily schedule and complete full course.';
}
return [
'genericname' => $generic,
'salt_name' => $salt,
'dosage_restriction' => $doseRestriction,
'caution_note' => '',
];
}
public function opd_medicince_remove(int $medId)
{
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$this->db->table($table)->where('id', (int) $medId)->delete();
return $this->response->setJSON(['update' => 1, 'error_text' => 'Medicine removed']);
}
public function opd_advice()
{
return view('billing/opd_advice_workspace', ['initial_advice_id' => 0]);
}
public function opd_advice_edit(int $adviceId)
{
return view('billing/opd_advice_workspace', ['initial_advice_id' => max(0, $adviceId)]);
}
public function opd_advice_data()
{
if (! $this->db->tableExists('opd_advice')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('opd_advice');
$idField = $this->resolveFirstField($fields, ['id', 'Code']);
$adviceField = $this->resolveFirstField($fields, ['advice', 'Name', 'advice_txt']);
$hindiField = $this->resolveFirstField($fields, ['advice_hindi', 'advice_local', 'hindi_advice', 'advice_hin']);
if ($idField === null || $adviceField === null) {
return $this->response->setJSON(['rows' => []]);
}
$select = $idField . ' as id,' . $adviceField . ' as advice';
if ($hindiField !== null) {
$select .= ',' . $hindiField . ' as advice_hindi';
}
$rows = $this->db->table('opd_advice')
->select($select)
->orderBy($adviceField, 'ASC')
->limit(5000)
->get()
->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function opd_advice_get(int $adviceId)
{
if (! $this->db->tableExists('opd_advice')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice table not found']);
}
$fields = $this->db->getFieldNames('opd_advice');
$idField = $this->resolveFirstField($fields, ['id', 'Code']);
if ($idField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice ID field not found']);
}
$row = $this->db->table('opd_advice')->where($idField, (int) $adviceId)->get(1)->getRowArray() ?? [];
if (empty($row)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice not found']);
}
return $this->response->setJSON(['update' => 1, 'row' => $row]);
}
public function opd_advice_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->db->tableExists('opd_advice')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice table not found']);
}
$fields = $this->db->getFieldNames('opd_advice');
$idField = $this->resolveFirstField($fields, ['id', 'Code']);
$adviceField = $this->resolveFirstField($fields, ['advice', 'Name', 'advice_txt']);
$hindiField = $this->resolveFirstField($fields, ['advice_hindi', 'advice_local', 'hindi_advice', 'advice_hin']);
if ($idField === null || $adviceField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice fields not found']);
}
$id = (int) $this->request->getPost('id');
$adviceText = trim((string) $this->request->getPost('advice'));
$adviceHindi = trim((string) $this->request->getPost('advice_hindi'));
if ($adviceText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice text is required']);
}
$data = [];
$data[$adviceField] = $adviceText;
if ($hindiField !== null) {
$data[$hindiField] = $adviceHindi;
}
if ($id > 0) {
$this->db->table('opd_advice')->where($idField, $id)->update($data);
$savedId = $id;
$message = 'Advice updated';
} else {
$this->db->table('opd_advice')->insert($data);
$savedId = (int) $this->db->insertID();
$message = 'Advice added';
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedId,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_advice_translate_ai()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$sourceText = trim((string) $this->request->getPost('advice'));
$targetLang = strtolower(trim((string) $this->request->getPost('target_lang')));
if ($sourceText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'English advice text is required']);
}
$supportedLanguages = [
'hi' => 'Hindi',
'bn' => 'Bengali',
'mr' => 'Marathi',
'ta' => 'Tamil',
'te' => 'Telugu',
'gu' => 'Gujarati',
'kn' => 'Kannada',
'ml' => 'Malayalam',
'pa' => 'Punjabi',
'or' => 'Odia',
'as' => 'Assamese',
'ur' => 'Urdu',
];
if (! isset($supportedLanguages[$targetLang])) {
$targetLang = 'hi';
}
$targetLanguageName = $supportedLanguages[$targetLang];
$this->lastAiProvider = 'none';
$translatedText = $this->translateAdviceWithAiServer($sourceText, $targetLang);
if ($translatedText === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'AI server unavailable. Check DIAGNOSIS_AI_SERVER_URL and token settings.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
return $this->response->setJSON([
'update' => 1,
'provider' => 'ai-server',
'target_lang' => $targetLang,
'target_language_name' => $targetLanguageName,
'translated_text' => $translatedText,
'error_text' => 'Advice translated',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_advice_remove(int $adviceId)
{
if (! $this->db->tableExists('opd_advice')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice table not found']);
}
$fields = $this->db->getFieldNames('opd_advice');
$idField = $this->resolveFirstField($fields, ['id', 'Code']);
if ($idField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice ID field not found']);
}
$this->db->table('opd_advice')->where($idField, (int) $adviceId)->delete();
return $this->response->setJSON(['update' => 1, 'error_text' => 'Advice removed']);
}
// ─── Investigation Master CRUD ─────────────────────────────────────────
public function opd_invest_master()
{
$doctorModel = new \App\Models\DoctorModel($this->db);
return view('billing/opd_invest_master_workspace', [
'initial_inv_id' => 0,
'med_specs' => $doctorModel->getMedSpecs(),
]);
}
public function opd_invest_master_specs()
{
$specs = [];
if ($this->db->tableExists('med_spec')) {
$specs = $this->db->table('med_spec')
->select('id, SpecName')
->orderBy('SpecName', 'ASC')
->get()->getResultArray();
}
return $this->response->setJSON(['specs' => $specs]);
}
public function opd_invest_master_data()
{
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['draw' => 1, 'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => []]);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
$codeField = $this->resolveFirstField($fields, ['Code', 'code']);
$nameField = $this->resolveFirstField($fields, ['Name', 'name']);
if ($keyField === null || $codeField === null || $nameField === null) {
return $this->response->setJSON(['draw' => 1, 'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => []]);
}
$filter = trim((string) $this->request->getGet('filter'));
$specId = (int) $this->request->getGet('spec_id');
$favOnly = (int) $this->request->getGet('fav_only');
$draw = (int) $this->request->getGet('draw');
$start = max(0, (int) $this->request->getGet('start'));
$length = (int) $this->request->getGet('length');
if ($length <= 0) {
$length = 25;
}
$selectCols = $keyField . ' as id, ' . $codeField . ' as code, ' . $nameField . ' as name';
foreach (['short_name', 'is_favourite', 'spec_ids', 'category_name'] as $f) {
if (in_array($f, $fields, true)) {
$selectCols .= ', ' . $f;
}
}
$applyFilters = function ($b) use ($filter, $favOnly, $specId, $fields, $nameField, $codeField) {
if ($filter !== '') {
$b->groupStart()
->like($nameField, $filter)
->orLike($codeField, $filter);
if (in_array('short_name', $fields, true)) {
$b->orLike('short_name', $filter);
}
$b->groupEnd();
}
if ($favOnly && in_array('is_favourite', $fields, true)) {
$b->where('is_favourite', 1);
}
if ($specId > 0 && in_array('spec_ids', $fields, true)) {
$b->where("FIND_IN_SET({$specId}, spec_ids) > 0", null, false);
}
};
$total = $this->db->table('investigation')->countAllResults();
$countBuilder = $this->db->table('investigation');
$applyFilters($countBuilder);
$filtered = $countBuilder->countAllResults();
$builder = $this->db->table('investigation')->select($selectCols);
$applyFilters($builder);
if (in_array('is_favourite', $fields, true)) {
$builder->orderBy('is_favourite', 'DESC');
}
$builder->orderBy($nameField, 'ASC');
$builder->limit($length, $start);
$rows = $builder->get()->getResultArray();
return $this->response->setJSON([
'draw' => $draw,
'recordsTotal' => $total,
'recordsFiltered' => $filtered,
'data' => $rows,
]);
}
public function opd_invest_master_get(int $invId)
{
if ($invId <= 0 || ! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Not found']);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
$codeField = $this->resolveFirstField($fields, ['Code', 'code']);
$nameField = $this->resolveFirstField($fields, ['Name', 'name']);
if ($keyField === null || $codeField === null || $nameField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid table structure']);
}
$row = $this->db->table('investigation')->where($keyField, $invId)->limit(1)->get()->getRowArray();
if (! $row) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Not found']);
}
return $this->response->setJSON([
'update' => 1,
'row' => [
'id' => (int) ($row[$keyField] ?? 0),
'code' => (string) ($row[$codeField ?? 'code'] ?? ''),
'name' => (string) ($row[$nameField ?? 'name'] ?? ''),
'short_name' => (string) ($row['short_name'] ?? ''),
'category_name' => (string) ($row['category_name'] ?? ''),
'is_favourite' => (int) ($row['is_favourite'] ?? 0),
'spec_ids' => (string) ($row['spec_ids'] ?? ''),
],
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_master_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation table not found']);
}
$id = (int) $this->request->getPost('id');
$name = trim((string) $this->request->getPost('name'));
if ($name === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation name is required']);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
$codeField = $this->resolveFirstField($fields, ['Code', 'code']);
$nameField = $this->resolveFirstField($fields, ['Name', 'name']);
if ($keyField === null || $codeField === null || $nameField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid table structure']);
}
$code = trim((string) $this->request->getPost('code'));
if ($code === '') {
$code = strtoupper(preg_replace('/[^A-Z0-9]/i', '', substr($name, 0, 8))) . rand(100, 999);
}
$data = [
$nameField => $name,
$codeField => $code,
];
foreach (['short_name', 'category_name', 'spec_ids'] as $f) {
if (in_array($f, $fields, true)) {
$data[$f] = trim((string) $this->request->getPost($f));
}
}
if (in_array('is_favourite', $fields, true)) {
$data['is_favourite'] = ((int) $this->request->getPost('is_favourite')) ? 1 : 0;
}
if ($id > 0) {
$this->db->table('investigation')->where($keyField, $id)->update($data);
$savedId = $id;
$message = 'Investigation updated';
} else {
$this->db->table('investigation')->insert($data);
$savedId = (int) $this->db->insertID();
$message = 'Investigation added';
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedId,
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_master_remove(int $invId)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if ($invId <= 0 || ! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Not found']);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
if ($keyField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid key field']);
}
$this->db->table('investigation')->where($keyField, $invId)->delete();
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Investigation deleted',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_master_toggle_fav(int $invId)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if ($invId <= 0 || ! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0]);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
if ($keyField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid key field']);
}
if (! in_array('is_favourite', $fields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Run database migration first']);
}
$row = $this->db->table('investigation')->where($keyField, $invId)->limit(1)->get()->getRowArray();
if (! $row) {
return $this->response->setJSON(['update' => 0]);
}
$newVal = ((int) ($row['is_favourite'] ?? 0)) === 1 ? 0 : 1;
$this->db->table('investigation')->where($keyField, $invId)->update(['is_favourite' => $newVal]);
return $this->response->setJSON([
'update' => 1,
'is_favourite' => $newVal,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_shortcuts_manager()
{
return view('billing/opd_invest_shortcuts_manager');
}
public function opd_invest_shortcuts_all()
{
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
$codeField = $this->resolveFirstField($fields, ['Code', 'code']);
$nameField = $this->resolveFirstField($fields, ['Name', 'name']);
if ($keyField === null || $codeField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$selectCols = $keyField . ' as id, ' . $codeField . ' as code, ' . $nameField . ' as name';
foreach (['short_name', 'sort_id'] as $f) {
if (in_array($f, $fields, true)) {
$selectCols .= ', ' . $f;
}
}
$rows = $this->db->table('investigation')
->select($selectCols)
->orderBy($nameField, 'ASC')
->get()->getResultArray();
$rows = array_map(static function (array $r): array {
return [
'id' => (int) ($r['id'] ?? 0),
'code' => (string) ($r['code'] ?? ''),
'name' => (string) ($r['name'] ?? ''),
'short_name' => (string) ($r['short_name'] ?? ''),
'sort_id' => (int) ($r['sort_id'] ?? 0),
];
}, $rows);
return $this->response->setJSON([
'rows' => $rows,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_shortcuts_save_item()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation table not found']);
}
$id = (int) $this->request->getPost('id');
if ($id <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid id']);
}
$fields = $this->db->getFieldNames('investigation');
$keyField = $this->resolveFirstField($fields, ['id', 'Code', 'code']);
if ($keyField === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid table structure']);
}
$data = [];
if (in_array('short_name', $fields, true)) {
$data['short_name'] = trim((string) $this->request->getPost('short_name'));
}
if (in_array('sort_id', $fields, true)) {
$data['sort_id'] = max(0, (int) $this->request->getPost('sort_id'));
}
if (empty($data)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Nothing to update']);
}
$this->db->table('investigation')->where($keyField, $id)->update($data);
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Saved',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_profile_master()
{
return view('billing/opd_invest_profile_master_workspace');
}
private function buildInvestigationProfileRows(string $filter = ''): array
{
if (! $this->db->tableExists('invprofiles') || ! $this->db->tableExists('invtprofiles') || ! $this->db->tableExists('investigation')) {
return [];
}
$pFields = $this->db->getFieldNames('invprofiles');
$jFields = $this->db->getFieldNames('invtprofiles');
$iFields = $this->db->getFieldNames('investigation');
$pCode = $this->resolveFirstField($pFields, ['Code', 'code', 'id']);
$pName = $this->resolveFirstField($pFields, ['Name', 'name']);
$jProfileCode = $this->resolveFirstField($jFields, ['ProfileCode', 'profile_code']);
$jInvestCode = $this->resolveFirstField($jFields, ['InvestigationCode', 'investigation_code']);
$jPrintOrder = $this->resolveFirstField($jFields, ['printOrder', 'print_order', 'id']);
$iCode = $this->resolveFirstField($iFields, ['Code', 'code']);
$iName = $this->resolveFirstField($iFields, ['Name', 'name']);
if ($pCode === null || $pName === null || $jProfileCode === null || $jInvestCode === null || $iCode === null || $iName === null) {
return [];
}
$sql = 'SELECT '
. 'p.`' . $pCode . '` AS profile_code, '
. 'p.`' . $pName . '` AS profile_name, '
. 'i.`' . $iCode . '` AS investigation_code, '
. 'i.`' . $iName . '` AS investigation_name '
. 'FROM `invprofiles` p '
. 'LEFT JOIN `invtprofiles` j ON p.`' . $pCode . '` = j.`' . $jProfileCode . '` '
. 'LEFT JOIN `investigation` i ON i.`' . $iCode . '` = j.`' . $jInvestCode . '` ';
$params = [];
if ($filter !== '') {
$sql .= 'WHERE p.`' . $pName . '` LIKE ? OR i.`' . $iName . '` LIKE ? ';
$params[] = '%' . $filter . '%';
$params[] = '%' . $filter . '%';
}
$sql .= 'ORDER BY p.`' . $pName . '` ASC';
if ($jPrintOrder !== null) {
$sql .= ', j.`' . $jPrintOrder . '` ASC';
}
$rows = $this->db->query($sql, $params)->getResultArray();
$map = [];
foreach ($rows as $row) {
$profileCode = (int) ($row['profile_code'] ?? 0);
$profileName = trim((string) ($row['profile_name'] ?? ''));
if ($profileCode <= 0 || $profileName === '') {
continue;
}
if (! isset($map[$profileCode])) {
$map[$profileCode] = [
'profile_code' => $profileCode,
'profile_name' => $profileName,
'tests' => [],
];
}
$testCode = trim((string) ($row['investigation_code'] ?? ''));
$testName = trim((string) ($row['investigation_name'] ?? ''));
if ($testCode !== '' && $testName !== '') {
$map[$profileCode]['tests'][] = [
'code' => $testCode,
'name' => $testName,
];
}
}
return array_values($map);
}
public function opd_invest_profile_master_list()
{
$filter = trim((string) $this->request->getGet('filter'));
return $this->response->setJSON(['rows' => $this->buildInvestigationProfileRows($filter)]);
}
public function opd_invest_profile_master_data()
{
$filter = trim((string) $this->request->getGet('filter'));
$list = $this->buildInvestigationProfileRows($filter);
$draw = (int) $this->request->getGet('draw');
$start = max(0, (int) $this->request->getGet('start'));
$length = (int) $this->request->getGet('length');
if ($length <= 0) {
$length = 25;
}
$paged = array_slice($list, $start, $length);
foreach ($paged as &$row) {
$row['test_count'] = count($row['tests'] ?? []);
$row['test_names'] = implode(', ', array_map(static function ($item) {
return (string) ($item['name'] ?? '');
}, $row['tests'] ?? []));
}
unset($row);
return $this->response->setJSON([
'draw' => $draw,
'recordsTotal' => count($list),
'recordsFiltered' => count($list),
'data' => $paged,
]);
}
public function opd_invest_profile_master_get(int $profileCode)
{
$rows = $this->buildInvestigationProfileRows();
foreach ($rows as $row) {
if ((int) ($row['profile_code'] ?? 0) === $profileCode) {
return $this->response->setJSON([
'update' => 1,
'row' => $row,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}
return $this->response->setJSON(['update' => 0, 'error_text' => 'Profile not found']);
}
public function opd_invest_profile_master_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if (! $this->db->tableExists('invprofiles') || ! $this->db->tableExists('invtprofiles') || ! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Profile tables not found']);
}
$profileCode = (int) $this->request->getPost('profile_code');
$profileName = trim((string) $this->request->getPost('profile_name'));
$investigationCodes = array_values(array_filter(array_map('intval', explode(',', (string) $this->request->getPost('investigation_codes')))));
if ($profileName === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Profile name is required']);
}
if (empty($investigationCodes)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Select at least one investigation']);
}
$pFields = $this->db->getFieldNames('invprofiles');
$jFields = $this->db->getFieldNames('invtprofiles');
$pCode = $this->resolveFirstField($pFields, ['Code', 'code', 'id']);
$pName = $this->resolveFirstField($pFields, ['Name', 'name']);
$jProfileCode = $this->resolveFirstField($jFields, ['ProfileCode', 'profile_code']);
$jInvestCode = $this->resolveFirstField($jFields, ['InvestigationCode', 'investigation_code']);
$jPrintOrder = $this->resolveFirstField($jFields, ['printOrder', 'print_order', 'id']);
if ($pCode === null || $pName === null || $jProfileCode === null || $jInvestCode === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid profile table structure']);
}
if ($profileCode > 0) {
$this->db->table('invprofiles')->where($pCode, $profileCode)->update([$pName => $profileName]);
$savedCode = $profileCode;
$this->db->table('invtprofiles')->where($jProfileCode, $savedCode)->delete();
} else {
$this->db->table('invprofiles')->insert([$pName => $profileName]);
$savedCode = (int) $this->db->insertID();
}
$order = 1;
foreach ($investigationCodes as $code) {
$joinRow = [
$jProfileCode => $savedCode,
$jInvestCode => $code,
];
if ($jPrintOrder !== null) {
$joinRow[$jPrintOrder] = $order;
}
$this->db->table('invtprofiles')->insert($joinRow);
$order++;
}
return $this->response->setJSON([
'update' => 1,
'insertid' => $savedCode,
'error_text' => $profileCode > 0 ? 'Profile updated' : 'Profile added',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_invest_profile_master_remove(int $profileCode)
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0]);
}
if (! $this->db->tableExists('invprofiles') || ! $this->db->tableExists('invtprofiles')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Profile tables not found']);
}
$pFields = $this->db->getFieldNames('invprofiles');
$jFields = $this->db->getFieldNames('invtprofiles');
$pCode = $this->resolveFirstField($pFields, ['Code', 'code', 'id']);
$jProfileCode = $this->resolveFirstField($jFields, ['ProfileCode', 'profile_code']);
if ($pCode === null || $jProfileCode === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid profile table structure']);
}
$this->db->table('invtprofiles')->where($jProfileCode, $profileCode)->delete();
$this->db->table('invprofiles')->where($pCode, $profileCode)->delete();
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Profile deleted',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
// ─────────────────────────────────────────────────────────────────────────
public function opd_medicince_duplicate_report()
{
$medicineModel = new OpdMedicineModel($this->db);
if (! $medicineModel->tableExists()) {
return $this->response->setJSON(['rows' => []]);
}
return $this->response->setJSON([
'rows' => $medicineModel->getDuplicateGroups(),
'update' => 1,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_medicince_merge_duplicates()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$keepId = (int) $this->request->getPost('keep_id');
$mergeRaw = trim((string) $this->request->getPost('merge_ids'));
$mergeIds = [];
if ($mergeRaw !== '') {
foreach (explode(',', $mergeRaw) as $token) {
$value = (int) trim($token);
if ($value > 0) {
$mergeIds[] = $value;
}
}
}
$medicineModel = new OpdMedicineModel($this->db);
$merged = $medicineModel->mergeDuplicates($keepId, $mergeIds);
if (! ($merged['ok'] ?? false)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => (string) ($merged['error'] ?? 'Unable to merge duplicates'),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Merged ' . (int) ($merged['merged_count'] ?? 0) . ' duplicate medicine(s).',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function opd_medicince_autofix_duplicates()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$medicineModel = new OpdMedicineModel($this->db);
if (! $medicineModel->tableExists()) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Medicine table not found',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$groups = $medicineModel->getDuplicateGroups();
if (empty($groups)) {
return $this->response->setJSON([
'update' => 1,
'error_text' => 'No duplicate medicine names found.',
'merged_groups' => 0,
'merged_rows' => 0,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$mergedGroups = 0;
$mergedRows = 0;
foreach ($groups as $group) {
$keepId = (int) ($group['keep_id'] ?? 0);
$mergeIds = is_array($group['merge_ids'] ?? null) ? $group['merge_ids'] : [];
if ($keepId <= 0 || empty($mergeIds)) {
continue;
}
$result = $medicineModel->mergeDuplicates($keepId, $mergeIds);
if (! ($result['ok'] ?? false)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => (string) ($result['error'] ?? 'Auto-fix stopped due to merge failure'),
'merged_groups' => $mergedGroups,
'merged_rows' => $mergedRows,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$mergedGroups++;
$mergedRows += (int) ($result['merged_count'] ?? 0);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Auto-fix completed. Merged ' . $mergedRows . ' duplicate medicine row(s) in ' . $mergedGroups . ' group(s).',
'merged_groups' => $mergedGroups,
'merged_rows' => $mergedRows,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function scan_text_extract()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$filePath = trim((string) $this->request->getPost('file_path'));
$scanType = trim((string) $this->request->getPost('scan_type'));
if ($filePath === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Scan file path is required']);
}
$localPath = $this->resolveLocalFilePathFromStoredPath($filePath);
if ($localPath === null || !is_file($localPath)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Scan file not found']);
}
$extracted = $this->extractClinicalTextFromScan($localPath, $scanType);
if ($extracted === null || trim($extracted) === '') {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Unable to extract report text from this scan currently.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
return $this->response->setJSON([
'update' => 1,
'extracted_text' => trim($extracted),
'error_text' => 'Scan text extracted',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function patient_scan_history(int $opdId)
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setBody('<div class="text-danger small">Access denied.</div>');
}
if ($opdId <= 0 || ! $this->db->tableExists('opd_master')) {
return $this->response->setBody('<div class="text-muted small">No scan history found.</div>');
}
$opdRow = $this->db->table('opd_master')->select('p_id')->where('opd_id', $opdId)->get(1)->getRowArray();
$patientId = (int) ($opdRow['p_id'] ?? 0);
if ($patientId <= 0) {
return $this->response->setBody('<div class="text-muted small">No scan history found.</div>');
}
return view('billing/opd_scan_history_panel', [
'scan_history_items' => $this->getPatientScanHistoryForConsult($patientId),
]);
}
public function fhir_bundle(int $opdId, int $sessionId = 0)
{
if (! $this->db->tableExists('opd_fhir_documents')) {
return $this->response->setStatusCode(404)->setBody('FHIR bundle storage not available.');
}
$builder = $this->db->table('opd_fhir_documents')
->where('opd_id', (int) $opdId)
->where('bundle_type', 'MedicationRequestBundle');
if ($sessionId > 0) {
$builder->where('opd_session_id', (int) $sessionId);
}
$row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
if (empty($row)) {
return $this->response->setStatusCode(404)->setBody('FHIR bundle not found.');
}
$bundleJson = (string) ($row['bundle_json'] ?? '{}');
$fileSession = (int) ($row['opd_session_id'] ?? 0);
$filename = 'opd_' . (int) $opdId . '_session_' . $fileSession . '_fhir.json';
return $this->response
->setHeader('Content-Type', 'application/fhir+json; charset=utf-8')
->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
->setBody($bundleJson);
}
public function fhir_bundle_history(int $opdId, int $sessionId = 0)
{
if (! $this->db->tableExists('opd_fhir_documents')) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table('opd_fhir_documents')
->where('opd_id', (int) $opdId)
->where('bundle_type', 'MedicationRequestBundle');
if ($sessionId > 0) {
$builder->where('opd_session_id', (int) $sessionId);
}
$rows = $builder->orderBy('id', 'DESC')->limit(5)->get()->getResultArray();
$result = [];
foreach ($rows as $row) {
$rowSessionId = (int) ($row['opd_session_id'] ?? 0);
$result[] = [
'id' => (int) ($row['id'] ?? 0),
'opd_session_id' => $rowSessionId,
'generated_at' => (string) ($row['generated_at'] ?? ''),
'generated_by' => (string) ($row['generated_by'] ?? ''),
'download_url' => base_url('Opd_prescription/fhir_bundle/' . (int) $opdId . '/' . $rowSessionId),
'preview_url' => base_url('Opd_prescription/fhir_bundle_preview/' . (int) $opdId . '/' . $rowSessionId),
'preview_latest_url' => base_url('Opd_prescription/fhir_bundle_preview/' . (int) $opdId),
];
}
return $this->response->setJSON(['rows' => $result]);
}
public function fhir_bundle_preview(int $opdId, int $sessionId = 0)
{
if (! $this->db->tableExists('opd_fhir_documents')) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'FHIR bundle storage not available.',
]);
}
$builder = $this->db->table('opd_fhir_documents')
->where('opd_id', (int) $opdId)
->where('bundle_type', 'MedicationRequestBundle');
if ($sessionId > 0) {
$builder->where('opd_session_id', (int) $sessionId);
}
$row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
if (empty($row)) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 'error',
'message' => 'FHIR bundle not found.',
'opd_id' => (int) $opdId,
'opd_session_id' => (int) $sessionId,
]);
}
$bundleJson = (string) ($row['bundle_json'] ?? '{}');
$bundle = json_decode($bundleJson, true);
if (! is_array($bundle)) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => 'Stored FHIR bundle JSON is invalid.',
'document_id' => (int) ($row['id'] ?? 0),
]);
}
return $this->response->setJSON([
'status' => 'ok',
'document_id' => (int) ($row['id'] ?? 0),
'opd_id' => (int) ($row['opd_id'] ?? 0),
'opd_session_id' => (int) ($row['opd_session_id'] ?? 0),
'bundle_type' => (string) ($row['bundle_type'] ?? ''),
'generated_at' => (string) ($row['generated_at'] ?? ''),
'generated_by' => (string) ($row['generated_by'] ?? ''),
'bundle' => $bundle,
]);
}
public function advice_search()
{
$q = trim((string) $this->request->getGet('q'));
if (! $this->db->tableExists('opd_advice')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('opd_advice');
$idField = in_array('id', $fields, true) ? 'id' : (in_array('Code', $fields, true) ? 'Code' : null);
$textField = in_array('advice', $fields, true) ? 'advice' : (in_array('Name', $fields, true) ? 'Name' : null);
if ($idField === null || $textField === null) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table('opd_advice')
->select($idField . ' as id,' . $textField . ' as label');
if ($q !== '') {
$builder->like($textField, $q);
}
$rows = $builder
->orderBy($idField, 'DESC')
->limit(20)
->get()
->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function complaints_search()
{
$q = trim((string) $this->request->getGet('q'));
if ($q === '') {
return $this->response->setJSON(['rows' => []]);
}
$rows = [];
if ($this->db->tableExists('complaints_master')) {
$rows = $this->db->table('complaints_master')
->select('Code as code, Name as name, name_hinglish, show_in_short, ai_hint')
->where('is_active', 1)
->groupStart()
->like('Name', $q)
->orLike('name_hinglish', $q)
->orLike('keywords', $q)
->groupEnd()
->orderBy('show_in_short', 'DESC')
->orderBy('Name', 'ASC')
->limit(20)
->get()
->getResultArray();
}
$keywordRows = $this->fetchAutotypeKeywords(
'complaints',
$q,
max(0, (int) ($this->getCurrentUserId() ?? 0)),
20
);
$out = [];
$seen = [];
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
foreach ($rows as $row) {
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$k = mb_strtoupper($name);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$row['name'] = $name;
$out[] = $row;
}
foreach ($keywordRows as $keyword) {
$name = $this->normalizeAutocompleteSuggestionText((string) $keyword);
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$k = mb_strtoupper($name);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'code' => '',
'name' => $name,
'name_hinglish' => '',
'show_in_short' => 0,
'ai_hint' => '',
];
}
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
public function provisional_diagnosis_search()
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['rows' => []]);
}
$q = trim((string) $this->request->getGet('q'));
if ($q === '') {
return $this->response->setJSON(['rows' => []]);
}
$out = [];
$seen = [];
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
$csnotkRows = (new CsnotkTerminologyService())->searchDiagnosis($q, 20);
foreach ($csnotkRows as $row) {
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['term'] ?? ''));
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$conceptId = trim((string) ($row['concept_id'] ?? ''));
$source = trim((string) ($row['source'] ?? 'csnotk'));
$k = mb_strtoupper($name) . '|' . $conceptId;
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'name' => $name,
'snomed_concept_id' => $conceptId,
'snomed_term' => $name,
'source' => $source,
];
if (count($out) >= 20) {
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
}
$snomedRows = $this->fetchSnomedDiagnosisRows($q, 20);
foreach ($snomedRows as $row) {
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['term'] ?? ''));
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$k = mb_strtoupper($name);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'name' => $name,
'snomed_concept_id' => (string) ($row['concept_id'] ?? ''),
'snomed_term' => $name,
'source' => 'snomed',
];
if (count($out) >= 20) {
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
}
if (! $this->db->tableExists('disease_master')) {
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
$fields = $this->db->getFieldNames('disease_master') ?? [];
$nameField = in_array('Name', $fields, true) ? 'Name' : (in_array('name', $fields, true) ? 'name' : null);
if ($nameField === null) {
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
$rows = $this->db->table('disease_master')
->select($nameField . ' as name')
->like($nameField, $q)
->orderBy($nameField, 'ASC')
->limit(20)
->get()
->getResultArray();
$keywordRows = $this->fetchAutotypeKeywords(
'provisional_diagnosis',
$q,
max(0, (int) ($this->getCurrentUserId() ?? 0)),
20
);
foreach ($rows as $row) {
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$k = mb_strtoupper($name);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'name' => $name,
'snomed_concept_id' => '',
'snomed_term' => '',
'source' => 'disease_master',
];
}
foreach ($keywordRows as $keyword) {
$name = $this->normalizeAutocompleteSuggestionText((string) $keyword);
if ($name === '') {
continue;
}
if ($needle !== '' && mb_stripos($name, $needle) === false) {
continue;
}
$k = mb_strtoupper($name);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = [
'name' => $name,
'snomed_concept_id' => '',
'snomed_term' => '',
'source' => 'keyword',
];
}
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
private function fetchSnomedDiagnosisRows(string $query, int $limit = 20): array
{
$q = trim($query);
if ($q === '') {
return [];
}
if (! $this->db->tableExists('snomed_concept') || ! $this->db->tableExists('snomed_description')) {
return [];
}
$normalized = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
if ($normalized === '') {
return [];
}
$builder = $this->db->table('snomed_description d')
->select('d.concept_id, d.term, MAX(CASE WHEN lr.acceptability_id = "900000000000548007" THEN 2 WHEN lr.acceptability_id = "900000000000549004" THEN 1 ELSE 0 END) AS accept_rank', false)
->join('snomed_concept c', 'c.concept_id = d.concept_id AND c.active = 1', 'inner')
->join('snomed_language_refset lr', 'lr.referenced_component_id = d.description_id AND lr.active = 1 AND lr.refset_id IN ("900000000000508004", "900000000000509007")', 'left')
->where('d.active', 1)
->where('d.language_code', 'en')
->groupStart()
->like('d.term_normalized', $normalized, 'after')
->orLike('d.term_normalized', ' ' . $normalized, 'both')
->groupEnd()
->groupBy('d.description_id')
->orderBy('accept_rank', 'DESC')
->orderBy('CHAR_LENGTH(d.term)', 'ASC', false)
->orderBy('d.term', 'ASC')
->limit(max(1, min(100, $limit)));
return $builder->get()->getResultArray();
}
private function resolveProvisionalDiagnosisSnomedMatch(string $text): array
{
$term = trim($text);
if ($term === '') {
return [];
}
if (! $this->db->tableExists('snomed_description')) {
return [];
}
$row = $this->db->table('snomed_description d')
->select('d.concept_id, d.term')
->join('snomed_concept c', 'c.concept_id = d.concept_id AND c.active = 1', 'inner')
->where('d.active', 1)
->where('d.language_code', 'en')
->where('d.term', $term)
->orderBy('d.term', 'ASC')
->get(1)
->getRowArray();
if (! is_array($row) || empty($row['concept_id'])) {
return [];
}
return [
'concept_id' => (string) ($row['concept_id'] ?? ''),
'term' => (string) ($row['term'] ?? $term),
'source' => 'snomed-exact',
];
}
private function getSnomedPreferredTermByConcept(string $conceptId): string
{
if (! $this->db->tableExists('snomed_description')) {
return '';
}
$preferred = $this->db->table('snomed_description d')
->select('d.term')
->join('snomed_language_refset lr', 'lr.referenced_component_id = d.description_id AND lr.active = 1 AND lr.acceptability_id = "900000000000548007"', 'inner')
->where('d.active', 1)
->where('d.concept_id', $conceptId)
->where('d.language_code', 'en')
->orderBy('d.term', 'ASC')
->get(1)
->getRowArray();
if (is_array($preferred) && ! empty($preferred['term'])) {
return trim((string) $preferred['term']);
}
$fallback = $this->db->table('snomed_description')
->select('term')
->where('active', 1)
->where('concept_id', $conceptId)
->where('language_code', 'en')
->orderBy('term', 'ASC')
->get(1)
->getRowArray();
return trim((string) ($fallback['term'] ?? ''));
}
private function fetchSnomedIcdMapRows(string $conceptId): array
{
$out = [];
$seen = [];
if ($this->db->tableExists('snomed_map_extended')) {
$extended = $this->db->table('snomed_map_extended')
->select('map_target,map_group,map_priority,map_rule,map_advice')
->where('active', 1)
->where('referenced_component_id', $conceptId)
->where('map_target IS NOT NULL', null, false)
->where('map_target <>', '')
->orderBy('map_group', 'ASC')
->orderBy('map_priority', 'ASC')
->limit(50)
->get()
->getResultArray();
foreach ($extended as $row) {
$code = trim((string) ($row['map_target'] ?? ''));
if ($code === '' || isset($seen['E:' . $code])) {
continue;
}
$seen['E:' . $code] = true;
$out[] = [
'icd_code' => $code,
'source' => 'extended_map',
'map_group' => (int) ($row['map_group'] ?? 0),
'map_priority' => (int) ($row['map_priority'] ?? 0),
'map_rule' => (string) ($row['map_rule'] ?? ''),
'map_advice' => (string) ($row['map_advice'] ?? ''),
];
}
}
if ($this->db->tableExists('snomed_map_simple')) {
$simple = $this->db->table('snomed_map_simple')
->select('map_target')
->where('active', 1)
->where('referenced_component_id', $conceptId)
->where('map_target IS NOT NULL', null, false)
->where('map_target <>', '')
->orderBy('map_target', 'ASC')
->limit(50)
->get()
->getResultArray();
foreach ($simple as $row) {
$code = trim((string) ($row['map_target'] ?? ''));
if ($code === '' || isset($seen['E:' . $code]) || isset($seen['S:' . $code])) {
continue;
}
$seen['S:' . $code] = true;
$out[] = [
'icd_code' => $code,
'source' => 'simple_map',
'map_group' => 0,
'map_priority' => 0,
'map_rule' => '',
'map_advice' => '',
];
}
}
return $out;
}
private function countTableRows(string $table): int
{
if (! preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
return 0;
}
if (! $this->db->tableExists($table)) {
return 0;
}
$row = $this->db->query('SELECT COUNT(*) AS row_count FROM ' . $table)->getRowArray();
return (int) ($row['row_count'] ?? 0);
}
public function finding_exam_search()
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['rows' => []]);
}
$q = trim((string) $this->request->getGet('q'));
if ($q === '' || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON(['rows' => []]);
}
$rows = $this->db->table('opd_prescription')
->select('Finding_Examinations as finding_examinations')
->where('Finding_Examinations IS NOT NULL', null, false)
->where('Finding_Examinations <>', '')
->like('Finding_Examinations', $q)
->groupBy('Finding_Examinations')
->orderBy('id', 'DESC')
->limit(20)
->get()
->getResultArray();
$keywordRows = $this->fetchAutotypeKeywords(
'finding_examinations',
$q,
max(0, (int) ($this->getCurrentUserId() ?? 0)),
20
);
$out = [];
$seen = [];
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
foreach ($rows as $row) {
$text = $this->normalizeAutocompleteSuggestionText((string) ($row['finding_examinations'] ?? ''));
if ($text === '') {
continue;
}
if ($needle !== '' && mb_stripos($text, $needle) === false) {
continue;
}
$k = mb_strtoupper($text);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = ['finding_examinations' => $text];
}
foreach ($keywordRows as $keyword) {
$text = $this->normalizeAutocompleteSuggestionText((string) $keyword);
if ($text === '') {
continue;
}
if ($needle !== '' && mb_stripos($text, $needle) === false) {
continue;
}
$k = mb_strtoupper($text);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = ['finding_examinations' => $text];
}
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
}
private function normalizeAutocompleteSuggestionText(string $text): string
{
$text = str_replace(["\r\n", "\r", "\n"], ' ', $text);
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$parts = preg_split('/\s*,\s*/', $text) ?: [];
$cleanParts = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '') {
continue;
}
$part = preg_replace('/\s+/', ' ', $part) ?? $part;
$part = preg_replace_callback('/\b([A-Za-z])\s*\/\s*([A-Za-z])\b/u', static function (array $m): string {
return mb_strtoupper($m[1]) . '/' . mb_strtoupper($m[2]);
}, $part) ?? $part;
// If commas split out punctuation-only fragments like "+", attach to previous token.
if (preg_match('/^[+-]{1,3}$/', $part) && $cleanParts !== []) {
$lastIndex = count($cleanParts) - 1;
$cleanParts[$lastIndex] = rtrim($cleanParts[$lastIndex]) . ' ' . $part;
continue;
}
if (preg_match('/^(.+?)([+-]{1,3})$/u', $part, $m) === 1) {
$base = trim((string) $m[1]);
$sign = trim((string) $m[2]);
if ($base !== '') {
$part = $base . ' ' . $sign;
}
}
$cleanParts[] = $part;
}
$normalized = implode(', ', $cleanParts);
return rtrim(trim($normalized), ', ');
}
public function autotype_keyword_save()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
}
$section = trim((string) $this->request->getPost('section'));
$keyword = trim((string) $this->request->getPost('keyword'));
$scope = strtolower(trim((string) $this->request->getPost('scope')));
if ($scope === '') {
$scope = 'doctor';
}
if (! $this->isAutotypeKeywordSectionAllowed($section)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported section']);
}
$keyword = $this->normalizeAutocompleteSuggestionText($keyword);
if (! $this->isUsableAutotypeKeyword($keyword)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Please enter a meaningful keyword (not only commas/symbols).']);
}
if (! $this->ensureAutotypeKeywordTable()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword storage unavailable']);
}
$docId = max(0, (int) ($this->getCurrentUserId() ?? 0));
if ($scope === 'master') {
$user = auth()->user();
$canMaster = $user && method_exists($user, 'can') && ($user->can('doctor_work.template_workspace.access') || $user->can('doctor_work.access'));
if (! $canMaster) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No permission to save master keyword']);
}
$docId = 0;
}
$table = $this->db->table('opd_autotype_keyword_master');
$existing = $table
->where('doc_id', $docId)
->where('section_key', $section)
->where('keyword', $keyword)
->get(1)
->getRowArray();
if (! empty($existing)) {
$table->where('id', (int) ($existing['id'] ?? 0))->update([
'keyword' => $keyword,
'is_active' => 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$dupId = $this->findNormalizedAutotypeKeywordId($docId, $section, $keyword, 0);
if ($dupId > 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword already exists in this scope']);
}
$table->insert([
'doc_id' => $docId,
'section_key' => $section,
'keyword' => $keyword,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return $this->response->setJSON([
'update' => 1,
'error_text' => $docId === 0 ? 'Keyword saved in master list' : 'Keyword saved for current doctor',
'keyword' => $keyword,
'section' => $section,
'scope' => $docId === 0 ? 'master' : 'doctor',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function autotype_keyword_list()
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['rows' => []]);
}
$section = trim((string) $this->request->getGet('section'));
$scope = strtolower(trim((string) $this->request->getGet('scope')));
$q = trim((string) $this->request->getGet('q'));
$docId = max(0, (int) ($this->getCurrentUserId() ?? 0));
$canMaster = $this->canManageMasterKeyword();
if ($scope === '') {
$scope = 'all';
}
if (! $this->ensureAutotypeKeywordTable()) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table('opd_autotype_keyword_master')
->select('id,doc_id,section_key,keyword,updated_at')
->where('is_active', 1);
if ($this->isAutotypeKeywordSectionAllowed($section)) {
$builder->where('section_key', $section);
}
if ($scope === 'master') {
$builder->where('doc_id', 0);
} elseif ($scope === 'doctor') {
$builder->where('doc_id', $docId);
} else {
$builder->groupStart()
->where('doc_id', 0)
->orWhere('doc_id', $docId)
->groupEnd();
}
if ($q !== '') {
$builder->like('keyword', $q);
}
$rows = $builder
->orderBy('doc_id', 'DESC')
->orderBy('updated_at', 'DESC')
->orderBy('keyword', 'ASC')
->limit(200)
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$rowDocId = (int) ($row['doc_id'] ?? 0);
$isMaster = $rowDocId === 0;
$canEdit = $isMaster ? $canMaster : ($rowDocId === $docId);
$out[] = [
'id' => $id,
'doc_id' => $rowDocId,
'section_key' => (string) ($row['section_key'] ?? ''),
'keyword' => (string) ($row['keyword'] ?? ''),
'scope' => $isMaster ? 'master' : 'doctor',
'scope_label' => $isMaster ? 'Master' : 'My',
'updated_at' => (string) ($row['updated_at'] ?? ''),
'can_edit' => $canEdit ? 1 : 0,
'can_delete' => $canEdit ? 1 : 0,
];
}
return $this->response->setJSON(['rows' => $out]);
}
public function autotype_keyword_update()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
}
$id = (int) $this->request->getPost('id');
$keyword = trim((string) $this->request->getPost('keyword'));
$keyword = $this->normalizeAutocompleteSuggestionText($keyword);
if ($id <= 0 || ! $this->isUsableAutotypeKeyword($keyword)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Please enter a meaningful keyword (not only commas/symbols).']);
}
if (! $this->ensureAutotypeKeywordTable()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword storage unavailable']);
}
$table = $this->db->table('opd_autotype_keyword_master');
$current = $table->where('id', $id)->where('is_active', 1)->get(1)->getRowArray();
if (empty($current)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword not found']);
}
$docId = max(0, (int) ($this->getCurrentUserId() ?? 0));
$rowDocId = (int) ($current['doc_id'] ?? 0);
if ($rowDocId === 0) {
if (! $this->canManageMasterKeyword()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No permission to edit master keyword']);
}
} elseif ($rowDocId !== $docId) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'You can edit only your own keyword']);
}
$section = (string) ($current['section_key'] ?? '');
if (! $this->isAutotypeKeywordSectionAllowed($section)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported section']);
}
$dup = $table
->where('doc_id', $rowDocId)
->where('section_key', $section)
->where('keyword', $keyword)
->where('id <>', $id)
->where('is_active', 1)
->get(1)
->getRowArray();
if (! empty($dup)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword already exists in this scope']);
}
$dupNormalizedId = $this->findNormalizedAutotypeKeywordId($rowDocId, $section, $keyword, $id);
if ($dupNormalizedId > 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword already exists in this scope']);
}
$table->where('id', $id)->update([
'keyword' => $keyword,
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Keyword updated',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function autotype_keyword_remove()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
}
$id = (int) $this->request->getPost('id');
if ($id <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword id is required']);
}
if (! $this->ensureAutotypeKeywordTable()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword storage unavailable']);
}
$table = $this->db->table('opd_autotype_keyword_master');
$current = $table->where('id', $id)->where('is_active', 1)->get(1)->getRowArray();
if (empty($current)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Keyword not found']);
}
$docId = max(0, (int) ($this->getCurrentUserId() ?? 0));
$rowDocId = (int) ($current['doc_id'] ?? 0);
if ($rowDocId === 0) {
if (! $this->canManageMasterKeyword()) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No permission to remove master keyword']);
}
} elseif ($rowDocId !== $docId) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'You can remove only your own keyword']);
}
$table->where('id', $id)->update([
'is_active' => 0,
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Keyword removed',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
private function isUsableAutotypeKeyword(string $keyword): bool
{
if (mb_strlen($keyword) < 2) {
return false;
}
// Must contain at least one letter/number to avoid symbol/comma-only junk keywords.
return preg_match('/[\p{L}\p{N}]/u', $keyword) === 1;
}
private function findNormalizedAutotypeKeywordId(int $docId, string $section, string $normalizedKeyword, int $ignoreId = 0): int
{
if (! $this->db->tableExists('opd_autotype_keyword_master')) {
return 0;
}
$builder = $this->db->table('opd_autotype_keyword_master')
->select('id, keyword')
->where('doc_id', $docId)
->where('section_key', $section)
->where('is_active', 1);
if ($ignoreId > 0) {
$builder->where('id <>', $ignoreId);
}
$rows = $builder->get()->getResultArray();
if ($rows === []) {
return 0;
}
$needle = mb_strtoupper($this->normalizeAutocompleteSuggestionText($normalizedKeyword));
foreach ($rows as $row) {
$candidate = mb_strtoupper($this->normalizeAutocompleteSuggestionText((string) ($row['keyword'] ?? '')));
if ($candidate === '' || $candidate !== $needle) {
continue;
}
return (int) ($row['id'] ?? 0);
}
return 0;
}
// Legacy-compatible wrappers used by older autocomplete clients.
public function get_complaints()
{
$q = trim((string) ($this->request->getGet('term') ?? ''));
if ($q === '' || ! $this->db->tableExists('complaints_master')) {
return $this->response->setJSON([]);
}
$rows = $this->db->table('complaints_master')
->select('Name as value')
->where('is_active', 1)
->like('Name', $q)
->orderBy('Name', 'ASC')
->limit(20)
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$value = trim((string) ($row['value'] ?? ''));
if ($value === '') {
continue;
}
$out[] = ['label' => $value, 'value' => $value];
}
return $this->response->setJSON($out);
}
public function get_disease()
{
$q = trim((string) ($this->request->getGet('term') ?? ''));
if ($q === '' || ! $this->db->tableExists('disease_master')) {
return $this->response->setJSON([]);
}
$fields = $this->db->getFieldNames('disease_master') ?? [];
$nameField = in_array('Name', $fields, true) ? 'Name' : (in_array('name', $fields, true) ? 'name' : null);
if ($nameField === null) {
return $this->response->setJSON([]);
}
$rows = $this->db->table('disease_master')
->select($nameField . ' as value')
->like($nameField, $q)
->orderBy($nameField, 'ASC')
->limit(20)
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$value = trim((string) ($row['value'] ?? ''));
if ($value === '') {
continue;
}
$out[] = ['label' => $value, 'value' => $value];
}
return $this->response->setJSON($out);
}
public function get_finding_exam()
{
$q = trim((string) ($this->request->getGet('term') ?? ''));
if ($q === '' || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setJSON([]);
}
$rows = $this->db->table('opd_prescription')
->select('Finding_Examinations as value')
->where('Finding_Examinations IS NOT NULL', null, false)
->where('Finding_Examinations <>', '')
->like('Finding_Examinations', $q)
->groupBy('Finding_Examinations')
->orderBy('id', 'DESC')
->limit(20)
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$value = trim((string) ($row['value'] ?? ''));
if ($value === '') {
continue;
}
$out[] = ['label' => $value, 'value' => $value];
}
return $this->response->setJSON($out);
}
public function complaints_parse()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$text = trim((string) $this->request->getPost('text'));
if ($text === '') {
return $this->response->setJSON(['update' => 0, 'rows' => [], 'error_text' => 'Enter complaint text']);
}
$matched = $this->getComplaintMatchesFromText($text);
return $this->response->setJSON([
'update' => 1,
'rows' => $matched,
'error_text' => empty($matched) ? 'No standard complaints matched.' : 'Complaint terms matched',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function complaints_ai_draft()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$currentText = trim((string) $this->request->getPost('current_text'));
$complaints = $this->request->getPost('complaints');
$complaints = is_array($complaints) ? $complaints : [];
$normalized = [];
foreach ($complaints as $item) {
$item = trim((string) $item);
if ($item === '') {
continue;
}
$normalized[strtoupper($item)] = $item;
}
if ($currentText !== '') {
foreach ($this->getComplaintMatchesFromText($currentText) as $item) {
$normalized[strtoupper($item)] = $item;
}
}
if (! empty($normalized) && $this->db->tableExists('complaints_master')) {
$names = array_values($normalized);
$hintRows = $this->db->table('complaints_master')
->select('Name, ai_hint')
->whereIn('Name', $names)
->get()
->getResultArray();
foreach ($hintRows as $hintRow) {
$name = trim((string) ($hintRow['Name'] ?? ''));
$hint = trim((string) ($hintRow['ai_hint'] ?? ''));
if ($name !== '' && $hint !== '') {
$normalized[strtoupper($name)] = $name . ' (' . $hint . ')';
}
}
}
$finalComplaints = array_values($normalized);
$this->lastAiProvider = 'none';
$aiDraft = $this->generateComplaintDraftWithAi($finalComplaints, $currentText);
if ($aiDraft !== null && trim($aiDraft) !== '') {
$provider = $this->lastAiProvider === 'ai-server' ? 'ai-server' : 'ai';
$providerLabel = $provider === 'ai-server' ? 'AI Server' : 'AI';
return $this->response->setJSON([
'update' => 1,
'draft_text' => trim($aiDraft),
'error_text' => 'AI draft prepared using ' . $providerLabel,
'ai_mode' => $provider,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$line = '';
if (! empty($finalComplaints)) {
$line = 'Chief complaints: ' . implode(', ', $finalComplaints) . '.';
}
$draft = trim($line . ' ' . $currentText);
if ($draft === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Please add complaint terms first.']);
}
return $this->response->setJSON([
'update' => 1,
'draft_text' => $draft,
'error_text' => 'Complaint draft prepared (template mode)',
'ai_mode' => 'template',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function advice_list($opdId, $sessionId = 0)
{
$table = $this->findExistingTable(['opd_prescription_advice']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table($table);
$fields = $this->db->getFieldNames($table);
if (in_array('opd_pre_id', $fields, true)) {
if ((int) $sessionId <= 0) {
return $this->response->setJSON(['rows' => []]);
}
$builder->where('opd_pre_id', (int) $sessionId);
} elseif (in_array('opd_id', $fields, true)) {
$builder->where('opd_id', (int) $opdId);
} else {
return $this->response->setJSON(['rows' => []]);
}
if (in_array('id', $fields, true)) {
$builder->orderBy('id', 'DESC');
}
$rows = $builder->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function advice_add()
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$adviceText = trim((string) $this->request->getPost('advice_text'));
$adviceId = (int) $this->request->getPost('advice_id');
$adviceHindi = trim((string) $this->request->getPost('advice_hindi'));
if ($opdId <= 0 || $adviceText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice text is required']);
}
$table = $this->findExistingTable(['opd_prescription_advice']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice table not found']);
}
$sessionId = $this->ensurePrescriptionSession($opdId, $sessionId);
if ($sessionId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to create prescription session']);
}
$fields = $this->db->getFieldNames($table);
if ($adviceHindi === '' && $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) {
$masterBuilder = $this->db->table('opd_advice')
->select($masterAdviceField . ' as advice_text, ' . $masterHindiField . ' as advice_hindi'
. ($masterIdField !== null ? (', ' . $masterIdField . ' as advice_id') : ''));
if ($masterIdField !== null && $adviceId > 0) {
$masterBuilder->where($masterIdField, $adviceId);
} else {
$normalizedAdviceText = preg_replace('/\s+/', ' ', $adviceText) ?? $adviceText;
$masterBuilder->groupStart()
->where($masterAdviceField, $adviceText)
->orWhere($masterAdviceField, $normalizedAdviceText)
->groupEnd();
}
$masterRow = $masterBuilder->get(1)->getRowArray() ?? [];
$adviceHindi = trim((string) ($masterRow['advice_hindi'] ?? ''));
}
}
$insert = [];
if (in_array('opd_id', $fields, true)) {
$insert['opd_id'] = $opdId;
}
if (in_array('opd_pre_id', $fields, true)) {
$insert['opd_pre_id'] = $sessionId;
}
if (in_array('advice_id', $fields, true)) {
$insert['advice_id'] = $adviceId;
}
if (in_array('advice_txt', $fields, true)) {
$insert['advice_txt'] = $adviceText;
} elseif (in_array('advice', $fields, true)) {
$insert['advice'] = $adviceText;
}
if ($adviceHindi !== '') {
if (in_array('advice_txt_hindi', $fields, true)) {
$insert['advice_txt_hindi'] = $adviceHindi;
} elseif (in_array('advice_hindi', $fields, true)) {
$insert['advice_hindi'] = $adviceHindi;
}
}
$this->db->table($table)->insert($insert);
$insertId = (int) $this->db->insertID();
$this->auditClinicalUpdate('opd_prescription_advice', 'added', $insertId, null, $insert);
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Advice added',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function advice_remove($id)
{
$table = $this->findExistingTable(['opd_prescription_advice']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice table not found']);
}
$fields = $this->db->getFieldNames($table);
if (!in_array('id', $fields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Advice id field missing']);
}
$row = $this->db->table($table)->where('id', (int) $id)->get(1)->getRowArray() ?? [];
$this->db->table($table)->where('id', (int) $id)->delete();
$this->auditClinicalUpdate('opd_prescription_advice', 'removed', (int) $id, $row, null);
return $this->response->setJSON(['update' => 1, 'error_text' => 'Advice removed']);
}
public function investigation_search()
{
$q = trim((string) $this->request->getGet('q'));
$favOnly = (int) $this->request->getGet('fav_only');
$specId = (int) $this->request->getGet('spec_id');
if (! $this->db->tableExists('investigation')) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames('investigation');
$codeField = in_array('Code', $fields, true) ? 'Code' : (in_array('code', $fields, true) ? 'code' : null);
$nameField = in_array('Name', $fields, true) ? 'Name' : (in_array('name', $fields, true) ? 'name' : null);
$shortField = in_array('short_name', $fields, true) ? 'short_name' : null;
$hasFav = in_array('is_favourite', $fields, true);
$hasSpec = in_array('spec_ids', $fields, true);
if ($codeField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
// When no search term but fav/spec filter applied, still return results
if ($q === '' && $favOnly === 0 && $specId === 0) {
return $this->response->setJSON(['rows' => []]);
}
$selectCols = $codeField . ' as code, ' . $nameField . ' as name';
if ($hasFav) {
$selectCols .= ', is_favourite';
}
$builder = $this->db->table('investigation')->select($selectCols);
if ($q !== '') {
$builder->groupStart()->like($nameField, $q);
if ($shortField !== null) {
$builder->orLike($shortField, $q);
}
$builder->groupEnd();
}
if ($favOnly && $hasFav) {
$builder->where('is_favourite', 1);
}
if ($specId > 0 && $hasSpec) {
$builder->where("FIND_IN_SET({$specId}, spec_ids) > 0", null, false);
}
if ($hasFav) {
$builder->orderBy('is_favourite', 'DESC');
}
$builder->orderBy('LOWER(' . $this->db->protectIdentifiers($nameField) . ')', 'ASC', false);
$rows = $builder->limit(40)->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function investigation_shortcuts()
{
$profiles = [];
$shortTests = [];
if ($this->db->tableExists('investigation')) {
$invFields = $this->db->getFieldNames('investigation');
$pickField = static function (array $fields, array $candidates): ?string {
$map = [];
foreach ($fields as $field) {
$map[strtolower((string) $field)] = (string) $field;
}
foreach ($candidates as $candidate) {
$key = strtolower((string) $candidate);
if (isset($map[$key])) {
return $map[$key];
}
}
return null;
};
$codeField = $pickField($invFields, ['Code', 'code']);
$nameField = $pickField($invFields, ['Name', 'name']);
$shortField = $pickField($invFields, ['short_name', 'shortName', 'short']);
$sortField = $pickField($invFields, ['sort_id', 'sort_order', 'id']);
if ($codeField !== null && $nameField !== null && $shortField !== null && $sortField !== null) {
// Keep legacy CI3 behavior: show only mostly-used tests and group/order by short_name, sort_id.
$shortTests = $this->db->table('investigation')
->select($codeField . ' as code,' . $nameField . ' as name,' . $shortField . ' as short_name,' . $sortField . ' as sort_id')
->where($shortField . ' !=', '')
->where($shortField . ' is not null', null, false)
->orderBy($shortField, 'ASC')
->orderBy($sortField, 'ASC')
->get()
->getResultArray();
}
}
if ($this->db->tableExists('invprofiles') && $this->db->tableExists('invtprofiles') && $this->db->tableExists('investigation')) {
$pFields = $this->db->getFieldNames('invprofiles');
$jFields = $this->db->getFieldNames('invtprofiles');
$iFields = $this->db->getFieldNames('investigation');
$pCode = $this->resolveFirstField($pFields, ['Code', 'code']);
$pName = $this->resolveFirstField($pFields, ['Name', 'name']);
$jProfileCode = $this->resolveFirstField($jFields, ['ProfileCode', 'profile_code']);
$jInvestCode = $this->resolveFirstField($jFields, ['InvestigationCode', 'investigation_code']);
$jPrintOrder = $this->resolveFirstField($jFields, ['printOrder', 'print_order', 'id']);
$iCode = $this->resolveFirstField($iFields, ['Code', 'code']);
$iName = $this->resolveFirstField($iFields, ['Name', 'name']);
if ($pCode !== null && $pName !== null && $jProfileCode !== null && $jInvestCode !== null && $iCode !== null && $iName !== null) {
$orderExpr = 'p.`' . $pName . '` ASC';
if ($jPrintOrder !== null) {
$orderExpr .= ', j.`' . $jPrintOrder . '` ASC';
}
$sql = 'SELECT '
. 'p.`' . $pCode . '` AS profile_code, '
. 'p.`' . $pName . '` AS profile_name, '
. 'i.`' . $iCode . '` AS investigation_code, '
. 'i.`' . $iName . '` AS investigation_name '
. 'FROM `invprofiles` p '
. 'JOIN `invtprofiles` j ON p.`' . $pCode . '` = j.`' . $jProfileCode . '` '
. 'JOIN `investigation` i ON i.`' . $iCode . '` = j.`' . $jInvestCode . '` '
. 'ORDER BY ' . $orderExpr;
$rows = $this->db->query($sql)->getResultArray();
$profileMap = [];
foreach ($rows as $row) {
$profileCode = trim((string) ($row['profile_code'] ?? ''));
$profileName = trim((string) ($row['profile_name'] ?? ''));
$testCode = trim((string) ($row['investigation_code'] ?? ''));
$testName = trim((string) ($row['investigation_name'] ?? ''));
if ($profileCode === '' || $profileName === '' || $testName === '') {
continue;
}
if (!isset($profileMap[$profileCode])) {
$profileMap[$profileCode] = [
'profile_code' => $profileCode,
'profile_name' => $profileName,
'tests' => [],
'_seen' => [],
];
}
$key = strtolower($testCode . '|' . $testName);
if (isset($profileMap[$profileCode]['_seen'][$key])) {
continue;
}
$profileMap[$profileCode]['_seen'][$key] = true;
$profileMap[$profileCode]['tests'][] = [
'code' => $testCode,
'name' => $testName,
];
}
foreach ($profileMap as $item) {
unset($item['_seen']);
$profiles[] = $item;
}
}
}
return $this->response->setJSON([
'update' => 1,
'profiles' => $profiles,
'short_tests' => $shortTests,
]);
}
// DEBUG ENDPOINT - Remove after verifying data
public function debug_investigation_fields()
{
if ($this->db->tableExists('investigation')) {
$fields = $this->db->getFieldNames('investigation');
$total = $this->db->table('investigation')->countAll();
$withShortName = $this->db->table('investigation')
->where("short_name != ''", null, false)
->where('short_name IS NOT NULL', null, false)
->countAll();
$sample = $this->db->table('investigation')
->where("short_name != ''", null, false)
->where('short_name IS NOT NULL', null, false)
->orderBy('short_name', 'ASC')
->orderBy('sort_id', 'ASC')
->limit(10)
->get()
->getResultArray();
return $this->response->setJSON([
'table_fields' => $fields,
'total_investigations' => $total,
'with_short_name' => $withShortName,
'sample_data' => $sample,
]);
}
return $this->response->setJSON(['error' => 'investigation table not found']);
}
public function investigation_list($opdId, $sessionId = 0)
{
$table = $this->findExistingTable(['opd_prescription_investigation']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$builder = $this->db->table($table);
$fields = $this->db->getFieldNames($table);
if (in_array('opd_pre_id', $fields, true)) {
if ((int) $sessionId <= 0) {
return $this->response->setJSON(['rows' => []]);
}
$builder->where('opd_pre_id', (int) $sessionId);
} elseif (in_array('opd_id', $fields, true)) {
$builder->where('opd_id', (int) $opdId);
} else {
return $this->response->setJSON(['rows' => []]);
}
if (in_array('investigation_name', $fields, true)) {
$builder->orderBy('investigation_name', 'ASC');
} elseif (in_array('id', $fields, true)) {
$builder->orderBy('id', 'DESC');
}
$rows = $builder->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function investigation_add()
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$code = trim((string) $this->request->getPost('investigation_code'));
$name = trim((string) $this->request->getPost('investigation_name'));
// Profile chips may send only investigation name; resolve code from master to avoid unique-key collisions.
if ($code === '' && $name !== '' && $this->db->tableExists('investigation')) {
$invFields = $this->db->getFieldNames('investigation');
$invCodeField = $this->resolveFirstField($invFields, ['Code', 'code']);
$invNameField = $this->resolveFirstField($invFields, ['Name', 'name']);
if ($invCodeField !== null && $invNameField !== null) {
$resolved = $this->db->table('investigation')
->select($invCodeField . ' as code')
->where($invNameField, $name)
->limit(1)
->get()
->getRowArray();
if (! empty($resolved['code'])) {
$code = trim((string) $resolved['code']);
}
}
}
if ($opdId <= 0 || ($code === '' && $name === '')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation is required']);
}
$table = $this->findExistingTable(['opd_prescription_investigation']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation table not found']);
}
$sessionId = $this->ensurePrescriptionSession($opdId, $sessionId);
if ($sessionId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to create prescription session']);
}
$fields = $this->db->getFieldNames($table);
$insert = [];
if (in_array('opd_id', $fields, true)) {
$insert['opd_id'] = $opdId;
}
if (in_array('opd_pre_id', $fields, true)) {
$insert['opd_pre_id'] = $sessionId;
}
if (in_array('investigation_code', $fields, true)) {
$insert['investigation_code'] = $code;
}
if (in_array('investigation_name', $fields, true)) {
$insert['investigation_name'] = $name;
}
$builder = $this->db->table($table);
if (in_array('opd_pre_id', $fields, true)) {
$builder->where('opd_pre_id', $sessionId);
} elseif (in_array('opd_id', $fields, true)) {
$builder->where('opd_id', $opdId);
}
if ($code !== '' && in_array('investigation_code', $fields, true)) {
$builder->where('investigation_code', $code);
} elseif ($name !== '' && in_array('investigation_name', $fields, true)) {
$builder->where('investigation_name', $name);
}
$already = $builder->get(1)->getRowArray();
if (! empty($already)) {
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Investigation already added',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
try {
$this->db->table($table)->insert($insert);
} catch (\Throwable $e) {
$msg = strtolower($e->getMessage());
if (strpos($msg, 'duplicate entry') !== false) {
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Investigation already added',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
throw $e;
}
$insertId = (int) $this->db->insertID();
$this->auditClinicalUpdate('opd_prescription_investigation', 'added', $insertId, null, $insert);
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Investigation added',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function investigation_remove($id)
{
$table = $this->findExistingTable(['opd_prescription_investigation']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation table not found']);
}
$fields = $this->db->getFieldNames($table);
if (!in_array('id', $fields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Investigation id field missing']);
}
$row = $this->db->table($table)->where('id', (int) $id)->get(1)->getRowArray() ?? [];
$this->db->table($table)->where('id', (int) $id)->delete();
$this->auditClinicalUpdate('opd_prescription_investigation', 'removed', (int) $id, $row, null);
return $this->response->setJSON(['update' => 1, 'error_text' => 'Investigation removed']);
}
public function medicine_search()
{
$q = trim((string) $this->request->getGet('q'));
$scope = strtolower(trim((string) $this->request->getGet('scope')));
$limit = (int) ($this->request->getGet('limit') ?? 10);
if ($limit <= 0) {
$limit = 10;
}
$limit = min(10, $limit);
if (! in_array($scope, ['active', 'favorite', 'all'], true)) {
$scope = 'active';
}
if ($q === '') {
return $this->response->setJSON(['rows' => []]);
}
$qUpper = strtoupper($q);
$qLoose = trim((string) (preg_replace('/[^A-Z0-9]+/', ' ', $qUpper) ?? $qUpper));
$qNormalized = trim((string) (preg_replace('/[^A-Z0-9]+/', '', $qUpper) ?? $qUpper));
$qTokens = array_values(array_filter(preg_split('/\s+/', $qLoose) ?: [], static fn(string $token): bool => $token !== ''));
$qFirstToken = $qTokens[0] ?? '';
$qAlpha = trim((string) (preg_replace('/[^A-Z]+/', '', $qUpper) ?? $qUpper));
$qSoundex = $qAlpha !== '' ? soundex($qAlpha) : '';
$qMetaphone = $qAlpha !== '' ? metaphone($qAlpha) : '';
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames($table);
$idField = in_array('id', $fields, true) ? 'id' : null;
$nameField = in_array('item_name', $fields, true) ? 'item_name' : null;
$formField = in_array('formulation', $fields, true) ? 'formulation' : null;
$dosageField = $this->resolveFirstField($fields, ['dosage']);
$whenField = $this->resolveFirstField($fields, ['dosage_when']);
$freqField = $this->resolveFirstField($fields, ['dosage_freq']);
$whereField = $this->resolveFirstField($fields, ['dosage_where']);
$daysField = $this->resolveFirstField($fields, ['no_of_days']);
$qtyField = $this->resolveFirstField($fields, ['qty']);
$remarkField = $this->resolveFirstField($fields, ['remark']);
if ($idField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$select = $idField . ' as id,' . $nameField . ' as med_name';
if ($formField !== null) {
$select .= ',' . $formField . ' as med_type';
}
if ($dosageField !== null) {
$select .= ',' . $dosageField . ' as dosage';
}
if ($whenField !== null) {
$select .= ',' . $whenField . ' as dosage_when';
}
if ($freqField !== null) {
$select .= ',' . $freqField . ' as dosage_freq';
}
if ($whereField !== null) {
$select .= ',' . $whereField . ' as dosage_where';
}
if ($daysField !== null) {
$select .= ',' . $daysField . ' as no_of_days';
}
if ($qtyField !== null) {
$select .= ',' . $qtyField . ' as qty';
}
if ($remarkField !== null) {
$select .= ',' . $remarkField . ' as remark';
}
$userId = (int) ($this->getCurrentUserId() ?? 0);
$cacheKey = 'medicine_search_v2_' . md5(json_encode([
'q' => strtoupper($q),
'scope' => $scope,
'user' => $userId,
'limit' => $limit,
]));
$cachedRows = cache($cacheKey);
if (is_array($cachedRows)) {
return $this->response->setJSON(['rows' => $cachedRows]);
}
$builder = $this->db->table($table)->select($select);
if ($q !== '') {
$builder->groupStart()
->like($nameField, $q, 'both');
if ($formField !== null) {
$builder->orLike($formField, $q, 'both');
}
if ($qFirstToken !== '' && mb_strlen($qFirstToken) >= 1) {
$builder->orLike($nameField, $qFirstToken, 'after');
}
// Phonetic candidates can differ by one letter (e.g. CETLAY/CETLEY),
// so include an alpha-prefix fallback to widen SQL fetch before PHP ranking.
if ($qAlpha !== '') {
if (mb_strlen($qAlpha) >= 3) {
$builder->orLike($nameField, substr($qAlpha, 0, 3), 'after');
} elseif (mb_strlen($qAlpha) >= 2) {
$builder->orLike($nameField, substr($qAlpha, 0, 2), 'after');
}
}
// Normalize separators so variants like "utor", "u-tor", "u tor" match each other.
if ($qNormalized !== '') {
$qNormalizedSql = str_replace("'", "''", strtoupper($qNormalized));
$nameNormalizedSql = "REPLACE(REPLACE(UPPER(" . $nameField . "), '-', ''), ' ', '')";
$builder->orWhere($nameNormalizedSql . " LIKE '%" . $qNormalizedSql . "%'", null, false);
if ($formField !== null) {
$formNormalizedSql = "REPLACE(REPLACE(UPPER(" . $formField . "), '-', ''), ' ', '')";
$builder->orWhere($formNormalizedSql . " LIKE '%" . $qNormalizedSql . "%'", null, false);
}
}
$builder->groupEnd();
}
$rows = $builder
->orderBy($nameField, 'ASC')
->limit(500)
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setJSON(['rows' => []]);
}
$favoriteMap = $userId > 0 ? $this->getUserFavoriteMedicineIds($userId) : [];
$usageMap = $userId > 0 ? $this->getUserMedicineUsageMap($userId) : [];
foreach ($rows as &$row) {
$medId = (int) ($row['id'] ?? 0);
$name = trim((string) ($row['med_name'] ?? ''));
$nameUpper = strtoupper($name);
$nameLoose = trim((string) (preg_replace('/[^A-Z0-9]+/', ' ', $nameUpper) ?? $nameUpper));
$nameNormalized = trim((string) (preg_replace('/[^A-Z0-9]+/', '', $nameUpper) ?? $nameUpper));
$nameAlpha = trim((string) (preg_replace('/[^A-Z]+/', '', $nameUpper) ?? $nameUpper));
$nameSoundex = $nameAlpha !== '' ? soundex($nameAlpha) : '';
$nameMetaphone = $nameAlpha !== '' ? metaphone($nameAlpha) : '';
$tokenMatch = false;
if (! empty($qTokens)) {
$tokenMatch = true;
foreach ($qTokens as $token) {
if ($token === '' || ! str_contains($nameLoose, $token)) {
$tokenMatch = false;
break;
}
}
}
$phoneticMatch = false;
if ($qAlpha !== '' && $nameAlpha !== '') {
$phoneticMatch = ($qSoundex !== '' && $qSoundex === $nameSoundex)
|| ($qMetaphone !== '' && $qMetaphone === $nameMetaphone);
}
$nearMatch = false;
if ($qNormalized !== '' && $nameNormalized !== '') {
$dist = levenshtein($qNormalized, $nameNormalized);
$nearMatch = $dist >= 0 && $dist <= 2;
}
$matchesSearch = false;
if ($qUpper !== '') {
$matchesSearch = str_contains($nameUpper, $qUpper)
|| ($qNormalized !== '' && str_contains($nameNormalized, $qNormalized))
|| $tokenMatch
|| $phoneticMatch
|| $nearMatch;
}
if (! $matchesSearch) {
$row['_skip'] = 1;
}
$isFavorite = isset($favoriteMap[$medId]);
$useCount = (int) ($usageMap[$medId]['use_count'] ?? 0);
$lastUsedAt = (string) ($usageMap[$medId]['last_used_at'] ?? '');
$score = 0;
if ($isFavorite) {
$score += 1000;
}
if ($useCount > 0) {
$score += min(300, $useCount * 10);
}
if ($lastUsedAt !== '') {
$daysAgo = (int) floor((time() - strtotime($lastUsedAt)) / 86400);
if ($daysAgo <= 7) {
$score += 120;
} elseif ($daysAgo <= 30) {
$score += 80;
} elseif ($daysAgo <= 90) {
$score += 40;
} elseif ($daysAgo <= 180) {
$score += 20;
}
}
if ($qUpper !== '') {
if (str_starts_with($nameUpper, $qUpper)) {
$score += 200;
} elseif (str_contains($nameUpper, $qUpper)) {
$score += 100;
}
if ($qNormalized !== '' && str_starts_with($nameNormalized, $qNormalized)) {
$score += 180;
} elseif ($qNormalized !== '' && str_contains($nameNormalized, $qNormalized)) {
$score += 120;
}
if ($tokenMatch) {
$score += 140;
if ($qFirstToken !== '' && str_starts_with($nameLoose, $qFirstToken)) {
$score += 40;
}
}
if ($phoneticMatch) {
$score += 110;
}
if ($nearMatch) {
$score += 70;
}
}
$row['is_favorite'] = $isFavorite ? 1 : 0;
$row['use_count'] = $useCount;
$row['last_used_at'] = $lastUsedAt;
$row['_score'] = $score;
}
unset($row);
$rows = array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['_skip'] ?? 0) !== 1));
if ($scope === 'favorite') {
$rows = array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['is_favorite'] ?? 0) === 1));
} elseif ($scope === 'active') {
$rows = array_values(array_filter($rows, static fn(array $row): bool => ((int) ($row['is_favorite'] ?? 0) === 1) || ((int) ($row['use_count'] ?? 0) > 0)));
}
usort($rows, static function (array $a, array $b): int {
$scoreA = (int) ($a['_score'] ?? 0);
$scoreB = (int) ($b['_score'] ?? 0);
if ($scoreA !== $scoreB) {
return $scoreB <=> $scoreA;
}
$nameA = strtoupper(trim((string) ($a['med_name'] ?? '')));
$nameB = strtoupper(trim((string) ($b['med_name'] ?? '')));
return $nameA <=> $nameB;
});
$rows = array_slice($rows, 0, $limit);
foreach ($rows as &$row) {
unset($row['_score']);
unset($row['_skip']);
}
unset($row);
cache()->save($cacheKey, $rows, 120);
return $this->response->setJSON(['rows' => $rows]);
}
public function medicine_substitutes()
{
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames($table);
$idField = $this->resolveFirstField($fields, ['id']);
$nameField = $this->resolveFirstField($fields, ['item_name', 'med_name']);
$formField = $this->resolveFirstField($fields, ['formulation']);
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$companyField = $this->resolveFirstField($fields, ['company_name', 'company']);
if ($idField === null || $nameField === null || ($genericField === null && $saltField === null)) {
return $this->response->setJSON(['rows' => []]);
}
$medId = (int) $this->request->getGet('med_id');
$medName = trim((string) $this->request->getGet('med_name'));
$current = [];
if ($medId > 0) {
$current = $this->db->table($table)->where($idField, $medId)->get(1)->getRowArray() ?? [];
}
if (empty($current) && $medName !== '') {
$current = $this->db->table($table)->where('UPPER(' . $nameField . ')', strtoupper($medName))->get(1)->getRowArray() ?? [];
if (! empty($current)) {
$medId = (int) ($current[$idField] ?? 0);
}
}
if (empty($current)) {
return $this->response->setJSON(['rows' => []]);
}
$generic = $genericField !== null ? trim((string) ($current[$genericField] ?? '')) : '';
$salt = $saltField !== null ? trim((string) ($current[$saltField] ?? '')) : '';
if ($generic === '' && $salt === '') {
return $this->response->setJSON(['rows' => []]);
}
$select = $idField . ' as id,' . $nameField . ' as med_name';
if ($formField !== null) {
$select .= ',' . $formField . ' as med_type';
}
if ($genericField !== null) {
$select .= ',' . $genericField . ' as genericname';
}
if ($saltField !== null) {
$select .= ',' . $saltField . ' as salt_name';
}
if ($companyField !== null) {
$select .= ',' . $companyField . ' as company_name';
}
$builder = $this->db->table($table)->select($select);
$builder->groupStart();
if ($genericField !== null && $generic !== '') {
$builder->where($genericField, $generic);
}
if ($saltField !== null && $salt !== '') {
if ($genericField !== null && $generic !== '') {
$builder->orWhere($saltField, $salt);
} else {
$builder->where($saltField, $salt);
}
}
$builder->groupEnd();
if ($medId > 0) {
$builder->where($idField . ' !=', $medId);
}
$rows = $builder->orderBy($nameField, 'ASC')->limit(25)->get()->getResultArray();
return $this->response->setJSON(['rows' => $rows]);
}
public function medicine_favorite_toggle()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$userId = (int) ($this->getCurrentUserId() ?? 0);
$medId = (int) $this->request->getPost('med_id');
if ($userId <= 0 || $medId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid user or medicine']);
}
if (! $this->db->tableExists('opd_medicine_favorites')) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine favorites table not found. Run migration first.']);
}
$exists = $this->db->table('opd_medicine_favorites')
->where('user_id', $userId)
->where('med_id', $medId)
->get(1)
->getRowArray();
if ($exists) {
$this->db->table('opd_medicine_favorites')
->where('user_id', $userId)
->where('med_id', $medId)
->delete();
return $this->response->setJSON([
'update' => 1,
'is_favorite' => 0,
'error_text' => 'Removed from favorites',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$this->db->table('opd_medicine_favorites')->insert([
'user_id' => $userId,
'med_id' => $medId,
'created_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON([
'update' => 1,
'is_favorite' => 1,
'error_text' => 'Added to favorites',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function medicine_list($sessionId)
{
$table = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($table === null) {
return $this->response->setJSON(['rows' => []]);
}
$fields = $this->db->getFieldNames($table);
$builder = $this->db->table($table);
if (in_array('opd_pre_id', $fields, true)) {
if ((int) $sessionId <= 0) {
return $this->response->setJSON(['rows' => []]);
}
$builder->where('opd_pre_id', (int) $sessionId);
} else {
return $this->response->setJSON(['rows' => []]);
}
if (in_array('id', $fields, true)) {
$builder->orderBy('id', 'DESC');
}
$rows = $builder->get()->getResultArray();
$doseMap = $this->getDoseMasterLabelMap('opd_dose_shed', ['dose_shed_id', 'id'], ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
$whenMap = $this->getDoseMasterLabelMap('opd_dose_when', ['dose_when_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$freqMap = $this->getDoseMasterLabelMap('opd_dose_frequency', ['dose_freq_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$whereMap = $this->getDoseMasterLabelMap('opd_dose_where', ['dose_where_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
foreach ($rows as &$row) {
$row['dosage_label'] = $this->resolveDoseMasterLabel($row['dosage'] ?? '', $doseMap);
$row['dosage_when_label'] = $this->resolveDoseMasterLabel($row['dosage_when'] ?? '', $whenMap);
$row['dosage_freq_label'] = $this->resolveDoseMasterLabel($row['dosage_freq'] ?? '', $freqMap);
$row['dosage_where_label'] = $this->resolveDoseMasterLabel($row['dosage_where'] ?? '', $whereMap);
}
unset($row);
return $this->response->setJSON(['rows' => $rows]);
}
public function medicine_add()
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$medName = trim((string) $this->request->getPost('med_name'));
$medType = trim((string) $this->request->getPost('med_type'));
$medId = (int) $this->request->getPost('med_id');
if ($opdId <= 0 || $medName === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine name is required']);
}
$table = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$sessionId = $this->ensurePrescriptionSession($opdId, $sessionId);
if ($sessionId <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unable to create prescription session']);
}
$masterInfo = [
'med_id' => 0,
'is_new' => false,
'details_updated' => false,
'details_source' => '',
];
if ($medId <= 0) {
$masterInfo = $this->ensureMedicineMasterRecordFromConsult($medName, $medType);
$medId = (int) ($masterInfo['med_id'] ?? 0);
}
$fields = $this->db->getFieldNames($table);
$insert = [];
if (in_array('opd_pre_id', $fields, true)) {
$insert['opd_pre_id'] = $sessionId;
}
if (in_array('med_id', $fields, true) && $medId > 0) {
$insert['med_id'] = $medId;
}
if (in_array('med_name', $fields, true)) {
$insert['med_name'] = strtoupper($medName);
}
if (in_array('med_type', $fields, true)) {
$insert['med_type'] = $medType;
}
if (in_array('dosage', $fields, true)) {
$insert['dosage'] = trim((string) $this->request->getPost('dosage'));
}
if (in_array('dosage_when', $fields, true)) {
$insert['dosage_when'] = trim((string) $this->request->getPost('dosage_when'));
}
if (in_array('dosage_freq', $fields, true)) {
$insert['dosage_freq'] = trim((string) $this->request->getPost('dosage_freq'));
}
if (in_array('dosage_where', $fields, true)) {
$insert['dosage_where'] = trim((string) $this->request->getPost('dosage_where'));
}
if (in_array('no_of_days', $fields, true)) {
$insert['no_of_days'] = trim((string) $this->request->getPost('no_of_days'));
}
if (in_array('qty', $fields, true)) {
$insert['qty'] = trim((string) $this->request->getPost('qty'));
}
if (in_array('remark', $fields, true)) {
$insert['remark'] = strtoupper(trim((string) $this->request->getPost('remark')));
}
$this->applyDoseMasterStringFields($insert, $fields);
$user = auth()->user();
if (in_array('update_by', $fields, true)) {
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
$insert['update_by'] = $userLabel . '[' . ($user->id ?? 0) . ']:' . date('d-m-Y H:i:s');
}
if ($this->isDuplicateMedicineRow($table, $fields, $sessionId, $insert)) {
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'error_text' => 'Medicine already exists in this prescription. Duplicate skipped.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$this->db->table($table)->insert($insert);
$insertId = (int) $this->db->insertID();
$this->auditClinicalUpdate('opd_prescription_medicine', 'added', $insertId, null, $insert);
if ($medId > 0) {
$this->trackMedicineUsage($medId);
$this->syncMedicineMasterDefaults($medId, [
'dosage' => $insert['dosage'] ?? '',
'dosage_when' => $insert['dosage_when'] ?? '',
'dosage_freq' => $insert['dosage_freq'] ?? '',
'dosage_where' => $insert['dosage_where'] ?? '',
'no_of_days' => $insert['no_of_days'] ?? '',
'qty' => $insert['qty'] ?? '',
'remark' => $insert['remark'] ?? '',
]);
}
$message = 'Medicine added';
if (! empty($masterInfo['is_new'])) {
$message .= ' | New medicine saved in OPD master';
}
if (! empty($masterInfo['details_updated'])) {
$src = trim((string) ($masterInfo['details_source'] ?? 'master'));
if ($src === '') {
$src = 'master';
}
$message .= ' | Master details updated (' . $src . ')';
}
return $this->response->setJSON([
'update' => 1,
'opd_session_id' => $sessionId,
'new_master_medicine' => ! empty($masterInfo['is_new']) ? 1 : 0,
'master_details_updated' => ! empty($masterInfo['details_updated']) ? 1 : 0,
'master_details_source' => (string) ($masterInfo['details_source'] ?? ''),
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
/**
* @return array{med_id:int,is_new:bool,details_updated:bool,details_source:string}
*/
private function ensureMedicineMasterRecordFromConsult(string $medName, string $medType = ''): array
{
$medName = trim($medName);
$medType = trim($medType);
if ($medName === '') {
return ['med_id' => 0, 'is_new' => false, 'details_updated' => false, 'details_source' => ''];
}
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return ['med_id' => 0, 'is_new' => false, 'details_updated' => false, 'details_source' => ''];
}
$fields = $this->db->getFieldNames($table);
if (! in_array('id', $fields, true)) {
return ['med_id' => 0, 'is_new' => false, 'details_updated' => false, 'details_source' => ''];
}
$medId = $this->resolveMedicineMasterIdByName($medName);
$isNew = false;
if ($medId <= 0) {
$insert = [];
if (in_array('item_name', $fields, true)) {
$insert['item_name'] = strtoupper($medName);
} elseif (in_array('med_name', $fields, true)) {
$insert['med_name'] = strtoupper($medName);
}
if (in_array('formulation', $fields, true) && $medType !== '') {
$insert['formulation'] = $medType;
}
if (! empty($insert)) {
$this->db->table($table)->insert($insert);
$medId = (int) $this->db->insertID();
$isNew = $medId > 0;
}
}
if ($medId <= 0) {
return ['med_id' => 0, 'is_new' => false, 'details_updated' => false, 'details_source' => ''];
}
$detailsUpdated = false;
$detailsSource = '';
$genericField = $this->resolveFirstField($fields, ['genericname', 'generic_name']);
$saltField = $this->resolveFirstField($fields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$restrictionField = $this->resolveFirstField($fields, ['dosage_restriction', 'dose_restriction', 'restriction_note', 'restriction']);
$row = $this->db->table($table)->where('id', $medId)->get(1)->getRowArray() ?? [];
$hasDetails = false;
if ($genericField !== null && trim((string) ($row[$genericField] ?? '')) !== '') {
$hasDetails = true;
}
if ($saltField !== null && trim((string) ($row[$saltField] ?? '')) !== '') {
$hasDetails = true;
}
if ($restrictionField !== null && trim((string) ($row[$restrictionField] ?? '')) !== '') {
$hasDetails = true;
}
if (! $hasDetails && ($genericField !== null || $saltField !== null || $restrictionField !== null)) {
$details = $this->lookupMedicineDetailsFromMaster($medName, $medType);
if (($details['genericname'] ?? '') !== '' || ($details['salt_name'] ?? '') !== '' || ($details['dosage_restriction'] ?? '') !== '') {
$detailsSource = 'local-master';
}
if (($details['genericname'] ?? '') === '' && ($details['salt_name'] ?? '') === '' && ($details['dosage_restriction'] ?? '') === '') {
$fallback = $this->inferMedicineDetailsFromBrandName($medName, $medType);
if (($fallback['genericname'] ?? '') !== '' || ($fallback['salt_name'] ?? '') !== '' || ($fallback['dosage_restriction'] ?? '') !== '') {
$details = $fallback;
$detailsSource = 'brand-fallback';
}
}
$update = [];
if ($genericField !== null && trim((string) ($details['genericname'] ?? '')) !== '') {
$update[$genericField] = trim((string) ($details['genericname'] ?? ''));
}
if ($saltField !== null && trim((string) ($details['salt_name'] ?? '')) !== '') {
$update[$saltField] = trim((string) ($details['salt_name'] ?? ''));
}
if ($restrictionField !== null && trim((string) ($details['dosage_restriction'] ?? '')) !== '') {
$update[$restrictionField] = trim((string) ($details['dosage_restriction'] ?? ''));
}
if (! empty($update)) {
$this->db->table($table)->where('id', $medId)->update($update);
$detailsUpdated = true;
}
}
return [
'med_id' => $medId,
'is_new' => $isNew,
'details_updated' => $detailsUpdated,
'details_source' => $detailsSource,
];
}
private function resolveMedicineMasterIdByName(string $medName): int
{
$medName = trim($medName);
if ($medName === '') {
return 0;
}
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return 0;
}
$fields = $this->db->getFieldNames($table);
if (! in_array('id', $fields, true)) {
return 0;
}
$nameField = in_array('item_name', $fields, true) ? 'item_name' : (in_array('med_name', $fields, true) ? 'med_name' : null);
if ($nameField === null) {
return 0;
}
$row = $this->db->table($table)
->select('id')
->where('UPPER(' . $nameField . ')', strtoupper($medName))
->get(1)
->getRowArray();
return (int) ($row['id'] ?? 0);
}
private function trackMedicineUsage(int $medId): void
{
$userId = (int) ($this->getCurrentUserId() ?? 0);
if ($userId <= 0 || $medId <= 0 || ! $this->db->tableExists('opd_medicine_usage')) {
return;
}
$existing = $this->db->table('opd_medicine_usage')
->where('user_id', $userId)
->where('med_id', $medId)
->get(1)
->getRowArray();
if ($existing) {
$this->db->table('opd_medicine_usage')
->where('user_id', $userId)
->where('med_id', $medId)
->update([
'use_count' => (int) ($existing['use_count'] ?? 0) + 1,
'last_used_at' => date('Y-m-d H:i:s'),
]);
return;
}
$this->db->table('opd_medicine_usage')->insert([
'user_id' => $userId,
'med_id' => $medId,
'use_count' => 1,
'last_used_at' => date('Y-m-d H:i:s'),
]);
}
/**
* Persist last-used dosage defaults back to master for quick reuse in consult form.
*
* @param array<string, mixed> $defaults
*/
private function syncMedicineMasterDefaults(int $medId, array $defaults): void
{
if ($medId <= 0) {
return;
}
$table = $this->findExistingTable(['opd_med_master']);
if ($table === null) {
return;
}
$fields = $this->db->getFieldNames($table);
if (! in_array('id', $fields, true)) {
return;
}
$fieldMap = [
'dosage' => $this->resolveFirstField($fields, ['dosage']),
'dosage_when' => $this->resolveFirstField($fields, ['dosage_when']),
'dosage_freq' => $this->resolveFirstField($fields, ['dosage_freq']),
'dosage_where' => $this->resolveFirstField($fields, ['dosage_where']),
'no_of_days' => $this->resolveFirstField($fields, ['no_of_days']),
'qty' => $this->resolveFirstField($fields, ['qty']),
'remark' => $this->resolveFirstField($fields, ['remark']),
];
$update = [];
foreach ($fieldMap as $payloadKey => $dbField) {
if ($dbField === null) {
continue;
}
$value = trim((string) ($defaults[$payloadKey] ?? ''));
if ($value === '') {
continue;
}
$update[$dbField] = $value;
}
if (empty($update)) {
return;
}
$this->db->table($table)->where('id', $medId)->update($update);
}
/**
* @return array<int, bool>
*/
private function getUserFavoriteMedicineIds(int $userId, array $medIds = []): array
{
if ($userId <= 0 || ! $this->db->tableExists('opd_medicine_favorites')) {
return [];
}
$builder = $this->db->table('opd_medicine_favorites')
->select('med_id')
->where('user_id', $userId);
$medIds = array_values(array_unique(array_filter(array_map('intval', $medIds), static fn (int $id): bool => $id > 0)));
if (! empty($medIds)) {
$builder->whereIn('med_id', $medIds);
}
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$medId = (int) ($row['med_id'] ?? 0);
if ($medId > 0) {
$out[$medId] = true;
}
}
return $out;
}
/**
* @return array<int, array{use_count:int,last_used_at:string}>
*/
private function getUserMedicineUsageMap(int $userId, array $medIds = []): array
{
if ($userId <= 0 || ! $this->db->tableExists('opd_medicine_usage')) {
return [];
}
$builder = $this->db->table('opd_medicine_usage')
->select('med_id,use_count,last_used_at')
->where('user_id', $userId);
$medIds = array_values(array_unique(array_filter(array_map('intval', $medIds), static fn (int $id): bool => $id > 0)));
if (! empty($medIds)) {
$builder->whereIn('med_id', $medIds);
}
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$medId = (int) ($row['med_id'] ?? 0);
if ($medId <= 0) {
continue;
}
$out[$medId] = [
'use_count' => (int) ($row['use_count'] ?? 0),
'last_used_at' => (string) ($row['last_used_at'] ?? ''),
];
}
return $out;
}
public function medicine_update($id)
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
}
$id = (int) $id;
if ($id <= 0) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid medicine id']);
}
$table = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$fields = $this->db->getFieldNames($table);
if (!in_array('id', $fields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine id field missing']);
}
$current = $this->db->table($table)->where('id', $id)->get(1)->getRowArray();
if (!$current) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine row not found']);
}
$update = [];
$map = [
'med_name' => strtoupper(trim((string) $this->request->getPost('med_name'))),
'med_type' => trim((string) $this->request->getPost('med_type')),
'dosage' => trim((string) $this->request->getPost('dosage')),
'dosage_when' => trim((string) $this->request->getPost('dosage_when')),
'dosage_freq' => trim((string) $this->request->getPost('dosage_freq')),
'dosage_where' => trim((string) $this->request->getPost('dosage_where')),
'no_of_days' => trim((string) $this->request->getPost('no_of_days')),
'qty' => trim((string) $this->request->getPost('qty')),
'remark' => strtoupper(trim((string) $this->request->getPost('remark'))),
];
foreach ($map as $field => $value) {
if (in_array($field, $fields, true)) {
$update[$field] = $value;
}
}
$this->applyDoseMasterStringFields($update, $fields);
if (empty($update)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'No editable fields found']);
}
if (isset($update['med_name']) && $update['med_name'] === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine name is required']);
}
$masterInfo = [
'med_id' => 0,
'is_new' => false,
'details_updated' => false,
'details_source' => '',
];
$resolvedMedName = trim((string) ($update['med_name'] ?? $current['med_name'] ?? ''));
$resolvedMedType = trim((string) ($update['med_type'] ?? $current['med_type'] ?? ''));
$resolvedMedId = (int) ($current['med_id'] ?? 0);
if ($resolvedMedId <= 0) {
$masterInfo = $this->ensureMedicineMasterRecordFromConsult($resolvedMedName, $resolvedMedType);
$resolvedMedId = (int) ($masterInfo['med_id'] ?? 0);
}
if ($resolvedMedId > 0 && in_array('med_id', $fields, true)) {
$update['med_id'] = $resolvedMedId;
}
$candidate = array_replace($current, $update);
$sessionId = (int) ($candidate['opd_pre_id'] ?? 0);
if ($sessionId > 0 && $this->isDuplicateMedicineRow($table, $fields, $sessionId, $candidate, $id)) {
return $this->response->setJSON([
'update' => 0,
'error_text' => 'Same medicine row already exists. Duplicate update blocked.',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$user = auth()->user();
if (in_array('update_by', $fields, true)) {
$userLabel = $user ? ($user->username ?? $user->email ?? 'User') : 'User';
$update['update_by'] = $userLabel . '[' . ($user->id ?? 0) . ']:' . date('d-m-Y H:i:s');
}
$this->db->table($table)->where('id', $id)->update($update);
$this->clinicalAuditTrail->logChangedFields('opd_prescription_medicine', $id, $current, array_replace($current, $update), $this->getCurrentUserId());
$masterMedId = (int) ($update['med_id'] ?? $current['med_id'] ?? 0);
if ($masterMedId <= 0) {
$masterMedId = $this->resolveMedicineMasterIdByName((string) ($update['med_name'] ?? $current['med_name'] ?? ''));
}
if ($masterMedId > 0) {
$merged = array_replace($current, $update);
$this->trackMedicineUsage($masterMedId);
$this->syncMedicineMasterDefaults($masterMedId, [
'dosage' => $merged['dosage'] ?? '',
'dosage_when' => $merged['dosage_when'] ?? '',
'dosage_freq' => $merged['dosage_freq'] ?? '',
'dosage_where' => $merged['dosage_where'] ?? '',
'no_of_days' => $merged['no_of_days'] ?? '',
'qty' => $merged['qty'] ?? '',
'remark' => $merged['remark'] ?? '',
]);
}
$message = 'Medicine updated';
if (! empty($masterInfo['is_new'])) {
$message .= ' | New medicine saved in OPD master';
}
if (! empty($masterInfo['details_updated'])) {
$src = trim((string) ($masterInfo['details_source'] ?? 'master'));
if ($src === '') {
$src = 'master';
}
$message .= ' | Master details updated (' . $src . ')';
}
return $this->response->setJSON([
'update' => 1,
'new_master_medicine' => ! empty($masterInfo['is_new']) ? 1 : 0,
'master_details_updated' => ! empty($masterInfo['details_updated']) ? 1 : 0,
'master_details_source' => (string) ($masterInfo['details_source'] ?? ''),
'error_text' => $message,
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
private function isDuplicateMedicineRow(string $table, array $fields, int $sessionId, array $row, int $excludeId = 0): bool
{
if ($sessionId <= 0 || ! in_array('opd_pre_id', $fields, true)) {
return false;
}
$builder = $this->db->table($table)->where('opd_pre_id', $sessionId);
if ($excludeId > 0 && in_array('id', $fields, true)) {
$builder->where('id !=', $excludeId);
}
foreach (['med_id', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'qty', 'remark'] as $field) {
if (! in_array($field, $fields, true)) {
continue;
}
$value = isset($row[$field]) ? trim((string) $row[$field]) : '';
if ($value === '') {
$builder->groupStart()->where($field, '')->orWhere($field, null)->groupEnd();
} else {
$builder->where($field, $value);
}
}
$candidates = $builder->get()->getResultArray();
if (empty($candidates)) {
return false;
}
if (! in_array('med_name', $fields, true)) {
return true;
}
$needle = $this->normalizeMedicineMatchKey((string) ($row['med_name'] ?? ''));
if ($needle === '') {
foreach ($candidates as $candidate) {
$name = trim((string) ($candidate['med_name'] ?? ''));
if ($name === '') {
return true;
}
}
return false;
}
foreach ($candidates as $candidate) {
$current = $this->normalizeMedicineMatchKey((string) ($candidate['med_name'] ?? ''));
if ($current !== '' && $current === $needle) {
return true;
}
}
return false;
}
private function normalizeMedicineMatchKey(string $value): string
{
$key = strtoupper(trim($value));
$key = preg_replace('/[^A-Z0-9]+/', '', $key) ?? $key;
return trim((string) $key);
}
public function medicine_remove($id)
{
$table = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($table === null) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine table not found']);
}
$fields = $this->db->getFieldNames($table);
if (!in_array('id', $fields, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Medicine id field missing']);
}
$row = $this->db->table($table)->where('id', (int) $id)->get(1)->getRowArray() ?? [];
$this->db->table($table)->where('id', (int) $id)->delete();
$this->auditClinicalUpdate('opd_prescription_medicine', 'removed', (int) $id, $row, null);
return $this->response->setJSON(['update' => 1, 'error_text' => 'Medicine removed']);
}
public function vital_data(int $opdSessionId)
{
if (! $this->db->tableExists('opd_prescription')) {
return $this->response->setBody('');
}
$row = $this->db->table('opd_prescription')->where('id', $opdSessionId)->get(1)->getRowArray();
if (empty($row)) {
return $this->response->setBody('');
}
$lines = [];
if (!empty($row['bp'])) {
$lines[] = '<span class="text-success">BP :</span> ' . esc($row['bp']) . ' / ' . esc((string) ($row['diastolic'] ?? ''));
}
if (!empty($row['pulse'])) {
$lines[] = '<span class="text-success">Pulse :</span> ' . esc($row['pulse']);
}
if (!empty($row['temp'])) {
$lines[] = '<span class="text-success">Temp :</span> ' . esc($row['temp']);
}
if (!empty($row['spo2'])) {
$lines[] = '<span class="text-success">SpO2 :</span> ' . esc($row['spo2']) . ' %';
}
return $this->response->setBody('<p class="text-primary">' . implode('<br>', $lines) . '</p>');
}
public function get_remark(int $pId)
{
if (! $this->db->tableExists('patient_remark')) {
return $this->response->setBody('');
}
$rows = $this->db->table('patient_remark')
->select('remark,insert_by,insert_datetime')
->where('p_id', $pId)
->orderBy('id', 'DESC')
->limit(10)
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setBody('<p class="text-muted">No patient remarks found.</p>');
}
$out = '';
foreach ($rows as $row) {
$stamp = !empty($row['insert_datetime']) ? date('d-m-Y H:i', strtotime((string) $row['insert_datetime'])) : '';
$out .= '<p>' . nl2br(esc((string) ($row['remark'] ?? ''))) . '<br><small>Update By: ' . esc((string) ($row['insert_by'] ?? '')) . ' ' . esc($stamp) . '</small></p>';
}
return $this->response->setBody($out);
}
public function patient_remark(int $pId)
{
return $this->get_remark($pId);
}
public function show_profile_opd(int $pId, int $opdSessionId = 0)
{
return redirect()->to(base_url('patient/show_profile_opd/' . $pId . '/' . $opdSessionId));
}
public function show_medical_item(int $pId)
{
if (! $this->db->tableExists('invoice_med_master')) {
return $this->response->setBody('<p class="text-muted">Medical purchase table not found.</p>');
}
$rows = $this->db->table('invoice_med_master')
->where('patient_id', $pId)
->orderBy('id', 'DESC')
->limit(50)
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setBody('<p class="text-muted">No medicine purchase found.</p>');
}
$html = '<table class="table table-bordered table-sm"><thead><tr><th>Date</th><th>Invoice</th><th>Amount</th></tr></thead><tbody>';
foreach ($rows as $row) {
$html .= '<tr>'
. '<td>' . esc((string) ($row['date'] ?? $row['invoice_date'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['invoice_code'] ?? $row['invoice_no'] ?? $row['id'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['net_amount'] ?? $row['total_amount'] ?? '')) . '</td>'
. '</tr>';
}
$html .= '</tbody></table>';
return $this->response->setBody($html);
}
public function show_old_Prescribed(int $pId, int $currentOpd)
{
if (! $this->db->tableExists('opd_master') || ! $this->db->tableExists('opd_prescription')) {
return $this->response->setBody('<p class="text-muted">Old prescription data not found.</p>');
}
$rows = $this->db->table('opd_master o')
->select('o.opd_id,o.opd_code,o.apointment_date,p.id as session_id,p.complaints,p.diagnosis')
->join('opd_prescription p', 'p.opd_id=o.opd_id', 'left')
->where('o.p_id', $pId)
->where('o.opd_id !=', $currentOpd)
->orderBy('o.opd_id', 'DESC')
->limit(20)
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setBody('<p class="text-muted mb-0">No old prescriptions found.</p>');
}
$medicineTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($medicineTable === null) {
return $this->response->setBody('<p class="text-muted mb-0">Old medicine table not found.</p>');
}
$html = '<div class="old-rx-list">';
foreach ($rows as $row) {
$sessionId = (int) ($row['session_id'] ?? 0);
$dateLabel = ! empty($row['apointment_date']) ? date('d/m/Y', strtotime((string) $row['apointment_date'])) : '';
$medRows = [];
if ($sessionId > 0) {
$medRows = $this->db->table($medicineTable)
->select('id,med_id,med_type,med_name,dosage,dosage_when,dosage_freq,no_of_days,qty,remark')
->where('opd_pre_id', $sessionId)
->orderBy('id', 'ASC')
->get()
->getResultArray();
}
$html .= '<div class="card mb-2 old-rx-card">';
$html .= '<div class="card-header py-2 d-flex justify-content-between align-items-center">';
$html .= '<div>';
$html .= '<strong>OPD: ' . esc((string) ($row['opd_code'] ?? $row['opd_id'])) . '</strong>';
if ($dateLabel !== '') {
$html .= '<span class="text-muted ms-2">' . esc($dateLabel) . '</span>';
}
$html .= '</div>';
$html .= '<button type="button" class="btn btn-sm btn-outline-primary btn-old-rx-add-all">+ All</button>';
$html .= '</div>';
$html .= '<div class="card-body py-2">';
$html .= '<div class="small mb-1"><strong>Complaint:</strong> ' . esc((string) ($row['complaints'] ?? '')) . '</div>';
$html .= '<div class="small mb-2"><strong>Diagnosis:</strong> ' . esc((string) ($row['diagnosis'] ?? '')) . '</div>';
if (empty($medRows)) {
$html .= '<div class="text-muted small">No medicine row found.</div>';
} else {
$html .= '<table class="table table-sm table-bordered mb-0">';
$html .= '<thead><tr><th>Prescribed</th><th style="width:95px;">Dose</th><th style="width:58px;">+</th></tr></thead><tbody>';
foreach ($medRows as $med) {
$medType = trim((string) ($med['med_type'] ?? ''));
$medName = trim((string) ($med['med_name'] ?? ''));
$medLabel = trim($medType . ' ' . $medName);
$doseLabel = trim(
trim((string) ($med['dosage'] ?? '')) . ' '
. trim((string) ($med['dosage_when'] ?? '')) . ' '
. trim((string) ($med['dosage_freq'] ?? ''))
);
if ($doseLabel === '') {
$doseLabel = '-';
}
$html .= '<tr>';
$html .= '<td>' . esc($medLabel !== '' ? $medLabel : '-') . '</td>';
$html .= '<td class="small">' . esc($doseLabel) . '</td>';
$html .= '<td class="text-center">'
. '<button type="button" class="btn btn-sm btn-outline-success btn-old-rx-add-one"'
. ' data-med-id="' . esc((string) ($med['med_id'] ?? 0), 'attr') . '"'
. ' data-med-name="' . esc($medName, 'attr') . '"'
. ' data-med-type="' . esc($medType, 'attr') . '"'
. ' data-dosage="' . esc((string) ($med['dosage'] ?? ''), 'attr') . '"'
. ' data-dosage-when="' . esc((string) ($med['dosage_when'] ?? ''), 'attr') . '"'
. ' data-dosage-freq="' . esc((string) ($med['dosage_freq'] ?? ''), 'attr') . '"'
. ' data-no-of-days="' . esc((string) ($med['no_of_days'] ?? ''), 'attr') . '"'
. ' data-qty="' . esc((string) ($med['qty'] ?? ''), 'attr') . '"'
. ' data-remark="' . esc((string) ($med['remark'] ?? ''), 'attr') . '">Add</button>'
. '</td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
}
$html .= '</div>';
$html .= '</div>';
}
$html .= '</div>';
return $this->response->setBody($html);
}
public function save_rx_group_list(int $docId = 0)
{
if (! $this->db->tableExists('opd_prescription_template')) {
return $this->response->setBody('<p class="text-muted">RX template table not found.</p>');
}
$fields = $this->db->getFieldNames('opd_prescription_template');
$idField = $this->resolveFirstField($fields, ['id']);
$nameField = $this->resolveFirstField($fields, ['rx_group_name']);
if ($idField === null || $nameField === null) {
return $this->response->setJSON(['rows' => []]);
}
$userId = $docId > 0 ? (int) $docId : max(0, $this->getCurrentUserId());
$hasDocId = in_array('doc_id', $fields, true);
$builder = $this->db->table('opd_prescription_template')
->select('MAX(' . $idField . ') as id,' . $nameField . ' as rx_group_name,COUNT(*) as row_count')
->where($nameField . ' <>', '');
if ($hasDocId) {
$builder->groupStart()
->where('doc_id', $userId)
->orWhere('doc_id', 0)
->groupEnd();
}
$rows = $builder
->groupBy($nameField)
->orderBy($nameField, 'ASC')
->limit(500)
->get()
->getResultArray();
$medTable = $this->findExistingTable(['opd_prescrption_prescribed_template', 'opd_prescription_prescribed_template']);
$medCountMap = [];
if ($medTable !== null) {
$medFields = $this->db->getFieldNames($medTable);
if (in_array('rx_group_id', $medFields, true)) {
$medRows = $this->db->table($medTable)
->select('rx_group_id,COUNT(*) as med_count')
->groupBy('rx_group_id')
->get()
->getResultArray();
foreach ($medRows as $medRow) {
$rxId = (int) ($medRow['rx_group_id'] ?? 0);
if ($rxId > 0) {
$medCountMap[$rxId] = (int) ($medRow['med_count'] ?? 0);
}
}
}
}
foreach ($rows as &$row) {
$row['med_count'] = (int) ($medCountMap[(int) ($row['id'] ?? 0)] ?? 0);
}
unset($row);
if ($this->request->isAJAX() || strtolower((string) $this->request->getHeaderLine('Accept')) === 'application/json') {
return $this->response->setJSON(['rows' => $rows]);
}
if (empty($rows)) {
return $this->response->setBody('<p class="text-muted">No RX groups found.</p>');
}
$html = '<ul class="list-group">';
foreach ($rows as $row) {
$html .= '<li class="list-group-item d-flex justify-content-between align-items-center">'
. '<span>' . esc((string) ($row['rx_group_name'] ?? 'RX Group')) . '</span>'
. '<span class="badge bg-secondary">#' . (int) ($row['id'] ?? 0) . ' (' . (int) ($row['med_count'] ?? 0) . ')</span>'
. '</li>';
}
$html .= '</ul>';
return $this->response->setBody($html);
}
public function prescribed_dose(int $opdId, int $opdSessionId)
{
$medicineTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($medicineTable === null) {
return $this->response->setBody('<p class="text-muted">No prescribed medicine table found.</p>');
}
$rows = $this->db->table($medicineTable)
->select('id,med_type,med_name,dosage,dosage_when,dosage_freq,no_of_days,qty,remark')
->where('opd_pre_id', $opdSessionId)
->orderBy('id', 'ASC')
->get()
->getResultArray();
if (empty($rows)) {
return $this->response->setBody('<p class="text-muted">No prescribed medicine found for this OPD session.</p>');
}
$html = '<table class="table table-bordered table-sm"><thead><tr><th>Type</th><th>Medicine</th><th>Dose</th><th>When</th><th>Freq</th><th>Days</th><th>Qty</th><th>Remark</th></tr></thead><tbody>';
foreach ($rows as $row) {
$html .= '<tr>'
. '<td>' . esc((string) ($row['med_type'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['med_name'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['dosage'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['dosage_when'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['dosage_freq'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['no_of_days'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['qty'] ?? '')) . '</td>'
. '<td>' . esc((string) ($row['remark'] ?? '')) . '</td>'
. '</tr>';
}
$html .= '</tbody></table>';
return $this->response->setBody($html);
}
private function findExistingTable(array $candidates): ?string
{
foreach ($candidates as $table) {
if ($this->db->tableExists($table)) {
return $table;
}
}
return null;
}
private function getDoseMasterRows(string $table, array $idCandidates, array $labelCandidates): array
{
if (! $this->db->tableExists($table)) {
return [];
}
$fields = $this->db->getFieldNames($table);
$idField = $this->resolveFirstField($fields, $idCandidates);
if ($idField === null) {
return [];
}
$labelField = null;
$secondaryLabelField = null;
$localLabelField = null;
if ($table === 'opd_dose_shed') {
$labelField = $this->resolveFirstField($fields, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
$localLabelField = $this->resolveFirstField($fields, ['dose_show_desc', 'dose_sign_hindi', 'dose_sign_desc']);
} else {
$labelField = $this->resolveFirstField($fields, ['dose_sign', 'dose_sign_desc', 'name']);
$secondaryLabelField = $this->resolveFirstField($fields, ['dose_sign_desc', 'name']);
$localLabelField = $this->resolveFirstField($fields, ['dose_sign_hindi', 'dose_sign_desc']);
}
if ($labelField === null) {
$labelField = $this->resolveFirstField($fields, $labelCandidates);
}
if ($labelField === null) {
return [];
}
$select = [$idField . ' as id', $labelField . ' as label'];
if ($secondaryLabelField !== null && $secondaryLabelField !== $labelField) {
$select[] = $secondaryLabelField . ' as secondary_label';
}
if ($localLabelField !== null && $localLabelField !== $labelField) {
$select[] = $localLabelField . ' as local_label';
}
return $this->db->table($table)
->select(implode(',', $select))
->where($labelField . ' !=', '')
->orderBy($idField, 'ASC')
->get()
->getResultArray();
}
private function getDoseMasterLabelMap(string $table, array $idCandidates, array $labelCandidates): array
{
$rows = $this->getDoseMasterRows($table, $idCandidates, $labelCandidates);
if (empty($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$id = trim((string) ($row['id'] ?? ''));
$label = trim((string) ($row['label'] ?? ''));
if ($id === '' || $id === '0' || $label === '') {
continue;
}
$map[$id] = $label;
}
return $map;
}
private function getDoseMasterLocalLabelMap(string $table, array $idCandidates, array $labelCandidates): array
{
$rows = $this->getDoseMasterRows($table, $idCandidates, $labelCandidates);
if (empty($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$id = trim((string) ($row['id'] ?? ''));
$label = trim((string) ($row['local_label'] ?? ($row['secondary_label'] ?? '')));
if ($id === '' || $id === '0' || $label === '') {
continue;
}
$map[$id] = $label;
}
return $map;
}
private function resolveDoseMasterLabel($value, array $map): string
{
$raw = trim((string) $value);
if ($raw === '' || $raw === '0') {
return '';
}
return $map[$raw] ?? $raw;
}
private function applyDoseMasterStringFields(array &$payload, array $fields): void
{
$doseMap = $this->getDoseMasterLabelMap('opd_dose_shed', ['dose_shed_id', 'id'], ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
$whenMap = $this->getDoseMasterLabelMap('opd_dose_when', ['dose_when_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$freqMap = $this->getDoseMasterLabelMap('opd_dose_frequency', ['dose_freq_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$whereMap = $this->getDoseMasterLabelMap('opd_dose_where', ['dose_where_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$stringFields = [
'dosage_str' => $this->resolveDoseMasterLabel($payload['dosage'] ?? '', $doseMap),
'dosage_when_str' => $this->resolveDoseMasterLabel($payload['dosage_when'] ?? '', $whenMap),
'dosage_freq_str' => $this->resolveDoseMasterLabel($payload['dosage_freq'] ?? '', $freqMap),
'dosage_where_str' => $this->resolveDoseMasterLabel($payload['dosage_where'] ?? '', $whereMap),
];
foreach ($stringFields as $field => $value) {
if ($value === '' || ! in_array($field, $fields, true)) {
continue;
}
$payload[$field] = $value;
}
}
private function hydrateNabhPrescriptionFields(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 mapNabhFieldsToExistingColumns(array &$payload, array $fields, array $input): array
{
$mapping = [
'drug_allergy_status' => ['drug_allergy_status', 'allergy_status', 'drug_allergy'],
'drug_allergy_details' => ['drug_allergy_details', 'allergy_details', 'drug_allergy_note', 'allergy_note'],
'adr_history' => ['adr_history', 'adverse_drug_reaction', 'adr_details', 'adverse_reaction_history'],
'current_medications' => ['current_medications', 'current_medication', 'current_medication_history', 'ongoing_medications'],
];
$unmapped = [];
foreach ($mapping as $logical => $candidates) {
$value = trim((string) ($input[$logical] ?? ''));
$target = null;
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
$target = $candidate;
break;
}
}
if ($target !== null) {
$payload[$target] = $value;
} else {
$unmapped[$logical] = $value;
}
}
return $unmapped;
}
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 upsertNabhFieldsIntoRemarks(string $remarks, array $input): string
{
$clean = $this->stripNabhFieldsFromRemarks($remarks);
$lines = [];
if (trim((string) ($input['drug_allergy_status'] ?? '')) !== '') {
$lines[] = 'Drug Allergy Status: ' . trim((string) ($input['drug_allergy_status'] ?? ''));
}
if (trim((string) ($input['drug_allergy_details'] ?? '')) !== '') {
$lines[] = 'Drug Allergy Details: ' . trim((string) ($input['drug_allergy_details'] ?? ''));
}
if (trim((string) ($input['adr_history'] ?? '')) !== '') {
$lines[] = 'ADR History: ' . trim((string) ($input['adr_history'] ?? ''));
}
if (trim((string) ($input['current_medications'] ?? '')) !== '') {
$lines[] = 'Current Medications: ' . trim((string) ($input['current_medications'] ?? ''));
}
if (empty($lines)) {
return $clean;
}
if ($clean !== '') {
return $clean . "\n" . implode("\n", $lines);
}
return implode("\n", $lines);
}
private function stripNabhFieldsFromRemarks(string $remarks): string
{
$clean = trim($remarks);
$clean = preg_replace('/\n?\s*Drug\s*Allergy\s*Status\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Drug\s*Allergy\s*Details\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*ADR\s*History\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Current\s*Medications\s*:\s*.+$/im', '', $clean) ?? $clean;
return trim($clean);
}
/**
* @param array<string, mixed> $row
* @return array<string, string>
*/
private function hydratePatientHistoryFields(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->extractPatientHistoryFieldsFromRemarks($remarks);
foreach ($data as $key => $value) {
if ($value === '' && ! empty($parsed[$key])) {
$data[$key] = (string) $parsed[$key];
}
}
return $data;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $fields
* @param array<string, string> $input
* @return array<string, string>
*/
private function mapPatientHistoryFieldsToExistingColumns(array &$payload, array $fields, array $input): array
{
$mapping = [
'obstetric_history' => ['obstetric_history', 'obstetric_hist', 'obstetric_details'],
'menstrual_history' => ['menstrual_history', 'menstrual_hist', 'menstrual_details'],
'medical_surgical_history' => ['medical_surgical_history', 'medical_surgical_hist', 'past_medical_surgical_history', 'medical_history', 'surgical_history'],
'family_history' => ['family_history', 'family_hist'],
'allergic_history' => ['allergic_history', 'allergy_history', 'allergy_hist'],
'vaccination_history' => ['vaccination_history', 'vaccination_hist', 'immunization_history'],
];
$unmapped = [];
foreach ($mapping as $logical => $candidates) {
$value = trim((string) ($input[$logical] ?? ''));
$target = null;
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
$target = $candidate;
break;
}
}
if ($target !== null) {
$payload[$target] = $value;
} else {
$unmapped[$logical] = $value;
}
}
return $unmapped;
}
/**
* @return array<string, string>
*/
private function extractPatientHistoryFieldsFromRemarks(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 stripPatientHistoryFieldsFromRemarks(string $remarks): string
{
$clean = trim($remarks);
$clean = preg_replace('/\n?\s*OBSTETRIC\s*HISTORY\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*MENSTRUAL\s*HISTORY\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*MEDICAL\s*\/\s*SURGICAL\s*HISTORY\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*FAMILY\s*HISTORY\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*ALLERGIC\s*HISTORY\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*VACCINATION\s*:\s*.+$/im', '', $clean) ?? $clean;
return trim($clean);
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $fields
* @param array<string, string> $input
* @return array<string, string>
*/
private function mapComplaintStructuredToExistingColumns(array &$payload, array $fields, array $input): array
{
$mapping = [
'complaint_onset' => ['complaint_onset', 'symptom_onset', 'onset'],
'complaint_duration_days' => ['complaint_duration_days', 'symptom_duration_days', 'duration_days', 'complaint_duration'],
'complaint_severity' => ['complaint_severity', 'symptom_severity', 'severity'],
];
$unmapped = [];
foreach ($mapping as $logical => $candidates) {
$value = trim((string) ($input[$logical] ?? ''));
$target = null;
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
$target = $candidate;
break;
}
}
if ($target !== null) {
$payload[$target] = $value;
} else {
$unmapped[$logical] = $value;
}
}
return $unmapped;
}
/**
* @param array<string, string> $input
*/
private function upsertComplaintStructuredIntoComplaints(string $complaints, array $input): string
{
$clean = $this->stripComplaintStructuredFromComplaints($complaints);
$lines = [];
if (trim((string) ($input['complaint_onset'] ?? '')) !== '') {
$lines[] = 'Complaint Onset: ' . trim((string) ($input['complaint_onset'] ?? ''));
}
if (trim((string) ($input['complaint_duration_days'] ?? '')) !== '') {
$lines[] = 'Complaint Duration (Days): ' . trim((string) ($input['complaint_duration_days'] ?? ''));
}
if (trim((string) ($input['complaint_severity'] ?? '')) !== '') {
$lines[] = 'Complaint Severity: ' . trim((string) ($input['complaint_severity'] ?? ''));
}
if (empty($lines)) {
return $clean;
}
if ($clean !== '') {
return $clean . "\n" . implode("\n", $lines);
}
return implode("\n", $lines);
}
private function stripComplaintStructuredFromComplaints(string $complaints): string
{
$clean = trim($complaints);
$clean = preg_replace('/\n?\s*Complaint\s*Onset\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Complaint\s*Duration\s*\(\s*Days\s*\)\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Complaint\s*Severity\s*:\s*.+$/im', '', $clean) ?? $clean;
return trim($clean);
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $fields
* @param array<string, string> $input
* @return array<string, string>
*/
private function mapDiagnosisCodingToExistingColumns(array &$payload, array $fields, array $input): array
{
$mapping = [
'diagnosis_snomed_id' => ['diagnosis_snomed_id', 'diagnosis_sctid', 'diagnosis_concept_id'],
'diagnosis_snomed_term' => ['diagnosis_snomed_term', 'diagnosis_sct_term', 'diagnosis_concept_term'],
'diagnosis_snomed_source' => ['diagnosis_snomed_source', 'diagnosis_sct_source', 'diagnosis_concept_source'],
];
$unmapped = [];
foreach ($mapping as $logical => $candidates) {
$value = trim((string) ($input[$logical] ?? ''));
$target = null;
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
$target = $candidate;
break;
}
}
if ($target !== null) {
$payload[$target] = $value;
} else {
$unmapped[$logical] = $value;
}
}
return $unmapped;
}
/**
* @param array<string, string> $input
*/
private function upsertDiagnosisCodingIntoRemarks(string $remarks, array $input): string
{
$clean = $this->stripDiagnosisCodingFromRemarks($remarks);
$lines = [];
if (trim((string) ($input['diagnosis_snomed_id'] ?? '')) !== '') {
$lines[] = 'Diagnosis SNOMED ID: ' . trim((string) ($input['diagnosis_snomed_id'] ?? ''));
}
if (trim((string) ($input['diagnosis_snomed_term'] ?? '')) !== '') {
$lines[] = 'Diagnosis SNOMED Term: ' . trim((string) ($input['diagnosis_snomed_term'] ?? ''));
}
if (trim((string) ($input['diagnosis_snomed_source'] ?? '')) !== '') {
$lines[] = 'Diagnosis SNOMED Source: ' . trim((string) ($input['diagnosis_snomed_source'] ?? ''));
}
if (empty($lines)) {
return $clean;
}
if ($clean !== '') {
return $clean . "\n" . implode("\n", $lines);
}
return implode("\n", $lines);
}
private function stripDiagnosisCodingFromRemarks(string $remarks): string
{
$clean = trim($remarks);
$clean = preg_replace('/\n?\s*Diagnosis\s*SNOMED\s*ID\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Diagnosis\s*SNOMED\s*Term\s*:\s*.+$/im', '', $clean) ?? $clean;
$clean = preg_replace('/\n?\s*Diagnosis\s*SNOMED\s*Source\s*:\s*.+$/im', '', $clean) ?? $clean;
return trim($clean);
}
/**
* @param array<string, string> $input
*/
private function upsertPatientHistoryFieldsIntoRemarks(string $remarks, array $input): string
{
$clean = $this->stripPatientHistoryFieldsFromRemarks($remarks);
$labels = [
'obstetric_history' => 'OBSTETRIC HISTORY',
'menstrual_history' => 'MENSTRUAL HISTORY',
'medical_surgical_history' => 'MEDICAL / SURGICAL HISTORY',
'family_history' => 'FAMILY HISTORY',
'allergic_history' => 'ALLERGIC HISTORY',
'vaccination_history' => 'VACCINATION',
];
$lines = [];
foreach ($labels as $key => $label) {
$value = trim((string) ($input[$key] ?? ''));
if ($value !== '') {
$lines[] = $label . ': ' . $value;
}
}
if (empty($lines)) {
return $clean;
}
if ($clean !== '') {
return $clean . "\n" . implode("\n", $lines);
}
return implode("\n", $lines);
}
/**
* @param array<string, string> $input
*/
private function hasAnyPatientHistoryField(array $input): bool
{
foreach ($input as $value) {
if (trim((string) $value) !== '') {
return true;
}
}
return false;
}
private function ensureClinicalTemplateTable(): bool
{
if ($this->db->tableExists('opd_clinical_templates')) {
return true;
}
try {
$sql = "CREATE TABLE IF NOT EXISTS opd_clinical_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
doc_id INT NOT NULL DEFAULT 0,
section_key VARCHAR(80) NOT NULL,
template_name VARCHAR(255) NOT NULL,
template_text TEXT NOT NULL,
is_active TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL,
INDEX idx_doc_section (doc_id, section_key),
INDEX idx_section_active (section_key, is_active)
)";
$this->db->query($sql);
} catch (\Throwable $e) {
return false;
}
return $this->db->tableExists('opd_clinical_templates');
}
private function ensureClinicalTemplateUsageTable(): bool
{
if ($this->db->tableExists('opd_clinical_template_usage')) {
return true;
}
try {
$sql = "CREATE TABLE IF NOT EXISTS opd_clinical_template_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
doc_id INT NOT NULL,
template_id INT NOT NULL,
section_key VARCHAR(80) NOT NULL,
use_count INT NOT NULL DEFAULT 0,
last_used_at DATETIME NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uniq_doc_template_section (doc_id, template_id, section_key),
INDEX idx_template (template_id),
INDEX idx_section_use (section_key, use_count)
)";
$this->db->query($sql);
} catch (\Throwable $e) {
return false;
}
return $this->db->tableExists('opd_clinical_template_usage');
}
private function ensureAutotypeKeywordTable(): bool
{
if ($this->db->tableExists('opd_autotype_keyword_master')) {
return true;
}
try {
$sql = "CREATE TABLE IF NOT EXISTS opd_autotype_keyword_master (
id INT AUTO_INCREMENT PRIMARY KEY,
doc_id INT NOT NULL DEFAULT 0,
section_key VARCHAR(80) NOT NULL,
keyword VARCHAR(255) NOT NULL,
is_active TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uniq_doc_section_keyword (doc_id, section_key, keyword(120)),
INDEX idx_section_keyword (section_key, keyword(120)),
INDEX idx_doc_active (doc_id, is_active)
)";
$this->db->query($sql);
} catch (\Throwable $e) {
// Fallback for stricter MySQL/InnoDB index length limits.
try {
$fallbackSql = "CREATE TABLE IF NOT EXISTS opd_autotype_keyword_master (
id INT AUTO_INCREMENT PRIMARY KEY,
doc_id INT NOT NULL DEFAULT 0,
section_key VARCHAR(60) NOT NULL,
keyword VARCHAR(180) NOT NULL,
is_active TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uniq_doc_section_keyword (doc_id, section_key, keyword),
INDEX idx_section_keyword (section_key, keyword),
INDEX idx_doc_active (doc_id, is_active)
)";
$this->db->query($fallbackSql);
} catch (\Throwable $e2) {
return false;
}
}
return $this->db->tableExists('opd_autotype_keyword_master');
}
private function isAutotypeKeywordSectionAllowed(string $section): bool
{
return in_array($section, ['complaints', 'provisional_diagnosis', 'finding_examinations'], true);
}
/**
* @return array<int, string>
*/
private function fetchAutotypeKeywords(string $section, string $q, int $docId, int $limit = 20): array
{
if ($q === '' || ! $this->isAutotypeKeywordSectionAllowed($section) || ! $this->ensureAutotypeKeywordTable()) {
return [];
}
$builder = $this->db->table('opd_autotype_keyword_master')
->select('keyword,doc_id')
->where('is_active', 1)
->where('section_key', $section)
->like('keyword', $q)
->orderBy('doc_id', 'DESC')
->orderBy('updated_at', 'DESC')
->limit(max(1, $limit));
if ($docId > 0) {
$builder->groupStart()
->where('doc_id', 0)
->orWhere('doc_id', $docId)
->groupEnd();
} else {
$builder->where('doc_id', 0);
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return [];
}
$out = [];
$seen = [];
foreach ($rows as $row) {
$keyword = trim((string) ($row['keyword'] ?? ''));
if ($keyword === '') {
continue;
}
$k = strtoupper($keyword);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $keyword;
}
return $out;
}
private function canManageMasterKeyword(): bool
{
$user = auth()->user();
if (! $user || ! method_exists($user, 'can')) {
return false;
}
return $user->can('doctor_work.template_workspace.access') || $user->can('doctor_work.access');
}
private function isTemplateSectionAllowed(string $section): bool
{
return in_array($section, ['finding_examinations', 'diagnosis', 'provisional_diagnosis', 'prescriber_remarks', 'advice'], true);
}
/**
* @param array<int, string> $candidateColumns
* @return array<int, string>
*/
private function collectRecentPrescriptionEntries(int $patientId, int $currentOpdId, array $candidateColumns, int $limit = 8): array
{
if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription')) {
return [];
}
$fields = $this->db->getFieldNames('opd_prescription') ?? [];
$column = $this->resolveFirstField($fields, $candidateColumns);
if ($column === null) {
return [];
}
$rows = $this->db->table('opd_prescription')
->select($column . ' as section_text')
->where('p_id', $patientId)
->where('opd_id !=', $currentOpdId)
->where($column . ' IS NOT NULL', null, false)
->where($column . ' !=', '')
->orderBy('id', 'DESC')
->limit(40)
->get()
->getResultArray();
$out = [];
$seen = [];
foreach ($rows as $row) {
$sectionText = trim((string) ($row['section_text'] ?? ''));
if ($sectionText === '') {
continue;
}
$parts = $this->splitRecentEntryText($sectionText);
foreach ($parts as $part) {
$normalized = $this->normalizeRecentEntryText($part);
if ($normalized === '') {
continue;
}
$key = strtolower($normalized);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $normalized;
if (count($out) >= $limit) {
return $out;
}
}
}
return $out;
}
/**
* @return array<int, string>
*/
private function collectRecentInvestigationEntries(int $patientId, int $currentOpdId, int $limit = 8): array
{
$out = $this->collectRecentPrescriptionEntries($patientId, $currentOpdId, ['investigation'], $limit);
$seen = [];
foreach ($out as $item) {
$seen[strtolower($item)] = true;
}
if (! $this->db->tableExists('opd_prescription_investigation') || ! $this->db->tableExists('opd_master')) {
return array_slice($out, 0, $limit);
}
$fields = $this->db->getFieldNames('opd_prescription_investigation') ?? [];
$nameField = $this->resolveFirstField($fields, ['investigation_name', 'name', 'test_name']);
$opdField = $this->resolveFirstField($fields, ['opd_id', 'opdid']);
if ($nameField === null || $opdField === null) {
return array_slice($out, 0, $limit);
}
$builder = $this->db->table('opd_prescription_investigation i')
->select('i.`' . $nameField . '` as investigation_name')
->join('opd_master o', 'o.opd_id = i.`' . $opdField . '`', 'inner')
->where('o.p_id', $patientId)
->where('i.`' . $opdField . '` !=', $currentOpdId)
->where('i.`' . $nameField . '` IS NOT NULL', null, false)
->where('i.`' . $nameField . '` !=', '')
->limit(60);
$idField = $this->resolveFirstField($fields, ['id', 'investigation_id']);
if ($idField !== null) {
$builder->orderBy('i.`' . $idField . '`', 'DESC');
} else {
$builder->orderBy('i.`' . $opdField . '`', 'DESC');
}
$rows = $builder->get()->getResultArray();
foreach ($rows as $row) {
$name = $this->normalizeRecentEntryText((string) ($row['investigation_name'] ?? ''));
if ($name === '') {
continue;
}
$key = strtolower($name);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $name;
if (count($out) >= $limit) {
break;
}
}
return array_slice($out, 0, $limit);
}
/**
* @return array<int, string>
*/
private function splitRecentEntryText(string $text): array
{
$normalized = trim(str_replace(["\r\n", "\r"], "\n", $text));
if ($normalized === '') {
return [];
}
$parts = preg_split('/[\n;]+/', $normalized) ?: [];
if (count($parts) <= 1 && substr_count($normalized, ',') > 0 && strlen($normalized) <= 220) {
$parts = preg_split('/,/', $normalized) ?: [];
}
if (empty($parts)) {
return [$normalized];
}
return $parts;
}
private function normalizeRecentEntryText(string $value, int $maxLen = 110): string
{
$text = trim($value);
if ($text === '') {
return '';
}
$text = preg_replace('/^[\-\*\x{2022}\s]+/u', '', $text) ?? $text;
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$text = trim($text);
if ($text === '') {
return '';
}
if (strlen($text) > $maxLen) {
$text = rtrim(substr($text, 0, $maxLen - 3)) . '...';
}
return $text;
}
private function resolveSectionColumn(string $section): ?string
{
$map = [
'finding_examinations' => 'Finding_Examinations',
'diagnosis' => 'diagnosis',
'provisional_diagnosis' => 'Provisional_diagnosis',
'prescriber_remarks' => 'Prescriber_Remarks',
'advice' => 'advice',
];
return $map[$section] ?? null;
}
private function ensurePrescriptionSession(int $opdId, int $sessionId = 0): int
{
if (!$this->db->tableExists('opd_prescription')) {
return 0;
}
$fields = $this->db->getFieldNames('opd_prescription');
if ($sessionId > 0) {
$row = $this->db->table('opd_prescription')->where('id', $sessionId)->where('opd_id', $opdId)->get(1)->getRow();
if ($row) {
return $sessionId;
}
}
$last = $this->db->table('opd_prescription')->where('opd_id', $opdId)->orderBy('id', 'DESC')->get(1)->getRow();
if ($last && !empty($last->id)) {
return (int) $last->id;
}
$opdRow = $this->db->table('opd_master')->where('opd_id', $opdId)->get(1)->getRow();
if (!$opdRow) {
return 0;
}
$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
$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;
}
$this->db->table('opd_prescription')->insert($insert);
return (int) $this->db->insertID();
}
public function create_opd_queue(int $opdId)
{
if (! $this->canAccessPrescription()) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
if ($opdId <= 0) {
return $this->response->setBody('Invalid OPD ID');
}
$sessionId = $this->ensurePrescriptionSession($opdId, 0);
if ($sessionId <= 0) {
return $this->response->setBody('Unable to create queue');
}
return $this->response->setBody('Queue Created');
}
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;
}
/**
* @return array<string, int>
*/
private function getPatientAddictionFlags(int $patientId): array
{
$flags = [
'is_smoking' => 0,
'is_alcohol' => 0,
'is_drug_abuse' => 0,
];
if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
return $flags;
}
$patientFields = $this->db->getFieldNames('patient_master');
$selectFields = [];
foreach (array_keys($flags) as $field) {
if (in_array($field, $patientFields, true)) {
$selectFields[] = $field;
}
}
if (empty($selectFields)) {
return $flags;
}
$row = $this->db->table('patient_master')
->select(implode(',', $selectFields))
->where('id', $patientId)
->get(1)
->getRowArray() ?? [];
foreach ($flags as $field => $_) {
if (array_key_exists($field, $row)) {
$flags[$field] = ((int) $row[$field]) > 0 ? 1 : 0;
}
}
return $flags;
}
/**
* @return array<int, array{id:string|int,name:string,selected:int}>
*/
private function getCoMorbiditiesWithSelection(int $patientId): array
{
if (! $this->db->tableExists('morbidities_master')) {
return $this->getDefaultCoMorbidityRows($patientId);
}
$masterFields = $this->db->getFieldNames('morbidities_master');
$idField = $this->resolveFirstField($masterFields, ['mor_id', 'id']);
$nameField = $this->resolveFirstField($masterFields, ['morbidities', 'name', 'title']);
if ($idField === null || $nameField === null) {
return [];
}
$builder = $this->db->table('morbidities_master')
->select($idField . ' as mor_id,' . $nameField . ' as morbidities')
->orderBy($nameField, 'ASC');
if (in_array('active', $masterFields, true)) {
$builder->where('active', 1);
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return $this->getDefaultCoMorbidityRows($patientId);
}
$selectedMap = [];
if ($patientId > 0 && $this->db->tableExists('patient_morbidities')) {
$patientMorFields = $this->db->getFieldNames('patient_morbidities');
$pIdField = $this->resolveFirstField($patientMorFields, ['p_id', 'patient_id']);
$morIdField = $this->resolveFirstField($patientMorFields, ['morbidities', 'mor_id', 'morbidity_id']);
if ($pIdField !== null && $morIdField !== null) {
$selectedRows = $this->db->table('patient_morbidities')
->select($morIdField . ' as mor_id')
->where($pIdField, $patientId)
->get()
->getResultArray();
foreach ($selectedRows as $selectedRow) {
$selectedMap[(int) ($selectedRow['mor_id'] ?? 0)] = true;
}
}
}
$result = [];
foreach ($rows as $row) {
$morId = (int) ($row['mor_id'] ?? 0);
if ($morId <= 0) {
continue;
}
$result[] = [
'id' => $morId,
'name' => (string) ($row['morbidities'] ?? ''),
'selected' => ! empty($selectedMap[$morId]) ? 1 : 0,
];
}
return $result;
}
/**
* @return array<int, array{id:string|int,name:string,selected:int}>
*/
private function getDefaultCoMorbidityRows(int $patientId = 0): array
{
$historical = $this->getHistoricalCoMorbiditySelection($patientId);
return [
['id' => 'DM', 'name' => 'Diabetes mellitus (DM)', 'selected' => !empty($historical['DM']) ? 1 : 0],
['id' => 'HTN', 'name' => 'high blood pressure (HTn)', 'selected' => !empty($historical['HTN']) ? 1 : 0],
['id' => 'CAD', 'name' => 'Coronary artery disease (CAD)', 'selected' => !empty($historical['CAD']) ? 1 : 0],
['id' => 'COPD', 'name' => 'Chronic Obstructive Pulmonary Disease', 'selected' => !empty($historical['COPD']) ? 1 : 0],
['id' => 'CVA', 'name' => 'cerebral vascular accident (CVA)', 'selected' => !empty($historical['CVA']) ? 1 : 0],
];
}
/**
* @return array<string, bool>
*/
private function getHistoricalCoMorbiditySelection(int $patientId): array
{
if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription')) {
return [];
}
$fields = $this->db->getFieldNames('opd_prescription');
if (! in_array('Prescriber_Remarks', $fields, true)) {
return [];
}
$rows = $this->db->table('opd_prescription')
->select('Prescriber_Remarks')
->where('p_id', $patientId)
->orderBy('id', 'DESC')
->limit(20)
->get()
->getResultArray();
$selected = [];
foreach ($rows as $row) {
$remarks = trim((string) ($row['Prescriber_Remarks'] ?? ''));
if ($remarks === '') {
continue;
}
if (preg_match('/Co-Morbidities\s*:\s*(.+)$/im', $remarks, $match) !== 1) {
continue;
}
$list = (string) ($match[1] ?? '');
$tokens = preg_split('/[,;|]+/', $list) ?: [];
foreach ($tokens as $token) {
$canonical = $this->mapCoMorbidityTokenToCanonical((string) $token);
if ($canonical !== null) {
$selected[$canonical] = true;
}
}
}
return $selected;
}
private function hasHistoricalCoMorbiditySelection(int $patientId): bool
{
return !empty($this->getHistoricalCoMorbiditySelection($patientId));
}
private function mapCoMorbidityTokenToCanonical(string $token): ?string
{
$normalized = strtolower((string) preg_replace('/[^a-z0-9]+/', '', trim($token)));
if ($normalized === '') {
return null;
}
$map = [
'dm' => 'DM',
'diabetes' => 'DM',
'diabetesmellitus' => 'DM',
'htn' => 'HTN',
'highbloodpressure' => 'HTN',
'hypertension' => 'HTN',
'cad' => 'CAD',
'coronaryarterydisease' => 'CAD',
'copd' => 'COPD',
'chronicobstructivepulmonarydisease' => 'COPD',
'cva' => 'CVA',
'cerebralvascularaccident' => 'CVA',
'stroke' => 'CVA',
];
return $map[$normalized] ?? null;
}
/**
* @param array<string, int> $flags
*/
private function syncPatientAddictionFlags(int $patientId, array $flags): void
{
if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
return;
}
$patientFields = $this->db->getFieldNames('patient_master');
$update = [];
foreach ($flags as $field => $value) {
if (in_array($field, $patientFields, true)) {
$update[$field] = ((int) $value) > 0 ? 1 : 0;
}
}
if (! empty($update)) {
$this->db->table('patient_master')->where('id', $patientId)->update($update);
}
}
/**
* @param array<int, int> $selectedMorbidityIds
*/
private function syncPatientMorbidities(int $patientId, array $selectedMorbidityIds): void
{
if ($patientId <= 0 || ! $this->db->tableExists('patient_morbidities')) {
return;
}
$patientMorFields = $this->db->getFieldNames('patient_morbidities');
$pIdField = $this->resolveFirstField($patientMorFields, ['p_id', 'patient_id']);
$morIdField = $this->resolveFirstField($patientMorFields, ['morbidities', 'mor_id', 'morbidity_id']);
if ($pIdField === null || $morIdField === null) {
return;
}
$selectedMap = [];
foreach ($selectedMorbidityIds as $morId) {
$morId = (int) $morId;
if ($morId > 0) {
$selectedMap[$morId] = true;
}
}
$existingRows = $this->db->table('patient_morbidities')
->select('id,' . $morIdField . ' as mor_id')
->where($pIdField, $patientId)
->get()
->getResultArray();
$existingMap = [];
foreach ($existingRows as $existingRow) {
$morId = (int) ($existingRow['mor_id'] ?? 0);
if ($morId > 0) {
$existingMap[$morId] = (int) ($existingRow['id'] ?? 0);
}
}
if (! empty($existingMap)) {
foreach ($existingMap as $morId => $rowId) {
if (empty($selectedMap[$morId])) {
$this->db->table('patient_morbidities')->where('id', $rowId)->delete();
}
}
}
$insertFields = $patientMorFields;
foreach (array_keys($selectedMap) as $morId) {
if (! isset($existingMap[$morId])) {
$insert = [
$pIdField => $patientId,
$morIdField => $morId,
];
if (in_array('update_date', $insertFields, true)) {
$insert['update_date'] = date('Y-m-d H:i:s');
}
if (in_array('insert_date', $insertFields, true)) {
$insert['insert_date'] = date('Y-m-d H:i:s');
}
if (in_array('create_date', $insertFields, true)) {
$insert['create_date'] = date('Y-m-d H:i:s');
}
$this->db->table('patient_morbidities')->insert($insert);
}
}
}
/**
* @return array<int, int>
*/
private function parseNumericList(string $list): array
{
if ($list === '') {
return [];
}
$tokens = preg_split('/[^0-9]+/', $list) ?: [];
$result = [];
foreach ($tokens as $token) {
$id = (int) trim((string) $token);
if ($id > 0) {
$result[$id] = $id;
}
}
return array_values($result);
}
private function buildLocalClinicalAssist(array $input): array
{
$text = mb_strtolower(trim((string) (($input['complaints'] ?? '') . ' ' . ($input['finding_examinations'] ?? ''))));
$normalizedText = preg_replace('/\s+/', ' ', $text) ?: $text;
$pulse = $this->parseNumericFromText((string) ($input['pulse'] ?? ''));
$spo2 = $this->parseNumericFromText((string) ($input['spo2'] ?? ''));
$temp = $this->parseNumericFromText((string) ($input['temp'] ?? ''));
$rr = $this->parseNumericFromText((string) ($input['rr_min'] ?? ''));
$bpSys = $this->parseNumericFromText((string) ($input['bp'] ?? ''));
$bpDia = $this->parseNumericFromText((string) ($input['diastolic'] ?? ''));
$vitalChecks = [];
if ($temp !== null && $temp >= 100.0) {
$vitalChecks[] = 'Fever range temperature noted (' . rtrim(rtrim(number_format($temp, 1, '.', ''), '0'), '.') . ').';
}
if ($pulse !== null && $pulse > 100) {
$vitalChecks[] = 'Pulse is elevated (>100/min).';
}
if ($spo2 !== null && $spo2 < 94) {
$vitalChecks[] = 'SpO2 is below 94%; respiratory risk check advised.';
}
if ($rr !== null && $rr > 22) {
$vitalChecks[] = 'Respiratory rate is high (>22/min).';
}
if ($bpSys !== null && $bpDia !== null && ($bpSys >= 140 || $bpDia >= 90)) {
$vitalChecks[] = 'Blood pressure is elevated (' . (int) round($bpSys) . '/' . (int) round($bpDia) . ').';
}
$diagnosis = [];
$investigations = [];
$advice = [];
$redFlags = [];
$has = static function (string $haystack, array $needles): bool {
foreach ($needles as $needle) {
if ($needle !== '' && str_contains($haystack, $needle)) {
return true;
}
}
return false;
};
$hasFever = $has($normalizedText, ['fever', 'pyrexia', 'bukhar']);
$hasCough = $has($normalizedText, ['cough', 'khansi']);
$hasSoreThroat = $has($normalizedText, ['sore throat', 'throat pain', 'gale', 'pharyngitis']);
$hasCold = $has($normalizedText, ['cold', 'coryza', 'running nose', 'dripping nose', 'rhinorrhoea']);
$hasBreathless = $has($normalizedText, ['breathlessness', 'breathing difficulty', 'dyspnea', 'shortness of breath']);
$hasChestPain = $has($normalizedText, ['chest pain', 'pain chest']);
$hasAcidity = $has($normalizedText, ['acidity', 'burning epigastrium', 'heartburn', 'epigastric']);
$hasLooseMotions = $has($normalizedText, ['diarrhoea', 'diarrhea', 'loose motion', 'motions']);
$hasVomiting = $has($normalizedText, ['vomiting', 'emesis']);
$hasDysuria = $has($normalizedText, ['burning micturition', 'dysuria', 'urinary frequency', 'urine burning']);
$hasHeadache = $has($normalizedText, ['headache', 'migraine']);
if ($hasFever && ($hasCough || $hasSoreThroat || $hasCold)) {
$diagnosis[] = 'Acute upper respiratory infection (viral etiology likely).';
$investigations[] = 'CBC';
if ($hasFever) {
$investigations[] = 'CRP (if persistent fever)';
}
$advice[] = 'Hydration, steam inhalation, and symptomatic care.';
}
if ($hasFever && !$hasCough && !$hasSoreThroat && !$hasLooseMotions) {
$diagnosis[] = 'Acute febrile illness (source to be localized clinically).';
$investigations[] = 'CBC';
$investigations[] = 'Urine routine (if clinically indicated)';
if ((int) ($input['dengue'] ?? 0) === 1 || ($temp !== null && $temp >= 101.0)) {
$investigations[] = 'Dengue NS1 / IgM (as per day of fever)';
$investigations[] = 'Platelet count trend';
}
}
if ($hasAcidity) {
$diagnosis[] = 'Acid peptic disease / gastritis.';
$advice[] = 'Avoid spicy/oily meals, late-night meals, and prolonged fasting.';
}
if ($hasLooseMotions || $hasVomiting) {
$diagnosis[] = 'Acute gastroenteritis.';
$investigations[] = 'Serum electrolytes (if dehydration suspected)';
$advice[] = 'ORS and oral hydration with warning signs explained.';
}
if ($hasDysuria) {
$diagnosis[] = 'Probable urinary tract infection.';
$investigations[] = 'Urine routine & microscopy';
$investigations[] = 'Urine culture (if recurrent/complicated)';
}
if ($hasHeadache && $bpSys !== null && $bpDia !== null && ($bpSys >= 140 || $bpDia >= 90)) {
$diagnosis[] = 'Headache with elevated blood pressure; evaluate hypertension status.';
$investigations[] = 'RFT and urine albumin (hypertension workup baseline)';
}
if ($hasChestPain || $hasBreathless) {
$redFlags[] = 'Chest pain/breathlessness present: rule out acute cardiopulmonary emergency.';
$investigations[] = 'ECG';
if ($spo2 !== null && $spo2 < 94) {
$investigations[] = 'Chest X-ray / ABG as per severity';
}
}
if ($spo2 !== null && $spo2 < 92) {
$redFlags[] = 'SpO2 <92%: urgent escalation and oxygen assessment advised.';
}
if ($temp !== null && $temp >= 103.0) {
$redFlags[] = 'High-grade fever (>=103): monitor for systemic complications.';
}
if ((int) ($input['pregnancy'] ?? 0) === 1) {
$redFlags[] = 'Pregnancy noted: verify medicine safety before final prescription.';
}
if (empty($diagnosis)) {
$diagnosis[] = 'Syndromic provisional diagnosis to be finalized clinically.';
}
if (empty($advice)) {
$advice[] = 'Review warning signs and follow-up if symptoms persist/worsen.';
}
$diagnosis = array_values(array_unique($diagnosis));
$investigations = array_values(array_unique($investigations));
$advice = array_values(array_unique($advice));
$redFlags = array_values(array_unique($redFlags));
$vitalChecks = array_values(array_unique($vitalChecks));
return [
'disclaimer' => 'Local rule-based clinical assist only. Final diagnosis and treatment decision must be by treating doctor.',
'vital_checks' => $vitalChecks,
'red_flags' => $redFlags,
'suggested_diagnosis' => implode("\n", array_map(static fn(string $v): string => '- ' . $v, $diagnosis)),
'suggested_investigation' => implode("\n", array_map(static fn(string $v): string => '- ' . $v, $investigations)),
'suggested_advice' => implode("\n", array_map(static fn(string $v): string => '- ' . $v, $advice)),
];
}
private function parseNumericFromText(string $value): ?float
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/-?\d+(?:\.\d+)?/', $value, $m) !== 1) {
return null;
}
return (float) $m[0];
}
private function resolveFirstField(array $fields, array $candidates): ?string
{
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
return $candidate;
}
}
return null;
}
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 '';
}
private function upsertWomenRelatedProblemsIntoRemarks(string $remarks, string $womenText): string
{
$remarks = preg_replace('/\n?\s*Women Related Problems\s*:\s*.+$/im', '', $remarks) ?? $remarks;
$remarks = preg_replace('/\n?\s*LMP(?:\s*Days\s*Before)?\s*:\s*.+$/im', '', $remarks) ?? $remarks;
$remarks = preg_replace('/\n?\s*Last Baby\s*:\s*.+$/im', '', $remarks) ?? $remarks;
$remarks = preg_replace('/\n?\s*Pregnancy Related\s*:\s*.+$/im', '', $remarks) ?? $remarks;
$remarks = trim($remarks);
$labelled = trim($womenText);
if ($remarks === '') {
return $labelled;
}
return $remarks . "\n" . $labelled;
}
private function upsertCoMorbiditiesIntoRemarks(string $remarks, string $coMorbiditiesText): string
{
$remarks = preg_replace('/\n?\s*Co-Morbidities\s*:\s*.+$/im', '', $remarks) ?? $remarks;
$remarks = trim($remarks);
$coMorbiditiesText = trim($coMorbiditiesText);
if ($coMorbiditiesText === '') {
return $remarks;
}
$line = 'Co-Morbidities: ' . $coMorbiditiesText;
if ($remarks === '') {
return $line;
}
return $remarks . "\n" . $line;
}
/**
* @param array<string, string> $womenStructured
*/
private function buildWomenCombinedText(string $womenProblems, array $womenStructured): string
{
$lines = [];
$womenProblems = trim($womenProblems);
if ($womenProblems !== '') {
$lines[] = 'Women Related Problems: ' . $womenProblems;
}
$lmp = trim((string) ($womenStructured['women_lmp'] ?? ''));
if ($lmp !== '') {
$lines[] = 'LMP Days Before: ' . $lmp;
}
$lastBaby = trim((string) ($womenStructured['women_last_baby'] ?? ''));
if ($lastBaby !== '') {
$lines[] = 'Last Baby: ' . $lastBaby;
}
$pregnancyRelated = trim((string) ($womenStructured['women_pregnancy_related'] ?? ''));
if ($pregnancyRelated !== '') {
$lines[] = 'Pregnancy Related: ' . $pregnancyRelated;
}
return trim(implode("\n", $lines));
}
/**
* @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,
];
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $fields
* @param array<string, string> $womenStructured
*/
private function mapWomenStructuredToExistingColumns(array &$payload, array $fields, array $womenStructured): void
{
$columnMap = [
'women_lmp' => ['women_lmp', 'lmp', 'lmp_date'],
'women_last_baby' => ['women_last_baby', 'last_baby', 'last_baby_details'],
'women_pregnancy_related' => ['women_pregnancy_related', 'pregnancy_related', 'pregnancy_related_problem'],
];
foreach ($columnMap as $key => $candidates) {
$resolved = $this->resolveFirstField($fields, $candidates);
if ($resolved !== null) {
$payload[$resolved] = trim((string) ($womenStructured[$key] ?? ''));
}
}
}
/**
* @param array<string, mixed> $patientRow
* @param array<string, mixed> $opdRow
*/
private function storePrescriptionFhirBundle(int $opdId, int $sessionId, array $patientRow, array $opdRow): bool
{
if (! $this->db->tableExists('opd_fhir_documents')) {
return false;
}
$abhaField = $this->resolvePatientAbhaField();
$abhaAddress = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
$patient = [
'id' => (string) ($patientRow['id'] ?? ($opdRow['p_id'] ?? 0)),
'name' => (string) ($patientRow['p_fname'] ?? $opdRow['P_name'] ?? ''),
'gender' => $this->normalizeGender((string) ($patientRow['gender'] ?? '')),
'birthDate' => !empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '',
'abhaAddress' => $abhaAddress,
];
$encounter = [
'id' => (string) ($opdRow['opd_id'] ?? $opdId),
'status' => 'finished',
'period_start' => Time::now('Asia/Kolkata')->toDateTimeString(),
'period_end' => Time::now('Asia/Kolkata')->toDateTimeString(),
];
$medications = $this->getPrescriptionMedicines($sessionId);
$conditions = $this->getPrescriptionConditions($sessionId);
$clinicalContext = $this->getPrescriptionClinicalContext($sessionId, $opdRow);
$bundle = $this->fhirR4Builder->buildPrescriptionBundle($patient, $encounter, $medications, $conditions, $clinicalContext);
$bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$user = auth()->user();
$generatedBy = (string) ($user->id ?? 'system');
$inserted = (bool) $this->db->table('opd_fhir_documents')->insert([
'opd_id' => $opdId,
'opd_session_id' => $sessionId,
'bundle_type' => 'MedicationRequestBundle',
'bundle_json' => $bundleJson,
'generated_by' => $generatedBy,
'generated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
]);
if (! $inserted) {
return false;
}
try {
$documentId = (int) $this->db->insertID();
$bridgeSync = new BridgeSyncService();
$bridgeSync->enqueue(
'opd.fhir.generated',
[
'opd_fhir_document_id' => $documentId,
'opd_id' => $opdId,
'opd_session_id' => $sessionId,
'bundle_type' => 'MedicationRequestBundle',
'generated_by' => $generatedBy,
'generated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
'bundle_json' => $bundle,
],
'opd_fhir_document',
(string) $documentId
);
} catch (\Throwable $e) {
}
return true;
}
/**
* @return array<int, array<string, mixed>>
*/
private function getPrescriptionMedicines(int $sessionId): array
{
$table = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($table === null) {
return [];
}
$rows = $this->db->table($table)->where('opd_pre_id', $sessionId)->get()->getResultArray();
$masterTable = $this->findExistingTable(['opd_med_master']);
$masterById = [];
if ($masterTable !== null) {
$masterFields = $this->db->getFieldNames($masterTable) ?? [];
$masterNameField = $this->resolveFirstField($masterFields, ['item_name', 'med_name']);
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
$masterTypeField = $this->resolveFirstField($masterFields, ['formulation']);
$masterSnomedField = $this->resolveFirstField($masterFields, ['snomed_code', 'medicine_snomed_code', 'snomed_ct_code', 'sctid']);
$masterAtcField = $this->resolveFirstField($masterFields, ['atc_code', 'who_atc_code']);
$medIds = [];
foreach ($rows as $row) {
$mid = (int) ($row['med_id'] ?? 0);
if ($mid > 0) {
$medIds[$mid] = $mid;
}
}
if (! empty($medIds)) {
$select = ['id'];
if ($masterNameField !== null) {
$select[] = $masterNameField . ' as item_name';
}
if ($masterGenericField !== null) {
$select[] = $masterGenericField . ' as genericname';
}
if ($masterTypeField !== null) {
$select[] = $masterTypeField . ' as formulation';
}
if ($masterSnomedField !== null) {
$select[] = $masterSnomedField . ' as snomed_code';
}
if ($masterAtcField !== null) {
$select[] = $masterAtcField . ' as atc_code';
}
$masterRows = $this->db->table($masterTable)
->select(implode(',', $select))
->whereIn('id', array_values($medIds))
->get()
->getResultArray();
foreach ($masterRows as $mrow) {
$mid = (int) ($mrow['id'] ?? 0);
if ($mid > 0) {
$masterById[$mid] = $mrow;
}
}
}
}
$medications = [];
foreach ($rows as $row) {
$dosageParts = array_filter([
trim((string) ($row['dosage'] ?? '')),
trim((string) ($row['dosage_when'] ?? '')),
trim((string) ($row['dosage_freq'] ?? '')),
trim((string) ($row['no_of_days'] ?? '')) !== '' ? ('for ' . trim((string) $row['no_of_days']) . ' days') : '',
]);
$mid = (int) ($row['med_id'] ?? 0);
$master = $mid > 0 ? ($masterById[$mid] ?? []) : [];
$drugName = trim((string) ($row['med_name'] ?? ''));
if ($drugName === '') {
$drugName = trim((string) ($master['item_name'] ?? ''));
}
$genericName = trim((string) ($master['genericname'] ?? ''));
$medType = trim((string) ($row['med_type'] ?? ''));
if ($medType === '') {
$medType = trim((string) ($master['formulation'] ?? ''));
}
$medications[] = [
'drug_name' => $drugName,
'status' => 'active',
'dosage' => implode(' | ', $dosageParts),
'route_text' => trim((string) ($row['dosage_where'] ?? '')),
'frequency_text' => trim((string) ($row['dosage_freq'] ?? '')),
'med_id' => $mid,
'generic_name' => $genericName,
'med_type' => $medType,
'snomed_code' => trim((string) ($master['snomed_code'] ?? '')),
'atc_code' => strtoupper(trim((string) ($master['atc_code'] ?? ''))),
];
}
return $medications;
}
/**
* @return array<int, array<string, mixed>>
*/
private function getPrescriptionConditions(int $sessionId): array
{
if (! $this->db->tableExists('opd_prescription') || $sessionId <= 0) {
return [];
}
$fields = $this->db->getFieldNames('opd_prescription') ?? [];
$selectFields = ['id'];
foreach (['diagnosis', 'diagnosis_snomed_id', 'diagnosis_snomed_term', 'Provisional_diagnosis', 'provisional_diagnosis_snomed_id', 'provisional_diagnosis_snomed_term'] as $field) {
if (in_array($field, $fields, true)) {
$selectFields[] = $field;
}
}
$row = $this->db->table('opd_prescription')
->select(implode(',', $selectFields))
->where('id', $sessionId)
->get(1)
->getRowArray();
if (! is_array($row) || empty($row)) {
return [];
}
$diagnosis = trim((string) ($row['diagnosis'] ?? ''));
$diagnosisSnomedId = trim((string) ($row['diagnosis_snomed_id'] ?? ''));
$diagnosisSnomedTerm = trim((string) ($row['diagnosis_snomed_term'] ?? ''));
$provisional = trim((string) ($row['Provisional_diagnosis'] ?? ''));
$snomedId = trim((string) ($row['provisional_diagnosis_snomed_id'] ?? ''));
$snomedTerm = trim((string) ($row['provisional_diagnosis_snomed_term'] ?? ''));
if ($diagnosisSnomedId === '' && $diagnosis !== '' && strpos($diagnosis, ',') === false) {
$resolvedDiagnosis = $this->resolveProvisionalDiagnosisSnomedMatch($diagnosis);
if (! empty($resolvedDiagnosis['concept_id'])) {
$diagnosisSnomedId = (string) ($resolvedDiagnosis['concept_id'] ?? '');
$diagnosisSnomedTerm = (string) ($resolvedDiagnosis['term'] ?? $diagnosis);
}
}
if ($snomedId === '' && $provisional !== '' && strpos($provisional, ',') === false) {
$resolved = $this->resolveProvisionalDiagnosisSnomedMatch($provisional);
if (! empty($resolved['concept_id'])) {
$snomedId = (string) ($resolved['concept_id'] ?? '');
$snomedTerm = (string) ($resolved['term'] ?? $provisional);
}
}
$out = [];
$seen = [];
if ($diagnosis !== '') {
$k = strtoupper($diagnosis) . '|' . $diagnosisSnomedId;
$seen[$k] = true;
$out[] = [
'text' => $diagnosis,
'verification_status' => 'confirmed',
'snomed_code' => $diagnosisSnomedId,
'snomed_display' => $diagnosisSnomedTerm !== '' ? $diagnosisSnomedTerm : $diagnosis,
];
}
if ($provisional !== '') {
$k = strtoupper($provisional) . '|' . $snomedId;
if (! isset($seen[$k])) {
$seen[$k] = true;
$out[] = [
'text' => $provisional,
'verification_status' => 'provisional',
'snomed_code' => $snomedId,
'snomed_display' => $snomedTerm !== '' ? $snomedTerm : $provisional,
];
}
}
return $out;
}
/**
* @param array<string, mixed> $opdRow
* @return array<string, mixed>
*/
private function getPrescriptionClinicalContext(int $sessionId, array $opdRow): array
{
if ($sessionId <= 0 || ! $this->db->tableExists('opd_prescription')) {
return [];
}
$row = $this->db->table('opd_prescription')->where('id', $sessionId)->get(1)->getRowArray() ?? [];
if (empty($row)) {
return [];
}
$remarks = (string) ($row['Prescriber_Remarks'] ?? '');
$nabh = $this->hydrateNabhPrescriptionFields($row, $remarks);
$complaintsText = trim((string) ($row['complaints'] ?? ''));
$complaints = [];
if ($complaintsText !== '') {
$parts = preg_split('/[,;\n\r]+/', $complaintsText) ?: [];
$seen = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '' || preg_match('/^Complaint\s*(Onset|Duration|Severity)\s*:/i', $part) === 1) {
continue;
}
$k = strtoupper($part);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$complaint = [
'text' => $part,
];
$resolvedComplaint = $this->resolveProvisionalDiagnosisSnomedMatch($part);
if (! empty($resolvedComplaint['code'])) {
$complaint['snomed_code'] = (string) $resolvedComplaint['code'];
$complaint['snomed_display'] = (string) ($resolvedComplaint['display'] ?? $part);
}
$complaints[] = $complaint;
}
}
$observations = [];
$observationMap = [
'pulse' => ['loinc' => '8867-4', 'display' => 'Heart rate', 'unit' => '/min', 'ucum' => '/min'],
'spo2' => ['loinc' => '59408-5', 'display' => 'Oxygen saturation in Arterial blood by Pulse oximetry', 'unit' => '%', 'ucum' => '%'],
'bp' => ['loinc' => '8480-6', 'display' => 'Systolic blood pressure', 'unit' => 'mmHg', 'ucum' => 'mm[Hg]'],
'diastolic' => ['loinc' => '8462-4', 'display' => 'Diastolic blood pressure', 'unit' => 'mmHg', 'ucum' => 'mm[Hg]'],
'temp' => ['loinc' => '8310-5', 'display' => 'Body temperature', 'unit' => 'Cel', 'ucum' => 'Cel'],
'rr_min' => ['loinc' => '9279-1', 'display' => 'Respiratory rate', 'unit' => '/min', 'ucum' => '/min'],
'height' => ['loinc' => '8302-2', 'display' => 'Body height', 'unit' => 'cm', 'ucum' => 'cm'],
'weight' => ['loinc' => '29463-7', 'display' => 'Body weight', 'unit' => 'kg', 'ucum' => 'kg'],
];
foreach ($observationMap as $field => $meta) {
$value = $this->parseNumericFromText((string) ($row[$field] ?? ''));
if ($value === null) {
continue;
}
$observations[] = [
'loinc' => (string) ($meta['loinc'] ?? ''),
'display' => (string) ($meta['display'] ?? ''),
'value' => $value,
'unit' => (string) ($meta['unit'] ?? ''),
'ucum' => (string) ($meta['ucum'] ?? ''),
];
}
$allergies = [];
$allergyStatus = strtolower(trim((string) ($nabh['drug_allergy_status'] ?? '')));
$allergyDetails = trim((string) ($nabh['drug_allergy_details'] ?? ''));
$adrHistory = trim((string) ($nabh['adr_history'] ?? ''));
if ($allergyStatus === 'known' && $allergyDetails !== '') {
$allergies[] = [
'code_text' => $allergyDetails,
'reaction_text' => $adrHistory,
'clinical_status' => 'active',
'verification_status' => 'confirmed',
];
}
$serviceRequests = [];
$investigationText = trim((string) ($row['investigation'] ?? ''));
if ($investigationText !== '') {
$tests = preg_split('/[,;\n\r]+/', $investigationText) ?: [];
$seenTests = [];
foreach ($tests as $test) {
$test = trim((string) $test);
if ($test === '') {
continue;
}
$k = strtoupper($test);
if (isset($seenTests[$k])) {
continue;
}
$seenTests[$k] = true;
$serviceRequests[] = [
'code_text' => $test,
'status' => 'active',
'intent' => 'order',
];
}
}
$appointments = [];
$nextVisit = trim((string) ($row['next_visit'] ?? ''));
$referTo = trim((string) ($row['refer_to'] ?? ''));
if ($nextVisit !== '' || $referTo !== '') {
$descParts = [];
if ($nextVisit !== '') {
$descParts[] = 'Follow-up: ' . $nextVisit;
}
if ($referTo !== '') {
$descParts[] = 'Refer to: ' . $referTo;
}
$appointments[] = [
'status' => 'proposed',
'description' => implode(' | ', $descParts),
];
}
$hospitalProfile = $this->getHospitalProfileForFhir();
$practitionerName = trim((string) ($opdRow['doc_name'] ?? ''));
$practitionerId = trim((string) ($opdRow['doc_id'] ?? ''));
$doctorIdentity = $this->getDoctorAbdmIdentity((int) ($opdRow['doc_id'] ?? 0));
return [
'complaints' => $complaints,
'observations' => $observations,
'allergies' => $allergies,
'service_requests' => $serviceRequests,
'appointments' => $appointments,
'practitioner' => [
'id' => $practitionerId !== '' ? $practitionerId : 'doctor-unknown',
'name' => $practitionerName,
'registration_number' => trim((string) ($doctorIdentity['registration_number'] ?? ($hospitalProfile['doctor_registration'] ?? ''))),
],
'organization' => [
'id' => trim((string) ($hospitalProfile['hfr_id'] ?? '')) !== '' ? preg_replace('/[^A-Za-z0-9\-]/', '-', (string) ($hospitalProfile['hfr_id'] ?? '')) : 'facility-unknown',
'name' => (string) ($hospitalProfile['name'] ?? ''),
'hfr_id' => (string) ($hospitalProfile['hfr_id'] ?? ''),
],
];
}
/**
* @return array{name:string,hfr_id:string,doctor_registration:string}
*/
private function getHospitalProfileForFhir(): array
{
$defaults = [
'name' => '',
'hfr_id' => '',
'doctor_registration' => '',
];
if (! $this->db->tableExists('hospital_setting')) {
return $defaults;
}
$rows = $this->db->table('hospital_setting')
->select('s_name,s_value')
->whereIn('s_name', ['H_name', 'H_HFR_ID', 'HPR_ID', 'DOCTOR_REG_NO'])
->get()
->getResultArray();
$map = [];
foreach ($rows as $row) {
$key = trim((string) ($row['s_name'] ?? ''));
if ($key === '') {
continue;
}
$map[$key] = trim((string) ($row['s_value'] ?? ''));
}
return [
'name' => (string) ($map['H_name'] ?? ''),
'hfr_id' => (string) ($map['H_HFR_ID'] ?? ''),
'doctor_registration' => (string) (($map['HPR_ID'] ?? $map['DOCTOR_REG_NO'] ?? '')),
];
}
/**
* @return array{hpr_id:string,registration_number:string}
*/
private function getDoctorAbdmIdentity(int $docId): array
{
$out = [
'hpr_id' => '',
'registration_number' => '',
];
if ($docId <= 0 || ! $this->db->tableExists('doctor_master')) {
return $out;
}
$fields = $this->db->getFieldNames('doctor_master') ?? [];
$select = ['id'];
foreach (['hpr_id', 'hpr_no', 'hpr_number', 'doctor_reg_no', 'registration_no', 'reg_no', 'doc_reg_no'] as $field) {
if (in_array($field, $fields, true)) {
$select[] = $field;
}
}
$row = $this->db->table('doctor_master')
->select(implode(',', $select))
->where('id', $docId)
->get(1)
->getRowArray() ?? [];
if (empty($row)) {
return $out;
}
foreach (['hpr_id', 'hpr_no', 'hpr_number'] as $field) {
if (! empty($row[$field])) {
$out['hpr_id'] = strtoupper(trim((string) $row[$field]));
break;
}
}
foreach (['doctor_reg_no', 'registration_no', 'reg_no', 'doc_reg_no'] as $field) {
if (! empty($row[$field])) {
$out['registration_number'] = trim((string) $row[$field]);
break;
}
}
if ($out['registration_number'] === '' && $out['hpr_id'] !== '') {
$out['registration_number'] = $out['hpr_id'];
}
return $out;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $patientRow
* @param array<string, mixed> $opdRow
* @return array<int, string>
*/
private function validateAbdmReadinessForConsult(array $payload, array $patientRow, array $opdRow): array
{
$errors = [];
$abhaField = $this->resolvePatientAbhaField();
$abha = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
if ($abha === '') {
$errors[] = 'ABDM requires ABHA Address before saving consult.';
}
$diagnosisCode = trim((string) ($payload['diagnosis_snomed_id'] ?? ''));
$provisionalCode = trim((string) ($payload['provisional_diagnosis_snomed_id'] ?? ''));
if ($diagnosisCode === '' && $provisionalCode === '') {
$errors[] = 'ABDM requires at least one SNOMED-coded diagnosis (Diagnosis or Provisional Diagnosis).';
}
$complaints = trim((string) ($payload['complaints'] ?? ''));
if ($complaints === '') {
$errors[] = 'ABDM OP consult requires chief complaints.';
}
$hasAnyVital = false;
foreach (['bp', 'diastolic', 'pulse', 'temp', 'spo2', 'rr_min', 'height', 'weight'] as $vitalField) {
if ($this->parseNumericFromText((string) ($payload[$vitalField] ?? '')) !== null) {
$hasAnyVital = true;
break;
}
}
if (! $hasAnyVital) {
$errors[] = 'ABDM OP consult requires at least one numeric vital observation.';
}
$hospitalProfile = $this->getHospitalProfileForFhir();
if (trim((string) ($hospitalProfile['hfr_id'] ?? '')) === '') {
$errors[] = 'Hospital HFR ID is required for ABDM document structure.';
}
$doctorIdentity = $this->getDoctorAbdmIdentity((int) ($opdRow['doc_id'] ?? 0));
$doctorReg = trim((string) ($doctorIdentity['registration_number'] ?? ''));
$fallbackDoctorReg = trim((string) ($hospitalProfile['doctor_registration'] ?? ''));
if ($doctorReg === '' && $fallbackDoctorReg === '') {
$errors[] = 'Doctor HPR/registration ID is required for ABDM practitioner mapping.';
}
return $errors;
}
private function normalizeGender(string $value): string
{
$value = trim($value);
if ($value === '1' || strtolower($value) === 'male') {
return 'male';
}
if ($value === '2' || strtolower($value) === 'female') {
return 'female';
}
return 'unknown';
}
/**
* @return string|int|null
*/
private function getCurrentUserId()
{
$user = auth()->user();
if ($user === null) {
return null;
}
return $user->id ?? null;
}
/**
* @return array<int, string>
*/
private function getComplaintMatchesFromText(string $text): array
{
$pieces = preg_split('/[,;\n\r]+/', strtolower($text)) ?: [];
$out = [];
$directMap = [
'kamar' => 'PAIN BACK',
'back pain' => 'PAIN BACK',
'pet dard' => 'PAIN IN ABDOMEN',
'body pain' => 'BODYACHE',
'kamzori' => 'WEAKNESS',
'weakness' => 'WEAKNESS',
'bukhar' => 'FEVER',
'khansi' => 'COUGH DRY',
'chakkar' => 'GIDDINESS',
'ulti' => 'VOMITING',
'thakan' => 'TIREDNESS',
];
foreach ($pieces as $piece) {
$token = trim((string) $piece);
if ($token === '') {
continue;
}
$matchedName = null;
foreach ($directMap as $key => $value) {
if (str_contains($token, $key)) {
$matchedName = $value;
break;
}
}
if ($matchedName !== null) {
$out[strtoupper($matchedName)] = $matchedName;
continue;
}
if (! $this->db->tableExists('complaints_master')) {
$out[strtoupper($token)] = strtoupper($token);
continue;
}
$row = $this->db->table('complaints_master')
->select('Name')
->where('is_active', 1)
->groupStart()
->like('Name', $token)
->orLike('name_hinglish', $token)
->orLike('keywords', $token)
->groupEnd()
->orderBy('show_in_short', 'DESC')
->orderBy('Code', 'ASC')
->get(1)
->getRowArray();
if (! empty($row['Name'])) {
$name = trim((string) $row['Name']);
$out[strtoupper($name)] = $name;
}
}
return array_values($out);
}
/**
* @param array<int, string> $settingCandidates
*/
private function readAiSettingValue(array $settingCandidates): string
{
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 '';
}
/**
* @param array<int, string> $complaints
*/
private function generateComplaintDraftWithAi(array $complaints, string $currentText): ?string
{
if (empty($complaints)) {
return null;
}
$prompt = "You are a clinical documentation assistant for Indian OPD.
Rewrite chief complaints in concise professional style.
Input complaints: " . implode(', ', $complaints) . "
Additional raw doctor notes: " . $currentText . "
Output only one plain text sentence starting with 'Chief complaints:' and no markdown.";
return $this->generateClinicalTextWithAi($prompt, 180);
}
private function buildClinicalPrompt(string $mode, string $text): string
{
if ($mode === 'hinglish_to_english') {
return "You are a clinical OPD assistant. Convert Hinglish medical text to clear professional English.
Keep all clinical meaning unchanged. Do not add new diagnosis.
Return plain text only, no markdown.
Input: " . $text;
}
if ($mode === 'local_to_medical') {
return "You are a medical documentation specialist for Indian OPD. Convert the following patient complaints written in Hindi, Hinglish, or a mix of local language and English into precise clinical English with correct standard medical terminology.
Rules:
- Replace colloquial terms with proper medical terms (e.g., bukhar → pyrexia/fever, khansi → cough, saans phoolna → dyspnoea, sir dard → cephalalgia/headache, body dard → myalgia, thakaan → fatigue, chakkar → vertigo/giddiness, ulti → vomiting/emesis, jalan → burning sensation, thandi lagana → chills/rigors, kamzori → weakness/asthenia).
- Preserve all durations, severity, and context exactly as mentioned.
- Output concise, professional clinical English suitable for OPD documentation.
- Return plain text only, no bullet points, no markdown.
Input: " . $text;
}
if ($mode === 'local_to_patient_voice') {
return "You are a translator for Indian hospital OPD. The following text is a patient describing their symptoms in Hindi, Hinglish, or mixed language.
Translate it into natural, first-person English exactly as the patient would say it — conversational, not clinical.
Rules:
- Use first-person (\"I feel\", \"I have\", \"I am\", \"I've had\").
- Preserve the exact meaning, durations, and sequence.
- Do NOT use medical jargon or clinical terms — keep it as natural spoken English.
- Example: \"Teen din se bukhar hai\" → \"I've had a fever for three days.\"
- Example: \"Thand lag rahi hai aur jukham hai\" → \"I'm feeling cold and I have a cold.\"
- Return plain text only, no bullet points, no markdown.
Input: " . $text;
}
if ($mode === 'diagnosis') {
return "You are a clinical OPD assistant. Rewrite diagnosis text into concise professional medical English.
Preserve meaning, do not invent findings. Return plain text only.
Input: " . $text;
}
if ($mode === 'finding') {
return "You are a clinical OPD assistant. Rewrite findings/examination notes into concise clinical English.
Preserve original meaning and sequence. Return plain text only.
Input: " . $text;
}
return "You are a clinical OPD assistant. Rewrite this OPD note into concise, professional English suitable for prescription documentation.
Preserve meaning, do not add new facts. Return plain text only.
Input: " . $text;
}
private function buildClinicalAutotypeLocalFallback(string $mode, string $text): string
{
$source = trim((string) $text);
if ($source === '') {
return '';
}
if ($mode === 'hinglish_to_english') {
$dictionary = [
'bukhar' => 'fever',
'khansi' => 'cough',
'pet dard' => 'abdominal pain',
'sir dard' => 'headache',
'ulti' => 'vomiting',
'kamzori' => 'weakness',
'saans' => 'breath',
'saans phoolna' => 'breathlessness',
'jukaam' => 'cold',
'chakkar' => 'giddiness',
'dast' => 'loose motions',
'jalan' => 'burning sensation',
];
$converted = mb_strtolower($source);
foreach ($dictionary as $from => $to) {
$converted = preg_replace('/\b' . preg_quote($from, '/') . '\b/u', $to, $converted) ?? $converted;
}
return $this->normalizeClinicalSentenceFlow($converted);
}
if ($mode === 'local_to_medical') {
$dictionary = [
'bukhar' => 'fever',
'khansi' => 'cough',
'pet dard' => 'abdominal pain',
'sir dard' => 'headache',
'sar dard' => 'headache',
'ulti' => 'vomiting',
'kamzori' => 'weakness',
'saans phoolna' => 'dyspnoea',
'saans' => 'breathing',
'jukaam' => 'rhinitis',
'chakkar' => 'vertigo',
'dast' => 'loose motions',
'jalan' => 'burning sensation',
'thakaan' => 'fatigue',
'body dard' => 'myalgia',
'badan dard' => 'myalgia',
'thandi lagna' => 'chills',
'thandi lagana' => 'chills',
'kaanp' => 'rigors',
'neend' => 'sleep',
'bhookh' => 'appetite',
'paani' => 'water intake',
'peshab' => 'urination',
'seena dard' => 'chest pain',
'ghabrahat' => 'palpitations',
];
$converted = mb_strtolower($source);
foreach ($dictionary as $from => $to) {
$converted = preg_replace('/\b' . preg_quote($from, '/') . '\b/u', $to, $converted) ?? $converted;
}
return $this->normalizeClinicalSentenceFlow($converted);
}
if ($mode === 'local_to_patient_voice') {
// Basic word-swap into first-person English; AI path is strongly preferred for this mode
$dictionary = [
'teen din se' => 'for three days I have had',
'do din se' => 'for two days I have had',
'ek din se' => 'since yesterday I have had',
'subah se' => 'since morning I have had',
'raat se' => 'since last night I have had',
'bukhar hai' => 'a fever',
'bukhar' => 'fever',
'khansi hai' => 'a cough',
'khansi' => 'a cough',
'jukham hai' => 'a cold',
'jukham' => 'a cold',
'thand lag rahi hai' => 'I am feeling cold',
'thandi lag rahi hai' => 'I am feeling cold',
'thand lag raha hai' => 'I am feeling cold',
'chakkar aa raha hai' => 'I am feeling dizzy',
'chakkar' => 'dizziness',
'ulti ho rahi hai' => 'I am vomiting',
'ulti' => 'vomiting',
'pet dard hai' => 'I have abdominal pain',
'pet dard' => 'abdominal pain',
'sir dard hai' => 'I have a headache',
'sir dard' => 'a headache',
'saans phool rahi hai' => 'I am having difficulty breathing',
'saans' => 'breathing',
'kamzori' => 'weakness',
'thakaan' => 'tiredness',
'dast' => 'loose motions',
'jalan' => 'a burning sensation',
'aur' => 'and',
'hai' => '',
'hain' => '',
];
$converted = mb_strtolower($source);
foreach ($dictionary as $from => $to) {
$converted = str_replace($from, $to, $converted);
}
$converted = preg_replace('/\s+/', ' ', trim($converted)) ?? $converted;
$converted = ucfirst($converted);
return $converted;
}
return $this->normalizeClinicalSentenceFlow($source);
}
/**
* @return array<string, mixed>|null
*/
private function parseJsonFromAiResponse(string $raw): ?array
{
$text = trim($raw);
if ($text === '') {
return null;
}
$decoded = json_decode($text, true);
if (is_array($decoded)) {
return $decoded;
}
if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $match) === 1) {
$decoded = json_decode((string) ($match[0] ?? ''), true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
private function normalizeClinicalSentenceFlow(string $text): string
{
$normalized = trim($text);
if ($normalized === '') {
return '';
}
$normalized = preg_replace('/\s+/', ' ', $normalized) ?? $normalized;
$normalized = str_replace([' ,', ' .', ' ;', ' :'], [',', '.', ';', ':'], $normalized);
$normalized = str_replace(["\r\n", "\r"], "\n", $normalized);
$chunks = preg_split('/(?<=[\.!?;])\s+|\n+/', $normalized) ?: [];
$sentences = [];
foreach ($chunks as $chunk) {
$chunk = trim((string) $chunk);
if ($chunk === '') {
continue;
}
$chunk = ucfirst($chunk);
if (! preg_match('/[\.!?]$/', $chunk)) {
$chunk .= '.';
}
$sentences[] = $chunk;
}
if (empty($sentences)) {
return ucfirst($normalized);
}
return implode(' ', $sentences);
}
private function generateClinicalTextWithAi(string $prompt, int $maxOutputTokens = 300): ?string
{
$prompt = trim($prompt);
if ($prompt === '') {
return null;
}
$apiKey = trim($this->readAiSettingValue([
'GEMINI_API_KEY',
'DIAGNOSIS_AI_GEMINI_API_KEY',
'DIAGNOSIS_GEMINI_API_KEY',
]));
if ($apiKey === '') {
$apiKey = trim((string) getenv('GEMINI_API_KEY'));
}
if ($apiKey === '') {
return null;
}
$modelCandidatesRaw = trim($this->readAiSettingValue([
'DIAGNOSIS_AI_GEMINI_MODELS',
'DIAGNOSIS_AI_GEMINI_MODEL',
'GEMINI_MODEL_CANDIDATES',
'GEMINI_MODEL',
]));
$models = [];
if ($modelCandidatesRaw !== '') {
$parts = preg_split('/[,\s]+/', $modelCandidatesRaw) ?: [];
foreach ($parts as $part) {
$model = trim((string) $part);
if ($model !== '') {
$models[] = $model;
}
}
}
if (empty($models)) {
$models = ['gemini-2.0-flash', 'gemini-1.5-flash'];
}
$maxOutputTokens = max(64, min(2048, (int) $maxOutputTokens));
foreach ($models as $model) {
try {
$url = 'https://generativelanguage.googleapis.com/v1beta/models/'
. rawurlencode($model)
. ':generateContent?key='
. rawurlencode($apiKey);
$payload = [
'contents' => [[
'parts' => [[
'text' => $prompt,
]],
]],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => $maxOutputTokens,
],
];
$options = [
'timeout' => 30,
'http_errors' => false,
];
if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) {
$options['verify'] = false;
}
$client = service('curlrequest', $options);
$response = $client->post($url, [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'body' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$code = (int) $response->getStatusCode();
if ($code < 200 || $code >= 300) {
continue;
}
$decoded = json_decode((string) $response->getBody(), true);
if (!is_array($decoded)) {
continue;
}
$out = '';
$candidates = $decoded['candidates'] ?? [];
if (is_array($candidates)) {
foreach ($candidates as $candidate) {
$parts = $candidate['content']['parts'] ?? [];
if (!is_array($parts)) {
continue;
}
foreach ($parts as $part) {
$piece = trim((string) ($part['text'] ?? ''));
if ($piece !== '') {
$out .= ($out === '' ? '' : "\n") . $piece;
}
}
}
}
$out = trim($out);
if ($out !== '') {
$this->lastAiProvider = 'gemini';
return $out;
}
} catch (\Throwable $e) {
continue;
}
}
return null;
}
private function resolveAiServerBaseUrl(): string
{
$base = rtrim($this->readAiSettingValue(['DIAGNOSIS_AI_SERVER_URL']), '/');
if ($base !== '') {
return $base;
}
$candidates = [
trim($this->readAiSettingValue(['DIAGNOSIS_AI_PARSE_ENDPOINT'])),
trim($this->readAiSettingValue(['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 '';
}
/**
* @return array<int, string>
*/
private function resolveAiServerBaseCandidates(): array
{
$bases = [];
$direct = trim($this->resolveAiServerBaseUrl());
if ($direct !== '') {
$bases[] = rtrim($direct, '/');
}
$derivedFromEndpoint = [
trim($this->readAiSettingValue(['DIAGNOSIS_AI_PARSE_ENDPOINT'])),
trim($this->readAiSettingValue(['DIAGNOSIS_AI_OCR_ENDPOINT'])),
];
foreach ($derivedFromEndpoint as $endpointUrl) {
if ($endpointUrl === '') {
continue;
}
$parts = @parse_url($endpointUrl);
if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
continue;
}
$origin = (string) $parts['scheme'] . '://' . (string) $parts['host'];
if (! empty($parts['port'])) {
$origin .= ':' . (int) $parts['port'];
}
$origin = rtrim($origin, '/');
$bases[] = $origin;
$path = trim((string) ($parts['path'] ?? ''));
if ($path !== '' && $path !== '/') {
$dirPath = trim((string) dirname($path));
if ($dirPath !== '' && $dirPath !== '.') {
$dirPath = '/' . trim($dirPath, '/');
$bases[] = $origin . $dirPath;
$segments = array_values(array_filter(explode('/', trim($dirPath, '/')), static fn($x) => $x !== ''));
if (count($segments) > 1) {
$running = '';
foreach ($segments as $index => $segment) {
if ($index >= count($segments) - 1) {
break;
}
$running .= '/' . $segment;
$bases[] = $origin . $running;
}
}
}
}
}
$bases[] = 'http://139.59.13.39:8000';
return array_values(array_unique(array_filter(array_map(static fn($v) => rtrim((string) $v, '/'), $bases))));
}
private function translateAdviceWithAiServer(string $sourceText, string $targetLang): string
{
$base = $this->resolveAiServerBaseUrl();
if ($base === '') {
return '';
}
$token = trim($this->readAiSettingValue(['DIAGNOSIS_AI_PARSE_TOKEN']));
try {
$options = [
'timeout' => 20,
'http_errors' => false,
];
if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) {
$options['verify'] = false;
}
$headers = ['Accept' => 'application/json'];
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$client = service('curlrequest', $options);
$url = $base . '/translate?text=' . rawurlencode($sourceText) . '&target_lang=' . rawurlencode($targetLang);
$response = $client->get($url, ['headers' => $headers]);
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
return '';
}
$decoded = json_decode((string) $response->getBody(), true);
$translated = trim((string) ($decoded['translated_text'] ?? ''));
if ($translated === '') {
return '';
}
$this->lastAiProvider = 'ai-server';
return $translated;
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string, mixed> $input
*/
private function buildFullClinicalDraftPrompt(array $input): string
{
$payload = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
$payload = '{}';
}
return "You are a senior OPD clinical documentation assistant.
Using the complete OPD snapshot JSON, produce concise professional drafts.
Do not invent facts. Use only the data provided.
Return ONLY valid minified JSON object with exactly these keys:
finding_examinations, diagnosis, provisional_diagnosis, prescriber_remarks, investigation, advice.
Each value must be plain text string (no markdown).
OPD SNAPSHOT JSON: " . $payload;
}
/**
* @return array<string, string>|null
*/
private function extractFullDraftJson(?string $aiText): ?array
{
if ($aiText === null || trim($aiText) === '') {
return null;
}
$candidate = trim($aiText);
if (!str_starts_with($candidate, '{') || !str_ends_with($candidate, '}')) {
if (preg_match('/\{.*\}/s', $candidate, $match) === 1) {
$candidate = (string) ($match[0] ?? '');
}
}
$decoded = json_decode($candidate, true);
if (!is_array($decoded)) {
return null;
}
$keys = ['finding_examinations', 'diagnosis', 'provisional_diagnosis', 'prescriber_remarks', 'investigation', 'advice'];
$result = [];
foreach ($keys as $key) {
$result[$key] = trim((string) ($decoded[$key] ?? ''));
}
return $result;
}
/**
* @param array<string, mixed> $input
* @return array<string, string>
*/
private function buildFullDraftFallback(array $input): array
{
$vitals = $this->joinParts([
($input['bp'] ?? '') !== '' || ($input['diastolic'] ?? '') !== '' ? 'BP ' . trim((string) ($input['bp'] ?? '')) . '/' . trim((string) ($input['diastolic'] ?? '')) : '',
($input['pulse'] ?? '') !== '' ? 'Pulse ' . trim((string) $input['pulse']) . '/min' : '',
($input['temp'] ?? '') !== '' ? 'Temp ' . trim((string) $input['temp']) : '',
($input['spo2'] ?? '') !== '' ? 'SpO2 ' . trim((string) $input['spo2']) . '%' : '',
($input['rr_min'] ?? '') !== '' ? 'RR ' . trim((string) $input['rr_min']) . '/min' : '',
], '; ');
$exam = $this->joinParts([
($input['pallor'] ?? '') !== '' ? 'Pallor: ' . trim((string) $input['pallor']) : '',
($input['icterus'] ?? '') !== '' ? 'Icterus: ' . trim((string) $input['icterus']) : '',
($input['cyanosis'] ?? '') !== '' ? 'Cyanosis: ' . trim((string) $input['cyanosis']) : '',
($input['clubbing'] ?? '') !== '' ? 'Clubbing: ' . trim((string) $input['clubbing']) : '',
($input['edema'] ?? '') !== '' ? 'Edema: ' . trim((string) $input['edema']) : '',
($input['pain_value'] ?? '') !== '' ? 'Pain scale: ' . trim((string) $input['pain_value']) . '/4' : '',
], '; ');
$complications = $this->joinParts([
!empty($input['pregnancy']) ? 'Pregnancy' : '',
!empty($input['lactation']) ? 'Lactation' : '',
!empty($input['liver_insufficiency']) ? 'Liver insufficiency' : '',
!empty($input['renal_insufficiency']) ? 'Renal insufficiency' : '',
!empty($input['pulmonary_insufficiency']) ? 'Pulmonary insufficiency' : '',
!empty($input['corona_suspected']) ? 'Corona suspected' : '',
!empty($input['dengue']) ? 'Dengue' : '',
], ', ');
$addiction = $this->joinParts([
!empty($input['is_smoking']) ? 'Smoking' : '',
!empty($input['is_alcohol']) ? 'Alcohol' : '',
!empty($input['is_drug_abuse']) ? 'Drug abuse' : '',
], ', ');
$women = $this->joinParts([
($input['women_related_problems'] ?? '') !== '' ? 'Women related: ' . trim((string) $input['women_related_problems']) : '',
($input['women_lmp'] ?? '') !== '' ? 'LMP days before: ' . trim((string) $input['women_lmp']) : '',
($input['women_last_baby'] ?? '') !== '' ? 'Last baby: ' . trim((string) $input['women_last_baby']) : '',
($input['women_pregnancy_related'] ?? '') !== '' ? 'Pregnancy related: ' . trim((string) $input['women_pregnancy_related']) : '',
], '; ');
$finding = $this->joinParts([
trim((string) ($input['finding_examinations'] ?? '')),
$vitals !== '' ? 'Vitals: ' . $vitals : '',
$exam !== '' ? 'General examination: ' . $exam : '',
], "\n");
$remarks = $this->joinParts([
trim((string) ($input['prescriber_remarks'] ?? '')),
$complications !== '' ? 'Complication: ' . $complications : '',
$addiction !== '' ? 'Addiction: ' . $addiction : '',
$women,
], "\n");
$diagnosis = trim((string) ($input['diagnosis'] ?? ''));
if ($diagnosis === '' && trim((string) ($input['complaints'] ?? '')) !== '') {
$diagnosis = 'Provisional diagnosis to correlate with complaints: ' . trim((string) ($input['complaints'] ?? ''));
}
$provisional = trim((string) ($input['provisional_diagnosis'] ?? ''));
if ($provisional === '') {
$provisional = $diagnosis;
}
$investigation = trim((string) ($input['investigation'] ?? ''));
$advice = $this->joinParts([
trim((string) ($input['advice'] ?? '')),
trim((string) ($input['next_visit'] ?? '')) !== '' ? 'Next visit: ' . trim((string) ($input['next_visit'] ?? '')) : '',
trim((string) ($input['refer_to'] ?? '')) !== '' ? 'Refer to: ' . trim((string) ($input['refer_to'] ?? '')) : '',
], "\n");
return [
'finding_examinations' => $finding,
'diagnosis' => $diagnosis,
'provisional_diagnosis' => $provisional,
'prescriber_remarks' => $remarks,
'investigation' => $investigation,
'advice' => $advice,
];
}
/**
* @param array<int, string> $parts
*/
private function joinParts(array $parts, string $separator = ', '): string
{
$filtered = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part !== '') {
$filtered[] = $part;
}
}
return implode($separator, $filtered);
}
/**
* @return array<int, array<string, string>>
*/
private function buildPatientHistoryAlerts(int $patientId, int $currentOpdId): array
{
if ($patientId <= 0 || ! $this->db->tableExists('opd_prescription') || ! $this->db->tableExists('opd_master')) {
return [];
}
$alerts = [];
$rows = $this->db->table('opd_prescription p')
->select('p.*,o.apointment_date')
->join('opd_master o', 'o.opd_id=p.opd_id', 'left')
->where('p.p_id', $patientId)
->where('p.opd_id !=', $currentOpdId)
->orderBy('p.id', 'DESC')
->limit(5)
->get()
->getResultArray();
foreach ($rows as $row) {
$dateLabel = ! empty($row['apointment_date']) ? date('d-m-Y', strtotime((string) $row['apointment_date'])) : 'Previous OPD';
if (trim((string) ($row['diagnosis'] ?? '')) !== '') {
$alerts[] = [
'type' => 'Diagnosis History',
'message' => $dateLabel . ': ' . trim((string) $row['diagnosis']),
];
}
if (trim((string) ($row['complaints'] ?? '')) !== '') {
$alerts[] = [
'type' => 'Complaint History',
'message' => $dateLabel . ': ' . trim((string) $row['complaints']),
];
}
$vitalLine = [];
foreach ([
'BP' => trim((string) ($row['bp'] ?? '')),
'DIA' => trim((string) ($row['diastolic'] ?? '')),
'Pulse' => trim((string) ($row['pulse'] ?? '')),
'Temp' => trim((string) ($row['temp'] ?? '')),
'SpO2' => trim((string) ($row['spo2'] ?? '')),
] as $k => $v) {
if ($v !== '') {
$vitalLine[] = $k . ':' . $v;
}
}
if (! empty($vitalLine)) {
$alerts[] = [
'type' => 'Vitals Trend',
'message' => $dateLabel . ': ' . implode(' | ', $vitalLine),
];
}
$historyFields = $this->hydratePatientHistoryFields($row, (string) ($row['Prescriber_Remarks'] ?? ''));
$historyLabels = [
'obstetric_history' => 'Obstetric History',
'menstrual_history' => 'Menstrual History',
'medical_surgical_history' => 'Medical / Surgical History',
'family_history' => 'Family History',
'allergic_history' => 'Allergic History',
'vaccination_history' => 'Vaccination',
];
foreach ($historyLabels as $key => $label) {
$value = trim((string) ($historyFields[$key] ?? ''));
if ($value === '') {
continue;
}
$alerts[] = [
'type' => $label,
'message' => $dateLabel . ': ' . $value,
];
}
}
if ($this->db->tableExists('patient_remark')) {
$remarks = $this->db->table('patient_remark')
->select('remark,insert_datetime')
->where('p_id', $patientId)
->orderBy('id', 'DESC')
->limit(3)
->get()
->getResultArray();
foreach ($remarks as $remarkRow) {
$msg = trim((string) ($remarkRow['remark'] ?? ''));
if ($msg === '') {
continue;
}
$time = ! empty($remarkRow['insert_datetime']) ? date('d-m-Y H:i', strtotime((string) $remarkRow['insert_datetime'])) : 'Recent';
$alerts[] = [
'type' => 'Patient Remark',
'message' => $time . ': ' . $msg,
];
}
}
return array_slice($alerts, 0, 8);
}
/**
* @return array<int, array<string, string>>
*/
private function fetchPatientRemarks(int $patientId, int $limit = 8): array
{
if ($patientId <= 0 || ! $this->db->tableExists('patient_remark')) {
return [];
}
$rows = $this->db->table('patient_remark')
->select('remark,insert_by,insert_datetime')
->where('p_id', $patientId)
->orderBy('id', 'DESC')
->limit(max(1, $limit))
->get()
->getResultArray();
$out = [];
foreach ($rows as $row) {
$msg = trim((string) ($row['remark'] ?? ''));
if ($msg === '') {
continue;
}
$time = ! empty($row['insert_datetime']) ? date('d-m-Y H:i', strtotime((string) $row['insert_datetime'])) : '';
$out[] = [
'remark' => $msg,
'insert_by' => trim((string) ($row['insert_by'] ?? '')),
'insert_time' => $time,
];
}
return $out;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildOldPrescriptionFastView(int $patientId, int $currentOpdId): array
{
if ($patientId <= 0 || ! $this->db->tableExists('opd_master')) {
return [];
}
$opdRows = $this->db->table('opd_master o')
->select('o.opd_id,o.opd_code,o.apointment_date,p.complaints,p.diagnosis,p.id as session_id')
->join('opd_prescription p', 'p.opd_id=o.opd_id', 'left')
->where('o.p_id', $patientId)
->where('o.opd_id !=', $currentOpdId)
->orderBy('o.opd_id', 'DESC')
->limit(6)
->get()
->getResultArray();
if (empty($opdRows)) {
return [];
}
$medicineMap = [];
$medicineTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
if ($medicineTable !== null) {
$sessionIds = [];
foreach ($opdRows as $opdRow) {
$sessionId = (int) ($opdRow['session_id'] ?? 0);
if ($sessionId > 0) {
$sessionIds[$sessionId] = true;
}
}
if (! empty($sessionIds)) {
$medicineFields = $this->db->getFieldNames($medicineTable);
if (in_array('opd_pre_id', $medicineFields, true)) {
$doseMap = $this->getDoseMasterLabelMap('opd_dose_shed', ['dose_shed_id', 'id'], ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
$whenMap = $this->getDoseMasterLabelMap('opd_dose_when', ['dose_when_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$freqMap = $this->getDoseMasterLabelMap('opd_dose_frequency', ['dose_freq_id', 'id'], ['dose_sign', 'dose_sign_desc', 'name']);
$query = $this->db->table($medicineTable)
->whereIn('opd_pre_id', array_map('intval', array_keys($sessionIds)));
if (in_array('id', $medicineFields, true)) {
$query->orderBy('id', 'ASC');
}
$medicineRows = $query->get()->getResultArray();
foreach ($medicineRows as $medicineRow) {
$sessionId = (int) ($medicineRow['opd_pre_id'] ?? 0);
if ($sessionId <= 0) {
continue;
}
if (! isset($medicineMap[$sessionId])) {
$medicineMap[$sessionId] = [];
}
$medicineMap[$sessionId][] = [
'id' => (int) ($medicineRow['id'] ?? 0),
'med_id' => (int) ($medicineRow['med_id'] ?? 0),
'med_name' => trim((string) ($medicineRow['med_name'] ?? '')),
'med_type' => trim((string) ($medicineRow['med_type'] ?? '')),
'dosage' => trim((string) ($medicineRow['dosage'] ?? '')),
'dosage_label' => $this->resolveDoseMasterLabel($medicineRow['dosage'] ?? '', $doseMap),
'dosage_when' => trim((string) ($medicineRow['dosage_when'] ?? '')),
'dosage_when_label' => $this->resolveDoseMasterLabel($medicineRow['dosage_when'] ?? '', $whenMap),
'dosage_freq' => trim((string) ($medicineRow['dosage_freq'] ?? '')),
'dosage_freq_label' => $this->resolveDoseMasterLabel($medicineRow['dosage_freq'] ?? '', $freqMap),
'no_of_days' => trim((string) ($medicineRow['no_of_days'] ?? '')),
'qty' => trim((string) ($medicineRow['qty'] ?? '')),
'remark' => trim((string) ($medicineRow['remark'] ?? '')),
];
}
}
}
}
$scanMap = [];
if ($this->db->tableExists('file_upload_data')) {
$opdIds = array_map(static fn(array $r): int => (int) ($r['opd_id'] ?? 0), $opdRows);
$opdIds = array_values(array_filter($opdIds));
if (!empty($opdIds)) {
$fields = $this->db->getFieldNames('file_upload_data');
$builder = $this->db->table('file_upload_data')
->select('opd_id,full_path,file_ext,id,show_type')
->whereIn('opd_id', $opdIds)
->orderBy('id', 'DESC');
if (in_array('show_type', $fields, true)) {
$builder->where('show_type', 0);
}
$files = $builder->get()->getResultArray();
foreach ($files as $file) {
$opdId = (int) ($file['opd_id'] ?? 0);
if ($opdId <= 0) {
continue;
}
if (!isset($scanMap[$opdId])) {
$scanMap[$opdId] = [];
}
if (count($scanMap[$opdId]) >= 2) {
continue;
}
$raw = (string) ($file['full_path'] ?? '');
$publicPath = $raw;
$pos = strpos($raw, '/uploads/', 1);
if ($pos !== false) {
$publicPath = substr($raw, $pos);
}
$ext = strtolower((string) ($file['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION)));
$ext = $ext !== '' && $ext[0] !== '.' ? '.' . $ext : $ext;
$scanMap[$opdId][] = [
'path' => $publicPath,
'is_pdf' => $ext === '.pdf',
'scan_type' => $this->detectScanTypeFromPath($publicPath),
];
}
}
}
$out = [];
foreach ($opdRows as $row) {
$opdId = (int) ($row['opd_id'] ?? 0);
$sessionId = (int) ($row['session_id'] ?? 0);
$out[] = [
'opd_id' => $opdId,
'opd_code' => (string) ($row['opd_code'] ?? $opdId),
'opd_date' => !empty($row['apointment_date']) ? date('d-m-Y', strtotime((string) $row['apointment_date'])) : '',
'complaints' => (string) ($row['complaints'] ?? ''),
'diagnosis' => (string) ($row['diagnosis'] ?? ''),
'session_id' => $sessionId,
'medicines' => $medicineMap[$sessionId] ?? [],
'scans' => $scanMap[$opdId] ?? [],
];
}
return $out;
}
/**
* @return array<int, array<string, mixed>>
*/
private function getCurrentOpdScansForConsult(int $opdId): array
{
if ($opdId <= 0 || ! $this->db->tableExists('file_upload_data')) {
return [];
}
$fields = $this->db->getFieldNames('file_upload_data');
$safeSelect = ['id', 'full_path', 'file_ext', 'insert_date'];
foreach (['show_type', 'scan_type', 'document_type', 'content_description', 'ai_status', 'ai_alert_flag', 'ai_alert_text', 'extract_text', 'extracted_text'] as $optional) {
if (in_array($optional, $fields, true)) {
$safeSelect[] = $optional;
}
}
$builder = $this->db->table('file_upload_data')
->select(implode(',', $safeSelect))
->where('opd_id', $opdId)
->orderBy('id', 'DESC')
->limit(6);
if (in_array('show_type', $fields, true)) {
$builder->where('show_type', 0);
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$raw = str_replace('\\', '/', (string) ($row['full_path'] ?? ''));
$publicPath = $raw;
$pos = strpos($raw, '/uploads/');
if ($pos !== false) {
$publicPath = substr($raw, $pos);
}
$ext = strtolower((string) ($row['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION)));
if ($ext !== '' && $ext[0] !== '.') {
$ext = '.' . $ext;
}
$reportText = trim((string) ($row['content_description'] ?? ''));
if ($reportText === '') {
$reportText = trim((string) ($row['extracted_text'] ?? $row['extract_text'] ?? ''));
}
$out[] = [
'id' => (int) ($row['id'] ?? 0),
'path' => $publicPath,
'is_pdf' => $ext === '.pdf',
'scan_type' => (string) ($row['scan_type'] ?? ''),
'document_type' => (string) ($row['document_type'] ?? ''),
'ai_status' => (string) ($row['ai_status'] ?? ''),
'ai_alert_flag' => (int) ($row['ai_alert_flag'] ?? 0),
'ai_alert_text' => (string) ($row['ai_alert_text'] ?? ''),
'report_text' => $reportText,
'insert_date' => !empty($row['insert_date']) ? date('d/m/Y H:i', strtotime((string) $row['insert_date'])) : '',
];
}
return $out;
}
/**
* @return array<int, array<string, mixed>>
*/
private function getPatientScanHistoryForConsult(int $patientId): array
{
if ($patientId <= 0 || ! $this->db->tableExists('file_upload_data') || ! $this->db->tableExists('opd_master')) {
return [];
}
$fields = $this->db->getFieldNames('file_upload_data');
$safeSelect = ['f.id', 'f.opd_id', 'f.full_path', 'f.file_ext', 'f.insert_date', 'o.opd_code', 'o.apointment_date'];
foreach (['show_type', 'scan_type', 'document_type', 'content_description', 'ai_status', 'ai_alert_flag', 'ai_alert_text', 'extract_text', 'extracted_text'] as $optional) {
if (in_array($optional, $fields, true)) {
$safeSelect[] = 'f.' . $optional;
}
}
$builder = $this->db->table('file_upload_data f')
->select(implode(',', $safeSelect))
->join('opd_master o', 'o.opd_id = f.opd_id', 'left')
->where('o.p_id', $patientId)
->orderBy('f.id', 'DESC')
->limit(120);
if (in_array('show_type', $fields, true)) {
$builder->groupStart()
->where('f.show_type', 0)
->orWhere('f.show_type IS NULL', null, false)
->groupEnd();
}
$rows = $builder->get()->getResultArray();
if (empty($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$raw = str_replace('\\', '/', (string) ($row['full_path'] ?? ''));
$publicPath = $raw;
$pos = strpos($raw, '/uploads/');
if ($pos !== false) {
$publicPath = substr($raw, $pos);
}
$ext = strtolower((string) ($row['file_ext'] ?? pathinfo($publicPath, PATHINFO_EXTENSION)));
if ($ext !== '' && $ext[0] !== '.') {
$ext = '.' . $ext;
}
$reportText = trim((string) ($row['content_description'] ?? ''));
if ($reportText === '') {
$reportText = trim((string) ($row['extracted_text'] ?? $row['extract_text'] ?? ''));
}
$out[] = [
'id' => (int) ($row['id'] ?? 0),
'opd_id' => (int) ($row['opd_id'] ?? 0),
'opd_code' => (string) ($row['opd_code'] ?? ''),
'opd_date' => !empty($row['apointment_date']) ? date('d-m-Y', strtotime((string) $row['apointment_date'])) : '',
'path' => $publicPath,
'is_pdf' => $ext === '.pdf',
'scan_type' => (string) ($row['scan_type'] ?? ''),
'document_type' => (string) ($row['document_type'] ?? ''),
'ai_status' => (string) ($row['ai_status'] ?? ''),
'ai_alert_flag' => (int) ($row['ai_alert_flag'] ?? 0),
'ai_alert_text' => (string) ($row['ai_alert_text'] ?? ''),
'report_text' => $reportText,
'insert_date' => !empty($row['insert_date']) ? date('d/m/Y H:i', strtotime((string) $row['insert_date'])) : '',
];
}
return $out;
}
private function detectScanTypeFromPath(string $path): string
{
$low = strtolower($path);
if (str_contains($low, 'ecg')) {
return 'ecg';
}
if (str_contains($low, 'lab') || str_contains($low, 'report')) {
return 'lab';
}
return 'general';
}
private function resolveLocalFilePathFromStoredPath(string $storedPath): ?string
{
$normalized = str_replace('\\', '/', trim($storedPath));
$uploadsPos = strpos($normalized, '/uploads/');
if ($uploadsPos === false) {
return null;
}
$relative = substr($normalized, $uploadsPos + 1);
$relative = str_replace(['../', '..\\'], '', $relative);
$candidates = [
rtrim(FCPATH, '/\\') . '/' . $relative,
rtrim(ROOTPATH, '/\\') . '/public/' . $relative,
rtrim(ROOTPATH, '/\\') . '/' . $relative,
];
foreach ($candidates as $candidate) {
$real = realpath($candidate);
if ($real !== false && is_file($real)) {
return $real;
}
}
return null;
}
private function extractClinicalTextFromScan(string $filePath, string $scanType = ''): ?string
{
// Legacy external scan extraction has been removed from this module.
// Keep returning null so existing fallback paths continue to work.
unset($filePath, $scanType);
return null;
}
public function opd_prescription_print(int $opdId, int $opdSessionId, int $printType = 0)
{
$this->createOpdPrescriptionWorkTask($opdId, $opdSessionId);
$this->markOpdVisitedOnConsultCompletion((int) $opdId);
$printConfig = $this->resolvePrescriptionLayoutByDoctorField($opdId, $printType);
$layoutMode = (string) ($printConfig['layout'] ?? 'content_only');
$templateKey = (string) ($printConfig['template'] ?? '');
$url = base_url('Opd/opd_lettre_pdf/' . (int) $opdId)
. '?session_id=' . (int) $opdSessionId
. '&layout=' . urlencode($layoutMode);
if ($templateKey !== '') {
$url .= '&template=' . urlencode($templateKey);
}
return redirect()->to($url);
}
private function markOpdVisitedOnConsultCompletion(int $opdId): void
{
if ($opdId <= 0 || ! $this->db->tableExists('opd_master')) {
return;
}
// Only promote waiting/booked OPD to visited; leave cancelled/visited untouched.
$this->db->table('opd_master')
->where('opd_id', $opdId)
->where('opd_status', 1)
->update(['opd_status' => 2]);
}
/**
* @return array{layout:string,template:string}
*/
private function resolvePrescriptionLayoutByDoctorField(int $opdId, int $printType): array
{
$allowed = ['full', 'meta_content', 'content_only', 'header_meta', 'meta_only'];
$defaultByType = [
0 => 'content_only',
1 => 'meta_content',
2 => 'full',
];
$fallback = $defaultByType[$printType] ?? 'content_only';
$fieldByType = [
0 => 'rx_plain_paper',
1 => 'rx_blank_letter_head',
2 => 'rx_pre_print_letter_head_format',
];
$fieldName = $fieldByType[$printType] ?? '';
if ($fieldName === '') {
return ['layout' => $fallback, 'template' => ''];
}
$query = $this->db->query(
'SELECT d.' . $fieldName . ' AS layout_field
FROM opd_master o
LEFT JOIN doctor_master d ON d.id = o.doc_id
WHERE o.opd_id = ?
LIMIT 1',
[(int) $opdId]
);
$row = $query->getRowArray();
$raw = strtolower(trim((string) ($row['layout_field'] ?? '')));
if ($raw === '') {
return ['layout' => $fallback, '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 = preg_replace('/[^a-z0-9_\-]/', '', $raw) ?? '';
if ($templateKey !== '') {
return ['layout' => $fallback, 'template' => $templateKey];
}
return ['layout' => $fallback, 'template' => ''];
}
private function createOpdPrescriptionWorkTask(int $opdId, int $opdSessionId): void
{
if ($opdId <= 0 || ! $this->db->tableExists('opd_master') || ! $this->db->tableExists('patient_master')) {
return;
}
$opdRow = $this->db->table('opd_master')->select('opd_id,p_id')->where('opd_id', $opdId)->get(1)->getRowArray();
if (empty($opdRow)) {
return;
}
$patientId = (int) ($opdRow['p_id'] ?? 0);
if ($patientId <= 0) {
return;
}
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
$abhaCols = array_filter(['abha_id', 'abha_no', 'abha_address', 'abha'], fn ($c) => in_array($c, $pmFields, true));
$abhaSelect = $abhaCols ? (', ' . implode(', ', $abhaCols)) : '';
$patient = $this->db->table('patient_master')
->select('p_fname' . $abhaSelect)
->where('id', $patientId)
->get(1)
->getRowArray();
$abhaId = trim((string) ($patient['abha_id'] ?? $patient['abha_no'] ?? $patient['abha_address'] ?? $patient['abha'] ?? ''));
if (! $this->isValidAbhaNumber($abhaId)) {
return;
}
$taskService = new AbdmWorkTaskService();
$taskService->createOrRefreshTask(
'opd_prescription_publish',
'opd_prescription',
'opd',
(string) $opdId,
$patientId,
trim((string) ($patient['p_fname'] ?? '')),
$abhaId,
'submit',
[
'opd_id' => $opdId,
'opd_session_id' => $opdSessionId,
'trigger' => 'opd.prescription.printed',
]
);
}
private function isValidAbhaNumber(string $abhaId): bool
{
return preg_match('/^\d{14}$/', trim($abhaId)) === 1;
}
}