All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
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.
357 lines
11 KiB
Python
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
|