diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 8215930..06bf0b2 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -502,6 +502,7 @@ $routes->post('Opd/confirm_payment', 'Opd::confirm_payment'); $routes->post('Opd/update_doc_date/(:num)', 'Opd::update_doc_date/$1'); $routes->post('Opd/opd_discount_update/(:num)', 'Opd::opd_discount_update/$1'); $routes->post('Opd/opd_cancel/(:num)', 'Opd::opd_cancel/$1'); +$routes->post('Opd/healthplix_push/(:num)', 'Opd::healthplix_push/$1'); $routes->match(['get', 'post'], 'Opd/opd_load_doc/(:num)', 'Opd::opd_load_doc/$1'); $routes->match(['get', 'post'], 'Opd/opd_file_list/(:num)', 'Opd::opd_file_list/$1'); $routes->match(['get', 'post'], 'Opd/opd_file_last_list', 'Opd::opd_file_last_list'); @@ -548,6 +549,10 @@ $routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index', ['filter' => $abdmPermFilt $routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list', ['filter' => $abdmPermFilter]); $routes->post('AbdmTaskBoard/perform_action', 'AbdmTaskBoard::performAction', ['filter' => $abdmPermFilter]); $routes->post('AbdmTaskBoard/mark_status', 'AbdmTaskBoard::markStatus', ['filter' => $abdmPermFilter]); +$healthplixPermFilter = 'permission:admin.access,admin.settings'; +$routes->post('HealthplixGateway/generate_token', 'HealthplixGateway::generateToken', ['filter' => $healthplixPermFilter]); +$routes->post('HealthplixGateway/register_patient', 'HealthplixGateway::registerPatient', ['filter' => $healthplixPermFilter]); +$routes->post('HealthplixGateway/book_appointment', 'HealthplixGateway::bookAppointment', ['filter' => $healthplixPermFilter]); $routes->get('Opd_prescription/fhir_bundle/(:num)', 'Opd_prescription::fhir_bundle/$1'); $routes->get('Opd_prescription/fhir_bundle/(:num)/(:num)', 'Opd_prescription::fhir_bundle/$1/$2'); $routes->get('Opd_prescription/fhir_bundle_history/(:num)', 'Opd_prescription::fhir_bundle_history/$1'); @@ -957,6 +962,9 @@ $routes->group('setting', static function($routes) { $routes->post('admin/ai-settings/save', 'Setting\\AiSettings::save'); $routes->post('admin/ai-settings/test', 'Setting\\AiSettings::test'); $routes->post('admin/ai-settings/usage', 'Setting\\AiSettings::usage'); + $routes->get('admin/healthplix-settings', 'Setting\\HealthplixSettings::index'); + $routes->post('admin/healthplix-settings/save', 'Setting\\HealthplixSettings::save'); + $routes->post('admin/healthplix-settings/test', 'Setting\\HealthplixSettings::test'); $routes->get('admin/hospital-profile', 'Setting\\HospitalProfile::index'); $routes->post('admin/hospital-profile/save', 'Setting\\HospitalProfile::save'); $routes->post('admin/hospital-profile/delete-logo', 'Setting\\HospitalProfile::deleteLogo'); diff --git a/app/Controllers/HealthplixGateway.php b/app/Controllers/HealthplixGateway.php new file mode 100644 index 0000000..803a818 --- /dev/null +++ b/app/Controllers/HealthplixGateway.php @@ -0,0 +1,113 @@ +request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $service = new HealthplixService(); + $result = $service->generateToken(); + if (($result['ok'] ?? false) !== true) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => (string) ($result['error'] ?? 'HealthPlix token generation failed'), + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => (int) ($result['status'] ?? 200), + 'message' => 'HealthPlix token generated', + ]); + } + + public function registerPatient() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $payload = $this->resolvePayload(); + if (empty($payload)) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => 'Patient payload is required', + ]); + } + + $service = new HealthplixService(); + $result = $service->registerPatient($payload); + if (($result['ok'] ?? false) !== true) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => (string) ($result['error'] ?? 'HealthPlix patient registration failed'), + 'status' => (int) ($result['status'] ?? 0), + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => (int) ($result['status'] ?? 200), + 'data' => $result['data'] ?? [], + ]); + } + + public function bookAppointment() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $payload = $this->resolvePayload(); + if (empty($payload)) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => 'Appointment payload is required', + ]); + } + + $service = new HealthplixService(); + $result = $service->bookAppointment($payload); + if (($result['ok'] ?? false) !== true) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => (string) ($result['error'] ?? 'HealthPlix appointment booking failed'), + 'status' => (int) ($result['status'] ?? 0), + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => (int) ($result['status'] ?? 200), + 'data' => $result['data'] ?? [], + ]); + } + + /** + * @return array + */ + private function resolvePayload(): array + { + $json = $this->request->getJSON(true); + if (is_array($json) && ! empty($json)) { + return $json; + } + + $payloadJson = trim((string) $this->request->getPost('payload_json')); + if ($payloadJson !== '') { + $decoded = json_decode($payloadJson, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } +} diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index a7c686e..e068c04 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\HealthplixService; use App\Models\OpdModel; use App\Models\PaymentModel; use CodeIgniter\I18n\Time; @@ -1692,9 +1693,173 @@ class Opd extends BaseController $query = $this->db->query($sql); $data['bank_data'] = $query->getResult(); + // ── HealthPlix sync status for invoice button ───────────────────────── + $data['healthplix_sync_applicable'] = false; + $data['healthplix_sync_done'] = false; + $data['healthplix_log'] = null; + + $healthplixEnabled = $this->readHealthplixSetting('HEALTHPLIX_ENABLED') === '1'; + if ($healthplixEnabled && (int) ($opdRow->payment_status ?? 0) === 1) { + $docId = (int) ($opdRow->doc_id ?? 0); + $doctorIdentifier = ''; + if ($docId > 0 && $this->db->tableExists('doctor_master')) { + $identifierField = $this->resolveDoctorHealthplixIdentifierField(); + if ($identifierField !== null) { + $docRow2 = $this->db->table('doctor_master') + ->select($identifierField) + ->where('id', $docId) + ->get(1) + ->getRowArray(); + $doctorIdentifier = trim((string) ($docRow2[$identifierField] ?? '')); + } + } + if ($doctorIdentifier !== '') { + $data['healthplix_sync_applicable'] = true; + if ($this->db->tableExists('abdm_api_logs')) { + $hpLog = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('event_type', 'healthplix.appointment.book') + ->where('entity_type', 'opd') + ->where('entity_id', (string) $opdId) + ->where('status', 'success') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($hpLog)) { + $data['healthplix_sync_done'] = true; + $data['healthplix_log'] = $hpLog; + } + } + } + } + // OPD-level sync ID stored in table after successful appointment push. + $data['healthplix_opd_sync_id'] = trim((string) ($opdRow->healthplix_sync_id ?? '')); + // ───────────────────────────────────────────────────────────────────── + return view('billing/opd_invoice_V', $data); } + public function healthplix_push(int $opdId) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'message' => 'Invalid request']); + } + + $opdId = (int) $opdId; + if ($opdId <= 0) { + return $this->response->setJSON([ + 'ok' => 0, + 'message' => 'Invalid OPD ID', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + // If a success log already exists, just return it. + if ($this->db->tableExists('abdm_api_logs')) { + $existing = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('event_type', 'healthplix.appointment.book') + ->where('entity_type', 'opd') + ->where('entity_id', (string) $opdId) + ->where('status', 'success') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($existing)) { + return $this->response->setJSON([ + 'ok' => 1, + 'already_synced' => true, + 'message' => 'Already synced to HealthPlix.', + 'log' => $existing, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + } + + $this->syncPaidOpdToHealthplix($opdId); + + // Check result + $synced = false; + $log = null; + $errorMsg = 'Sync failed. Check HealthPlix settings or logs.'; + if ($this->db->tableExists('abdm_api_logs')) { + $log = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('event_type', 'healthplix.appointment.book') + ->where('entity_type', 'opd') + ->where('entity_id', (string) $opdId) + ->where('status', 'success') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $synced = ! empty($log); + + if (! $synced) { + $errLog = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('status', 'error') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + // If no OPD-level error, look for any recent healthplix error (token/patient) + if (empty($errLog)) { + $errLog = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('status', 'error') + ->where('created_at >=', date('Y-m-d H:i:s', time() - 30)) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + } + if (! empty($errLog)) { + $errDetail = trim((string) ($errLog['error_message'] ?? '')); + $errCode = (int) ($errLog['response_code'] ?? 0); + $errEvent = trim((string) ($errLog['event_type'] ?? '')); + if ($errCode > 0 && $errDetail !== '') { + $errorMsg = '[' . $errEvent . '] HTTP ' . $errCode . ': ' . $errDetail; + } elseif ($errCode > 0) { + $errorMsg = '[' . $errEvent . '] HealthPlix API returned HTTP ' . $errCode . '.'; + } elseif ($errDetail !== '') { + $errorMsg = '[' . $errEvent . '] ' . $errDetail; + } + } else { + // No API log at all — likely a gate/config issue. + $healthplixEnabled = $this->readHealthplixSetting('HEALTHPLIX_ENABLED') === '1'; + if (! $healthplixEnabled) { + $errorMsg = 'HealthPlix integration is disabled in settings.'; + } else { + $identifierField = $this->resolveDoctorHealthplixIdentifierField(); + $hasDoctorId = false; + if ($identifierField !== null) { + $opdRow2 = $this->db->table('opd_master')->select('doc_id')->where('opd_id', $opdId)->get(1)->getRowArray(); + $docId2 = (int) ($opdRow2['doc_id'] ?? 0); + if ($docId2 > 0) { + $docRow2 = $this->db->table('doctor_master')->select($identifierField)->where('id', $docId2)->get(1)->getRowArray(); + $hasDoctorId = trim((string) ($docRow2[$identifierField] ?? '')) !== ''; + } + } + if (! $hasDoctorId) { + $errorMsg = 'Doctor does not have a HealthPlix Doctor Identifier set. Add it in Doctor Master.'; + } + } + } + } + } + + return $this->response->setJSON([ + 'ok' => $synced ? 1 : 0, + 'synced' => $synced, + 'message' => $synced + ? 'Appointment registered in HealthPlix.' + : $errorMsg, + 'log' => $log, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + public function invoice_print(int $opdId) { $data = $this->buildOpdInvoicePrintData($opdId); @@ -4229,6 +4394,7 @@ class Opd extends BaseController $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); + $this->syncPaidOpdToHealthplix($oid); return $this->response->setJSON([ 'update' => 1, @@ -4267,6 +4433,7 @@ class Opd extends BaseController $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); + $this->syncPaidOpdToHealthplix($oid); return $this->response->setJSON([ 'update' => 1, @@ -4316,6 +4483,7 @@ class Opd extends BaseController $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); + $this->syncPaidOpdToHealthplix($oid); return $this->response->setJSON([ 'update' => 1, @@ -4338,6 +4506,7 @@ class Opd extends BaseController $this->db->table('opd_master')->where('opd_id', $oid)->update($data); $this->autoCreateOpdQueue($oid); + $this->syncPaidOpdToHealthplix($oid); return $this->response->setJSON([ 'update' => 1, @@ -4355,6 +4524,250 @@ class Opd extends BaseController ]); } + private function syncPaidOpdToHealthplix(int $opdId): void + { + if ($opdId <= 0 || ! $this->db->tableExists('opd_master')) { + return; + } + + // Skip duplicate push if one success log already exists for this OPD. + if ($this->db->tableExists('abdm_api_logs')) { + $existing = $this->db->table('abdm_api_logs') + ->where('channel', 'healthplix') + ->where('event_type', 'healthplix.appointment.book') + ->where('entity_type', 'opd') + ->where('entity_id', (string) $opdId) + ->where('status', 'success') + ->get(1) + ->getRowArray(); + if (! empty($existing)) { + return; + } + } + + try { + $opd = $this->db->table('opd_master') + ->where('opd_id', $opdId) + ->get(1) + ->getRowArray(); + if (! is_array($opd) || empty($opd)) { + return; + } + + $patientId = (int) ($opd['p_id'] ?? 0); + $patient = []; + if ($patientId > 0 && $this->db->tableExists('patient_master')) { + $patient = $this->db->table('patient_master') + ->where('id', $patientId) + ->get(1) + ->getRowArray() ?? []; + } + + $doctorRow = []; + $doctorId = (int) ($opd['doc_id'] ?? 0); + if ($doctorId > 0 && $this->db->tableExists('doctor_master')) { + $doctorRow = $this->db->table('doctor_master') + ->where('id', $doctorId) + ->get(1) + ->getRowArray() ?? []; + } + + $doctorIdentifier = $this->readHealthplixSetting('HEALTHPLIX_DOCTOR_IDENTIFIER'); + $doctorEmail = $this->readHealthplixSetting('HEALTHPLIX_DOCTOR_EMAIL'); + $serviceIdentifier = $this->readHealthplixSetting('HEALTHPLIX_SERVICE_IDENTIFIER'); + $serviceName = $this->readHealthplixSetting('HEALTHPLIX_SERVICE_NAME'); + + if (! empty($doctorRow)) { + $doctorIdentifierField = $this->resolveDoctorHealthplixIdentifierField(); + if ($doctorIdentifierField !== null) { + $rowDoctorIdentifier = trim((string) ($doctorRow[$doctorIdentifierField] ?? '')); + if ($rowDoctorIdentifier !== '') { + $doctorIdentifier = $rowDoctorIdentifier; + } + } + + $rowDoctorEmail = trim((string) ($doctorRow['email1'] ?? '')); + if ($rowDoctorEmail !== '') { + $doctorEmail = $rowDoctorEmail; + } + } + + // Appointment booking should use a valid HealthPlix doctor UUID, not local doctor id. + if ($doctorIdentifier === '') { + return; + } + + $service = new HealthplixService(); + + $patientPayload = [ + 'external_patient_id' => (string) $patientId, + 'patient_code' => trim((string) ($patient['p_code'] ?? $patientId)), + 'full_name' => trim((string) ($patient['p_fname'] ?? ($opd['P_name'] ?? ''))), + 'mobile' => trim((string) ($patient['mphone1'] ?? '')), + 'email' => trim((string) ($patient['email1'] ?? '')), + 'gender' => $this->mapPatientGenderToHealthplix((string) ($patient['gender'] ?? '')), + 'dob' => trim((string) ($patient['dob'] ?? '')), + 'estimate_dob' => trim((string) ($patient['estimate_dob'] ?? '')), + 'age_years' => trim((string) ($patient['age'] ?? '')), + 'age_months' => trim((string) ($patient['age_in_month'] ?? '')), + 'address_line1' => trim((string) ($patient['add1'] ?? '')), + 'city' => trim((string) ($patient['city'] ?? '')), + 'district' => trim((string) ($patient['district'] ?? '')), + 'state' => trim((string) ($patient['state'] ?? '')), + 'zip' => trim((string) ($patient['zip'] ?? '')), + 'aadhar' => trim((string) ($patient['udai'] ?? '')), + 'abha_id' => '', + 'trigger' => 'opd.payment.confirmed', + ]; + + $patientResult = $service->registerPatient($patientPayload); + $patientStatus = (int) ($patientResult['status'] ?? 0); + $patientAlreadyExists = $patientStatus === 409; + if (($patientResult['ok'] ?? false) !== true && ! $patientAlreadyExists) { + return; + } + + // Persist patient sync id when HealthPlix accepted/recognized this patient. + $patientSyncFallback = trim((string) ($patient['p_code'] ?? $patientId)); + $patientSyncId = $this->extractHealthplixSyncId($patientResult, $patientSyncFallback); + $this->persistHealthplixSyncId('patient_master', 'id', $patientId, $patientSyncId); + + $payload = [ + 'external_appointment_id' => (string) $opdId, + 'appointment_id' => (string) ($opd['opd_code'] ?? ''), + 'appointment_datetime' => trim((string) ($opd['apointment_date'] ?? '')), + 'patient_identifier' => (string) ($patient['p_code'] ?? $patientId), + 'patient_name' => trim((string) ($patient['p_fname'] ?? ($opd['P_name'] ?? ''))), + 'patient_mobile' => trim((string) ($patient['mphone1'] ?? '')), + 'doctor_identifier' => $doctorIdentifier, + 'doctor_name' => trim((string) ($opd['doc_name'] ?? '')), + 'doctor_email' => $doctorEmail, + 'service_identifier' => $serviceIdentifier, + 'service_name' => $serviceName !== '' ? $serviceName : trim((string) ($opd['opd_fee_desc'] ?? 'Consultation')), + 'price' => (float) ($opd['opd_fee_amount'] ?? 0), + 'local_trigger' => 'opd.payment.confirmed', + ]; + + $appointmentResult = $service->bookAppointment($payload); + if (($appointmentResult['ok'] ?? false) === true) { + $appointmentSyncFallback = trim((string) ($opd['opd_code'] ?? $opdId)); + $appointmentSyncId = $this->extractHealthplixSyncId($appointmentResult, $appointmentSyncFallback); + $this->persistHealthplixSyncId('opd_master', 'opd_id', $opdId, $appointmentSyncId); + } + } catch (\Throwable $e) { + // Never block OPD payment flow due to external integration errors. + } + } + + /** + * @param array $result + */ + private function extractHealthplixSyncId(array $result, string $fallback = ''): string + { + $data = (array) ($result['data'] ?? []); + $candidates = [ + $data['sync_id'] ?? null, + $data['reference_id'] ?? null, + $data['referenceId'] ?? null, + $data['request_id'] ?? null, + $data['appointment_id'] ?? null, + $data['appointment_identifier'] ?? null, + $data['patient_id'] ?? null, + $data['patient_identifier'] ?? null, + $data['id'] ?? null, + $data['uuid'] ?? null, + $result['sync_id'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $value = trim((string) $candidate); + if ($value !== '') { + return $value; + } + } + + return trim($fallback); + } + + private function persistHealthplixSyncId(string $table, string $idField, int $entityId, string $syncId): void + { + if ($entityId <= 0 || $syncId === '') { + return; + } + if (! $this->db->tableExists($table)) { + return; + } + + $fields = array_map( + static fn (object $field): string => (string) ($field->name ?? ''), + $this->db->getFieldData($table) + ); + + if (! in_array($idField, $fields, true) || ! in_array('healthplix_sync_id', $fields, true)) { + return; + } + + $this->db->table($table) + ->where($idField, $entityId) + ->update(['healthplix_sync_id' => $syncId]); + } + + private function mapPatientGenderToHealthplix(string $gender): string + { + $gender = trim(strtolower($gender)); + if ($gender === '1' || $gender === 'male' || $gender === 'm') { + return 'male'; + } + if ($gender === '2' || $gender === 'female' || $gender === 'f') { + return 'female'; + } + + return 'other'; + } + + private function readHealthplixSetting(string $name): string + { + $value = trim((string) env($name, '')); + if ($value !== '') { + return $value; + } + + if (defined($name)) { + $defined = trim((string) constant($name)); + if ($defined !== '') { + return $defined; + } + } + + if (! $this->db->tableExists('hospital_setting')) { + return ''; + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->get(1) + ->getRowArray(); + + return trim((string) ($row['s_value'] ?? '')); + } + + private function resolveDoctorHealthplixIdentifierField(): ?string + { + if (! $this->db->tableExists('doctor_master')) { + return null; + } + + $fields = $this->db->getFieldNames('doctor_master') ?? []; + foreach (['healthplix_doctor_identifier', 'healthplix_identifier'] as $field) { + if (in_array($field, $fields, true)) { + return $field; + } + } + + return null; + } + private function autoCreateOpdQueue(int $opdId): void { if ($opdId <= 0 || ! $this->db->tableExists('opd_prescription')) { diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 3ca9a40..4f96eef 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -5,6 +5,7 @@ namespace App\Controllers; use App\Controllers\BaseController; use App\Libraries\AbdmWorkTaskService; use App\Libraries\BridgeSyncService; +use App\Libraries\HealthplixService; use App\Models\BloodGroupModel; use App\Models\PatientModel; @@ -1331,5 +1332,64 @@ class Patient extends BaseController ); } + private function syncPatientToHealthplix(int $patientId, string $trigger): void + { + if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) { + return; + } + + try { + $row = $this->db->table('patient_master') + ->where('id', $patientId) + ->get(1) + ->getRowArray(); + + if (! is_array($row) || empty($row)) { + return; + } + + $abhaField = $this->resolvePatientAbhaIdField(); + $abhaId = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : ''; + + $payload = [ + 'external_patient_id' => (string) $patientId, + 'patient_code' => trim((string) ($row['p_code'] ?? '')), + 'full_name' => trim((string) ($row['p_fname'] ?? '')), + 'mobile' => trim((string) ($row['mphone1'] ?? '')), + 'email' => trim((string) ($row['email1'] ?? '')), + 'gender' => $this->mapPatientGenderToHealthplix((string) ($row['gender'] ?? '')), + 'dob' => trim((string) ($row['dob'] ?? '')), + 'age_years' => trim((string) ($row['age'] ?? '')), + 'age_months' => trim((string) ($row['age_in_month'] ?? '')), + 'address_line1' => trim((string) ($row['add1'] ?? '')), + 'city' => trim((string) ($row['city'] ?? '')), + 'district' => trim((string) ($row['district'] ?? '')), + 'state' => trim((string) ($row['state'] ?? '')), + 'zip' => trim((string) ($row['zip'] ?? '')), + 'aadhar' => trim((string) ($row['udai'] ?? '')), + 'abha_id' => $abhaId, + 'trigger' => $trigger, + ]; + + $service = new HealthplixService(); + $service->registerPatient($payload); + } catch (\Throwable $e) { + // Never block patient workflows due to external integration failures. + } + } + + private function mapPatientGenderToHealthplix(string $gender): string + { + $gender = trim(strtolower($gender)); + if ($gender === '1' || $gender === 'male' || $gender === 'm') { + return 'male'; + } + if ($gender === '2' || $gender === 'female' || $gender === 'f') { + return 'female'; + } + + return 'other'; + } + } diff --git a/app/Controllers/Setting/Doctor.php b/app/Controllers/Setting/Doctor.php index 3b33f1f..85e8f3a 100644 --- a/app/Controllers/Setting/Doctor.php +++ b/app/Controllers/Setting/Doctor.php @@ -28,6 +28,7 @@ class Doctor extends BaseController 'opd_print_template_options' => $templateContext['opd_print_options'], 'template_fields' => $templateContext['fields'], 'hospital_default_opd_valid_no_days' => $this->getHospitalDefaultOpdValidityDays(), + 'healthplix_enabled' => $this->isHealthplixEnabled(), ]); } @@ -49,6 +50,7 @@ class Doctor extends BaseController 'opd_print_template_options' => $templateContext['opd_print_options'], 'template_fields' => $templateContext['fields'], 'hospital_default_opd_valid_no_days' => $this->getHospitalDefaultOpdValidityDays(), + 'healthplix_enabled' => $this->isHealthplixEnabled(), ]); } @@ -58,6 +60,8 @@ class Doctor extends BaseController $doctorModel = new DoctorModel(); $regField = $this->resolveDoctorRegField(); $hprField = $this->resolveDoctorHprField(); + $healthplixDoctorIdentifier = trim((string) $this->request->getPost('input_healthplix_doctor_identifier')); + $doctorFields = $this->db->tableExists('doctor_master') ? ($this->db->getFieldNames('doctor_master') ?? []) : []; $shortDescription = trim((string) $this->request->getPost('txt_doc_sign')); $regInput = trim((string) $this->request->getPost('input_doc_reg_no')); $hprInput = strtoupper(trim((string) $this->request->getPost('input_hpr_id'))); @@ -75,6 +79,27 @@ class Doctor extends BaseController 'template_options' => $templateContext['options'], 'opd_print_template_options' => $templateContext['opd_print_options'], 'template_fields' => $templateContext['fields'], + 'healthplix_enabled' => $this->isHealthplixEnabled(), + ]); + } + + return redirect()->back()->withInput()->with('errors', $errors); + } + + if ($this->isHealthplixEnabled() + && in_array('healthplix_doctor_identifier', $doctorFields, true) + && $healthplixDoctorIdentifier === '') { + $errors = ['HealthPlix Doctor Identifier is required when HealthPlix integration is enabled.']; + if ($this->request->isAJAX()) { + $templateContext = $this->getDoctorTemplateContext(); + return view('Setting/Doctor/Doctor_V', [ + 'errors' => $errors, + 'formData' => $this->request->getPost(), + 'template_options' => $templateContext['options'], + 'opd_print_template_options' => $templateContext['opd_print_options'], + 'template_fields' => $templateContext['fields'], + 'hospital_default_opd_valid_no_days' => $this->getHospitalDefaultOpdValidityDays(), + 'healthplix_enabled' => $this->isHealthplixEnabled(), ]); } @@ -92,7 +117,6 @@ class Doctor extends BaseController 'doc_sign' => $shortDescription, ]; - $doctorFields = $this->db->tableExists('doctor_master') ? ($this->db->getFieldNames('doctor_master') ?? []) : []; if (in_array('opd_valid_no_days', $doctorFields, true)) { $data['opd_valid_no_days'] = $this->resolveOpdValidityInput((string) $this->request->getPost('input_opd_valid_no_days')); } @@ -120,6 +144,9 @@ class Doctor extends BaseController if ($hprField !== null) { $data[$hprField] = $hprInput; } + if (in_array('healthplix_doctor_identifier', $doctorFields, true)) { + $data['healthplix_doctor_identifier'] = $healthplixDoctorIdentifier; + } $insertId = $doctorModel->insert($data); @@ -134,6 +161,7 @@ class Doctor extends BaseController 'opd_print_template_options' => $templateContext['opd_print_options'], 'template_fields' => $templateContext['fields'], 'hospital_default_opd_valid_no_days' => $this->getHospitalDefaultOpdValidityDays(), + 'healthplix_enabled' => $this->isHealthplixEnabled(), ]); } @@ -163,6 +191,7 @@ class Doctor extends BaseController 'template_fields' => $templateContext['fields'], 'hospital_default_opd_valid_no_days' => $this->getHospitalDefaultOpdValidityDays(), 'doctor_opd_valid_no_days' => $this->resolveDoctorOpdValidityDaysForView($doctorRow), + 'healthplix_enabled' => $this->isHealthplixEnabled(), ]); } @@ -176,6 +205,8 @@ class Doctor extends BaseController $docId = (int) $this->request->getPost('doc_id'); $regField = $this->resolveDoctorRegField(); $hprField = $this->resolveDoctorHprField(); + $healthplixDoctorIdentifier = trim((string) $this->request->getPost('input_healthplix_doctor_identifier')); + $doctorFields = $this->db->tableExists('doctor_master') ? ($this->db->getFieldNames('doctor_master') ?? []) : []; $shortDescription = trim((string) $this->request->getPost('txt_doc_sign')); $regInput = trim((string) $this->request->getPost('input_doc_reg_no')); $hprInput = strtoupper(trim((string) $this->request->getPost('input_hpr_id'))); @@ -191,6 +222,16 @@ class Doctor extends BaseController ]); } + if ($this->isHealthplixEnabled() + && in_array('healthplix_doctor_identifier', $doctorFields, true) + && $healthplixDoctorIdentifier === '') { + return $this->response->setJSON([ + 'update' => 0, + 'showcontent' => '', + 'error_text' => 'HealthPlix Doctor Identifier is required when HealthPlix integration is enabled.', + ]); + } + $data = [ 'p_title' => $this->request->getPost('select_title'), 'mphone1' => $this->request->getPost('input_mphone1'), @@ -202,7 +243,6 @@ class Doctor extends BaseController 'email1' => $this->request->getPost('input_email'), ]; - $doctorFields = $this->db->tableExists('doctor_master') ? ($this->db->getFieldNames('doctor_master') ?? []) : []; if (in_array('opd_valid_no_days', $doctorFields, true)) { $data['opd_valid_no_days'] = $this->resolveOpdValidityInput((string) $this->request->getPost('input_opd_valid_no_days')); } @@ -230,6 +270,9 @@ class Doctor extends BaseController if ($hprField !== null) { $data[$hprField] = $hprInput; } + if (in_array('healthplix_doctor_identifier', $doctorFields, true)) { + $data['healthplix_doctor_identifier'] = $healthplixDoctorIdentifier; + } $updated = $doctorModel->updateDoctor($data, $docId); @@ -781,4 +824,24 @@ class Doctor extends BaseController return 5; } + private function isHealthplixEnabled(): bool + { + $envValue = trim((string) env('HEALTHPLIX_ENABLED', '')); + if ($envValue !== '') { + return $envValue === '1'; + } + + if (! $this->db->tableExists('hospital_setting')) { + return false; + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', 'HEALTHPLIX_ENABLED') + ->get(1) + ->getRowArray(); + + return trim((string) ($row['s_value'] ?? '0')) === '1'; + } + } diff --git a/app/Controllers/Setting/HealthplixSettings.php b/app/Controllers/Setting/HealthplixSettings.php new file mode 100644 index 0000000..6b7286c --- /dev/null +++ b/app/Controllers/Setting/HealthplixSettings.php @@ -0,0 +1,232 @@ +canManageSettings()) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + return view('Setting/Admin/healthplix_settings', [ + 'healthplix_enabled' => $this->readSettingValue('HEALTHPLIX_ENABLED') === '1' ? '1' : '0', + 'healthplix_base_url' => $this->readSettingValue('HEALTHPLIX_BASE_URL') ?: 'https://consultation-edge-dev.healthplix.com', + 'healthplix_tenant_id' => $this->readSettingValue('HEALTHPLIX_TENANT_ID'), + 'healthplix_tenant_key_masked' => $this->maskKey($this->readSettingValue('HEALTHPLIX_TENANT_KEY')), + 'healthplix_tenant_key_exists' => $this->readSettingValue('HEALTHPLIX_TENANT_KEY') !== '', + 'healthplix_doctor_identifier' => $this->readSettingValue('HEALTHPLIX_DOCTOR_IDENTIFIER'), + 'healthplix_doctor_email' => $this->readSettingValue('HEALTHPLIX_DOCTOR_EMAIL'), + 'healthplix_service_identifier' => $this->readSettingValue('HEALTHPLIX_SERVICE_IDENTIFIER'), + 'healthplix_service_name' => $this->readSettingValue('HEALTHPLIX_SERVICE_NAME'), + 'healthplix_tenant_header_name' => $this->readSettingValue('HEALTHPLIX_TENANT_HEADER_NAME') ?: 'tenant_id', + 'healthplix_token_path' => $this->readSettingValue('HEALTHPLIX_TOKEN_PATH') ?: 'v1/generate/token', + 'healthplix_patient_path' => $this->readSettingValue('HEALTHPLIX_PATIENT_PATH') ?: 'v1/patient/register', + 'healthplix_appointment_path' => $this->readSettingValue('HEALTHPLIX_APPOINTMENT_PATH') ?: 'v1/appointment/register', + ]); + } + + public function save() + { + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->canManageSettings()) { + return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); + } + + if (! $this->db->tableExists('hospital_setting')) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'hospital_setting table not found']); + } + + $fields = [ + 'HEALTHPLIX_ENABLED' => $this->request->getPost('healthplix_enabled') === '1' ? '1' : '0', + 'HEALTHPLIX_BASE_URL' => trim((string) $this->request->getPost('healthplix_base_url')), + 'HEALTHPLIX_TENANT_ID' => trim((string) $this->request->getPost('healthplix_tenant_id')), + 'HEALTHPLIX_TENANT_KEY' => trim((string) $this->request->getPost('healthplix_tenant_key')), + 'HEALTHPLIX_DOCTOR_IDENTIFIER' => trim((string) $this->request->getPost('healthplix_doctor_identifier')), + 'HEALTHPLIX_DOCTOR_EMAIL' => trim((string) $this->request->getPost('healthplix_doctor_email')), + 'HEALTHPLIX_SERVICE_IDENTIFIER' => trim((string) $this->request->getPost('healthplix_service_identifier')), + 'HEALTHPLIX_SERVICE_NAME' => trim((string) $this->request->getPost('healthplix_service_name')), + 'HEALTHPLIX_TENANT_HEADER_NAME' => trim((string) $this->request->getPost('healthplix_tenant_header_name')), + 'HEALTHPLIX_TOKEN_PATH' => trim((string) $this->request->getPost('healthplix_token_path')), + 'HEALTHPLIX_PATIENT_PATH' => trim((string) $this->request->getPost('healthplix_patient_path')), + 'HEALTHPLIX_APPOINTMENT_PATH' => trim((string) $this->request->getPost('healthplix_appointment_path')), + ]; + + $savedCount = 0; + foreach ($fields as $name => $value) { + if ($value === '' && $name !== 'HEALTHPLIX_ENABLED') { + continue; + } + + if ($this->upsertSettingValue($name, $value)) { + $savedCount++; + } + } + + if ($savedCount === 0) { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => 'Provide at least one setting value to save', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + return $this->response->setJSON([ + 'update' => 1, + 'error_text' => 'Saved ' . $savedCount . ' setting(s)', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function test() + { + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->canManageSettings()) { + return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']); + } + + $overrides = [ + 'HEALTHPLIX_ENABLED' => $this->request->getPost('healthplix_enabled') === '1' ? '1' : '0', + ]; + + $overrideFields = [ + 'HEALTHPLIX_BASE_URL' => 'healthplix_base_url', + 'HEALTHPLIX_TENANT_HEADER_NAME' => 'healthplix_tenant_header_name', + 'HEALTHPLIX_TENANT_ID' => 'healthplix_tenant_id', + 'HEALTHPLIX_TENANT_KEY' => 'healthplix_tenant_key', + 'HEALTHPLIX_TOKEN_PATH' => 'healthplix_token_path', + 'HEALTHPLIX_PATIENT_PATH' => 'healthplix_patient_path', + 'HEALTHPLIX_APPOINTMENT_PATH' => 'healthplix_appointment_path', + ]; + + foreach ($overrideFields as $settingKey => $postKey) { + $value = trim((string) $this->request->getPost($postKey)); + if ($value !== '') { + $overrides[$settingKey] = $value; + } + } + + $service = new HealthplixService(); + $result = $service->generateToken($overrides); + if (($result['ok'] ?? false) !== true) { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => (string) ($result['error'] ?? 'HealthPlix token test failed'), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + return $this->response->setJSON([ + 'update' => 1, + 'error_text' => 'HealthPlix token generated successfully', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + private function canManageSettings(): bool + { + if (! function_exists('auth') || ! auth()->loggedIn()) { + return false; + } + + return true; + } + + private function readSettingValue(string $name): string + { + if (defined($name)) { + $definedValue = trim((string) constant($name)); + if ($definedValue !== '') { + return $definedValue; + } + } + + if (! $this->db->tableExists('hospital_setting')) { + return ''; + } + + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->get(1) + ->getRowArray(); + + $value = trim((string) ($row['s_value'] ?? '')); + if ($value !== '') { + return $value; + } + + $rows = $this->db->table('hospital_setting') + ->select('s_name, s_value') + ->get() + ->getResultArray(); + + $target = strtoupper(trim($name)); + foreach ($rows as $item) { + $itemName = strtoupper(trim((string) ($item['s_name'] ?? ''))); + if ($itemName === $target) { + $itemValue = trim((string) ($item['s_value'] ?? '')); + if ($itemValue !== '') { + return $itemValue; + } + } + } + + return ''; + } + + private function upsertSettingValue(string $name, string $value): bool + { + $existing = $this->db->table('hospital_setting') + ->select('id, s_value') + ->where('s_name', $name) + ->get(1) + ->getRowArray(); + + $oldValue = trim((string) ($existing['s_value'] ?? '')); + if ($existing) { + $ok = $this->db->table('hospital_setting') + ->where('id', (int) ($existing['id'] ?? 0)) + ->update(['s_value' => $value]); + } else { + $ok = $this->db->table('hospital_setting')->insert([ + 's_name' => $name, + 's_value' => $value, + ]); + } + + if ($ok) { + $this->auditClinicalUpdate('hospital_setting', $name, $name, $oldValue, $value); + } + + return (bool) $ok; + } + + private function maskKey(string $key): string + { + $key = trim($key); + if ($key === '') { + return ''; + } + + if (mb_strlen($key) <= 8) { + return str_repeat('*', mb_strlen($key)); + } + + return mb_substr($key, 0, 4) . str_repeat('*', mb_strlen($key) - 8) . mb_substr($key, -4); + } +} diff --git a/app/Database/Migrations/2026-04-27-000101_AddHealthplixDoctorIdentifierToDoctorMaster.php b/app/Database/Migrations/2026-04-27-000101_AddHealthplixDoctorIdentifierToDoctorMaster.php new file mode 100644 index 0000000..ea130ad --- /dev/null +++ b/app/Database/Migrations/2026-04-27-000101_AddHealthplixDoctorIdentifierToDoctorMaster.php @@ -0,0 +1,64 @@ +tableExists('doctor_master')) { + return; + } + + if (! $this->fieldExists('doctor_master', 'healthplix_doctor_identifier')) { + $this->forge->addColumn('doctor_master', [ + 'healthplix_doctor_identifier' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + 'after' => 'email1', + ], + ]); + } + } + + public function down(): void + { + if ($this->tableExists('doctor_master') + && $this->fieldExists('doctor_master', 'healthplix_doctor_identifier')) { + $this->forge->dropColumn('doctor_master', 'healthplix_doctor_identifier'); + } + } + + private function tableExists(string $table): bool + { + $dbName = (string) ($this->db->database ?? ''); + if ($dbName === '' || $table === '') { + return false; + } + + $row = $this->db->query( + 'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema = ? AND table_name = ?', + [$dbName, $table] + )->getRowArray(); + + return ((int) ($row['total'] ?? 0)) > 0; + } + + private function fieldExists(string $table, string $field): bool + { + $dbName = (string) ($this->db->database ?? ''); + if ($dbName === '' || $table === '' || $field === '') { + return false; + } + + $row = $this->db->query( + 'SELECT COUNT(*) AS total FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ?', + [$dbName, $table, $field] + )->getRowArray(); + + return ((int) ($row['total'] ?? 0)) > 0; + } +} diff --git a/app/Database/Migrations/2026-04-27-000102_AddHealthplixSyncIdFields.php b/app/Database/Migrations/2026-04-27-000102_AddHealthplixSyncIdFields.php new file mode 100644 index 0000000..65c0d50 --- /dev/null +++ b/app/Database/Migrations/2026-04-27-000102_AddHealthplixSyncIdFields.php @@ -0,0 +1,91 @@ +addPatientSyncField(); + $this->addOpdSyncField(); + } + + public function down(): void + { + if ($this->tableExists('patient_master') + && $this->fieldExists('patient_master', 'healthplix_sync_id')) { + $this->forge->dropColumn('patient_master', 'healthplix_sync_id'); + } + + if ($this->tableExists('opd_master') + && $this->fieldExists('opd_master', 'healthplix_sync_id')) { + $this->forge->dropColumn('opd_master', 'healthplix_sync_id'); + } + } + + private function addPatientSyncField(): void + { + if (! $this->tableExists('patient_master')) { + return; + } + + if (! $this->fieldExists('patient_master', 'healthplix_sync_id')) { + $this->forge->addColumn('patient_master', [ + 'healthplix_sync_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + ]); + } + } + + private function addOpdSyncField(): void + { + if (! $this->tableExists('opd_master')) { + return; + } + + if (! $this->fieldExists('opd_master', 'healthplix_sync_id')) { + $this->forge->addColumn('opd_master', [ + 'healthplix_sync_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + ]); + } + } + + private function tableExists(string $table): bool + { + $dbName = (string) ($this->db->database ?? ''); + if ($dbName === '' || $table === '') { + return false; + } + + $row = $this->db->query( + 'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema = ? AND table_name = ?', + [$dbName, $table] + )->getRowArray(); + + return ((int) ($row['total'] ?? 0)) > 0; + } + + private function fieldExists(string $table, string $field): bool + { + $dbName = (string) ($this->db->database ?? ''); + if ($dbName === '' || $table === '' || $field === '') { + return false; + } + + $row = $this->db->query( + 'SELECT COUNT(*) AS total FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ?', + [$dbName, $table, $field] + )->getRowArray(); + + return ((int) ($row['total'] ?? 0)) > 0; + } +} diff --git a/app/Libraries/HealthplixService.php b/app/Libraries/HealthplixService.php new file mode 100644 index 0000000..8f205a9 --- /dev/null +++ b/app/Libraries/HealthplixService.php @@ -0,0 +1,497 @@ + $overrides + * @return array + */ + public function generateToken(array $overrides = []): array + { + if (! $this->isEnabled($overrides)) { + return ['ok' => false, 'error' => 'HealthPlix integration is disabled for this hospital.']; + } + + $baseUrl = rtrim($this->readSetting('HEALTHPLIX_BASE_URL', $overrides), '/'); + $tokenPath = trim($this->readSetting('HEALTHPLIX_TOKEN_PATH', $overrides)); + $tenantId = trim($this->readSetting('HEALTHPLIX_TENANT_ID', $overrides)); + $tenantKey = trim($this->readSetting('HEALTHPLIX_TENANT_KEY', $overrides)); + + if ($baseUrl === '' || $tokenPath === '') { + return ['ok' => false, 'error' => 'HEALTHPLIX_BASE_URL and HEALTHPLIX_TOKEN_PATH are required.']; + } + if ($tenantId === '' || $tenantKey === '') { + return ['ok' => false, 'error' => 'HEALTHPLIX_TENANT_ID and HEALTHPLIX_TENANT_KEY are required.']; + } + + $endpoint = $this->buildUrl($baseUrl, $tokenPath); + // Token endpoint uses Basic Auth only — no request body per HealthPlix API doc v1.7 + $headers = [ + 'Authorization' => 'Basic ' . base64_encode($tenantId . ':' . $tenantKey), + 'Accept' => 'application/json', + ]; + + $result = $this->request('POST', $endpoint, $headers, [], 'healthplix.token.generate', 'tenant', $tenantId); + if (($result['ok'] ?? false) !== true) { + return $result; + } + + $data = (array) ($result['data'] ?? []); + $token = trim((string) ($data['token'] ?? ($data['access_token'] ?? ''))); + if ($token === '') { + return ['ok' => false, 'error' => 'HealthPlix token response did not include token/access_token.']; + } + + return [ + 'ok' => true, + 'token' => $token, + 'status' => (int) ($result['status'] ?? 200), + 'data' => $data, + ]; + } + + /** + * @param array $payload + * @param array $overrides + * @return array + */ + public function registerPatient(array $payload, array $overrides = []): array + { + if (! $this->isEnabled($overrides)) { + return ['ok' => false, 'error' => 'HealthPlix integration is disabled for this hospital.']; + } + + $baseUrl = rtrim($this->readSetting('HEALTHPLIX_BASE_URL', $overrides), '/'); + $path = trim($this->readSetting('HEALTHPLIX_PATIENT_PATH', $overrides)); + if ($baseUrl === '' || $path === '') { + return ['ok' => false, 'error' => 'HEALTHPLIX_BASE_URL and HEALTHPLIX_PATIENT_PATH are required.']; + } + + $tokenResult = $this->generateToken($overrides); + if (($tokenResult['ok'] ?? false) !== true) { + return $tokenResult; + } + + $token = (string) ($tokenResult['token'] ?? ''); + $endpoint = $this->buildUrl($baseUrl, $path); + $tenantHeaderName = trim($this->readSetting('HEALTHPLIX_TENANT_HEADER_NAME', $overrides)); + if ($tenantHeaderName === '') { + $tenantHeaderName = 'tenant_id'; + } + $tenantId = trim($this->readSetting('HEALTHPLIX_TENANT_ID', $overrides)); + + $headers = [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + $tenantHeaderName => $tenantId, + ]; + + $entityId = trim((string) ($payload['external_patient_id'] ?? ($payload['patient_id'] ?? ''))); + + $apiPayload = $this->buildPatientApiPayload($payload); + return $this->request('POST', $endpoint, $headers, $apiPayload, 'healthplix.patient.register', 'patient', $entityId); + } + + /** + * @param array $payload + * @param array $overrides + * @return array + */ + public function bookAppointment(array $payload, array $overrides = []): array + { + if (! $this->isEnabled($overrides)) { + return ['ok' => false, 'error' => 'HealthPlix integration is disabled for this hospital.']; + } + + $baseUrl = rtrim($this->readSetting('HEALTHPLIX_BASE_URL', $overrides), '/'); + $path = trim($this->readSetting('HEALTHPLIX_APPOINTMENT_PATH', $overrides)); + if ($baseUrl === '' || $path === '') { + return ['ok' => false, 'error' => 'HEALTHPLIX_BASE_URL and HEALTHPLIX_APPOINTMENT_PATH are required.']; + } + + $tokenResult = $this->generateToken($overrides); + if (($tokenResult['ok'] ?? false) !== true) { + return $tokenResult; + } + + $token = (string) ($tokenResult['token'] ?? ''); + $endpoint = $this->buildUrl($baseUrl, $path); + $tenantHeaderName = trim($this->readSetting('HEALTHPLIX_TENANT_HEADER_NAME', $overrides)); + if ($tenantHeaderName === '') { + $tenantHeaderName = 'tenant_id'; + } + $tenantId = trim($this->readSetting('HEALTHPLIX_TENANT_ID', $overrides)); + + $headers = [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + $tenantHeaderName => $tenantId, + ]; + + $entityId = trim((string) ($payload['external_appointment_id'] ?? ($payload['appointment_id'] ?? ''))); + + $apiPayload = $this->buildAppointmentApiPayload($payload); + return $this->request('POST', $endpoint, $headers, $apiPayload, 'healthplix.appointment.book', 'opd', $entityId); + } + + /** + * @param array $overrides + */ + private function isEnabled(array $overrides = []): bool + { + return $this->readSetting('HEALTHPLIX_ENABLED', $overrides) === '1'; + } + + /** + * Map internal patient payload to HealthPlix API v1 field names. + * Ref: API Spec Doc v1.7 - Register Patient (POST v1/patient/register) + * @param array $payload + * @return array + */ + private function buildPatientApiPayload(array $payload): array + { + $genderRaw = strtolower(trim((string) ($payload['gender'] ?? ''))); + $genderMap = [ + 'male' => 'Male', 'm' => 'Male', '1' => 'Male', + 'female' => 'Female', 'f' => 'Female', '2' => 'Female', + 'other' => 'Others', 'o' => 'Others', + ]; + $gender = $genderMap[$genderRaw] ?? 'Others'; + + $honorific = trim((string) ($payload['honorific'] ?? '')); + if ($honorific === '') { + $honorific = ($gender === 'Female') ? 'Mrs.' : 'Mr.'; + } + + $patientCode = trim((string) ($payload['patient_code'] ?? ($payload['external_patient_id'] ?? ''))); + $patientIdentifier = $patientCode !== '' ? $patientCode : (string) ($payload['external_patient_id'] ?? ''); + + // Minimum 3 characters required by API + if (mb_strlen($patientIdentifier) < 3) { + $patientIdentifier = str_pad($patientIdentifier, 3, '0', STR_PAD_LEFT); + } + + $api = [ + 'patient_identifier' => $patientIdentifier, + 'name' => trim((string) ($payload['full_name'] ?? '')), + 'phone' => trim((string) ($payload['mobile'] ?? '')), + 'gender' => $gender, + 'honorific' => $honorific, + ]; + + // HealthPlix expects dob; derive from estimate_dob/age when explicit DOB is missing. + $resolvedDob = $this->resolveDobForApi($payload); + if ($resolvedDob !== '') { + $api['dob'] = $resolvedDob; + } + + // Optional fields — only send if non-empty + foreach ([ + 'email' => 'email', + 'address_line1' => 'address', + 'city' => 'city', + 'zip' => 'pincode', + 'phone_secondary' => 'phone_secondary', + 'occupation' => 'occupation', + ] as $internalKey => $apiKey) { + $val = trim((string) ($payload[$internalKey] ?? '')); + if ($val !== '') { + $api[$apiKey] = $val; + } + } + + return $api; + } + + /** + * Resolve DOB in YYYY-MM-DD from explicit dob, estimate_dob, or age values. + * + * @param array $payload + */ + private function resolveDobForApi(array $payload): string + { + $dob = $this->normalizeDateForApi((string) ($payload['dob'] ?? '')); + if ($dob !== '') { + return $dob; + } + + $estimateDob = $this->normalizeDateForApi((string) ($payload['estimate_dob'] ?? '')); + if ($estimateDob !== '') { + return $estimateDob; + } + + $ageYears = $this->parseNonNegativeInt($payload['age_years'] ?? ($payload['age'] ?? null)); + $ageMonths = $this->parseNonNegativeInt($payload['age_months'] ?? ($payload['age_in_month'] ?? null)); + + if ($ageYears === null && $ageMonths === null) { + return ''; + } + + try { + $dobFromAge = new \DateTime('today'); + if ($ageYears !== null && $ageYears > 0) { + $dobFromAge->sub(new \DateInterval('P' . $ageYears . 'Y')); + } + if ($ageMonths !== null && $ageMonths > 0) { + $dobFromAge->sub(new \DateInterval('P' . $ageMonths . 'M')); + } + + return $dobFromAge->format('Y-m-d'); + } catch (\Throwable $e) { + return ''; + } + } + + private function normalizeDateForApi(string $value): string + { + $value = trim($value); + if ($value === '' || $value === '0000-00-00') { + return ''; + } + + try { + return (new \DateTime($value))->format('Y-m-d'); + } catch (\Throwable $e) { + return ''; + } + } + + /** + * @param mixed $value + */ + private function parseNonNegativeInt($value): ?int + { + if (is_int($value)) { + return $value >= 0 ? $value : null; + } + + $text = trim((string) $value); + if ($text === '') { + return null; + } + + if (preg_match('/\d+/', $text, $m) !== 1) { + return null; + } + + return max(0, (int) $m[0]); + } + + /** + * Map internal appointment payload to HealthPlix API v1 field names. + * Ref: API Spec Doc v1.7 - Register Appointment (POST v1/appointment/register) + * @param array $payload + * @return array + */ + private function buildAppointmentApiPayload(array $payload): array + { + $patientCode = trim((string) ($payload['patient_identifier'] ?? '')); + if (mb_strlen($patientCode) < 3) { + $patientCode = str_pad($patientCode, 3, '0', STR_PAD_LEFT); + } + + // Parse appointment date/time from stored datetime string + $datetimeRaw = trim((string) ($payload['appointment_datetime'] ?? '')); + $appntDate = ''; + $appntTime = ''; + + if ($datetimeRaw !== '') { + try { + $dt = new \DateTime($datetimeRaw); + $appntDate = $dt->format('Y-m-d'); + $appntTime = $dt->format('H:i:s'); + } catch (\Throwable $e) { + // fallback to today below + } + } + + if ($appntDate === '') { + $appntDate = date('Y-m-d'); + } + if ($appntTime === '') { + $appntTime = date('H:i:s'); + } + + return [ + 'patient_identifier' => $patientCode, + 'doctor_identifier' => trim((string) ($payload['doctor_identifier'] ?? '')), + 'appnt_date' => $appntDate, + 'appnt_time' => $appntTime, + 'appnt_duration' => (int) ($payload['appnt_duration'] ?? 30), + 'service_identifier' => trim((string) ($payload['service_identifier'] ?? '')), + ]; + } + + /** + * @param array $overrides + */ + private function readSetting(string $name, array $overrides = []): string + { + if (array_key_exists($name, $overrides)) { + return trim((string) $overrides[$name]); + } + + $envValue = trim((string) env($name, '')); + if ($envValue !== '') { + return $envValue; + } + + if (defined($name)) { + $defined = trim((string) constant($name)); + if ($defined !== '') { + return $defined; + } + } + + try { + $db = db_connect(); + if ($db && method_exists($db, 'tableExists') && $db->tableExists('hospital_setting')) { + $row = $db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->get(1) + ->getRowArray(); + + $value = trim((string) ($row['s_value'] ?? '')); + if ($value !== '') { + return $value; + } + } + } catch (\Throwable $e) { + } + + return ''; + } + + private function buildUrl(string $baseUrl, string $path): string + { + if (preg_match('~^https?://~i', $path) === 1) { + return $path; + } + + return $baseUrl . '/' . ltrim($path, '/'); + } + + /** + * @param array $json + * @return array + */ + private function request( + string $method, + string $url, + array $headers, + array $json, + string $eventType = '', + string $entityType = '', + string $entityId = '' + ): array + { + $options = [ + 'timeout' => 20, + 'http_errors' => false, + ]; + + if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) { + $options['verify'] = false; + } + + try { + $client = service('curlrequest', $options); + $requestOptions = ['headers' => $headers]; + if (! empty($json)) { + $requestOptions['json'] = $json; + } + $response = $client->request($method, $url, $requestOptions); + + $status = $response->getStatusCode(); + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + if (! is_array($decoded)) { + $decoded = []; + } + + if ($status < 200 || $status >= 300) { + $this->logApiCall($eventType, $url, $method, $entityType, $entityId, $json, $status, $body, 'error', 'HTTP ' . $status); + + return [ + 'ok' => false, + 'status' => $status, + 'error' => 'HealthPlix API request failed (HTTP ' . $status . ')', + 'body' => mb_substr(trim($body), 0, 600), + 'data' => $decoded, + ]; + } + + $this->logApiCall($eventType, $url, $method, $entityType, $entityId, $json, $status, $body, 'success', ''); + + return [ + 'ok' => true, + 'status' => $status, + 'data' => $decoded, + 'body' => mb_substr(trim($body), 0, 600), + ]; + } catch (\Throwable $e) { + $this->logApiCall($eventType, $url, $method, $entityType, $entityId, $json, 0, '', 'error', $e->getMessage()); + + return [ + 'ok' => false, + 'status' => 0, + 'error' => 'HealthPlix API request failed: ' . $e->getMessage(), + ]; + } + } + + /** + * @param array $requestPayload + */ + private function logApiCall( + string $eventType, + string $endpoint, + string $method, + string $entityType, + string $entityId, + array $requestPayload, + int $responseCode, + string $responseBody, + string $status, + string $errorMessage + ): void { + if ($eventType === '') { + return; + } + + try { + $db = db_connect(); + if (! $db || ! method_exists($db, 'tableExists') || ! $db->tableExists('abdm_api_logs')) { + return; + } + + $requestJson = (string) json_encode($requestPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $decodedBody = json_decode($responseBody, true); + $responseJson = is_array($decodedBody) + ? (string) json_encode($decodedBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + : trim($responseBody); + + $db->table('abdm_api_logs')->insert([ + 'channel' => 'healthplix', + 'event_type' => $eventType, + 'endpoint' => $endpoint, + 'http_method' => strtoupper($method), + 'entity_type' => $entityType !== '' ? $entityType : null, + 'entity_id' => $entityId !== '' ? $entityId : null, + 'request_json' => $requestJson, + 'response_code' => $responseCode > 0 ? $responseCode : null, + 'response_json' => $responseJson !== '' ? mb_substr($responseJson, 0, 4000) : null, + 'status' => $status, + 'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 1000) : null, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // Logging must never block business flow. + } + } +} diff --git a/app/Views/Setting/Admin/healthplix_settings.php b/app/Views/Setting/Admin/healthplix_settings.php new file mode 100644 index 0000000..eae0ea4 --- /dev/null +++ b/app/Views/Setting/Admin/healthplix_settings.php @@ -0,0 +1,195 @@ +
+
+

HealthPlix Integration

+ API Version: V1 +
+
+ +
+ Base URL (QA/Dev): https://consultation-edge-dev.healthplix.com  |  + Doc version: 1.7 (26-Apr-2025)  |  + All paths below are pre-filled as per official API Specification Doc. +
+ +
+ > + +
Keep disabled for hospitals that do not use HealthPlix.
+
+ +
+
+ + +
QA/Dev URL from HealthPlix doc. Production URL is provided by HealthPlix team.
+
+
+ + +
Header name for tenant ID in API requests. Default: tenant_id (do NOT put your Tenant ID value here).
+
+
+ +
+
API Endpoints V1
+
Paths are relative to the Base URL above. Pre-filled from HealthPlix API Specification Doc v1.7.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ + ✓ Key saved (). Leave blank to keep existing. + + No tenant key configured yet. + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+
+ + diff --git a/app/Views/Setting/Admin/index.php b/app/Views/Setting/Admin/index.php index 23c9d00..6bcc760 100644 --- a/app/Views/Setting/Admin/index.php +++ b/app/Views/Setting/Admin/index.php @@ -68,6 +68,12 @@ AI Settings + + +
+ + +
+
diff --git a/app/Views/Setting/Doctor/Doctor_profile_V.php b/app/Views/Setting/Doctor/Doctor_profile_V.php index 8806ab4..042ad38 100644 --- a/app/Views/Setting/Doctor/Doctor_profile_V.php +++ b/app/Views/Setting/Doctor/Doctor_profile_V.php @@ -1,6 +1,7 @@ {$hpField}) && trim((string) $data[0]->{$hpField}) !== '') { + $doctorHealthplixIdentifier = trim((string) $data[0]->{$hpField}); + break; + } + } } ?> dob)) {
+ +
+ + +
+
diff --git a/app/Views/billing/Person_profile_V.php b/app/Views/billing/Person_profile_V.php index 050516a..c3a8d3e 100644 --- a/app/Views/billing/Person_profile_V.php +++ b/app/Views/billing/Person_profile_V.php @@ -111,6 +111,15 @@
ABHA ID
+ healthplix_sync_id ?? '')); ?> + +
+
HealthPlix ID
+
+ +
+
+
@@ -481,6 +516,43 @@ $paymentHistoryRows = $payment_history_rows ?? []; } }); + $('#btn_healthplix_push').click(function() { + var btn = $(this); + var opdId = btn.data('opd-id'); + var csrf = getCsrfPair(); + btn.prop('disabled', true).text('Syncing...'); + $.post('/' + opdId, { + [csrf.name]: csrf.value + }, function(data) { + updateCsrf(data); + if (data.ok == 1) { + btn.removeClass('btn-danger').addClass('btn-success') + .text('HealthPlix ✓') + .prop('disabled', true); + // Show sync ID below the button + var syncId = (data.log && (data.log.sync_id || data.log.entity_id)) || ''; + if (syncId) { + $('#hp_opd_sync_id_display').text('🔗 ' + syncId).show(); + } + if (typeof notify === 'function') { + notify('success', 'HealthPlix', data.message || 'Appointment synced.'); + } + } else { + btn.prop('disabled', false).text('HealthPlix — Retry'); + if (typeof notify === 'function') { + notify('error', 'HealthPlix Sync Failed', data.message || 'Sync failed. Check HealthPlix settings.'); + } else { + alert(data.message || 'HealthPlix sync failed. Check settings.'); + } + } + }, 'json').fail(function() { + btn.prop('disabled', false).text('HealthPlix — Retry'); + if (typeof notify === 'function') { + notify('error', 'HealthPlix', 'Network error. Try again.'); + } + }); + }); + $('#btn_cr_org').click(function() { var csrf = getCsrfPair(); if (confirm('Are you sure process this invoice')) { diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index f32ed6d..b8a2cde 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -77,7 +77,19 @@ - +