HH/apps/organizations/management/commands/backfill_department_hierarchy.py
ismail 7369d08012
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
feat: unified reference numbers + feedback modules QA audit
Reference numbers (unified scheme PREFIX-YYYYMM-HOSP-NNNN, e.g. CMP-202606-HHN-0001):
- new ReferenceSequence model + generate_reference() helper (apps/core)
- Complaint/Inquiry/Observation/Appreciation/Suggestion emit unified refs via save()
- prefix-based auto-routing in public track API (CMP/INQ/OBS trackable; APR/SGT internal-only)
- removed legacy CMP-/INQ- generators in ui_views, integrations, px_sources
- migrations: core.0003_referencesequence, appreciation.0006, feedback.0008, observations.0012
- unit tests (format, sanitization, monthly reset, 40-thread concurrency)

QA audit:
- isolated E2E hospital sandbox mirroring HH-N + 10 role users (create_e2e_isolated_env)
- feedback-modules-audit.spec.ts + audit helper (headed, run-to-completion)
- reports/feedback-modules-qa-report.md

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.
2026-06-14 14:29:23 +03:00

357 lines
11 KiB
Python

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