HH/apps/integrations/models.py
2026-03-28 14:03:56 +03:00

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}"