915 lines
34 KiB
Python
915 lines
34 KiB
Python
"""
|
|
HIS Adapter Service - Transforms real HIS data format to internal format
|
|
|
|
This service handles the transformation of HIS patient data into PX360's
|
|
internal format for sending surveys based on PatientType.
|
|
|
|
Simplified Flow:
|
|
1. Parse HIS patient data
|
|
2. Determine survey type from PatientType
|
|
3. Create/update HISPatientVisit record
|
|
4. Create patient record (always)
|
|
5. Create survey instance with PENDING status (only when visit complete)
|
|
6. Queue delayed send task
|
|
7. Survey sent after delay (e.g., 1 hour for OPD)
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Tuple
|
|
import logging
|
|
|
|
from django.utils import timezone
|
|
|
|
from apps.organizations.models import Hospital, Patient, Staff
|
|
from apps.surveys.models import SurveyTemplate, SurveyInstance, SurveyStatus
|
|
from apps.integrations.models import InboundEvent, HISPatientVisit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PATIENT_TYPE_TO_VISIT_LIST = {
|
|
"ED": "FetchPatientDataTimeStampVisitEDDataList",
|
|
"IP": "FetchPatientDataTimeStampVisitIPDataList",
|
|
"OP": "FetchPatientDataTimeStampVisitOPDataList",
|
|
}
|
|
|
|
|
|
class HISAdapter:
|
|
"""
|
|
Adapter for transforming HIS patient data format to internal format.
|
|
|
|
HIS Data Structure:
|
|
{
|
|
"FetchPatientDataTimeStampList": [{...patient demographics...}],
|
|
"FetchPatientDataTimeStampVisitEDDataList": [...],
|
|
"FetchPatientDataTimeStampVisitIPDataList": [...],
|
|
"FetchPatientDataTimeStampVisitOPDataList": [...],
|
|
"Code": 200,
|
|
"Status": "Success"
|
|
}
|
|
|
|
PatientType Codes:
|
|
- "1" -> Inpatient
|
|
- "2" or "O" -> OPD (Outpatient)
|
|
- "3" or "E" -> EMS (Emergency)
|
|
"""
|
|
|
|
@staticmethod
|
|
def parse_date(date_str: Optional[str]) -> Optional[datetime]:
|
|
"""Parse HIS date format 'DD-Mon-YYYY HH:MM' to timezone-aware datetime"""
|
|
if not date_str:
|
|
return None
|
|
|
|
try:
|
|
naive_dt = datetime.strptime(date_str, "%d-%b-%Y %H:%M")
|
|
return timezone.make_aware(naive_dt)
|
|
except ValueError:
|
|
return None
|
|
|
|
@staticmethod
|
|
def map_patient_type_to_survey_type(patient_type: str) -> str:
|
|
"""
|
|
Map HIS PatientType string to survey type name.
|
|
|
|
Based on HIS sample data:
|
|
- "IP" -> Inpatient
|
|
- "OP" -> Outpatient
|
|
- "ED" -> Emergency
|
|
|
|
Returns survey type name for template lookup.
|
|
"""
|
|
patient_type_upper = patient_type.upper() if patient_type else ""
|
|
|
|
if patient_type_upper == "IP":
|
|
return "INPATIENT"
|
|
elif patient_type_upper == "OP":
|
|
return "OPD"
|
|
elif patient_type_upper == "ED":
|
|
return "EMS"
|
|
elif patient_type_upper == "DAYCASE":
|
|
return "DAYCASE"
|
|
elif patient_type_upper == "APPOINTMENT":
|
|
return "APPOINTMENT"
|
|
else:
|
|
return "OPD"
|
|
|
|
@staticmethod
|
|
def split_patient_name(full_name: str) -> Tuple[str, str]:
|
|
"""Split patient name into first and last name"""
|
|
parts = full_name.strip().split()
|
|
if len(parts) == 1:
|
|
return parts[0], ""
|
|
elif len(parts) == 2:
|
|
return parts[0], parts[1]
|
|
else:
|
|
return parts[0], " ".join(parts[1:])
|
|
|
|
@staticmethod
|
|
def extract_patient_visits(his_data: Dict, patient_id: str, patient_type: str) -> List[Dict]:
|
|
"""
|
|
Extract visit events for a specific patient from the appropriate sublist.
|
|
|
|
Uses PatientID to match and PatientType to determine which sublist to use:
|
|
- "ED" -> FetchPatientDataTimeStampVisitEDDataList
|
|
- "IP" -> FetchPatientDataTimeStampVisitIPDataList
|
|
- "OP" -> FetchPatientDataTimeStampVisitOPDataList
|
|
|
|
Args:
|
|
his_data: Full HIS response dict
|
|
patient_id: HIS PatientID to filter by
|
|
patient_type: HIS PatientType (ED, IP, OP)
|
|
|
|
Returns:
|
|
List of visit event dicts for this patient, sorted by BillDate
|
|
"""
|
|
list_key = PATIENT_TYPE_TO_VISIT_LIST.get(patient_type.upper())
|
|
if not list_key:
|
|
logger.warning(f"Unknown patient type '{patient_type}', no visit list found")
|
|
return []
|
|
|
|
all_visits = his_data.get(list_key, [])
|
|
|
|
patient_visits = []
|
|
for visit in all_visits:
|
|
if visit.get("PatientID") == str(patient_id):
|
|
visit_event = {
|
|
"type": visit.get("Type", ""),
|
|
"bill_date": visit.get("BillDate", ""),
|
|
"patient_type": visit.get("PatientType", patient_type),
|
|
"visit_category": patient_type,
|
|
"admission_id": visit.get("AdmissionID", ""),
|
|
"patient_id": visit.get("PatientID", ""),
|
|
"reg_code": visit.get("RegCode", ""),
|
|
"ssn": visit.get("SSN", ""),
|
|
"mobile_no": visit.get("MobileNo", ""),
|
|
}
|
|
|
|
if visit_event["bill_date"]:
|
|
parsed_date = HISAdapter.parse_date(visit_event["bill_date"])
|
|
visit_event["parsed_date"] = parsed_date.isoformat() if parsed_date else None
|
|
visit_event["sort_key"] = parsed_date if parsed_date else datetime.min
|
|
else:
|
|
visit_event["parsed_date"] = None
|
|
visit_event["sort_key"] = datetime.min
|
|
|
|
patient_visits.append(visit_event)
|
|
|
|
patient_visits.sort(key=lambda x: x.get("sort_key", datetime.min))
|
|
|
|
for visit in patient_visits:
|
|
visit.pop("sort_key", None)
|
|
|
|
return patient_visits
|
|
|
|
@staticmethod
|
|
def extract_visit_timeline(his_data: Dict) -> List[Dict]:
|
|
"""
|
|
Extract and sort visit timeline from HIS data (all patients).
|
|
|
|
Combines visits from all 3 lists and sorts by BillDate.
|
|
Used for backward compatibility with the webhook flow.
|
|
"""
|
|
all_visits = []
|
|
|
|
visit_lists = [
|
|
("ED", his_data.get("FetchPatientDataTimeStampVisitEDDataList", [])),
|
|
("IP", his_data.get("FetchPatientDataTimeStampVisitIPDataList", [])),
|
|
("OP", his_data.get("FetchPatientDataTimeStampVisitOPDataList", [])),
|
|
]
|
|
|
|
for visit_type, visits in visit_lists:
|
|
for visit in visits:
|
|
visit_event = {
|
|
"type": visit.get("Type", ""),
|
|
"bill_date": visit.get("BillDate", ""),
|
|
"patient_type": visit.get("PatientType", visit_type),
|
|
"visit_category": visit_type,
|
|
"admission_id": visit.get("AdmissionID", ""),
|
|
"patient_id": visit.get("PatientID", ""),
|
|
"reg_code": visit.get("RegCode", ""),
|
|
"ssn": visit.get("SSN", ""),
|
|
"mobile_no": visit.get("MobileNo", ""),
|
|
}
|
|
|
|
if visit_event["bill_date"]:
|
|
parsed_date = HISAdapter.parse_date(visit_event["bill_date"])
|
|
visit_event["parsed_date"] = parsed_date.isoformat() if parsed_date else None
|
|
visit_event["sort_key"] = parsed_date if parsed_date else datetime.min
|
|
|
|
all_visits.append(visit_event)
|
|
|
|
all_visits.sort(key=lambda x: x.get("sort_key", datetime.min))
|
|
|
|
for visit in all_visits:
|
|
visit.pop("sort_key", None)
|
|
|
|
return all_visits
|
|
|
|
@staticmethod
|
|
def get_or_create_hospital(hospital_data: Dict) -> Optional[Hospital]:
|
|
"""Get or create hospital from HIS data"""
|
|
hospital_name = hospital_data.get("HospitalName")
|
|
hospital_id = hospital_data.get("HospitalID")
|
|
|
|
if not hospital_name:
|
|
return None
|
|
|
|
hospital = Hospital.objects.filter(name__icontains=hospital_name).first()
|
|
|
|
if hospital:
|
|
return hospital
|
|
|
|
hospital_code = hospital_id if hospital_id else f"HOSP-{hospital_name[:3].upper()}"
|
|
|
|
hospital, created = Hospital.objects.get_or_create(
|
|
code=hospital_code, defaults={"name": hospital_name, "status": "active"}
|
|
)
|
|
|
|
return hospital
|
|
|
|
@staticmethod
|
|
def get_or_create_patient(patient_data: Dict, hospital: Hospital) -> Patient:
|
|
"""Get or create patient from HIS demographic data"""
|
|
patient_id = patient_data.get("PatientID")
|
|
mrn = patient_id
|
|
national_id = patient_data.get("SSN")
|
|
phone = patient_data.get("MobileNo")
|
|
email = patient_data.get("Email")
|
|
full_name = patient_data.get("PatientName")
|
|
nationality = patient_data.get("PatientNationality", "")
|
|
|
|
first_name, last_name = HISAdapter.split_patient_name(full_name)
|
|
|
|
dob_str = patient_data.get("DOB")
|
|
date_of_birth = HISAdapter.parse_date(dob_str) if dob_str else None
|
|
|
|
gender = patient_data.get("Gender", "").lower()
|
|
|
|
patient = Patient.objects.filter(mrn=mrn, primary_hospital=hospital).first()
|
|
|
|
if patient:
|
|
patient.first_name = first_name
|
|
patient.last_name = last_name
|
|
patient.national_id = national_id
|
|
patient.phone = phone
|
|
if email is not None:
|
|
patient.email = email
|
|
patient.date_of_birth = date_of_birth
|
|
patient.gender = gender
|
|
patient.nationality = nationality
|
|
patient.save()
|
|
return patient
|
|
|
|
mrn_taken = Patient.objects.filter(mrn=mrn).exists()
|
|
|
|
if mrn_taken and national_id:
|
|
patient = Patient.objects.filter(national_id=national_id).first()
|
|
if patient:
|
|
patient.mrn = mrn
|
|
patient.primary_hospital = hospital
|
|
patient.first_name = first_name
|
|
patient.last_name = last_name
|
|
patient.phone = phone
|
|
if email is not None:
|
|
patient.email = email
|
|
patient.date_of_birth = date_of_birth
|
|
patient.gender = gender
|
|
patient.nationality = nationality
|
|
patient.save()
|
|
return patient
|
|
|
|
if mrn_taken:
|
|
unique_mrn = f"{mrn}_{hospital.id}"
|
|
logger.warning(f"MRN collision for {mrn}, using {unique_mrn}")
|
|
mrn = unique_mrn
|
|
|
|
patient = Patient.objects.create(
|
|
mrn=mrn,
|
|
primary_hospital=hospital,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
national_id=national_id,
|
|
phone=phone,
|
|
email=email if email else "",
|
|
date_of_birth=date_of_birth,
|
|
gender=gender,
|
|
nationality=nationality,
|
|
)
|
|
|
|
return patient
|
|
|
|
@staticmethod
|
|
def get_survey_template(patient_type: str, hospital: Hospital) -> Optional[SurveyTemplate]:
|
|
"""
|
|
Get appropriate survey template based on PatientType using explicit mapping.
|
|
"""
|
|
from apps.integrations.models import SurveyTemplateMapping
|
|
|
|
survey_template = SurveyTemplateMapping.get_template_for_patient_type(patient_type, hospital)
|
|
|
|
return survey_template
|
|
|
|
@staticmethod
|
|
def get_delay_for_patient_type(patient_type: str, hospital) -> int:
|
|
"""
|
|
Get delay hours from SurveyTemplateMapping.
|
|
|
|
Falls back to default delays if no mapping found.
|
|
"""
|
|
from apps.integrations.models import SurveyTemplateMapping
|
|
|
|
mapping = SurveyTemplateMapping.objects.filter(
|
|
patient_type=patient_type, hospital=hospital, is_active=True
|
|
).first()
|
|
|
|
if mapping and mapping.send_delay_hours:
|
|
return mapping.send_delay_hours
|
|
|
|
mapping = SurveyTemplateMapping.objects.filter(
|
|
patient_type=patient_type, hospital__isnull=True, is_active=True
|
|
).first()
|
|
|
|
if mapping and mapping.send_delay_hours:
|
|
return mapping.send_delay_hours
|
|
|
|
default_delays = {
|
|
"IP": 24,
|
|
"OP": 1,
|
|
"ED": 2,
|
|
"DAYCASE": 4,
|
|
}
|
|
|
|
return default_delays.get(patient_type, 1)
|
|
|
|
@staticmethod
|
|
def is_op_visit_complete(
|
|
visit_timeline: List[Dict], patient_type: str, hospital
|
|
) -> Tuple[bool, Optional[datetime]]:
|
|
"""
|
|
Check if an OP visit is complete by checking if the last visit event
|
|
is older than the configured send_delay_hours.
|
|
|
|
Args:
|
|
visit_timeline: List of visit events for this patient
|
|
patient_type: HIS PatientType code
|
|
hospital: Hospital instance
|
|
|
|
Returns:
|
|
Tuple of (is_complete: bool, last_visit_date: datetime or None)
|
|
"""
|
|
if not visit_timeline:
|
|
return False, None
|
|
|
|
last_visit_date = None
|
|
for event in visit_timeline:
|
|
if event.get("bill_date"):
|
|
parsed = HISAdapter.parse_date(event["bill_date"])
|
|
if parsed and (last_visit_date is None or parsed > last_visit_date):
|
|
last_visit_date = parsed
|
|
|
|
if not last_visit_date:
|
|
return False, None
|
|
|
|
delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital)
|
|
cutoff = timezone.now() - timedelta(hours=delay_hours)
|
|
|
|
is_complete = last_visit_date <= cutoff
|
|
|
|
return is_complete, last_visit_date
|
|
|
|
@staticmethod
|
|
def save_patient_visit(
|
|
patient: Patient,
|
|
hospital: Hospital,
|
|
patient_data: Dict,
|
|
visit_timeline: List[Dict],
|
|
is_visit_complete: bool = False,
|
|
discharge_date: Optional[datetime] = None,
|
|
effective_discharge_date: Optional[datetime] = None,
|
|
) -> HISPatientVisit:
|
|
"""
|
|
Create or update HISPatientVisit record.
|
|
|
|
This is called on every fetch for every patient, regardless of
|
|
whether the visit is complete.
|
|
"""
|
|
admission_id = patient_data.get("AdmissionID", "")
|
|
patient_id_his = str(patient_data.get("PatientID", ""))
|
|
patient_type = patient_data.get("PatientType", "")
|
|
reg_code = patient_data.get("RegCode", "")
|
|
|
|
admit_date_str = patient_data.get("AdmitDate")
|
|
admit_date = HISAdapter.parse_date(admit_date_str) if admit_date_str else None
|
|
|
|
is_vip = str(patient_data.get("IsVIP", "0")).strip() == "1"
|
|
|
|
visit, created = HISPatientVisit.objects.update_or_create(
|
|
admission_id=admission_id,
|
|
defaults={
|
|
"patient": patient,
|
|
"hospital": hospital,
|
|
"reg_code": reg_code,
|
|
"patient_id_his": patient_id_his,
|
|
"patient_type": patient_type,
|
|
"admit_date": admit_date,
|
|
"discharge_date": discharge_date,
|
|
"effective_discharge_date": effective_discharge_date,
|
|
"visit_data": patient_data,
|
|
"visit_timeline": visit_timeline,
|
|
"primary_doctor": patient_data.get("PrimaryDoctor", ""),
|
|
"consultant_id": patient_data.get("ConsultantID", ""),
|
|
"company_name": patient_data.get("CompanyName", ""),
|
|
"grade_name": patient_data.get("GradeName", ""),
|
|
"insurance_company_name": patient_data.get("InsuranceCompanyName", ""),
|
|
"bill_type": patient_data.get("BillType", ""),
|
|
"is_vip": is_vip,
|
|
"nationality": patient_data.get("PatientNationality", ""),
|
|
"is_visit_complete": is_visit_complete,
|
|
"last_his_fetch_at": timezone.now(),
|
|
},
|
|
)
|
|
|
|
HISAdapter._resolve_staff_fks(visit, patient_data)
|
|
HISAdapter._sync_visit_events(visit, visit_timeline)
|
|
|
|
action = "Created" if created else "Updated"
|
|
logger.info(
|
|
f"{action} HISPatientVisit: {admission_id} type={patient_type} complete={is_visit_complete} patient={patient}"
|
|
)
|
|
|
|
return visit
|
|
|
|
@staticmethod
|
|
def _resolve_staff_fks(visit: HISPatientVisit, patient_data: Dict) -> None:
|
|
"""
|
|
Match HIS doctor/consultant IDs to Staff records via employee_id.
|
|
|
|
HIS formats:
|
|
- ConsultantID: "11065" (numeric string only, no name)
|
|
- PrimaryDoctor: "16468-HEBA ELSHABOURY ABDELATTY" (ID prefix + dash + name)
|
|
|
|
PrimaryDoctor: get_or_create on employee_id (auto-creates Staff from HIS name).
|
|
ConsultantID: lookup only (no creation — no name available from HIS).
|
|
Uses .update() to avoid extra queries or save signals.
|
|
"""
|
|
updates = {}
|
|
|
|
consultant_raw = patient_data.get("ConsultantID", "").strip()
|
|
if consultant_raw and consultant_raw.isdigit():
|
|
consultant = Staff.objects.filter(employee_id=consultant_raw).first()
|
|
if consultant:
|
|
updates["consultant_fk"] = consultant
|
|
|
|
primary_doctor_raw = patient_data.get("PrimaryDoctor", "").strip()
|
|
if primary_doctor_raw and "-" in primary_doctor_raw:
|
|
doctor_code, doctor_name = primary_doctor_raw.split("-", 1)
|
|
doctor_code = doctor_code.strip()
|
|
doctor_name = doctor_name.strip()
|
|
if doctor_code.isdigit() and doctor_name:
|
|
defaults = HISAdapter._staff_defaults_from_name(doctor_name, visit.hospital)
|
|
doctor, created = Staff.objects.get_or_create(
|
|
employee_id=doctor_code,
|
|
defaults=defaults,
|
|
)
|
|
if created:
|
|
logger.info(f"Auto-created Staff from HIS: {doctor} (employee_id={doctor_code})")
|
|
updates["primary_doctor_fk"] = doctor
|
|
|
|
if updates:
|
|
HISPatientVisit.objects.filter(pk=visit.pk).update(**updates)
|
|
|
|
@staticmethod
|
|
def _staff_defaults_from_name(full_name: str, hospital: Hospital) -> Dict:
|
|
"""
|
|
Build Staff defaults dict from a raw HIS doctor name string.
|
|
|
|
E.g. "HEBA ELSHABOURY ABDELATTY" → first_name="HEBA", last_name="ELSABOURY ABDELATTY"
|
|
"""
|
|
parts = full_name.split(None, 1)
|
|
first_name = parts[0] if parts else "Doctor"
|
|
last_name = parts[1] if len(parts) > 1 else ""
|
|
return {
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"name": full_name,
|
|
"staff_type": Staff.StaffType.PHYSICIAN,
|
|
"job_title": "Physician",
|
|
"hospital": hospital,
|
|
"physician": True,
|
|
}
|
|
|
|
@staticmethod
|
|
def _sync_visit_events(visit: HISPatientVisit, visit_timeline: List[Dict]) -> None:
|
|
"""
|
|
Sync timeline events to HISVisitEvent model.
|
|
|
|
Creates/updates HISVisitEvent records from the timeline JSON.
|
|
Also auto-creates HISEventType records for unique event types.
|
|
"""
|
|
from apps.integrations.models import HISVisitEvent, HISEventType
|
|
|
|
if not visit_timeline:
|
|
return
|
|
|
|
existing_keys = set(HISVisitEvent.objects.filter(visit=visit).values_list("bill_date", "event_type"))
|
|
|
|
events_to_create = []
|
|
event_types_seen = set()
|
|
for event in visit_timeline:
|
|
bill_date = event.get("bill_date", "")
|
|
event_type = event.get("type", "")
|
|
patient_type = event.get("patient_type", "")
|
|
key = (bill_date, event_type)
|
|
|
|
if key in existing_keys:
|
|
continue
|
|
|
|
parsed_date_str = event.get("parsed_date")
|
|
parsed_date = None
|
|
if parsed_date_str:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
parsed_date = datetime.fromisoformat(parsed_date_str.replace("Z", "+00:00"))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
events_to_create.append(
|
|
HISVisitEvent(
|
|
visit=visit,
|
|
event_type=event_type,
|
|
bill_date=bill_date,
|
|
parsed_date=parsed_date,
|
|
patient_type=patient_type,
|
|
visit_category=event.get("visit_category", ""),
|
|
admission_id=event.get("admission_id", ""),
|
|
patient_id=event.get("patient_id", ""),
|
|
reg_code=event.get("reg_code", ""),
|
|
ssn=event.get("ssn", ""),
|
|
mobile_no=event.get("mobile_no", ""),
|
|
)
|
|
)
|
|
|
|
if event_type:
|
|
event_types_seen.add((event_type, patient_type))
|
|
|
|
if events_to_create:
|
|
HISVisitEvent.objects.bulk_create(events_to_create, ignore_conflicts=True)
|
|
logger.info(f"Created {len(events_to_create)} HISVisitEvent records for visit {visit.admission_id}")
|
|
|
|
if event_types_seen:
|
|
from django.db.models import F
|
|
|
|
for event_type, patient_type in event_types_seen:
|
|
et, created = HISEventType.objects.get_or_create(event_type=event_type)
|
|
if patient_type and patient_type not in et.patient_types:
|
|
et.patient_types.append(patient_type)
|
|
et.save(update_fields=["patient_types"])
|
|
HISEventType.objects.filter(id=et.id).update(event_count=F("event_count") + 1)
|
|
|
|
@staticmethod
|
|
def create_and_send_survey(
|
|
patient: Patient,
|
|
hospital: Hospital,
|
|
patient_data: Dict,
|
|
survey_template: SurveyTemplate = None,
|
|
visit_timeline: List[Dict] = None,
|
|
his_visit: HISPatientVisit = None,
|
|
) -> Optional[SurveyInstance]:
|
|
"""
|
|
Create survey instance using the template from SurveyTemplateMapping.
|
|
|
|
The template's questions are filtered at display time by the public
|
|
serializer - is_base questions are always shown, event_type questions
|
|
are only shown if the patient experienced that event.
|
|
"""
|
|
from apps.surveys.models import (
|
|
SurveyInstance,
|
|
SurveyStatus,
|
|
)
|
|
from apps.surveys.tasks import send_scheduled_survey
|
|
|
|
admission_id = patient_data.get("AdmissionID")
|
|
discharge_date_str = patient_data.get("DischargeDate")
|
|
patient_type = patient_data.get("PatientType")
|
|
|
|
existing_survey = SurveyInstance.objects.filter(
|
|
patient=patient, hospital=hospital, metadata__admission_id=admission_id
|
|
).first()
|
|
|
|
if existing_survey:
|
|
logger.info(f"Survey already exists for admission {admission_id}")
|
|
if his_visit and not his_visit.survey_instance:
|
|
his_visit.survey_instance = existing_survey
|
|
his_visit.save(update_fields=["survey_instance"])
|
|
return existing_survey
|
|
|
|
if not survey_template:
|
|
survey_template = HISAdapter.get_survey_template(patient_type, hospital)
|
|
|
|
if not survey_template:
|
|
logger.warning(f"No survey template mapping found for {patient_type} at {hospital}")
|
|
return None
|
|
|
|
delay_hours = HISAdapter.get_delay_for_patient_type(patient_type, hospital)
|
|
scheduled_send_at = timezone.now() + timedelta(hours=delay_hours)
|
|
|
|
effective_discharge = None
|
|
if his_visit and his_visit.effective_discharge_date:
|
|
effective_discharge = his_visit.effective_discharge_date.isoformat()
|
|
|
|
# Collect unique event types from patient's journey for metadata
|
|
event_types = []
|
|
if his_visit:
|
|
events_qs = his_visit.visit_events.order_by("parsed_date")
|
|
for evt in events_qs:
|
|
if evt.event_type and evt.event_type not in event_types:
|
|
event_types.append(evt.event_type)
|
|
|
|
survey = SurveyInstance.objects.create(
|
|
survey_template=survey_template,
|
|
patient=patient,
|
|
hospital=hospital,
|
|
status=SurveyStatus.PENDING,
|
|
delivery_channel="SMS",
|
|
recipient_phone=patient.phone,
|
|
recipient_email=patient.email,
|
|
scheduled_send_at=scheduled_send_at,
|
|
metadata={
|
|
"admission_id": admission_id,
|
|
"patient_type": patient_type,
|
|
"hospital_id": patient_data.get("HospitalID"),
|
|
"insurance_company": patient_data.get("InsuranceCompanyName"),
|
|
"is_vip": patient_data.get("IsVIP") == "1",
|
|
"discharge_date": discharge_date_str,
|
|
"effective_discharge_date": effective_discharge,
|
|
"scheduled_send_at": scheduled_send_at.isoformat(),
|
|
"delay_hours": delay_hours,
|
|
"visit_timeline": visit_timeline or [],
|
|
"event_types": event_types,
|
|
"question_source": "template",
|
|
},
|
|
)
|
|
|
|
send_scheduled_survey.apply_async(args=[str(survey.id)], countdown=delay_hours * 3600)
|
|
|
|
if his_visit:
|
|
his_visit.survey_instance = survey
|
|
his_visit.save(update_fields=["survey_instance"])
|
|
|
|
logger.info(
|
|
f"Survey {survey.id} created for {patient_type} (template: {survey_template.name}), "
|
|
f"will send in {delay_hours}h at {scheduled_send_at}"
|
|
)
|
|
|
|
return survey
|
|
|
|
@staticmethod
|
|
def process_single_patient(his_data: Dict, patient_data: Dict) -> Dict:
|
|
"""
|
|
Process a single patient from HIS data.
|
|
|
|
Two phases:
|
|
Phase A: Always save patient and visit data
|
|
Phase B: Create survey only if visit is complete
|
|
|
|
Args:
|
|
his_data: Full HIS response dict (needed for visit timeline extraction)
|
|
patient_data: Single patient dict from FetchPatientDataTimeStampList
|
|
|
|
Returns:
|
|
Dict with processing results
|
|
"""
|
|
result = {
|
|
"success": False,
|
|
"message": "",
|
|
"patient": None,
|
|
"survey": None,
|
|
"survey_queued": False,
|
|
"visit_saved": False,
|
|
"visit_complete": False,
|
|
"patient_type": None,
|
|
}
|
|
|
|
try:
|
|
patient_type = patient_data.get("PatientType")
|
|
patient_id = str(patient_data.get("PatientID", ""))
|
|
discharge_date_str = patient_data.get("DischargeDate")
|
|
|
|
hospital = HISAdapter.get_or_create_hospital(patient_data)
|
|
if not hospital:
|
|
result["message"] = "Could not determine hospital"
|
|
return result
|
|
|
|
patient = HISAdapter.get_or_create_patient(patient_data, hospital)
|
|
result["patient"] = patient
|
|
result["patient_type"] = patient_type
|
|
|
|
visit_timeline = HISAdapter.extract_patient_visits(his_data, patient_id, patient_type)
|
|
|
|
discharge_date = HISAdapter.parse_date(discharge_date_str) if discharge_date_str else None
|
|
|
|
is_visit_complete = False
|
|
effective_discharge_date = None
|
|
|
|
if patient_type and patient_type.upper() in ("ED", "IP"):
|
|
if discharge_date:
|
|
is_visit_complete = True
|
|
else:
|
|
result["message"] = f"{patient_type} patient not discharged - visit saved, no survey"
|
|
result["success"] = True
|
|
result["visit_saved"] = True
|
|
HISAdapter.save_patient_visit(
|
|
patient,
|
|
hospital,
|
|
patient_data,
|
|
visit_timeline,
|
|
is_visit_complete=False,
|
|
discharge_date=None,
|
|
)
|
|
return result
|
|
|
|
elif patient_type and patient_type.upper() == "OP":
|
|
is_complete, last_visit_date = HISAdapter.is_op_visit_complete(visit_timeline, patient_type, hospital)
|
|
if is_complete:
|
|
is_visit_complete = True
|
|
effective_discharge_date = last_visit_date
|
|
else:
|
|
result["message"] = f"OP visit still in progress (last activity: {last_visit_date})"
|
|
result["success"] = True
|
|
result["visit_saved"] = True
|
|
result["visit_complete"] = False
|
|
HISAdapter.save_patient_visit(
|
|
patient,
|
|
hospital,
|
|
patient_data,
|
|
visit_timeline,
|
|
is_visit_complete=False,
|
|
discharge_date=None,
|
|
)
|
|
return result
|
|
else:
|
|
if discharge_date:
|
|
is_visit_complete = True
|
|
else:
|
|
result["message"] = f"Patient type {patient_type} not discharged - visit saved, no survey"
|
|
result["success"] = True
|
|
result["visit_saved"] = True
|
|
HISAdapter.save_patient_visit(
|
|
patient,
|
|
hospital,
|
|
patient_data,
|
|
visit_timeline,
|
|
is_visit_complete=False,
|
|
discharge_date=None,
|
|
)
|
|
return result
|
|
|
|
his_visit = HISAdapter.save_patient_visit(
|
|
patient,
|
|
hospital,
|
|
patient_data,
|
|
visit_timeline,
|
|
is_visit_complete=is_visit_complete,
|
|
discharge_date=discharge_date,
|
|
effective_discharge_date=effective_discharge_date,
|
|
)
|
|
result["visit_saved"] = True
|
|
result["visit_complete"] = True
|
|
|
|
if his_visit.survey_instance:
|
|
result["message"] = f"Survey already exists for admission {patient_data.get('AdmissionID')}"
|
|
result["success"] = True
|
|
result["survey"] = his_visit.survey_instance
|
|
result["survey_queued"] = True
|
|
return result
|
|
|
|
survey = HISAdapter.create_and_send_survey(
|
|
patient, hospital, patient_data, visit_timeline=visit_timeline, his_visit=his_visit
|
|
)
|
|
|
|
if survey:
|
|
survey_queued = survey.status == SurveyStatus.PENDING
|
|
else:
|
|
survey_queued = False
|
|
|
|
result.update(
|
|
{
|
|
"success": True,
|
|
"message": "Patient data processed successfully",
|
|
"survey": survey,
|
|
"survey_queued": survey_queued,
|
|
"scheduled_send_at": survey.scheduled_send_at.isoformat()
|
|
if survey and survey.scheduled_send_at
|
|
else None,
|
|
"survey_url": survey.get_survey_url() if survey else None,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing HIS data: {str(e)}", exc_info=True)
|
|
result["message"] = f"Error processing HIS data: {str(e)}"
|
|
result["success"] = False
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def process_his_response(his_data: Dict) -> Dict:
|
|
"""
|
|
Process a full HIS API response containing multiple patients.
|
|
|
|
Iterates each patient in FetchPatientDataTimeStampList, extracts
|
|
their visits from the appropriate sublist, saves patient/visit data,
|
|
and creates surveys for completed visits.
|
|
|
|
Args:
|
|
his_data: Full HIS response dict
|
|
|
|
Returns:
|
|
Dict with summary of processing results
|
|
"""
|
|
result = {
|
|
"success": False,
|
|
"total_patients": 0,
|
|
"visits_saved": 0,
|
|
"surveys_created": 0,
|
|
"surveys_skipped": 0,
|
|
"errors": [],
|
|
"details": [],
|
|
}
|
|
|
|
try:
|
|
if his_data.get("Code") != 200 or his_data.get("Status") != "Success":
|
|
result["errors"].append(f"HIS Error: {his_data.get('Message', 'Unknown error')}")
|
|
return result
|
|
|
|
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
|
|
|
|
if not patient_list:
|
|
result["message"] = "No patient data found"
|
|
result["success"] = True
|
|
return result
|
|
|
|
result["total_patients"] = len(patient_list)
|
|
|
|
for patient_data in patient_list:
|
|
patient_result = HISAdapter.process_single_patient(his_data, patient_data)
|
|
|
|
detail = {
|
|
"patient_name": patient_data.get("PatientName", "Unknown"),
|
|
"patient_type": patient_data.get("PatientType"),
|
|
"admission_id": patient_data.get("AdmissionID"),
|
|
}
|
|
|
|
if patient_result["success"]:
|
|
if patient_result.get("visit_saved"):
|
|
result["visits_saved"] += 1
|
|
|
|
if patient_result.get("visit_complete") and patient_result.get("survey"):
|
|
result["surveys_created"] += 1
|
|
detail["survey_created"] = True
|
|
elif patient_result.get("visit_complete") and not patient_result.get("survey"):
|
|
result["surveys_skipped"] += 1
|
|
detail["reason"] = patient_result.get("message", "No survey created")
|
|
else:
|
|
detail["reason"] = patient_result.get("message", "Visit in progress")
|
|
else:
|
|
result["errors"].append(f"{patient_data.get('PatientName')}: {patient_result.get('message')}")
|
|
detail["error"] = patient_result.get("message")
|
|
|
|
result["details"].append(detail)
|
|
|
|
result["success"] = True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing HIS response: {str(e)}", exc_info=True)
|
|
result["errors"].append(str(e))
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def process_his_data(his_data: Dict) -> Dict:
|
|
"""
|
|
Process HIS patient data (webhook/push flow).
|
|
|
|
Handles a full HIS payload received via webhook.
|
|
Processes the first patient in the list.
|
|
For the pull flow, use process_his_response() instead.
|
|
|
|
Args:
|
|
his_data: HIS data in real format
|
|
|
|
Returns:
|
|
Dict with processing results
|
|
"""
|
|
patient_list = his_data.get("FetchPatientDataTimeStampList", [])
|
|
|
|
if not patient_list:
|
|
return {"success": False, "message": "No patient data found"}
|
|
|
|
patient_data = patient_list[0]
|
|
|
|
if his_data.get("Code") != 200 or his_data.get("Status") != "Success":
|
|
return {"success": False, "message": f"HIS Error: {his_data.get('Message', 'Unknown error')}"}
|
|
|
|
return HISAdapter.process_single_patient(his_data, patient_data)
|