1821 lines
79 KiB
PHP
1821 lines
79 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries\Abdm\Sync;
|
|
|
|
use App\Libraries\Abdm\AbdmConnectorFactory;
|
|
use App\Libraries\Abdm\AbdmConnectorInterface;
|
|
use App\Libraries\AbdmWorkTaskService;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use CodeIgniter\I18n\Time;
|
|
|
|
class AbdmTaskBoardSyncService
|
|
{
|
|
private BaseConnection $db;
|
|
private AbdmConnectorInterface $connector;
|
|
private AbdmWorkTaskService $taskService;
|
|
|
|
public function __construct(
|
|
?BaseConnection $db = null,
|
|
?AbdmConnectorInterface $connector = null
|
|
) {
|
|
$this->db = $db ?? db_connect();
|
|
$this->connector = $connector ?? AbdmConnectorFactory::make();
|
|
$this->taskService = new AbdmWorkTaskService();
|
|
}
|
|
|
|
/**
|
|
* Run full ABDM Work Task Board sync (both OPD Consult and Work Queue tasks).
|
|
*
|
|
* @return array{
|
|
* opd: array{eligible: int, linked: int, failed: int, skipped: int, details: array<int, array<string, mixed>>},
|
|
* tasks: array{eligible: int, linked: int, failed: int, skipped: int, details: array<int, array<string, mixed>>}
|
|
* }
|
|
*/
|
|
public function syncAll(int $limit = 20, bool $dryRun = false): array
|
|
{
|
|
$opdSummary = $this->syncOpdConsultRecords($limit, $dryRun);
|
|
$tasksSummary = $this->syncOpenWorkTasks($limit, $dryRun);
|
|
$invoicesSummary = $this->syncOpenInvoices($limit, $dryRun);
|
|
|
|
return [
|
|
'opd' => $opdSummary,
|
|
'tasks' => $tasksSummary,
|
|
'invoices' => $invoicesSummary,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Scan and link Done OPD records that have an ABHA identity and a generated FHIR bundle.
|
|
*
|
|
* @return array{
|
|
* eligible: int,
|
|
* linked: int,
|
|
* failed: int,
|
|
* skipped: int,
|
|
* details: array<int, array<string, mixed>>
|
|
* }
|
|
*/
|
|
public function syncOpdConsultRecords(int $limit = 20, bool $dryRun = false): array
|
|
{
|
|
$summary = [
|
|
'eligible' => 0,
|
|
'linked' => 0,
|
|
'cooling' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'details' => [],
|
|
];
|
|
|
|
if (! $this->db->tableExists('opd_master') || ! $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;
|
|
}
|
|
|
|
// 1. Fetch done appointments from the last 30 days where patient has ABHA
|
|
$rows = $this->db->table('opd_master o')
|
|
->select('o.opd_id, o.p_id, o.P_name, o.apointment_date, o.opd_status, o.doc_name, p.' . $abhaCol . ' as abha_id, p.p_fname, p.gender, p.dob', false)
|
|
->join('patient_master p', 'p.id = o.p_id', 'left')
|
|
->where('o.opd_status', 2)
|
|
->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 * 3))
|
|
->get()
|
|
->getResultArray();
|
|
|
|
if (empty($rows)) {
|
|
return $summary;
|
|
}
|
|
|
|
// Filter for valid ABHA (either 14-digit number or contains @)
|
|
$validRows = [];
|
|
foreach ($rows as $r) {
|
|
$rawAbha = trim((string) ($r['abha_id'] ?? ''));
|
|
if (preg_match('/^\d{14}$/', $rawAbha) === 1 || str_contains($rawAbha, '@')) {
|
|
$validRows[] = $r;
|
|
}
|
|
}
|
|
|
|
if (empty($validRows)) {
|
|
return $summary;
|
|
}
|
|
|
|
$opdIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['opd_id'] ?? 0), $validRows)));
|
|
|
|
// 2. Fetch latest FHIR documents for these OPD records
|
|
$docByOpd = [];
|
|
if (! empty($opdIds) && $this->db->tableExists('opd_fhir_documents')) {
|
|
$docRows = $this->db->table('opd_fhir_documents')
|
|
->select('id, opd_id, opd_session_id, bundle_type, bundle_json, generated_at')
|
|
->whereIn('opd_id', $opdIds)
|
|
->whereIn('bundle_type', ['OPConsultRecord', 'MedicationRequestBundle', 'PrescriptionRecord'])
|
|
->orderBy("CASE WHEN bundle_type = 'OPConsultRecord' THEN 1 ELSE 2 END", 'ASC', false)
|
|
->orderBy('id', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($docRows as $doc) {
|
|
$oid = (int) ($doc['opd_id'] ?? 0);
|
|
if ($oid > 0 && ! isset($docByOpd[$oid])) {
|
|
$docByOpd[$oid] = $doc;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fetch existing health_records
|
|
$hrByOpd = [];
|
|
if (! empty($opdIds) && $this->db->tableExists('health_records')) {
|
|
$sessionIds = [];
|
|
foreach ($docByOpd as $d) {
|
|
$sId = (int) ($d['opd_session_id'] ?? 0);
|
|
if ($sId > 0) {
|
|
$sessionIds[] = (string) $sId;
|
|
}
|
|
}
|
|
|
|
$searchEntityIds = array_merge(
|
|
array_map(static fn ($v) => (string) $v, $opdIds),
|
|
$sessionIds
|
|
);
|
|
|
|
$hrRows = $this->db->table('health_records')
|
|
->select('id, entity_id, push_status, abdm_txn_id, bridge_record_id, care_context_reference, push_at, linked_at, updated_at')
|
|
->where('entity_type', 'opd')
|
|
->whereIn('entity_id', $searchEntityIds)
|
|
->orderBy('id', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($hrRows as $hr) {
|
|
$eid = (int) ($hr['entity_id'] ?? 0);
|
|
if ($eid <= 0) {
|
|
continue;
|
|
}
|
|
if (! isset($hrByOpd[$eid])) {
|
|
$hrByOpd[$eid] = $hr;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Process each eligible row
|
|
$processedCount = 0;
|
|
foreach ($validRows as $row) {
|
|
if ($processedCount >= $limit) {
|
|
break;
|
|
}
|
|
|
|
$opdId = (int) ($row['opd_id'] ?? 0);
|
|
$patientId = (int) ($row['p_id'] ?? 0);
|
|
if ($opdId <= 0 || $patientId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$doc = $docByOpd[$opdId] ?? null;
|
|
if ($doc === null) {
|
|
// No FHIR bundle generated yet for this OPD
|
|
continue;
|
|
}
|
|
|
|
$sessionId = (int) ($doc['opd_session_id'] ?? 0);
|
|
|
|
// Reconcile: check if health_records row was stored under opd_id or session_id
|
|
$hr = $hrByOpd[$opdId] ?? ($sessionId > 0 ? ($hrByOpd[$sessionId] ?? null) : null);
|
|
|
|
// If stored under session_id, reconcile entity_id to opdId
|
|
if ($hr !== null && (string) ($hr['entity_id'] ?? '') === (string) $sessionId && (string) $sessionId !== (string) $opdId) {
|
|
try {
|
|
$this->db->table('health_records')
|
|
->where('id', (int) $hr['id'])
|
|
->update(['entity_id' => (string) $opdId]);
|
|
$hr['entity_id'] = (string) $opdId;
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
$pushStatus = strtolower(trim((string) ($hr['push_status'] ?? '')));
|
|
|
|
// Already pushed/submitted/linked successfully?
|
|
if (in_array($pushStatus, ['queued', 'linked', 'pushed'], true)) {
|
|
$summary['skipped']++;
|
|
continue;
|
|
}
|
|
|
|
$consultDate = (string) ($row['apointment_date'] ?? '');
|
|
$visitDate = $consultDate !== '' ? date('Y-m-d', strtotime($consultDate)) : date('Y-m-d');
|
|
$cleanDate = str_replace('-', '', $visitDate);
|
|
$derivedCcRef = 'OPD-' . $patientId . '-S' . ($sessionId > 0 ? $sessionId : 0) . '-' . $cleanDate;
|
|
|
|
$careContextRef = trim((string) ($hr['care_context_reference'] ?? ''));
|
|
if ($careContextRef === '') {
|
|
$careContextRef = $derivedCcRef;
|
|
}
|
|
|
|
// Check cooling period from the latest modification/generation timestamp
|
|
$docTimestamps = array_filter([
|
|
! empty($doc['updated_at']) ? (string) $doc['updated_at'] : null,
|
|
! empty($doc['generated_at']) ? (string) $doc['generated_at'] : null,
|
|
! empty($hr['updated_at']) ? (string) $hr['updated_at'] : null,
|
|
! empty($doc['created_at']) ? (string) $doc['created_at'] : null,
|
|
]);
|
|
$lastModified = ! empty($docTimestamps) ? max($docTimestamps) : ($row['apointment_date'] ?? date('Y-m-d H:i:s'));
|
|
|
|
$cooling = self::calculateCooling('opd_prescription_publish', $lastModified);
|
|
if ($cooling['is_cooling_active']) {
|
|
$summary['cooling']++;
|
|
$summary['details'][] = [
|
|
'opd_id' => $opdId,
|
|
'patient' => trim((string) ($row['P_name'] ?? '')),
|
|
'abha' => trim((string) ($row['abha_id'] ?? '')),
|
|
'care_context' => $careContextRef,
|
|
'last_modified' => $lastModified,
|
|
'remaining_minutes' => $cooling['remaining_minutes'],
|
|
'auto_link_at' => $cooling['auto_link_at'],
|
|
'status' => 'cooling',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
$summary['eligible']++;
|
|
|
|
if ($dryRun) {
|
|
$summary['details'][] = [
|
|
'opd_id' => $opdId,
|
|
'patient' => trim((string) ($row['P_name'] ?? '')),
|
|
'abha' => trim((string) ($row['abha_id'] ?? '')),
|
|
'care_context' => $careContextRef,
|
|
'status' => 'dry_run_eligible',
|
|
];
|
|
$processedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Perform care context linking and push to ABDM bridge
|
|
$pushResult = $this->linkAndPushOpdRecord($row, $doc, $careContextRef, $visitDate, $hr);
|
|
$processedCount++;
|
|
|
|
if ($pushResult['ok'] === 1) {
|
|
$summary['linked']++;
|
|
$summary['details'][] = [
|
|
'opd_id' => $opdId,
|
|
'patient' => trim((string) ($row['P_name'] ?? '')),
|
|
'care_context' => $careContextRef,
|
|
'queue_id' => $pushResult['queue_id'] ?? '',
|
|
'bridge_record_id' => $pushResult['bridge_record_id'] ?? null,
|
|
'status' => 'linked',
|
|
];
|
|
} else {
|
|
$summary['failed']++;
|
|
$summary['details'][] = [
|
|
'opd_id' => $opdId,
|
|
'patient' => trim((string) ($row['P_name'] ?? '')),
|
|
'care_context' => $careContextRef,
|
|
'error' => $pushResult['error'] ?? 'Unknown push error',
|
|
'status' => 'failed',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* Link and push a single OPD record to ABDM gateway/bridge.
|
|
*
|
|
* @param array<string, mixed> $opdRow
|
|
/**
|
|
* Resolve comprehensive demographics (name, gender, year_of_birth, abha_address, abha_number)
|
|
* strictly and reliably from patient_master.
|
|
*
|
|
* @return array{patient_name: string, gender: string, year_of_birth: string, abha_address: string, abha_number: string, effective_abha: string}
|
|
*/
|
|
private function resolvePatientMasterDemographics(int $patientId, string $fallbackName = '', string $fallbackAbha = ''): array
|
|
{
|
|
$patientRow = [];
|
|
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
|
|
$patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? [];
|
|
}
|
|
|
|
$patientName = trim((string) ($patientRow['p_fname'] ?? $fallbackName));
|
|
if ($patientName === '') {
|
|
$patientName = 'PATIENT-' . $patientId;
|
|
}
|
|
|
|
$genderRaw = (string) ($patientRow['gender'] ?? '');
|
|
$gender = match ((string) $genderRaw) {
|
|
'1', 'M', 'm', 'Male' => 'M',
|
|
'2', 'F', 'f', 'Female' => 'F',
|
|
default => 'O',
|
|
};
|
|
|
|
// Resolve Year of Birth (from DOB, Age, or ABHA regex)
|
|
$yearOfBirth = '';
|
|
$dob = trim((string) ($patientRow['dob'] ?? ''));
|
|
if ($dob !== '' && $dob !== '0000-00-00' && ! str_starts_with($dob, '0000')) {
|
|
$yearOfBirth = substr($dob, 0, 4);
|
|
} elseif (! empty($patientRow['age']) && (int) $patientRow['age'] > 0) {
|
|
$yearOfBirth = (string) ((int) date('Y') - (int) $patientRow['age']);
|
|
}
|
|
|
|
// Resolve ABHA Address and Number
|
|
$abhaAddress = '';
|
|
$abhaNumber = '';
|
|
$candidates = [
|
|
trim((string) ($patientRow['abha_address'] ?? '')),
|
|
trim($fallbackAbha),
|
|
trim((string) ($patientRow['abha_id'] ?? '')),
|
|
trim((string) ($patientRow['abha_no'] ?? '')),
|
|
trim((string) ($patientRow['abha'] ?? '')),
|
|
];
|
|
|
|
foreach ($candidates as $cand) {
|
|
if ($cand === '') {
|
|
continue;
|
|
}
|
|
if ($abhaAddress === '' && str_contains($cand, '@')) {
|
|
$abhaAddress = $cand;
|
|
}
|
|
$clean14 = preg_replace('/\D/', '', $cand);
|
|
if ($abhaNumber === '' && strlen($clean14) === 14) {
|
|
$abhaNumber = $clean14;
|
|
}
|
|
}
|
|
|
|
// If YOB is still empty, try extracting 4-digit year from ABHA address or number
|
|
if ($yearOfBirth === '' && ($abhaAddress !== '' || $abhaNumber !== '')) {
|
|
$src = $abhaAddress !== '' ? $abhaAddress : $abhaNumber;
|
|
if (preg_match('/(19\d{2}|20\d{2})/', $src, $m) === 1) {
|
|
$yearOfBirth = $m[1];
|
|
}
|
|
}
|
|
|
|
$effectiveAbha = $abhaAddress !== '' ? $abhaAddress : $abhaNumber;
|
|
|
|
return [
|
|
'name' => $patientName,
|
|
'patient_name' => $patientName,
|
|
'gender' => $gender,
|
|
'year_of_birth' => $yearOfBirth,
|
|
'abha_address' => $abhaAddress,
|
|
'abha_number' => $abhaNumber,
|
|
'effective_abha' => $effectiveAbha,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $opdRow
|
|
* @param array<string, mixed> $docRow
|
|
* @param array<string, mixed>|null $existingHr
|
|
* @return array{ok: int, queue_id?: string|null, bridge_record_id?: int|null, error?: string}
|
|
*/
|
|
private function linkAndPushOpdRecord(
|
|
array $opdRow,
|
|
array $docRow,
|
|
string $careContextRef,
|
|
string $visitDate,
|
|
?array $existingHr
|
|
): array {
|
|
$opdId = (int) ($opdRow['opd_id'] ?? 0);
|
|
$patientId = (int) ($opdRow['p_id'] ?? 0);
|
|
$rawAbha = trim((string) ($opdRow['abha_id'] ?? ''));
|
|
$rawName = trim((string) ($opdRow['p_fname'] ?? $opdRow['P_name'] ?? ''));
|
|
|
|
// Always resolve full patient demographics from patient_master
|
|
$demo = $this->resolvePatientMasterDemographics($patientId, $rawName, $rawAbha);
|
|
$patientName = $demo['patient_name'];
|
|
$gender = $demo['gender'];
|
|
$yearOfBirth = $demo['year_of_birth'];
|
|
$abhaAddress = $demo['abha_address'];
|
|
$abhaNumber = $demo['abha_number'];
|
|
$effectiveAbha = $demo['effective_abha'];
|
|
|
|
if ($effectiveAbha === '') {
|
|
return ['ok' => 0, 'error' => 'No valid ABHA found for patient #' . $patientId];
|
|
}
|
|
|
|
$bundleJson = (string) ($docRow['bundle_json'] ?? '{}');
|
|
$bundle = json_decode($bundleJson, true);
|
|
if (! is_array($bundle)) {
|
|
$bundle = ['raw' => $bundleJson];
|
|
}
|
|
|
|
$bundleType = trim((string) ($docRow['bundle_type'] ?? 'OPConsultRecord'));
|
|
$hiType = match ($bundleType) {
|
|
'PrescriptionRecord' => 'PrescriptionRecord',
|
|
default => 'OPConsultRecord',
|
|
};
|
|
$careContextDisplay = $hiType === 'PrescriptionRecord'
|
|
? 'Prescription - ' . $visitDate
|
|
: 'Consultation Record - ' . $visitDate;
|
|
|
|
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
|
|
// 1. Upsert into health_records
|
|
$healthRecordId = (int) ($existingHr['id'] ?? 0);
|
|
if ($healthRecordId <= 0) {
|
|
$foundHr = $this->db->table('health_records')
|
|
->select('id')
|
|
->where('care_context_reference', $careContextRef)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($foundHr['id'])) {
|
|
$healthRecordId = (int) $foundHr['id'];
|
|
}
|
|
}
|
|
$hrData = [
|
|
'patient_id' => $patientId,
|
|
'abha_id' => $effectiveAbha,
|
|
'hi_type' => $hiType,
|
|
'entity_type' => 'opd',
|
|
'entity_id' => (string) $opdId,
|
|
'care_context_reference' => $careContextRef,
|
|
'record_data' => $bundleJson,
|
|
'push_status' => 'pending',
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
try {
|
|
if ($healthRecordId > 0) {
|
|
$this->db->table('health_records')->where('id', $healthRecordId)->update($hrData);
|
|
} else {
|
|
$hrData['created_at'] = $now;
|
|
$this->db->table('health_records')->insert($hrData);
|
|
$healthRecordId = (int) $this->db->insertID();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', "[AbdmTaskBoardSync] health_records upsert error: " . $e->getMessage());
|
|
}
|
|
|
|
// 2. Push to ABDM Bridge Gateway
|
|
$pushData = [
|
|
'patient_id' => (string) $patientId,
|
|
'patient_name' => $patientName !== '' ? $patientName : ('PATIENT-' . $patientId),
|
|
'abha_id' => $abhaNumber,
|
|
'abha_address' => $abhaAddress,
|
|
'year_of_birth' => $yearOfBirth,
|
|
'gender' => $gender,
|
|
'hi_type' => $hiType,
|
|
'record_type' => $hiType,
|
|
'visit_date' => $visitDate,
|
|
'care_context_reference' => $careContextRef,
|
|
'care_context_display' => $careContextDisplay,
|
|
'notes' => $careContextDisplay,
|
|
'queue_id' => $careContextRef,
|
|
'record_data' => $bundle,
|
|
'fhir_bundle' => $bundle,
|
|
];
|
|
|
|
$queueId = null;
|
|
$bridgeRecordId = 0;
|
|
$connectorError = null;
|
|
$isSuccess = false;
|
|
|
|
try {
|
|
$result = $this->connector->pushRecord($pushData);
|
|
|
|
$okVal = (int) ($result['ok'] ?? 0);
|
|
$httpCode = (int) ($result['http_code'] ?? 0);
|
|
$statusVal = strtolower((string) ($result['status'] ?? ''));
|
|
|
|
if ($okVal === 1 || in_array($httpCode, [200, 201, 202, 409], true) || in_array($statusVal, ['queued', 'pushed', 'linked', 'duplicate'], true)) {
|
|
$isSuccess = true;
|
|
$queueId = (string) ($result['queue_id'] ?? $result['request_id'] ?? $careContextRef);
|
|
$bridgeRecordId = (int) ($result['record_id'] ?? $result['id'] ?? 0);
|
|
} else {
|
|
$connectorError = trim((string) ($result['message'] ?? $result['error_text'] ?? $result['error'] ?? 'Bridge push failed'));
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$connectorError = $e->getMessage();
|
|
log_message('warning', "[AbdmTaskBoardSync] Network exception pushing OPD #{$opdId}: " . $connectorError);
|
|
}
|
|
|
|
// 3. Update health_records with final push status
|
|
if ($healthRecordId > 0 && $this->db->tableExists('health_records')) {
|
|
$hrUpdate = [
|
|
'push_status' => $isSuccess ? 'linked' : 'failed',
|
|
'updated_at' => $now,
|
|
];
|
|
if ($queueId !== null && $queueId !== '') {
|
|
$hrUpdate['abdm_txn_id'] = $queueId;
|
|
}
|
|
if ($bridgeRecordId > 0) {
|
|
$hrFields = $this->db->getFieldNames('health_records') ?? [];
|
|
if (in_array('bridge_record_id', $hrFields, true)) {
|
|
$hrUpdate['bridge_record_id'] = $bridgeRecordId;
|
|
}
|
|
}
|
|
if ($isSuccess) {
|
|
$hrFields = $this->db->getFieldNames('health_records') ?? [];
|
|
if (in_array('push_at', $hrFields, true)) {
|
|
$hrUpdate['push_at'] = $now;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->db->table('health_records')->where('id', $healthRecordId)->update($hrUpdate);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
// 4. Update record_links
|
|
if ($this->db->tableExists('record_links')) {
|
|
try {
|
|
$existingLink = $this->db->table('record_links')
|
|
->where('care_context_reference', $careContextRef)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$linkData = [
|
|
'abha_id' => $effectiveAbha,
|
|
'care_context_reference' => $careContextRef,
|
|
'health_record_id' => $healthRecordId > 0 ? $healthRecordId : null,
|
|
'abdm_txn_id' => $queueId !== '' ? $queueId : null,
|
|
'link_status' => $isSuccess ? 'pending' : 'failed',
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
if (! empty($existingLink)) {
|
|
$this->db->table('record_links')->where('id', (int) $existingLink['id'])->update($linkData);
|
|
} else {
|
|
$linkData['created_at'] = $now;
|
|
$this->db->table('record_links')->insert($linkData);
|
|
}
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
// 5. Complete open work task if any exists
|
|
if ($isSuccess && $this->db->tableExists('abdm_work_tasks')) {
|
|
try {
|
|
$this->db->table('abdm_work_tasks')
|
|
->where('task_type', 'opd_prescription_publish')
|
|
->where('entity_id', (string) $opdId)
|
|
->whereIn('status', ['pending', 'in_progress'])
|
|
->update([
|
|
'status' => 'completed',
|
|
'last_action_result' => 'Linked by cron. Queue: ' . ($queueId ?? '-') . ' Bridge: #' . $bridgeRecordId,
|
|
'completed_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
// 6. Log API activity
|
|
if ($this->db->tableExists('abdm_api_logs')) {
|
|
try {
|
|
$this->db->table('abdm_api_logs')->insert([
|
|
'channel' => 'bridge',
|
|
'event_type' => 'abdm.opd.prescription.share.result',
|
|
'endpoint' => '/api/v3/records/push',
|
|
'http_method' => 'POST',
|
|
'entity_type' => 'opd',
|
|
'entity_id' => (string) $opdId,
|
|
'status' => $isSuccess ? 'success' : 'error',
|
|
'error_message' => $connectorError !== null ? mb_substr($connectorError, 0, 1000) : null,
|
|
'created_at' => $now,
|
|
]);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
// In ABDM standard, OPConsultRecord is a single unified bundle that already includes
|
|
// consultation note, physical examination (wellness/vitals), prescriptions (medications),
|
|
// and attached health documents. We intentionally do NOT create duplicate care contexts.
|
|
|
|
if ($isSuccess) {
|
|
return [
|
|
'ok' => 1,
|
|
'queue_id' => $queueId,
|
|
'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => 0,
|
|
'error' => $connectorError ?? 'Bridge push failed',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Process open tasks on the ABDM Work Task Board (Lab, Radiology, Immunization, Wellness, Health Docs, Discharge).
|
|
*
|
|
* @return array{
|
|
* eligible: int,
|
|
* linked: int,
|
|
* failed: int,
|
|
* skipped: int,
|
|
* details: array<int, array<string, mixed>>
|
|
* }
|
|
*/
|
|
public function syncOpenWorkTasks(int $limit = 20, bool $dryRun = false): array
|
|
{
|
|
$summary = [
|
|
'eligible' => 0,
|
|
'linked' => 0,
|
|
'cooling' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'details' => [],
|
|
];
|
|
|
|
if (! $this->db->tableExists('abdm_work_tasks')) {
|
|
return $summary;
|
|
}
|
|
|
|
// Auto-discover any newly completed clinical records with ABHA
|
|
$this->backfillMissingWorkTasks(30, 100);
|
|
|
|
$supportedTaskTypes = [
|
|
'opd_prescription_publish',
|
|
'lab_report_publish',
|
|
'radiology_report_publish',
|
|
'immunization_record_publish',
|
|
'wellness_record_publish',
|
|
'health_document_publish',
|
|
'invoice_record_publish',
|
|
'invoice_publish',
|
|
'ipd_discharge_publish',
|
|
];
|
|
|
|
// Fetch candidate batch wider than $limit to bypass cooling or retrying items without queue starvation
|
|
$tasks = $this->db->table('abdm_work_tasks')
|
|
->whereIn('task_type', $supportedTaskTypes)
|
|
->whereIn('status', ['pending', 'in_progress'])
|
|
->orderBy('id', 'ASC')
|
|
->limit(max(60, $limit * 3))
|
|
->get()
|
|
->getResultArray();
|
|
|
|
if (empty($tasks)) {
|
|
return $summary;
|
|
}
|
|
|
|
$processedCount = 0;
|
|
|
|
foreach ($tasks as $task) {
|
|
$taskId = (int) $task['id'];
|
|
$taskType = (string) $task['task_type'];
|
|
$entityId = (int) ($task['entity_id'] ?? 0);
|
|
$patientId = (int) ($task['patient_id'] ?? 0);
|
|
$abhaId = trim((string) ($task['abha_id'] ?? ''));
|
|
|
|
// Check cooling period from the actual clinical event/modification timestamp
|
|
$lastModified = self::resolveTaskClinicalTimestamp($task, $this->db);
|
|
$cooling = self::calculateCooling($taskType, $lastModified);
|
|
|
|
if ($cooling['is_cooling_active']) {
|
|
$summary['cooling']++;
|
|
$summary['details'][] = [
|
|
'task_id' => $taskId,
|
|
'task_type' => $taskType,
|
|
'entity_id' => $entityId,
|
|
'patient' => $task['patient_name'] ?? '',
|
|
'last_modified' => $lastModified,
|
|
'remaining_minutes' => $cooling['remaining_minutes'],
|
|
'auto_link_at' => $cooling['auto_link_at'],
|
|
'status' => 'cooling',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$summary['eligible']++;
|
|
$summary['details'][] = [
|
|
'task_id' => $taskId,
|
|
'task_type' => $taskType,
|
|
'entity_id' => $entityId,
|
|
'patient' => $task['patient_name'] ?? '',
|
|
'status' => 'dry_run_eligible',
|
|
];
|
|
$processedCount++;
|
|
if ($processedCount >= $limit) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$summary['eligible']++;
|
|
$result = $this->processIndividualWorkTask($task);
|
|
|
|
if ($result['ok'] === 1) {
|
|
$summary['linked']++;
|
|
$this->taskService->markTaskStatus(
|
|
$taskId,
|
|
'completed',
|
|
'Linked by cron: ' . ($result['queue_id'] ?? 'done')
|
|
);
|
|
$summary['details'][] = [
|
|
'task_id' => $taskId,
|
|
'task_type' => $taskType,
|
|
'entity_id' => $entityId,
|
|
'status' => 'linked',
|
|
'queue_id' => $result['queue_id'] ?? '',
|
|
];
|
|
} else {
|
|
$summary['failed']++;
|
|
$payload = ! empty($task['payload_json']) ? json_decode((string) $task['payload_json'], true) : [];
|
|
$retryCount = (int) ($payload['retry_count'] ?? 0) + 1;
|
|
$payload['retry_count'] = $retryCount;
|
|
$payload['last_error'] = (string) ($result['error'] ?? 'Sync failed');
|
|
$payload['last_failed_at'] = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
|
|
$errJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$this->db->table('abdm_work_tasks')->where('id', $taskId)->update(['payload_json' => $errJson]);
|
|
|
|
if ($retryCount >= 5) {
|
|
$this->taskService->markTaskStatus(
|
|
$taskId,
|
|
'failed',
|
|
'Max cron retries (5) exceeded: ' . ($result['error'] ?? 'Sync failed')
|
|
);
|
|
} else {
|
|
$this->taskService->markTaskStatus(
|
|
$taskId,
|
|
'pending', // remain pending for retry on next cron tick
|
|
'Cron retry #' . $retryCount . ' pending: ' . ($result['error'] ?? 'Sync failed')
|
|
);
|
|
}
|
|
|
|
$summary['details'][] = [
|
|
'task_id' => $taskId,
|
|
'task_type' => $taskType,
|
|
'entity_id' => $entityId,
|
|
'status' => 'failed',
|
|
'error' => $result['error'] ?? 'Sync failed',
|
|
];
|
|
}
|
|
|
|
$processedCount++;
|
|
if ($processedCount >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $task
|
|
* @return array{ok: int, queue_id?: string|null, error?: string}
|
|
*/
|
|
private function processIndividualWorkTask(array $task): array
|
|
{
|
|
$taskType = (string) ($task['task_type'] ?? '');
|
|
$entityId = (int) ($task['entity_id'] ?? 0);
|
|
$patientId = (int) ($task['patient_id'] ?? 0);
|
|
$abhaId = trim((string) ($task['abha_id'] ?? ''));
|
|
|
|
if ($entityId <= 0 || $patientId <= 0) {
|
|
return ['ok' => 0, 'error' => 'Invalid task entity or patient'];
|
|
}
|
|
|
|
// If it's an OPD prescription publish task, delegate to OPD consult push
|
|
if ($taskType === 'opd_prescription_publish') {
|
|
$docRow = null;
|
|
if ($this->db->tableExists('opd_fhir_documents')) {
|
|
$docRow = $this->db->table('opd_fhir_documents')
|
|
->where('opd_id', $entityId)
|
|
->orderBy("CASE WHEN bundle_type = 'OPConsultRecord' THEN 1 ELSE 2 END", 'ASC', false)
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
}
|
|
if (empty($docRow)) {
|
|
return ['ok' => 0, 'error' => 'No FHIR bundle for OPD #' . $entityId];
|
|
}
|
|
|
|
$opdRow = $this->db->table('opd_master')->where('opd_id', $entityId)->get(1)->getRowArray();
|
|
if (empty($opdRow)) {
|
|
return ['ok' => 0, 'error' => 'OPD record not found'];
|
|
}
|
|
|
|
$opdRow['abha_id'] = $abhaId;
|
|
$visitDate = ! empty($opdRow['apointment_date']) ? date('Y-m-d', strtotime((string) $opdRow['apointment_date'])) : date('Y-m-d');
|
|
$cleanDate = str_replace('-', '', $visitDate);
|
|
$targetPatientId = (int) ($opdRow['p_id'] ?? $patientId);
|
|
$targetSessionId = (int) ($docRow['opd_session_id'] ?? 0);
|
|
$ccRef = 'OPD-' . $targetPatientId . '-S' . $targetSessionId . '-' . $cleanDate;
|
|
|
|
return $this->linkAndPushOpdRecord($opdRow, $docRow, $ccRef, $visitDate, null);
|
|
}
|
|
|
|
// Other record types: map and push care context
|
|
$hiType = match ($taskType) {
|
|
'lab_report_publish', 'radiology_report_publish' => 'DiagnosticReportRecord',
|
|
'immunization_record_publish' => 'ImmunizationRecord',
|
|
'wellness_record_publish' => 'WellnessRecord',
|
|
'health_document_publish' => 'HealthDocumentRecord',
|
|
'invoice_record_publish', 'invoice_publish' => 'InvoiceRecord',
|
|
'ipd_discharge_publish' => 'DischargeSummaryRecord',
|
|
default => 'HealthDocumentRecord',
|
|
};
|
|
|
|
$demo = $this->resolvePatientMasterDemographics($patientId, (string) ($task['patient_name'] ?? ''), $abhaId);
|
|
$patientName = $demo['patient_name'];
|
|
$gender = $demo['gender'];
|
|
$yearOfBirth = $demo['year_of_birth'];
|
|
$abhaAddress = $demo['abha_address'];
|
|
$abhaNumber = $demo['abha_number'];
|
|
|
|
$clinTs = self::resolveTaskClinicalTimestamp($task, $this->db);
|
|
$visitDate = ! empty($clinTs) && strtotime($clinTs) > 0 ? date('Y-m-d', strtotime($clinTs)) : date('Y-m-d');
|
|
$prefix = match ($taskType) {
|
|
'radiology_report_publish' => 'RAD-',
|
|
'lab_report_publish' => 'LAB-',
|
|
'immunization_record_publish' => 'IMM-',
|
|
'wellness_record_publish' => 'WELLNESS-',
|
|
'health_document_publish' => 'DOC-',
|
|
'invoice_record_publish', 'invoice_publish' => 'INV-',
|
|
'ipd_discharge_publish' => 'DISCHARGE-',
|
|
default => 'REC-',
|
|
};
|
|
$cleanVisitDate = str_replace('-', '', $visitDate);
|
|
$careContextRef = $prefix . $entityId . '-' . $cleanVisitDate;
|
|
if ($taskType === 'immunization_record_publish' && $this->db->tableExists('immunization_records')) {
|
|
$immRec = $this->db->table('immunization_records')->select('abdm_care_context_reference, vaccine_name')->where('id', (int) $entityId)->get(1)->getRowArray();
|
|
if (! empty($immRec['abdm_care_context_reference'])) {
|
|
$careContextRef = trim((string) $immRec['abdm_care_context_reference']);
|
|
} else {
|
|
$careContextRef = 'IMM-' . $entityId;
|
|
}
|
|
}
|
|
if ($taskType === 'wellness_record_publish' && $this->db->tableExists('patient_wellness_records')) {
|
|
$wRec = $this->db->table('patient_wellness_records')->where('id', (int) $entityId)->get(1)->getRowArray();
|
|
if (! empty($wRec)) {
|
|
if (! empty($wRec['care_context_reference'])) {
|
|
$careContextRef = trim((string) $wRec['care_context_reference']);
|
|
} else {
|
|
$careContextRef = 'WELLNESS-' . $patientId . '-W' . $entityId . '-' . $cleanVisitDate;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($taskType === 'immunization_record_publish' && ! empty($immRec['vaccine_name'])) {
|
|
$careContextDisplay = 'ImmunizationRecord - ' . trim((string) $immRec['vaccine_name']) . ' (' . date('d M Y', strtotime($visitDate)) . ')';
|
|
} else {
|
|
$careContextDisplay = $hiType . ' ' . $visitDate;
|
|
}
|
|
|
|
$pushData = [
|
|
'patient_id' => (string) $patientId,
|
|
'patient_name' => $patientName !== '' ? $patientName : ('PATIENT-' . $patientId),
|
|
'abha_id' => $abhaNumber,
|
|
'abha_address' => $abhaAddress,
|
|
'year_of_birth' => $yearOfBirth,
|
|
'gender' => $gender,
|
|
'hi_type' => $hiType,
|
|
'record_type' => $hiType,
|
|
'visit_date' => $visitDate,
|
|
'care_context_reference' => $careContextRef,
|
|
'care_context_display' => $careContextDisplay,
|
|
'notes' => $careContextDisplay,
|
|
'queue_id' => $careContextRef,
|
|
];
|
|
|
|
$bundleJson = null;
|
|
if ($taskType === 'wellness_record_publish') {
|
|
if ($this->db->tableExists('patient_wellness_records')) {
|
|
$wRec = $this->db->table('patient_wellness_records')->where('id', (int) $entityId)->get(1)->getRowArray();
|
|
if (! empty($wRec['fhir_bundle_json'])) {
|
|
$decodedBundle = json_decode((string) $wRec['fhir_bundle_json'], true);
|
|
if (is_array($decodedBundle) && ! empty($decodedBundle)) {
|
|
$pushData['bundle'] = $decodedBundle;
|
|
$pushData['fhir_bundle'] = $decodedBundle;
|
|
$pushData['record_data'] = $decodedBundle;
|
|
$bundleJson = (string) $wRec['fhir_bundle_json'];
|
|
}
|
|
}
|
|
}
|
|
if (empty($pushData['bundle']) && class_exists('\App\Controllers\DoctorDocument')) {
|
|
try {
|
|
$docCtrl = new \App\Controllers\DoctorDocument();
|
|
$wSource = $docCtrl->buildWellnessRecordSource($patientId, $entityId);
|
|
if (! empty($wSource)) {
|
|
$wFactory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
|
|
$wGen = $wFactory->wellness()->generate($wSource);
|
|
if (! empty($wGen['bundle'])) {
|
|
$pushData['bundle'] = $wGen['bundle'];
|
|
$pushData['fhir_bundle'] = $wGen['bundle'];
|
|
$pushData['record_data'] = $wGen['bundle'];
|
|
$bundleJson = (string) json_encode($wGen['bundle'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
}
|
|
} catch (\Throwable $we) {
|
|
log_message('warning', '[processIndividualWorkTask] Wellness bundle generation error: ' . $we->getMessage());
|
|
}
|
|
}
|
|
} elseif ($taskType === 'ipd_discharge_publish') {
|
|
if ($this->db->tableExists('abdm_sync_record')) {
|
|
$syncRec = $this->db->table('abdm_sync_record')
|
|
->where('local_record_id', 'ipd-discharge-' . $entityId)
|
|
->orWhere('care_context_reference', $careContextRef)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($syncRec['fhir_bundle_json'])) {
|
|
$decodedBundle = json_decode((string) $syncRec['fhir_bundle_json'], true);
|
|
if (is_array($decodedBundle) && ! empty($decodedBundle)) {
|
|
$pushData['bundle'] = $decodedBundle;
|
|
$pushData['fhir_bundle'] = $decodedBundle;
|
|
$pushData['record_data'] = $decodedBundle;
|
|
$bundleJson = (string) $syncRec['fhir_bundle_json'];
|
|
}
|
|
}
|
|
}
|
|
} elseif ($taskType === 'immunization_record_publish') {
|
|
if (empty($pushData['bundle']) && class_exists('\App\Controllers\AbdmGateway')) {
|
|
try {
|
|
$gw = new \App\Controllers\AbdmGateway();
|
|
$immPayload = $gw->buildImmunizationRecordPayload((int) $entityId, $patientId, $abhaAddress !== '' ? $abhaAddress : $abhaNumber);
|
|
if (! empty($immPayload['bundle'])) {
|
|
$pushData['bundle'] = $immPayload['bundle'];
|
|
$pushData['fhir_bundle'] = $immPayload['bundle'];
|
|
$pushData['record_data'] = $immPayload['bundle'];
|
|
$bundleJson = (string) json_encode($immPayload['bundle'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
} catch (\Throwable $ie) {
|
|
log_message('warning', '[processIndividualWorkTask] Immunization bundle generation error: ' . $ie->getMessage());
|
|
}
|
|
}
|
|
} elseif (in_array($taskType, ['invoice_record_publish', 'invoice_publish'], true)) {
|
|
if (empty($pushData['bundle']) && class_exists('\App\Controllers\AbdmGateway')) {
|
|
try {
|
|
$gw = new \App\Controllers\AbdmGateway();
|
|
$invPayload = $gw->buildInvoiceRecordPayload((int) $entityId, $patientId, $abhaAddress !== '' ? $abhaAddress : $abhaNumber);
|
|
if (! empty($invPayload['bundle'])) {
|
|
$pushData['bundle'] = $invPayload['bundle'];
|
|
$pushData['fhir_bundle'] = $invPayload['bundle'];
|
|
$pushData['record_data'] = $invPayload['bundle'];
|
|
$bundleJson = (string) json_encode($invPayload['bundle'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
} catch (\Throwable $inve) {
|
|
log_message('warning', '[processIndividualWorkTask] Invoice bundle generation error: ' . $inve->getMessage());
|
|
}
|
|
}
|
|
} elseif (in_array($taskType, ['lab_report_publish', 'radiology_report_publish'], true)) {
|
|
if (empty($pushData['bundle']) && $this->db->tableExists('health_records')) {
|
|
$hr = $this->db->table('health_records')
|
|
->where('entity_id', (string) $entityId)
|
|
->where('hi_type', 'DiagnosticReportRecord')
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
$raw = $hr['record_data'] ?? $hr['fhir_bundle'] ?? null;
|
|
if (! empty($raw)) {
|
|
$dec = json_decode((string) $raw, true);
|
|
if (is_array($dec) && ! empty($dec) && isset($dec['resourceType'])) {
|
|
$pushData['bundle'] = $dec;
|
|
$pushData['fhir_bundle'] = $dec;
|
|
$pushData['record_data'] = $dec;
|
|
$bundleJson = (string) $raw;
|
|
if (! empty($hr['care_context_reference'])) {
|
|
$careContextRef = (string) $hr['care_context_reference'];
|
|
$pushData['care_context_reference'] = $careContextRef;
|
|
$pushData['queue_id'] = $careContextRef;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (empty($pushData['bundle']) && class_exists('\App\Controllers\AbdmGateway')) {
|
|
try {
|
|
$gw = new \App\Controllers\AbdmGateway();
|
|
$diagRes = $gw->pushDiagnosisReportBundleInternal((int) $entityId, $patientId, $abhaAddress !== '' ? $abhaAddress : $abhaNumber, '', '', $careContextRef, true);
|
|
if (! empty($diagRes['health_record_id']) && $this->db->tableExists('health_records')) {
|
|
$genHr = $this->db->table('health_records')->where('id', (int) $diagRes['health_record_id'])->get(1)->getRowArray();
|
|
$rawGen = $genHr['record_data'] ?? $genHr['fhir_bundle'] ?? null;
|
|
if (! empty($rawGen)) {
|
|
$decGen = json_decode((string) $rawGen, true);
|
|
if (is_array($decGen) && ! empty($decGen)) {
|
|
$pushData['bundle'] = $decGen;
|
|
$pushData['fhir_bundle'] = $decGen;
|
|
$pushData['record_data'] = $decGen;
|
|
$bundleJson = (string) $rawGen;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $de) {
|
|
log_message('warning', '[processIndividualWorkTask] Diagnostic report bundle generation error: ' . $de->getMessage());
|
|
}
|
|
}
|
|
} elseif ($taskType === 'health_document_publish') {
|
|
if (empty($pushData['bundle']) && class_exists('\App\Controllers\DoctorDocument')) {
|
|
try {
|
|
$docCtrl = new \App\Controllers\DoctorDocument();
|
|
$src = $docCtrl->buildHealthDocumentSource((int) $entityId);
|
|
if (! empty($src)) {
|
|
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
|
|
$gen = $factory->healthDocument()->generate($src);
|
|
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
|
|
$hfrId = (string) ($src['hfr_id'] ?? 'HFR-IN-HMS');
|
|
$docPayload = $adapter->toGatewayPayload($gen, $src, $hfrId);
|
|
if (! empty($docPayload['fhir_bundle'])) {
|
|
$pushData['bundle'] = $docPayload['fhir_bundle'];
|
|
$pushData['fhir_bundle'] = $docPayload['fhir_bundle'];
|
|
$pushData['record_data'] = $docPayload['fhir_bundle'];
|
|
$bundleJson = (string) json_encode($docPayload['fhir_bundle'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (! empty($docPayload['care_context_reference'])) {
|
|
$careContextRef = (string) $docPayload['care_context_reference'];
|
|
$pushData['care_context_reference'] = $careContextRef;
|
|
$pushData['queue_id'] = $careContextRef;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $he) {
|
|
log_message('warning', '[processIndividualWorkTask] Health document bundle generation error: ' . $he->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generic fallback: check if bundle already exists in health_records
|
|
if (empty($pushData['bundle']) && $this->db->tableExists('health_records')) {
|
|
$hr = $this->db->table('health_records')
|
|
->groupStart()
|
|
->where('care_context_reference', $careContextRef)
|
|
->orGroupStart()
|
|
->where('entity_id', (string) $entityId)
|
|
->where('patient_id', $patientId)
|
|
->groupEnd()
|
|
->groupEnd()
|
|
->orderBy('id', 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
$rawBundle = $hr['record_data'] ?? $hr['fhir_bundle'] ?? null;
|
|
if (! empty($rawBundle)) {
|
|
$decoded = json_decode((string) $rawBundle, true);
|
|
if (is_array($decoded) && ! empty($decoded) && isset($decoded['resourceType'])) {
|
|
$pushData['bundle'] = $decoded;
|
|
$pushData['fhir_bundle'] = $decoded;
|
|
$pushData['record_data'] = $decoded;
|
|
$bundleJson = (string) $rawBundle;
|
|
if (! empty($hr['care_context_reference'])) {
|
|
$careContextRef = (string) $hr['care_context_reference'];
|
|
$pushData['care_context_reference'] = $careContextRef;
|
|
$pushData['queue_id'] = $careContextRef;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
$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);
|
|
|
|
// Upsert health_records & record_links
|
|
$this->recordLinkedState(
|
|
$patientId,
|
|
$abhaAddress !== '' ? $abhaAddress : $abhaNumber,
|
|
$hiType,
|
|
$taskType,
|
|
(string) $entityId,
|
|
$careContextRef,
|
|
$queueId,
|
|
(int) ($result['record_id'] ?? 0),
|
|
$bundleJson
|
|
);
|
|
|
|
if ($taskType === 'wellness_record_publish' && $this->db->tableExists('patient_wellness_records')) {
|
|
$this->db->table('patient_wellness_records')->where('id', (int) $entityId)->update([
|
|
'care_context_reference' => $careContextRef,
|
|
'bridge_record_id' => (int) ($result['record_id'] ?? 0) > 0 ? (int) $result['record_id'] : null,
|
|
'queue_id' => $queueId,
|
|
'abdm_status' => 'queued',
|
|
]);
|
|
}
|
|
|
|
return ['ok' => 1, 'queue_id' => $queueId];
|
|
}
|
|
|
|
return ['ok' => 0, 'error' => (string) ($result['message'] ?? $result['error_text'] ?? 'Push failed')];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => 0, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
private function recordLinkedState(
|
|
int $patientId,
|
|
string $abhaId,
|
|
string $hiType,
|
|
string $entityType,
|
|
string $entityId,
|
|
string $careContextRef,
|
|
string $queueId,
|
|
int $bridgeRecordId,
|
|
?string $recordData = null
|
|
): void {
|
|
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
|
|
if ($this->db->tableExists('health_records')) {
|
|
try {
|
|
$existing = $this->db->table('health_records')
|
|
->where('care_context_reference', $careContextRef)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$hrData = [
|
|
'patient_id' => $patientId,
|
|
'abha_id' => $abhaId,
|
|
'hi_type' => $hiType,
|
|
'entity_type' => $entityType,
|
|
'entity_id' => $entityId,
|
|
'care_context_reference' => $careContextRef,
|
|
'push_status' => 'linked',
|
|
'abdm_txn_id' => $queueId,
|
|
'updated_at' => $now,
|
|
];
|
|
if ($recordData !== null && $recordData !== '') {
|
|
$hrData['record_data'] = $recordData;
|
|
}
|
|
if ($bridgeRecordId > 0 && in_array('bridge_record_id', $this->db->getFieldNames('health_records') ?? [], true)) {
|
|
$hrData['bridge_record_id'] = $bridgeRecordId;
|
|
}
|
|
|
|
if (! empty($existing)) {
|
|
$this->db->table('health_records')->where('id', (int) $existing['id'])->update($hrData);
|
|
$hrId = (int) $existing['id'];
|
|
} else {
|
|
$hrData['created_at'] = $now;
|
|
$this->db->table('health_records')->insert($hrData);
|
|
$hrId = (int) $this->db->insertID();
|
|
}
|
|
|
|
if ($this->db->tableExists('record_links')) {
|
|
$this->db->table('record_links')->insert([
|
|
'health_record_id' => $hrId,
|
|
'care_context_reference' => $careContextRef,
|
|
'abdm_txn_id' => $queueId,
|
|
'abha_id' => $abhaId,
|
|
'link_status' => 'linked',
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string[] $fields
|
|
* @param string[] $candidates
|
|
*/
|
|
private function resolveFirstExistingColumn(array $fields, array $candidates): ?string
|
|
{
|
|
foreach ($candidates as $candidate) {
|
|
if (in_array($candidate, $fields, true)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Calculate cooling period state for any task or document.
|
|
*
|
|
* Rules:
|
|
* - IPD Discharge Summary: 24 hours from last modified/printed time.
|
|
* - All other HI types (OPD Consult/Prescription, Lab, Radiology, Wellness, Immunization, etc.):
|
|
* 60 minutes from last modified/updated/printed time.
|
|
* - Any modification/update in HMS refreshes the timestamp, resetting the cooling period.
|
|
*
|
|
* @param string $taskType Task type, HI type, or entity name
|
|
* @param string|null $lastModified DateTime string of the last update/generation
|
|
* @return array{
|
|
* is_cooling_active: bool,
|
|
* remaining_seconds: int,
|
|
* remaining_minutes: int,
|
|
* required_seconds: int,
|
|
* last_modified: string,
|
|
* auto_link_at: string
|
|
* }
|
|
*/
|
|
public static function calculateCooling(string $taskType, ?string $lastModified): array
|
|
{
|
|
$cfg = config('AbdmConnector');
|
|
$autoLinkEnabled = (bool) ($cfg->autoLinkEnabled ?? true);
|
|
if (! $autoLinkEnabled) {
|
|
return [
|
|
'is_cooling_active' => true,
|
|
'cooling_active' => true,
|
|
'remaining_seconds' => PHP_INT_MAX,
|
|
'remaining_minutes' => PHP_INT_MAX,
|
|
'required_seconds' => PHP_INT_MAX,
|
|
'last_modified' => (string) $lastModified,
|
|
'auto_link_at' => 'disabled',
|
|
];
|
|
}
|
|
|
|
$typeLower = strtolower(trim($taskType));
|
|
if (in_array($typeLower, ['ipd_discharge_publish', 'ipd_discharge', 'dischargesummaryrecord', 'ipd'], true)) {
|
|
$hours = max(0, (int) ($cfg->autoLinkDelayDischargeHours ?? 0));
|
|
$requiredSeconds = $hours * 3600;
|
|
} else {
|
|
$minutes = max(0, (int) ($cfg->autoLinkDelayMinutes ?? 0));
|
|
$requiredSeconds = $minutes * 60;
|
|
}
|
|
|
|
if ($requiredSeconds <= 0) {
|
|
return [
|
|
'is_cooling_active' => false,
|
|
'cooling_active' => false,
|
|
'remaining_seconds' => 0,
|
|
'remaining_minutes' => 0,
|
|
'required_seconds' => 0,
|
|
'last_modified' => (string) $lastModified,
|
|
'auto_link_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
|
|
$lastTimestamp = ! empty($lastModified) ? strtotime($lastModified) : 0;
|
|
if ($lastTimestamp <= 0) {
|
|
$lastTimestamp = time();
|
|
}
|
|
|
|
$now = time();
|
|
$elapsedSeconds = max(0, $now - $lastTimestamp);
|
|
$remainingSeconds = max(0, $requiredSeconds - $elapsedSeconds);
|
|
$isCoolingActive = ($remainingSeconds > 0);
|
|
$remainingMinutes = (int) ceil($remainingSeconds / 60);
|
|
$autoLinkAt = date('Y-m-d H:i:s', $lastTimestamp + $requiredSeconds);
|
|
|
|
return [
|
|
'is_cooling_active' => $isCoolingActive,
|
|
'cooling_active' => $isCoolingActive,
|
|
'remaining_seconds' => $remainingSeconds,
|
|
'remaining_minutes' => $remainingMinutes,
|
|
'required_seconds' => $requiredSeconds,
|
|
'last_modified' => (string) $lastModified,
|
|
'auto_link_at' => $autoLinkAt,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Instance wrapper for calculateCooling.
|
|
*
|
|
* @return array{
|
|
* is_cooling_active: bool,
|
|
* remaining_seconds: int,
|
|
* remaining_minutes: int,
|
|
* required_seconds: int,
|
|
* last_modified: string,
|
|
* auto_link_at: string
|
|
* }
|
|
*/
|
|
public function resolveCoolingState(string $taskType, ?string $lastModified): array
|
|
{
|
|
return self::calculateCooling($taskType, $lastModified);
|
|
}
|
|
|
|
/**
|
|
* Resolves the actual clinical / event timestamp for a task to accurately measure cooling.
|
|
* Checks task payload meta, underlying clinical record tables, and falls back to created_at.
|
|
*
|
|
* @param array<string, mixed> $task
|
|
*/
|
|
public static function resolveTaskClinicalTimestamp(array $task, ?BaseConnection $db = null): string
|
|
{
|
|
$payload = ! empty($task['payload_json']) ? json_decode((string) $task['payload_json'], true) : [];
|
|
$meta = is_array($payload) ? ($payload['meta'] ?? []) : [];
|
|
|
|
// 1. Direct meta timestamps
|
|
foreach (['clinical_timestamp', 'event_date', 'reported_time', 'collected_time', 'given_date', 'date_opd_visit', 'discharge_date'] as $key) {
|
|
if (! empty($meta[$key]) && strtotime((string) $meta[$key]) > 0) {
|
|
return (string) $meta[$key];
|
|
}
|
|
}
|
|
|
|
$taskType = (string) ($task['task_type'] ?? '');
|
|
$entityType = (string) ($task['entity_type'] ?? '');
|
|
$entityId = (int) ($task['entity_id'] ?? 0);
|
|
|
|
if ($entityId > 0) {
|
|
try {
|
|
$dbConn = $db ?? db_connect();
|
|
|
|
// 2. Health Documents: file_upload_data or patient_doc
|
|
if (in_array($entityType, ['patient_document', 'file_upload_data'], true) || $taskType === 'health_document_publish') {
|
|
if ($dbConn->tableExists('file_upload_data')) {
|
|
$fRow = $dbConn->table('file_upload_data')->select('insert_date')->where('id', $entityId)->get(1)->getRowArray();
|
|
if (! empty($fRow['insert_date']) && strtotime((string) $fRow['insert_date']) > 0) {
|
|
return (string) $fRow['insert_date'];
|
|
}
|
|
}
|
|
if ($dbConn->tableExists('patient_doc')) {
|
|
$pRow = $dbConn->table('patient_doc')->select('created_at, date_issue')->where('id', $entityId)->get(1)->getRowArray();
|
|
$date = ! empty($pRow['created_at']) ? $pRow['created_at'] : ($pRow['date_issue'] ?? null);
|
|
if (! empty($date) && strtotime((string) $date) > 0) {
|
|
return (string) $date;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Lab / Radiology: lab_request
|
|
if ($entityType === 'lab_request' || in_array($taskType, ['lab_report_publish', 'radiology_report_publish'], true)) {
|
|
if ($dbConn->tableExists('lab_request')) {
|
|
$fields = $dbConn->getFieldNames('lab_request');
|
|
$selectCols = array_intersect(['reported_time', 'collected_time', 'Request_Date'], $fields);
|
|
if (! empty($selectCols)) {
|
|
$lRow = $dbConn->table('lab_request')->select(implode(', ', $selectCols))->where('id', $entityId)->get(1)->getRowArray();
|
|
foreach (['reported_time', 'collected_time', 'Request_Date'] as $col) {
|
|
if (! empty($lRow[$col]) && strtotime((string) $lRow[$col]) > 0) {
|
|
return (string) $lRow[$col];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Immunization: immunization_records
|
|
if ($entityType === 'immunization' || $taskType === 'immunization_record_publish') {
|
|
if ($dbConn->tableExists('immunization_records')) {
|
|
$iRow = $dbConn->table('immunization_records')->select('given_date, created_at')->where('id', $entityId)->get(1)->getRowArray();
|
|
$date = ! empty($iRow['given_date']) ? $iRow['given_date'] : ($iRow['created_at'] ?? null);
|
|
if (! empty($date) && strtotime((string) $date) > 0) {
|
|
return (string) $date;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Wellness: opd_prescription
|
|
if ($entityType === 'opd_prescription' || $entityType === 'opd_vitals' || $taskType === 'wellness_record_publish') {
|
|
if ($dbConn->tableExists('opd_prescription')) {
|
|
$fields = $dbConn->getFieldNames('opd_prescription');
|
|
$dateCol = in_array('date_opd_visit', $fields, true) ? 'date_opd_visit' : 'id';
|
|
$rxRow = $dbConn->table('opd_prescription')->select($dateCol)->where('id', $entityId)->get(1)->getRowArray();
|
|
if (! empty($rxRow['date_opd_visit']) && strtotime((string) $rxRow['date_opd_visit']) > 0) {
|
|
return (string) $rxRow['date_opd_visit'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. IPD Discharge: ipd_discharge
|
|
if ($entityType === 'ipd_discharge' || $taskType === 'ipd_discharge_publish') {
|
|
if ($dbConn->tableExists('ipd_discharge')) {
|
|
$dRow = $dbConn->table('ipd_discharge')->select('discharge_date, created_at')->where('id', $entityId)->get(1)->getRowArray();
|
|
$date = ! empty($dRow['discharge_date']) ? $dRow['discharge_date'] : ($dRow['created_at'] ?? null);
|
|
if (! empty($date) && strtotime((string) $date) > 0) {
|
|
return (string) $date;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// Silently fallback
|
|
}
|
|
}
|
|
|
|
// Fallback to task's created_at, or updated_at
|
|
return ! empty($task['created_at'])
|
|
? (string) $task['created_at']
|
|
: (! empty($task['updated_at']) ? (string) $task['updated_at'] : date('Y-m-d H:i:s'));
|
|
}
|
|
|
|
/**
|
|
* Backfill missing work tasks for finalized clinical records with ABHA.
|
|
* Ensures cron discovers and queues tasks even if staff haven't opened the web Task Board.
|
|
*/
|
|
public function backfillMissingWorkTasks(int $days = 30, int $limit = 100): void
|
|
{
|
|
if (! $this->db->tableExists('abdm_work_tasks') || ! $this->db->tableExists('patient_master')) {
|
|
return;
|
|
}
|
|
|
|
$patientFields = $this->db->getFieldNames('patient_master') ?? [];
|
|
$abhaCol = $this->resolveFirstExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']);
|
|
if ($abhaCol === null) {
|
|
return;
|
|
}
|
|
|
|
$sinceDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));
|
|
|
|
// 1. Health Documents: file_upload_data
|
|
if ($this->db->tableExists('file_upload_data')) {
|
|
$fileRows = $this->db->table('file_upload_data f')
|
|
->select('f.id, f.pid, f.insert_date, p.p_fname, p.' . $abhaCol . ' as abha_id', false)
|
|
->join('patient_master p', 'p.id = f.pid', 'inner')
|
|
->where('p.' . $abhaCol . ' !=', '')
|
|
->where('f.insert_date >=', $sinceDate)
|
|
->orderBy('f.id', 'DESC')
|
|
->limit($limit)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($fileRows as $fRow) {
|
|
$fileId = (int) ($fRow['id'] ?? 0);
|
|
$patientId = (int) ($fRow['pid'] ?? 0);
|
|
$abhaId = trim((string) ($fRow['abha_id'] ?? ''));
|
|
if ($fileId <= 0 || $patientId <= 0 || preg_match('/^\d{14}$/', $abhaId) !== 1) {
|
|
continue;
|
|
}
|
|
|
|
$exists = $this->db->table('abdm_work_tasks')
|
|
->select('id')
|
|
->where('task_type', 'health_document_publish')
|
|
->where('entity_type', 'patient_document')
|
|
->where('entity_id', (string) $fileId)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($exists)) {
|
|
continue;
|
|
}
|
|
|
|
$this->taskService->createOrRefreshTask(
|
|
'health_document_publish',
|
|
'file_upload_data',
|
|
'patient_document',
|
|
(string) $fileId,
|
|
$patientId,
|
|
trim((string) ($fRow['p_fname'] ?? '')),
|
|
$abhaId,
|
|
'submit',
|
|
[
|
|
'file_upload_id' => $fileId,
|
|
'clinical_timestamp' => (string) ($fRow['insert_date'] ?? ''),
|
|
'trigger' => 'cron.backfill',
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Health Documents: patient_doc
|
|
if ($this->db->tableExists('patient_doc')) {
|
|
$docRows = $this->db->table('patient_doc pd')
|
|
->select('pd.id, pd.p_id, pd.date_issue, pd.created_at, p.p_fname, p.' . $abhaCol . ' as abha_id', false)
|
|
->join('patient_master p', 'p.id = pd.p_id', 'inner')
|
|
->where('p.' . $abhaCol . ' !=', '')
|
|
->where('pd.created_at >=', $sinceDate)
|
|
->orderBy('pd.id', 'DESC')
|
|
->limit($limit)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($docRows as $dRow) {
|
|
$docId = (int) ($dRow['id'] ?? 0);
|
|
$patientId = (int) ($dRow['p_id'] ?? 0);
|
|
$abhaId = trim((string) ($dRow['abha_id'] ?? ''));
|
|
if ($docId <= 0 || $patientId <= 0 || preg_match('/^\d{14}$/', $abhaId) !== 1) {
|
|
continue;
|
|
}
|
|
|
|
$exists = $this->db->table('abdm_work_tasks')
|
|
->select('id')
|
|
->where('task_type', 'health_document_publish')
|
|
->where('entity_type', 'doctor_document')
|
|
->where('entity_id', (string) $docId)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($exists)) {
|
|
continue;
|
|
}
|
|
|
|
$this->taskService->createOrRefreshTask(
|
|
'health_document_publish',
|
|
'patient_doc',
|
|
'doctor_document',
|
|
(string) $docId,
|
|
$patientId,
|
|
trim((string) ($dRow['p_fname'] ?? '')),
|
|
$abhaId,
|
|
'submit',
|
|
[
|
|
'patient_doc_id' => $docId,
|
|
'clinical_timestamp' => (string) (! empty($dRow['created_at']) ? $dRow['created_at'] : ($dRow['date_issue'] ?? '')),
|
|
'trigger' => 'cron.backfill',
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
// 3. Lab & Radiology: lab_request
|
|
if ($this->db->tableExists('lab_request')) {
|
|
$labFields = $this->db->getFieldNames('lab_request') ?? [];
|
|
$dateFields = array_intersect(['reported_time', 'collected_time', 'Request_Date'], $labFields);
|
|
$dateSelect = ! empty($dateFields) ? (', r.' . implode(', r.', $dateFields)) : '';
|
|
|
|
$abhaParts = [];
|
|
foreach (['abha_id', 'abha_no', 'abha_address', 'abha'] as $f) {
|
|
if (in_array($f, $patientFields, true)) {
|
|
$abhaParts[] = 'p.' . $f;
|
|
}
|
|
}
|
|
$abhaSelect = ! empty($abhaParts) ? (', ' . implode(', ', $abhaParts)) : '';
|
|
|
|
$labRows = $this->db->table('lab_request r')
|
|
->select('r.id, r.patient_id, r.patient_name, r.lab_type, r.charge_id, r.status' . $dateSelect . $abhaSelect, false)
|
|
->join('patient_master p', 'p.id = r.patient_id', 'inner')
|
|
->where('r.status >=', 2)
|
|
->orderBy('r.id', 'DESC')
|
|
->limit($limit)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($labRows as $lRow) {
|
|
$labReqId = (int) ($lRow['id'] ?? 0);
|
|
$patientId = (int) ($lRow['patient_id'] ?? 0);
|
|
if ($labReqId <= 0 || $patientId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$rawAbha = '';
|
|
$abhaAddress = '';
|
|
foreach (['abha_address', 'abha_id', 'abha_no', 'abha'] as $f) {
|
|
$val = trim((string) ($lRow[$f] ?? ''));
|
|
if ($val === '') {
|
|
continue;
|
|
}
|
|
if ($abhaAddress === '' && str_contains($val, '@')) {
|
|
$abhaAddress = $val;
|
|
continue;
|
|
}
|
|
$digits = preg_replace('/\D/', '', $val);
|
|
if ($rawAbha === '' && strlen($digits) === 14) {
|
|
$rawAbha = $digits;
|
|
}
|
|
}
|
|
|
|
$abhaId = $rawAbha !== '' ? $rawAbha : $abhaAddress;
|
|
if ($abhaId === '') {
|
|
continue;
|
|
}
|
|
|
|
$labType = (int) ($lRow['lab_type'] ?? 0);
|
|
$taskType = in_array($labType, [1, 2, 3, 4, 6], true) ? 'radiology_report_publish' : 'lab_report_publish';
|
|
|
|
$exists = $this->db->table('abdm_work_tasks')
|
|
->select('id')
|
|
->where('task_type', $taskType)
|
|
->where('entity_type', 'lab_request')
|
|
->where('entity_id', (string) $labReqId)
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($exists)) {
|
|
continue;
|
|
}
|
|
|
|
$clinTs = ! empty($lRow['reported_time'])
|
|
? $lRow['reported_time']
|
|
: (! empty($lRow['collected_time']) ? $lRow['collected_time'] : ($lRow['Request_Date'] ?? ''));
|
|
|
|
$this->taskService->createOrRefreshTask(
|
|
$taskType,
|
|
'diagnosis',
|
|
'lab_request',
|
|
(string) $labReqId,
|
|
$patientId,
|
|
trim((string) ($lRow['patient_name'] ?? '')),
|
|
$abhaId,
|
|
'submit',
|
|
[
|
|
'lab_type' => $labType,
|
|
'invoice_id' => (int) ($lRow['charge_id'] ?? 0),
|
|
'clinical_timestamp' => (string) $clinTs,
|
|
'trigger' => 'cron.backfill',
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<int, array<string, mixed>>
|
|
* }
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|