import re from django.core.management.base import BaseCommand from django.db import transaction from apps.complaints.models import Complaint from apps.organizations.models import ( Department, LegacyHierarchyMapping, LegacyMainSection, LegacySubSection, Section, ) def _normalize(s): if not s: return "" s = s.lower().strip() for suffix in [ " clinics", " clinic", " department", " dept", " ward", " unit", " services", ]: s = s.replace(suffix, "") s = re.sub(r"\s+", " ", s).strip() return s MANUAL_MAP = { 2: "Internal Medicine", 5: "Internal Medicine", 6: "Internal Medicine", 9: "Internal Medicine", 11: "Internal Medicine", 13: "Internal Medicine", 14: "Internal Medicine", 15: "Surgeries", 18: "Surgeries", 20: "Surgeries", 21: "Surgeries", 22: "Surgeries", 23: "Surgeries", 32: "Surgeries", 34: "Emergency Department", 35: "Critical Care", 36: "Pediatric Department", 37: "Pediatric Department", 38: "Critical Care", 39: "Pediatric Department", 40: "Inpatient Department", 41: "Inpatient Department", 42: "Inpatient Department", 43: "Anesthesia & OR Department", 44: "Anesthesia & OR Department", 24: "Surgeries", 97: "Patient Relations & Patient Experience Department", 45: "Surgeries", 46: "Inpatient Department", 47: "Obstetrics & Gynecology", 48: "Inpatient Department", 50: "Medical Ancillary Services", 51: "Medical Ancillary Services", 53: "Pharmacy", 55: "Medical Ancillary Services", 56: "Medical Ancillary Services", 57: "Medical Ancillary Services", 58: "Medical Ancillary Services", 59: "Medical Ancillary Services", 60: "Medical Ancillary Services", 61: "Medical Ancillary Services", 62: "Surgeries", 78: "Nursing Department", 79: "Nursing Department", 88: "Outpatient Department", 89: "Corporate Administration", 90: "Finance Department", 92: "Outpatient Department", 94: "Outpatient Department", 95: "Security Department", 96: "Emergency Department", 98: "Emergency Department", 99: "Outpatient Department", 100: "Emergency Department", 102: "Medical Records Department", 103: "Outpatient Department", 104: "Medical Approvals Department", 105: "Security Department", 109: "Nursing Department", 110: "Nursing Department", 111: "Security Department", 113: "Outpatient Department", 147: "Internal Medicine", 149: "Housekeeping & Hospitality Department", 151: "Nursing Department", 152: "Nursing Department", 153: "Nursing Department", 155: "Nursing Department", 156: "Nursing Department", 157: "Nursing Department", 158: "Nursing Department", 159: "Nursing Department", 160: "Nursing Department", 161: "Nursing Department", 162: "Nursing Department", 163: "Nursing Department", 164: "Food Services Department", 165: "Housekeeping & Hospitality Department", 167: "Outpatient Department", 168: "Corporate Administration", 169: "Finance Department", 170: "Patient Relations & Patient Experience Department", 171: "Patient Affairs Department", 172: "Inpatient Department", 173: "Medical Approvals Department", 174: "Executive Administration", 176: "Obstetrics & Gynecology", 177: "Nursing Department", 178: "Nursing Department", 180: "Laboratory", 182: "Nursing Department", 183: "Nursing Department", 184: "Patient Affairs Department", 185: "Nursing Department", 186: "Radiology", 187: "Medical Ancillary Services", 188: "Internal Medicine", 189: "Pediatric Department", 190: "Pediatric Department", 191: "Pediatric Department", 192: "Pediatric Department", 193: "Surgeries", 194: "Surgeries", 195: "Pediatric Department", 196: "Pediatric Department", 197: "Surgeries", 198: "Obstetrics & Gynecology", 199: "Internal Medicine", 200: "Internal Medicine", 201: "Medical Ancillary Services", 202: "Pediatric Department", 203: "Corporate Administration", 205: "Pharmacy", 206: "Medical Ancillary Services", 207: "Facility Management & Maintenance", 208: "Internal Medicine", 209: "Pediatric Department", 210: "Emergency Department", 211: "Laboratory", 212: "Surgeries", 213: "Information Technology", 215: "Outpatient Department", 216: "Critical Care", 219: "Laboratory", 220: "Emergency Department", 222: "Information Technology", 223: "Outpatient Department", 224: "Information Technology", 225: "Medical Ancillary Services", 226: "Medical Records Department", 227: "Medical Ancillary Services", 228: "Medical Records Department", 229: "Inpatient Department", 230: "Finance Department", } class Command(BaseCommand): help = "Backfill complaint.department from legacy subsection hierarchy" def add_arguments(self, parser): parser.add_argument( "--dry-run", action="store_true", help="Show what would be updated without making changes", ) parser.add_argument( "--backfill-section", action="store_true", help="Also backfill complaint.section from LegacyHierarchyMapping", ) parser.add_argument( "--model", type=str, default="complaint", choices=["complaint"], help="Model to backfill (default: complaint)", ) def handle(self, *args, **options): dry_run = options["dry_run"] backfill_section = options["backfill_section"] self._build_lookup_tables() if backfill_section: self._backfill_sections(dry_run) return complaints = Complaint.objects.filter( department__isnull=True, legacy_subsection__isnull=False, ).select_related("legacy_subsection", "legacy_main_section") self.stdout.write(f"Complaints to backfill: {complaints.count()}") matched = 0 unmatched = 0 unmatched_details = {} with transaction.atomic(): for complaint in complaints: dept = self._resolve_department(complaint) if dept: matched += 1 if not dry_run: complaint.department = dept complaint.save(update_fields=["department"]) else: unmatched += 1 ss = complaint.legacy_subsection key = (ss.name_en, ss.main_section.name_en if ss.main_section else "?") unmatched_details[key] = unmatched_details.get(key, 0) + 1 if dry_run: transaction.set_rollback(True) self.stdout.write(self.style.SUCCESS(f"\nMatched: {matched}")) self.stdout.write(self.style.WARNING(f"Unmatched: {unmatched}")) if unmatched_details: self.stdout.write("\nUnmatched subsections:") for (name, ms), cnt in sorted( unmatched_details.items(), key=lambda x: -x[1] ): self.stdout.write(f" {cnt:4d} | {ms[:15]:15s} | {name[:50]}") def _build_lookup_tables(self): self.dept_by_name = {} for d in Department.objects.all(): self.dept_by_name[d.name_en.lower().strip()] = d self.dept_by_old_name = {} self.section_by_old_name = {} for m in LegacyHierarchyMapping.objects.select_related("main_section", "subsection"): key = m.old_subsection_en.lower().strip() if key and m.main_section: self.dept_by_old_name[key] = m.main_section if key and m.subsection: self.section_by_old_name[key] = m.subsection self.norm_dept_names = {} for name, dept in self.dept_by_name.items(): self.norm_dept_names[_normalize(name)] = dept self.stdout.write( f"Lookup tables: {len(self.dept_by_name)} depts, " f"{len(self.dept_by_old_name)} legacy mappings, " f"{len(self.section_by_old_name)} section mappings" ) def _backfill_sections(self, dry_run): """Backfill complaint.section from LegacyHierarchyMapping.""" complaints = Complaint.objects.filter( section__isnull=True, legacy_subsection__isnull=False, ).select_related("legacy_subsection") self.stdout.write(f"Complaints to backfill section: {complaints.count()}") matched = 0 unmatched = 0 with transaction.atomic(): for complaint in complaints: section = self._resolve_section(complaint) if section: matched += 1 if not dry_run: complaint.section = section complaint.save(update_fields=["section"]) else: unmatched += 1 if dry_run: transaction.set_rollback(True) self.stdout.write(self.style.SUCCESS(f"\nSection matched: {matched}")) self.stdout.write(self.style.WARNING(f"Section unmatched: {unmatched}")) def _resolve_section(self, complaint): """Resolve section from legacy subsection via LegacyHierarchyMapping.""" ss = complaint.legacy_subsection if not ss: return None # 1. Exact match via LegacyHierarchyMapping.subsection ss_name = ss.name_en.lower().strip() if ss_name in self.section_by_old_name: return self.section_by_old_name[ss_name] # 2. Try to find section by name within the complaint's department if complaint.department: section = Section.objects.filter( department=complaint.department, name_en__iexact=ss.name_en, ).first() if section: return section # Normalized match norm_ss = _normalize(ss.name_en) for sec in Section.objects.filter(department=complaint.department): if _normalize(sec.name_en) == norm_ss: return sec return None def _resolve_department(self, complaint): ss = complaint.legacy_subsection if not ss: return None # 1. Manual override map (highest priority) pk = ss.pk if pk in MANUAL_MAP: dept_name = MANUAL_MAP[pk] return self.dept_by_name.get(dept_name.lower().strip()) # 2. Exact match via LegacyHierarchyMapping ss_name = ss.name_en.lower().strip() if ss_name in self.dept_by_old_name: return self.dept_by_old_name[ss_name] # 3. Exact name match to new Department if ss_name in self.dept_by_name: return self.dept_by_name[ss_name] # 4. Normalized match norm_ss = _normalize(ss.name_en) if norm_ss in self.norm_dept_names: return self.norm_dept_names[norm_ss] # 5. Contains match (one name contains the other) for norm_dept, dept in self.norm_dept_names.items(): if len(norm_ss) > 5 and ( norm_ss in norm_dept or norm_dept in norm_ss ): if abs(len(norm_ss) - len(norm_dept)) < 15: return dept return None