From 18505968bd6420fb9d23f31f85d122dfb1efb58f Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 8 Oct 2026 12:46:12 +0530 Subject: [PATCH] feat(abdm): add automatic invoice linking and syncOpenInvoices for OPD consultation fees and charge invoices --- app/Commands/AbdmPushSync.php | 1 + app/Controllers/AbdmGateway.php | 20 +- app/Controllers/Opd.php | 9 + .../Abdm/Sync/AbdmTaskBoardSyncService.php | 239 +++++++++++++++++- 4 files changed, 264 insertions(+), 5 deletions(-) diff --git a/app/Commands/AbdmPushSync.php b/app/Commands/AbdmPushSync.php index d68e3d8..dfab06a 100644 --- a/app/Commands/AbdmPushSync.php +++ b/app/Commands/AbdmPushSync.php @@ -56,6 +56,7 @@ class AbdmPushSync extends BaseCommand CLI::write('OPD Consults -> Eligible: ' . ($tbSummary['opd']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['opd']['linked'] ?? 0) . ' | Cooling: ' . ($tbSummary['opd']['cooling'] ?? 0) . ' | Failed: ' . ($tbSummary['opd']['failed'] ?? 0), 'green'); CLI::write('Work Tasks -> Eligible: ' . ($tbSummary['tasks']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['tasks']['linked'] ?? 0) . ' | Cooling: ' . ($tbSummary['tasks']['cooling'] ?? 0) . ' | Failed: ' . ($tbSummary['tasks']['failed'] ?? 0), 'green'); + CLI::write('Invoices -> Eligible: ' . ($tbSummary['invoices']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['invoices']['linked'] ?? 0) . ' | Failed: ' . ($tbSummary['invoices']['failed'] ?? 0), 'green'); // 3. Process pending health_records directly (instant background sync for newly saved records) $this->pushPendingHealthRecords($limit); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 2be0f4c..03f7192 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -10557,8 +10557,14 @@ class AbdmGateway extends BaseController return ['ok' => 1, 'status' => 'queued_for_cron']; case 'invoice': + case 'opd_invoice': + case 'charges_invoice': + case 'ipd_invoice': $abhaId = $patientId > 0 ? $this->resolvePatientAbhaIdentifier($patientId) : ''; - $payload = $this->buildInvoiceRecordPayload($entityId, $patientId, $abhaId); + $source = in_array($docType, ['opd_invoice', 'charges_invoice', 'ipd_invoice'], true) ? $docType : (string) ($extra['source'] ?? ''); + $payload = $source !== '' + ? $this->buildInvoiceSourceRecordPayload($source, $entityId, $patientId, $abhaId) + : $this->buildInvoiceRecordPayload($entityId, $patientId, $abhaId); if ($payload === null) { return ['ok' => 0, 'status' => 'payload_null']; } @@ -10759,7 +10765,7 @@ class AbdmGateway extends BaseController ]; } - private function buildInvoiceRecordPayload(int $invoiceId, int $patientId, string $abhaId): ?array + public function buildInvoiceRecordPayload(int $invoiceId, int $patientId, string $abhaId): ?array { if ($this->db->tableExists('invoice_master')) { $invoice = $this->db->table('invoice_master')->where('id', $invoiceId)->get(1)->getRowArray() ?? []; @@ -10836,10 +10842,18 @@ class AbdmGateway extends BaseController } } + // Support OPD registration / consultation fee invoice (opd_master) + if ($this->db->tableExists('opd_master')) { + $opdPayload = $this->buildInvoiceSourceRecordPayload('opd_invoice', $invoiceId, $patientId, $abhaId); + if ($opdPayload !== null) { + return $opdPayload; + } + } + return null; } - private function buildInvoiceSourceRecordPayload(string $source, int $billId, int $patientId, string $abhaId): ?array + public function buildInvoiceSourceRecordPayload(string $source, int $billId, int $patientId, string $abhaId): ?array { $invoice = []; $items = []; diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index a50caeb..9198e9f 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -1756,6 +1756,15 @@ class Opd extends BaseController $this->auditClinicalUpdate('patient_master', $abhaField, $pid, $oldAbha, $abhaAddress); } } + + // Queue OPD consultation fee invoice for ABDM linking + if ((float) ($docFee[0]->amount ?? 0) > 0 && class_exists('\App\Controllers\AbdmGateway')) { + try { + \App\Controllers\AbdmGateway::autoPushRecord('opd_invoice', $insertId, $pid); + } catch (\Throwable $ie) { + log_message('warning', '[insert_opd_patient] autoPushRecord opd_invoice error: ' . $ie->getMessage()); + } + } } return $this->response->setJSON([ diff --git a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php index 695770f..cae73e4 100644 --- a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php +++ b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php @@ -35,10 +35,12 @@ class AbdmTaskBoardSyncService { $opdSummary = $this->syncOpdConsultRecords($limit, $dryRun); $tasksSummary = $this->syncOpenWorkTasks($limit, $dryRun); + $invoicesSummary = $this->syncOpenInvoices($limit, $dryRun); return [ - 'opd' => $opdSummary, - 'tasks' => $tasksSummary, + 'opd' => $opdSummary, + 'tasks' => $tasksSummary, + 'invoices' => $invoicesSummary, ]; } @@ -1503,5 +1505,238 @@ class AbdmTaskBoardSyncService } } } + + /** + * Scan and link paid invoices (OPD registration fees, IPD bills, Charge invoices) + * for patients who have an ABHA identity. + * + * @return array{ + * eligible: int, + * linked: int, + * failed: int, + * skipped: int, + * details: array> + * } + */ + public function syncOpenInvoices(int $limit = 20, bool $dryRun = false): array + { + $summary = [ + 'eligible' => 0, + 'linked' => 0, + 'failed' => 0, + 'skipped' => 0, + 'details' => [], + ]; + + if (! $this->db->tableExists('patient_master')) { + return $summary; + } + + $patientFields = $this->db->getFieldNames('patient_master') ?? []; + $abhaCol = $this->resolveFirstExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']); + if ($abhaCol === null) { + return $summary; + } + + $candidates = []; + + // 1. OPD Invoices (from opd_master where opd_fee_amount > 0) + if ($this->db->tableExists('opd_master')) { + $rows = $this->db->table('opd_master o') + ->select('o.opd_id as bill_id, o.opd_code as bill_code, o.p_id as patient_id, o.P_name as patient_name, o.apointment_date as bill_date, o.opd_fee_amount as amount, p.' . $abhaCol . ' as abha_id', false) + ->join('patient_master p', 'p.id = o.p_id', 'left') + ->where('o.opd_fee_amount >', 0) + ->where('DATE(o.apointment_date) >=', date('Y-m-d', strtotime('-30 days')), false) + ->where('p.' . $abhaCol . ' !=', '') + ->orderBy('o.opd_id', 'DESC') + ->limit(max(50, $limit * 2)) + ->get() + ->getResultArray(); + + foreach ($rows as $r) { + $candidates[] = array_merge($r, ['source' => 'opd_invoice']); + } + } + + // 2. Charges Invoices (from invoice_master where net_amount > 0) + if ($this->db->tableExists('invoice_master')) { + $rows = $this->db->table('invoice_master i') + ->select('i.id as bill_id, i.invoice_code as bill_code, i.attach_id as patient_id, i.inv_name as patient_name, i.inv_date as bill_date, i.net_amount as amount, p.' . $abhaCol . ' as abha_id', false) + ->join('patient_master p', 'p.id = i.attach_id AND i.attach_type = 0', 'left') + ->where('i.net_amount >', 0) + ->where('DATE(i.inv_date) >=', date('Y-m-d', strtotime('-30 days')), false) + ->where('p.' . $abhaCol . ' !=', '') + ->orderBy('i.id', 'DESC') + ->limit(max(50, $limit * 2)) + ->get() + ->getResultArray(); + + foreach ($rows as $r) { + $candidates[] = array_merge($r, ['source' => 'charges_invoice']); + } + } + + if (empty($candidates)) { + return $summary; + } + + // Filter valid ABHA + $validCandidates = []; + foreach ($candidates as $c) { + $rawAbha = trim((string) ($c['abha_id'] ?? '')); + if (preg_match('/^\d{14}$/', $rawAbha) === 1 || str_contains($rawAbha, '@')) { + $validCandidates[] = $c; + } + } + + if (empty($validCandidates)) { + return $summary; + } + + // Check health_records to see which are already linked + $linkedKeys = []; + if ($this->db->tableExists('health_records')) { + $existing = $this->db->table('health_records') + ->select('entity_type, entity_id, care_context_reference, push_status') + ->where('hi_type', 'InvoiceRecord') + ->whereIn('push_status', ['linked', 'pushed', 'queued']) + ->get() + ->getResultArray(); + foreach ($existing as $ex) { + $linkedKeys[$ex['entity_type'] . ':' . $ex['entity_id']] = true; + if (! empty($ex['care_context_reference'])) { + $linkedKeys[$ex['care_context_reference']] = true; + } + } + } + + $gw = class_exists('\App\Controllers\AbdmGateway') ? new \App\Controllers\AbdmGateway() : null; + $processedCount = 0; + + foreach ($validCandidates as $cand) { + if ($processedCount >= $limit) { + break; + } + + $source = (string) $cand['source']; + $billId = (int) $cand['bill_id']; + $patientId = (int) $cand['patient_id']; + $billDate = ! empty($cand['bill_date']) ? date('Y-m-d', strtotime((string) $cand['bill_date'])) : date('Y-m-d'); + $sourcePrefix = $source === 'opd_invoice' ? 'OPD' : ($source === 'ipd_invoice' ? 'IPD' : 'CHG'); + $careContextRef = 'INVOICE-' . $sourcePrefix . '-' . $billId . '-' . $billDate; + + if (isset($linkedKeys[$source . ':' . $billId]) || isset($linkedKeys[$careContextRef])) { + $summary['skipped']++; + continue; + } + + $summary['eligible']++; + + if ($dryRun) { + $summary['details'][] = [ + 'source' => $source, + 'bill_id' => $billId, + 'care_context' => $careContextRef, + 'status' => 'dry_run_eligible', + ]; + $processedCount++; + continue; + } + + if ($gw === null) { + $summary['failed']++; + continue; + } + + // Build payload and push + try { + $payload = $gw->buildInvoiceSourceRecordPayload($source, $billId, $patientId, (string) ($cand['abha_id'] ?? '')); + if ($payload === null) { + $summary['failed']++; + $summary['details'][] = [ + 'source' => $source, + 'bill_id' => $billId, + 'care_context' => $careContextRef, + 'error' => 'Could not build invoice payload', + 'status' => 'failed', + ]; + continue; + } + + $demo = $this->resolvePatientMasterDemographics($patientId, (string) ($cand['patient_name'] ?? ''), (string) ($cand['abha_id'] ?? '')); + $pushData = [ + 'patient_id' => (string) $patientId, + 'patient_name' => $demo['patient_name'], + 'abha_id' => $demo['abha_number'], + 'abha_address' => $demo['abha_address'], + 'year_of_birth' => $demo['year_of_birth'], + 'gender' => $demo['gender'], + 'hi_type' => 'InvoiceRecord', + 'record_type' => 'InvoiceRecord', + 'visit_date' => $billDate, + 'care_context_reference' => $careContextRef, + 'care_context_display' => (string) ($payload['care_context_display'] ?? ('Invoice ' . $billId)), + 'notes' => (string) ($payload['care_context_display'] ?? ('Invoice ' . $billId)), + 'queue_id' => $careContextRef, + 'record_data' => $payload['bundle'], + ]; + + $bundleJson = json_encode($payload['bundle'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $result = $this->connector->pushRecord($pushData); + $ok = (int) ($result['ok'] ?? 0); + $httpCode = (int) ($result['http_code'] ?? 0); + $statusVal = strtolower((string) ($result['status'] ?? '')); + + if ($ok === 1 || in_array($httpCode, [200, 201, 202, 409], true) || in_array($statusVal, ['queued', 'pushed', 'linked', 'duplicate'], true)) { + $queueId = (string) ($result['queue_id'] ?? $careContextRef); + + $this->recordLinkedState( + $patientId, + $demo['effective_abha'], + 'InvoiceRecord', + $source, + (string) $billId, + $careContextRef, + $queueId, + (int) ($result['record_id'] ?? 0), + $bundleJson + ); + + $summary['linked']++; + $summary['details'][] = [ + 'source' => $source, + 'bill_id' => $billId, + 'care_context' => $careContextRef, + 'queue_id' => $queueId, + 'status' => 'linked', + ]; + $linkedKeys[$source . ':' . $billId] = true; + $linkedKeys[$careContextRef] = true; + } else { + $summary['failed']++; + $summary['details'][] = [ + 'source' => $source, + 'bill_id' => $billId, + 'care_context' => $careContextRef, + 'error' => (string) ($result['message'] ?? $result['error'] ?? 'Push failed'), + 'status' => 'failed', + ]; + } + } catch (\Throwable $e) { + $summary['failed']++; + $summary['details'][] = [ + 'source' => $source, + 'bill_id' => $billId, + 'care_context' => $careContextRef, + 'error' => $e->getMessage(), + 'status' => 'error', + ]; + } + $processedCount++; + } + + return $summary; + } }