522 lines
20 KiB
Python
522 lines
20 KiB
Python
"""
|
|
Integrations models - Inbound events from external systems
|
|
|
|
This module handles integration events from:
|
|
- HIS (Hospital Information System)
|
|
- Lab systems
|
|
- Radiology systems
|
|
- Pharmacy systems
|
|
- MOH (Ministry of Health)
|
|
- CHI (Council of Health Insurance)
|
|
- Other external systems
|
|
"""
|
|
|
|
from django.db import models
|
|
|
|
from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel
|
|
|
|
|
|
class EventStatus(BaseChoices):
|
|
"""Event processing status"""
|
|
|
|
PENDING = "pending", "Pending"
|
|
PROCESSING = "processing", "Processing"
|
|
PROCESSED = "processed", "Processed"
|
|
FAILED = "failed", "Failed"
|
|
IGNORED = "ignored", "Ignored"
|
|
|
|
|
|
class SourceSystem(BaseChoices):
|
|
"""Source system choices"""
|
|
|
|
HIS = "his", "Hospital Information System"
|
|
LAB = "lab", "Laboratory System"
|
|
RADIOLOGY = "radiology", "Radiology System"
|
|
PHARMACY = "pharmacy", "Pharmacy System"
|
|
MOH = "moh", "Ministry of Health"
|
|
CHI = "chi", "Council of Health Insurance"
|
|
PXCONNECT = "pxconnect", "PX Connect"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class InboundEvent(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inbound integration event from external systems.
|
|
|
|
Events trigger journey stage completions. For example:
|
|
- Event code: "OPD_VISIT_COMPLETED" → completes "MD Consultation" stage
|
|
- Event code: "LAB_ORDER_COMPLETED" → completes "Lab" stage
|
|
- Event code: "RADIOLOGY_REPORT_FINALIZED" → completes "Radiology" stage
|
|
- Event code: "PHARMACY_DISPENSED" → completes "Pharmacy" stage
|
|
|
|
Processing flow:
|
|
1. Event received via API (POST /api/integrations/events/)
|
|
2. Stored with status=PENDING
|
|
3. Celery task processes event:
|
|
a. Find journey instance by encounter_id
|
|
b. Find matching stage by trigger_event_code
|
|
c. Complete the stage
|
|
d. Create survey instance if configured
|
|
e. Update event status to PROCESSED
|
|
"""
|
|
|
|
# Source information
|
|
source_system = models.CharField(
|
|
max_length=50, choices=SourceSystem.choices, db_index=True, help_text="System that sent this event"
|
|
)
|
|
event_code = models.CharField(
|
|
max_length=100, db_index=True, help_text="Event type code (e.g., OPD_VISIT_COMPLETED, LAB_ORDER_COMPLETED)"
|
|
)
|
|
|
|
# Identifiers
|
|
encounter_id = models.CharField(max_length=100, db_index=True, help_text="Encounter ID from HIS system")
|
|
patient_identifier = models.CharField(
|
|
max_length=100, blank=True, db_index=True, help_text="Patient MRN or other identifier"
|
|
)
|
|
|
|
# Event data
|
|
payload_json = models.JSONField(help_text="Full event payload from source system")
|
|
|
|
# Processing status
|
|
status = models.CharField(max_length=20, choices=EventStatus.choices, default=EventStatus.PENDING, db_index=True)
|
|
|
|
# Timestamps
|
|
received_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
processed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Processing results
|
|
error = models.TextField(blank=True, help_text="Error message if processing failed")
|
|
processing_attempts = models.IntegerField(default=0, help_text="Number of processing attempts")
|
|
|
|
# Extracted context (from payload)
|
|
physician_license = models.CharField(max_length=100, blank=True, help_text="Physician license number from event")
|
|
department_code = models.CharField(max_length=50, blank=True, help_text="Department code from event")
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional processing metadata")
|
|
|
|
class Meta:
|
|
ordering = ["-received_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "-received_at"]),
|
|
models.Index(fields=["encounter_id", "event_code"]),
|
|
models.Index(fields=["source_system", "-received_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.source_system} - {self.event_code} - {self.encounter_id} ({self.status})"
|
|
|
|
def mark_processing(self):
|
|
"""Mark event as being processed"""
|
|
self.status = EventStatus.PROCESSING
|
|
self.processing_attempts += 1
|
|
self.save(update_fields=["status", "processing_attempts"])
|
|
|
|
def mark_processed(self):
|
|
"""Mark event as successfully processed"""
|
|
from django.utils import timezone
|
|
|
|
self.status = EventStatus.PROCESSED
|
|
self.processed_at = timezone.now()
|
|
self.save(update_fields=["status", "processed_at"])
|
|
|
|
def mark_failed(self, error_message):
|
|
"""Mark event as failed with error message"""
|
|
self.status = EventStatus.FAILED
|
|
self.error = error_message
|
|
self.save(update_fields=["status", "error"])
|
|
|
|
def mark_ignored(self, reason):
|
|
"""Mark event as ignored (e.g., no matching journey)"""
|
|
self.status = EventStatus.IGNORED
|
|
self.error = reason
|
|
self.save(update_fields=["status", "error"])
|
|
|
|
|
|
class IntegrationConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configuration for external system integrations.
|
|
|
|
Stores API endpoints, credentials, and mapping rules.
|
|
"""
|
|
|
|
name = models.CharField(max_length=200, unique=True)
|
|
source_system = models.CharField(max_length=50, choices=SourceSystem.choices, unique=True)
|
|
|
|
# Connection details
|
|
api_url = models.URLField(blank=True, help_text="API endpoint URL")
|
|
api_key = models.CharField(max_length=500, blank=True, help_text="API key (encrypted)")
|
|
|
|
# Configuration
|
|
is_active = models.BooleanField(default=True)
|
|
config_json = models.JSONField(
|
|
default=dict, blank=True, help_text="Additional configuration (event mappings, field mappings, etc.)"
|
|
)
|
|
|
|
# Metadata
|
|
description = models.TextField(blank=True)
|
|
last_sync_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["name"]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.source_system})"
|
|
|
|
|
|
class PatientType(BaseChoices):
|
|
"""HIS Patient Type codes"""
|
|
|
|
INPATIENT = "IP", "Inpatient"
|
|
OPD = "OP", "Outpatient"
|
|
EMS = "ED", "Emergency"
|
|
DAYCASE = "DAYCASE", "Day Case"
|
|
APPOINTMENT = "APPOINTMENT", "Appointment"
|
|
|
|
|
|
class SurveyTemplateMapping(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Maps patient types to survey templates for automatic survey delivery.
|
|
|
|
This replaces the search-based template selection with explicit mappings.
|
|
Allows administrators to control which survey template is sent for each
|
|
patient type and hospital.
|
|
|
|
Example:
|
|
- PatientType: "1" (Inpatient) → Inpatient Satisfaction Survey
|
|
- PatientType: "2" (OPD) → Outpatient Satisfaction Survey
|
|
- PatientType: "APPOINTMENT" → Appointment Satisfaction Survey
|
|
"""
|
|
|
|
# Mapping key
|
|
patient_type = models.CharField(
|
|
max_length=20, choices=PatientType.choices, db_index=True, help_text="Patient type from HIS system"
|
|
)
|
|
|
|
# Target survey
|
|
survey_template = models.ForeignKey(
|
|
"surveys.SurveyTemplate",
|
|
on_delete=models.CASCADE,
|
|
related_name="patient_type_mappings",
|
|
help_text="Survey template to send for this patient type",
|
|
)
|
|
|
|
# Hospital specificity (null = global mapping)
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
related_name="survey_template_mappings",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Hospital for this mapping (null = applies to all hospitals)",
|
|
)
|
|
|
|
# Activation
|
|
is_active = models.BooleanField(default=True, db_index=True, help_text="Whether this mapping is active")
|
|
|
|
# Delay configuration
|
|
send_delay_hours = models.IntegerField(default=1, help_text="Hours after discharge to send survey")
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "patient_type"]
|
|
indexes = [
|
|
models.Index(fields=["patient_type", "hospital", "is_active"]),
|
|
]
|
|
# Ensure only one active mapping per patient type per hospital
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["patient_type", "hospital"],
|
|
condition=models.Q(is_active=True),
|
|
name="unique_active_mapping_per_type_hospital",
|
|
)
|
|
]
|
|
|
|
def __str__(self):
|
|
hospital_name = self.hospital.name if self.hospital else "All Hospitals"
|
|
status = "Active" if self.is_active else "Inactive"
|
|
return f"{self.get_patient_type_display()} -> {self.survey_template.name} ({hospital_name}) [{status}]"
|
|
|
|
@staticmethod
|
|
def get_template_for_patient_type(patient_type: str, hospital):
|
|
"""
|
|
Get the active survey template for a patient type and hospital.
|
|
|
|
Search order:
|
|
1. Hospital-specific active mapping
|
|
2. Global active mapping (hospital is null)
|
|
|
|
Args:
|
|
patient_type: HIS PatientType code (e.g., "1", "2", "APPOINTMENT")
|
|
hospital: Hospital instance
|
|
|
|
Returns:
|
|
SurveyTemplate or None if no active mapping found
|
|
"""
|
|
# Try hospital-specific mapping first
|
|
mapping = SurveyTemplateMapping.objects.filter(
|
|
patient_type=patient_type, hospital=hospital, is_active=True
|
|
).first()
|
|
|
|
if mapping:
|
|
return mapping.survey_template
|
|
|
|
# Fall back to global mapping
|
|
mapping = SurveyTemplateMapping.objects.filter(
|
|
patient_type=patient_type, hospital__isnull=True, is_active=True
|
|
).first()
|
|
|
|
return mapping.survey_template if mapping else None
|
|
|
|
|
|
class HISPatientVisit(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Stores patient visit data fetched from HIS system.
|
|
|
|
Decoupled from survey creation - patient and visit data are saved
|
|
on every fetch regardless of whether the visit is complete.
|
|
Survey is created and linked only when the visit is complete.
|
|
"""
|
|
|
|
patient = models.ForeignKey(
|
|
"organizations.Patient",
|
|
on_delete=models.CASCADE,
|
|
related_name="his_visits",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="his_visits",
|
|
)
|
|
admission_id = models.CharField(max_length=100, db_index=True)
|
|
reg_code = models.CharField(max_length=100, blank=True, db_index=True)
|
|
patient_id_his = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS")
|
|
patient_type = models.CharField(max_length=10, help_text="ED, IP, OP from HIS")
|
|
admit_date = models.DateTimeField(null=True, blank=True)
|
|
discharge_date = models.DateTimeField(null=True, blank=True, help_text="From HIS DischargeDate (null for OP)")
|
|
effective_discharge_date = models.DateTimeField(
|
|
null=True, blank=True, help_text="For OP: last visit timestamp when deemed complete"
|
|
)
|
|
visit_data = models.JSONField(default=dict, blank=True, help_text="Full patient demographic dict from HIS")
|
|
visit_timeline = models.JSONField(default=list, blank=True, help_text="Extracted visit events for this patient")
|
|
|
|
primary_doctor = models.CharField(
|
|
max_length=300, blank=True, help_text="From HIS PrimaryDoctor (raw text fallback)"
|
|
)
|
|
primary_doctor_fk = models.ForeignKey(
|
|
"organizations.Staff",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="his_visits_as_doctor",
|
|
help_text="Resolved Staff record from PrimaryDoctor ID prefix",
|
|
)
|
|
consultant_id = models.CharField(max_length=50, blank=True, help_text="From HIS ConsultantID (raw text fallback)")
|
|
consultant_fk = models.ForeignKey(
|
|
"organizations.Staff",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="his_visits_as_consultant",
|
|
help_text="Resolved Staff record from ConsultantID",
|
|
)
|
|
company_name = models.CharField(max_length=300, blank=True, help_text="From HIS CompanyName (insurance sponsor)")
|
|
grade_name = models.CharField(max_length=100, blank=True, help_text="From HIS GradeName (insurance grade)")
|
|
insurance_company_name = models.CharField(max_length=300, blank=True, help_text="From HIS InsuranceCompanyName")
|
|
bill_type = models.CharField(max_length=20, blank=True, help_text="From HIS BillType (CS/CR)")
|
|
is_vip = models.BooleanField(default=False, db_index=True, help_text="From HIS IsVIP")
|
|
nationality = models.CharField(max_length=100, blank=True, help_text="From HIS PatientNationality")
|
|
|
|
is_visit_complete = models.BooleanField(default=False, db_index=True)
|
|
survey_instance = models.ForeignKey(
|
|
"surveys.SurveyInstance",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="his_visit",
|
|
)
|
|
last_his_fetch_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="Last time this visit was seen in a HIS fetch"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["patient_type", "is_visit_complete"]),
|
|
models.Index(fields=["patient_type", "last_his_fetch_at"]),
|
|
models.Index(fields=["admission_id", "is_visit_complete"]),
|
|
]
|
|
constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_his_visit_per_admission")]
|
|
|
|
def __str__(self):
|
|
name = self.patient.get_full_name() if self.patient else self.patient_id_his
|
|
return f"{name} ({self.patient_type}) - {self.admission_id}"
|
|
|
|
@property
|
|
def doctor_display(self):
|
|
if self.primary_doctor_fk:
|
|
return str(self.primary_doctor_fk)
|
|
return self.primary_doctor or "-"
|
|
|
|
@property
|
|
def consultant_display(self):
|
|
if self.consultant_fk:
|
|
return str(self.consultant_fk)
|
|
return self.consultant_id or "-"
|
|
|
|
|
|
class HISVisitEvent(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Individual visit event from HIS timeline.
|
|
|
|
Extracted from visit_timeline JSON for better querying and filtering.
|
|
Each event represents a single touchpoint in the patient journey.
|
|
"""
|
|
|
|
visit = models.ForeignKey(
|
|
HISPatientVisit,
|
|
on_delete=models.CASCADE,
|
|
related_name="visit_events",
|
|
)
|
|
event_type = models.CharField(max_length=200, blank=True, help_text="Type from HIS (e.g., 'Registration')")
|
|
bill_date = models.CharField(max_length=50, blank=True, help_text="Raw BillDate from HIS (DD-Mon-YYYY HH:MM)")
|
|
parsed_date = models.DateTimeField(null=True, blank=True, db_index=True, help_text="Parsed bill_date")
|
|
patient_type = models.CharField(max_length=10, blank=True, help_text="PatientType for this event")
|
|
visit_category = models.CharField(max_length=10, blank=True, help_text="Visit category: ED, IP, OP")
|
|
admission_id = models.CharField(max_length=100, blank=True, db_index=True)
|
|
patient_id = models.CharField(max_length=100, blank=True, db_index=True, help_text="PatientID from HIS")
|
|
reg_code = models.CharField(max_length=100, blank=True, db_index=True)
|
|
ssn = models.CharField(max_length=50, blank=True, db_index=True)
|
|
mobile_no = models.CharField(max_length=50, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["parsed_date"]
|
|
indexes = [
|
|
models.Index(fields=["visit", "parsed_date"]),
|
|
models.Index(fields=["visit_category", "parsed_date"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.event_type} ({self.visit_category}) - {self.admission_id}"
|
|
|
|
|
|
class HISEventType(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Unique event types extracted from HIS data.
|
|
|
|
Auto-populated when HISPatientVisit events are synced.
|
|
Used to populate the event type dropdown in question configuration.
|
|
"""
|
|
|
|
event_type = models.CharField(
|
|
max_length=200,
|
|
unique=True,
|
|
db_index=True,
|
|
help_text="HIS event type name (e.g., 'Lab Bill', 'Triage')",
|
|
)
|
|
patient_types = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Patient types that have this event: ['OP', 'IP', 'ED']",
|
|
)
|
|
event_count = models.IntegerField(
|
|
default=0,
|
|
help_text="Total number of times this event type has been seen",
|
|
)
|
|
last_seen_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ["event_type"]
|
|
verbose_name = "HIS Event Type"
|
|
verbose_name_plural = "HIS Event Types"
|
|
|
|
def __str__(self):
|
|
types = ", ".join(self.patient_types) if self.patient_types else "N/A"
|
|
return f"{self.event_type} ({types})"
|
|
|
|
|
|
class EventMapping(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Maps external event codes to internal trigger codes.
|
|
|
|
Example:
|
|
- External: "VISIT_COMPLETE" → Internal: "OPD_VISIT_COMPLETED"
|
|
- External: "LAB_RESULT_READY" → Internal: "LAB_ORDER_COMPLETED"
|
|
"""
|
|
|
|
integration_config = models.ForeignKey(IntegrationConfig, on_delete=models.CASCADE, related_name="event_mappings")
|
|
|
|
external_event_code = models.CharField(max_length=100, help_text="Event code from external system")
|
|
internal_event_code = models.CharField(max_length=100, help_text="Internal event code used in journey stages")
|
|
|
|
# Field mappings
|
|
field_mappings = models.JSONField(
|
|
default=dict, blank=True, help_text="Maps external field names to internal field names"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
unique_together = [["integration_config", "external_event_code"]]
|
|
ordering = ["integration_config", "external_event_code"]
|
|
|
|
def __str__(self):
|
|
return f"{self.external_event_code} → {self.internal_event_code}"
|
|
|
|
|
|
class HISTestPatient(TimeStampedModel):
|
|
"""Test patient data loaded from visit_data.json for testing HIS integration."""
|
|
|
|
admission_id = models.CharField(max_length=100, db_index=True)
|
|
patient_id = models.CharField(max_length=100, db_index=True)
|
|
patient_type = models.CharField(max_length=10, db_index=True)
|
|
reg_code = models.CharField(max_length=100, blank=True, db_index=True)
|
|
ssn = models.CharField(max_length=50, blank=True, db_index=True)
|
|
mobile_no = models.CharField(max_length=20, blank=True, db_index=True)
|
|
admit_date = models.DateTimeField(db_index=True)
|
|
discharge_date = models.DateTimeField(null=True, blank=True)
|
|
patient_data = models.JSONField(default=dict)
|
|
hospital_id = models.CharField(max_length=20, blank=True)
|
|
hospital_name = models.CharField(max_length=200, blank=True)
|
|
patient_name = models.CharField(max_length=300, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["admit_date"]
|
|
indexes = [
|
|
models.Index(fields=["patient_type", "admit_date"]),
|
|
models.Index(fields=["ssn", "admit_date"]),
|
|
models.Index(fields=["mobile_no", "admit_date"]),
|
|
]
|
|
constraints = [models.UniqueConstraint(fields=["admission_id"], name="unique_test_patient_admission")]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient_name} ({self.patient_type}) - {self.admission_id}"
|
|
|
|
|
|
class HISTestVisit(TimeStampedModel):
|
|
"""Test visit events loaded from visit_data.json for testing HIS integration."""
|
|
|
|
admission_id = models.CharField(max_length=100, db_index=True)
|
|
patient_id = models.CharField(max_length=100, db_index=True)
|
|
visit_category = models.CharField(max_length=10, db_index=True)
|
|
event_type = models.CharField(max_length=200, blank=True)
|
|
bill_date = models.DateTimeField(null=True, blank=True, db_index=True)
|
|
reg_code = models.CharField(max_length=100, blank=True)
|
|
ssn = models.CharField(max_length=50, blank=True, db_index=True)
|
|
mobile_no = models.CharField(max_length=20, blank=True, db_index=True)
|
|
visit_data = models.JSONField(default=dict)
|
|
|
|
class Meta:
|
|
ordering = ["bill_date"]
|
|
indexes = [
|
|
models.Index(fields=["admission_id", "visit_category"]),
|
|
models.Index(fields=["patient_id", "visit_category"]),
|
|
models.Index(fields=["admission_id", "bill_date"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.event_type} ({self.visit_category}) - {self.admission_id}"
|