Changes between Version 2 and Version 3 of AdvancedDatabaseDevelopment
- Timestamp:
- 09/23/26 20:22:29 (8 hours ago)
Legend:
- Unmodified
- Added
- Removed
- Modified
-
AdvancedDatabaseDevelopment
v2 v3 6 6 == Allergy–prescription safety enforcement == 7 7 8 A patient should never be prescribed a medication that conflicts with one of their documented allergies. This requirement blocks the unsafe insert outright, at the database level before it can happen: a new **prescription_medical_records** row is rejected if the prescription is linked, via **allergy_prescription_restrictions** → **prescription_restriction** to any allergy already on that patient's medical record.8 A patient should never be prescribed a medication that conflicts with one of their documented allergies. This requirement blocks the unsafe prescription outright, at the database level: a new or changed **prescription_medical_records** row is rejected if the prescription is linked, via **prescription_restriction** → **allergy_prescription_restrictions**, to an allergy the patient has on file, either in **patient_allergies** or in **medical_record_allergies** on any of the patient's medical records. The trigger only prevents new conflicts; prescriptions that were recorded before it existed are listed by the **v_prescription_allergy_conflicts** view so they can be reviewed. 9 9 10 10 === Custom domains === 11 11 12 12 {{{ 13 CREATE DOMAIN non_negative_c urrencyAS DECIMAL(12,2)13 CREATE DOMAIN non_negative_cost AS DECIMAL(12,2) 14 14 CHECK (VALUE >= 0); 15 16 ALTER TABLE procedures ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost; 17 ALTER TABLE lab_tests ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost; 18 ALTER TABLE billing ALTER COLUMN total_cost TYPE non_negative_cost USING total_cost::non_negative_cost; 19 20 ALTER TABLE procedures DROP CONSTRAINT IF EXISTS procedures_cost_chk; 21 ALTER TABLE lab_tests DROP CONSTRAINT IF EXISTS lab_tests_cost_chk; 22 ALTER TABLE billing DROP CONSTRAINT IF EXISTS billing_cost_chk; 15 23 }}} 16 24 … … 18 26 19 27 {{{ 20 CREATE TABLE IF NOT EXISTS prescription_allergy_conflicts_log (21 log_id BIGSERIAL PRIMARY KEY,22 record_id BIGINT NOT NULL REFERENCES medical_records(record_id),23 prescription_id BIGINT NOT NULL REFERENCES prescriptions(prescription_id),24 allergy_id BIGINT NOT NULL REFERENCES allergies(allergy_id),25 conflict_type TEXT CHECK (conflict_type IN ('ACTIVE', 'RESOLVED')),26 detected_date TIMESTAMP DEFAULT NOW(),27 resolution_notes TEXT,28 resolved_date TIMESTAMP29 );30 31 28 CREATE OR REPLACE FUNCTION t1_prescription_allergy_check() 32 29 RETURNS TRIGGER … … 46 43 END IF; 47 44 48 SELECT a pr.allergy_id, a.name45 SELECT a.allergy_id, a.name 49 46 INTO v_conflict_allergy_id, v_allergy_name 50 47 FROM prescription_restriction pr_rest 51 48 JOIN allergy_prescription_restrictions apr ON apr.restriction_id = pr_rest.restriction_id 52 JOIN allergies a ON apr.allergy_id = a.allergy_id 53 JOIN medical_record_allergies mra ON a.allergy_id = mra.allergy_id 49 JOIN allergies a ON a.allergy_id = apr.allergy_id 54 50 WHERE pr_rest.prescription_id = NEW.prescription_id 55 AND mra.record_id = NEW.record_id 51 AND ( 52 EXISTS (SELECT 1 FROM patient_allergies pa 53 WHERE pa.patient_id = v_patient_id 54 AND pa.allergy_id = a.allergy_id) 55 OR EXISTS (SELECT 1 FROM medical_record_allergies mra 56 JOIN medical_records mr2 ON mr2.record_id = mra.record_id 57 WHERE mr2.patient_id = v_patient_id 58 AND mra.allergy_id = a.allergy_id) 59 ) 56 60 LIMIT 1; 57 61 58 62 IF v_conflict_allergy_id IS NOT NULL THEN 59 RAISE EXCEPTION 'PRESCRIPTION_ALLERGY_CONFLICT: Prescription % conflicts with allergy % (%) in patient''s record%',60 NEW.prescription_id, v_conflict_allergy_id, v_allergy_name, NEW.record_id;63 RAISE EXCEPTION 'PRESCRIPTION_ALLERGY_CONFLICT: Prescription % conflicts with allergy % (%) of patient %', 64 NEW.prescription_id, v_conflict_allergy_id, v_allergy_name, v_patient_id; 61 65 END IF; 62 66 … … 67 71 DROP TRIGGER IF EXISTS trg_prescription_allergy_check ON prescription_medical_records; 68 72 CREATE TRIGGER trg_prescription_allergy_check 69 BEFORE INSERT 73 BEFORE INSERT OR UPDATE OF prescription_id, record_id 70 74 ON prescription_medical_records 71 75 FOR EACH ROW … … 88 92 a.allergy_severity, 89 93 apr.restriction_id, 90 pr_rest.description AS restriction_description, 91 CASE 92 WHEN mra.record_id IS NOT NULL THEN 'ACTIVE_CONFLICT' 93 ELSE 'ARCHIVED' 94 END AS conflict_status 94 pr_rest.description AS restriction_description 95 95 FROM prescription_medical_records pmr 96 96 JOIN medical_records mr ON pmr.record_id = mr.record_id … … 100 100 JOIN allergy_prescription_restrictions apr ON pr_rest.restriction_id = apr.restriction_id 101 101 JOIN allergies a ON apr.allergy_id = a.allergy_id 102 LEFT JOIN medical_record_allergies mra ON mr.record_id = mra.record_id 103 AND a.allergy_id = mra.allergy_id 104 WHERE a.allergy_severity IN ('HIGH', 'CRITICAL') 105 ORDER BY p.patient_id, a.allergy_severity DESC; 102 WHERE EXISTS (SELECT 1 FROM patient_allergies pa 103 WHERE pa.patient_id = p.patient_id 104 AND pa.allergy_id = a.allergy_id) 105 OR EXISTS (SELECT 1 FROM medical_record_allergies mra 106 JOIN medical_records mr2 ON mr2.record_id = mra.record_id 107 WHERE mr2.patient_id = p.patient_id 108 AND mra.allergy_id = a.allergy_id) 109 ORDER BY 110 p.patient_id, 111 CASE a.allergy_severity 112 WHEN 'CRITICAL' THEN 1 113 WHEN 'HIGH' THEN 2 114 WHEN 'MEDIUM' THEN 3 115 ELSE 4 116 END; 106 117 }}} 107 118 … … 110 121 == Appointment scheduling integrity == 111 122 112 Appointments must obey real scheduling constraints so no scheduling in the past, no doublebooking the same doctor or patient in an overlapping time, no marking an appointment `COMPLETED` before its scheduled time, and valid status transitions only (`SCHEDULED → IN_PROGRESS/COMPLETED/CANCELLED`, `IN_PROGRESS → COMPLETED/CANCELLED`, nothing further out of `COMPLETED`/`CANCELLED`/`NO_SHOW`). A background job autotransitions appointments that remain `SCHEDULED` if they are 45+ minutes past their time to `NO_SHOW`. 113 114 === Schema change === 115 116 {{{ 117 123 Appointments must obey real scheduling constraints: no scheduling in the past (when an appointment is created or moved to a new date or time), no double-booking the same doctor or patient in an overlapping time, no marking an appointment `COMPLETED` before its scheduled time, and valid status transitions only (`SCHEDULED → IN_PROGRESS/COMPLETED/CANCELLED/NO_SHOW`, `IN_PROGRESS → COMPLETED/CANCELLED`, nothing further out of `COMPLETED`/`CANCELLED`/`NO_SHOW`). Each appointment is treated as a 30-minute slot, and only `SCHEDULED`, `IN_PROGRESS` and `COMPLETED` appointments occupy a slot. The **job_mark_no_show** procedure moves appointments that are still `SCHEDULED` 45+ minutes past their time to `NO_SHOW`; it is run with `CALL job_mark_no_show();`, either manually or from a scheduler such as pg_cron, and **v_overdue_appointments** shows the appointments it will pick up. 124 125 === Schema change === 126 127 {{{ 118 128 ALTER TABLE appointments DROP CONSTRAINT appointments_status_chk; 119 129 ALTER TABLE appointments ADD CONSTRAINT appointments_status_chk 120 CHECK (status IN ('SCHEDULED', 'COMPLETED','CANCELLED','IN_PROGRESS','NO_SHOW'));130 CHECK (status IN ('SCHEDULED', 'COMPLETED', 'CANCELLED', 'IN_PROGRESS', 'NO_SHOW')); 121 131 }}} 122 132 … … 131 141 SELECT CASE 132 142 WHEN p_old = p_new THEN TRUE 133 WHEN p_old = 'SCHEDULED' AND p_new IN ('IN_PROGRESS', 'COMPLETED', 'CANCELLED') THEN TRUE134 WHEN p_old = 'IN_PROGRESS' AND p_new IN ('COMPLETED', 'CANCELLED') THEN TRUE143 WHEN p_old = 'SCHEDULED' AND p_new IN ('IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW') THEN TRUE 144 WHEN p_old = 'IN_PROGRESS' AND p_new IN ('COMPLETED', 'CANCELLED') THEN TRUE 135 145 ELSE FALSE 136 END;146 END; 137 147 $$; 138 148 … … 152 162 153 163 IF TG_OP = 'UPDATE' THEN 164 IF (NEW.appointment_date, NEW.appointment_time) IS DISTINCT FROM (OLD.appointment_date, OLD.appointment_time) 165 AND v_combined_datetime < NOW() THEN 166 RAISE EXCEPTION 'Cannot move appointment % into the past (appointment_date=%, appointment_time=%)', 167 NEW.appointment_id, NEW.appointment_date, NEW.appointment_time; 168 END IF; 169 154 170 IF NOT is_valid_appointment_transition(OLD.status, NEW.status) THEN 155 171 RAISE EXCEPTION 'Appointment status cannot transition from % to %', 156 172 OLD.status, NEW.status; 157 173 END IF; 158 159 IF NEW.status = 'COMPLETED' AND v_combined_datetime > NOW() THEN 160 RAISE EXCEPTION 'Cannot mark appointment COMPLETED before its scheduled time (scheduled for %)',161 v_combined_datetime;162 END IF;174 END IF; 175 176 IF NEW.status = 'COMPLETED' AND v_combined_datetime > NOW() THEN 177 RAISE EXCEPTION 'Cannot mark appointment COMPLETED before its scheduled time (scheduled for %)', 178 v_combined_datetime; 163 179 END IF; 164 180 … … 197 213 AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id) 198 214 ) THEN 199 RAISE EXCEPTION 'Doctor % has overlapping appointment at %', NEW.doctor_id, NEW.appointment_date; 215 RAISE EXCEPTION 'Doctor % has an overlapping appointment at % %', 216 NEW.doctor_id, NEW.appointment_date, NEW.appointment_time; 200 217 END IF; 201 218 … … 208 225 AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id) 209 226 ) THEN 210 RAISE EXCEPTION 'Patient % has overlapping appointment at %', NEW.patient_id, NEW.appointment_date; 227 RAISE EXCEPTION 'Patient % has an overlapping appointment at % %', 228 NEW.patient_id, NEW.appointment_date, NEW.appointment_time; 211 229 END IF; 212 230 … … 259 277 == Medical record consistency == 260 278 261 **Diagnoses, procedures, and referrals** can be created independently but must stay consistent with the patient they actually serve as a mismatch like a diagnosis for one patient attached to a different patient's record, a doctor referring a patient to themselves, is both an **integrity problem** and a clinical **safety risk**.279 **Diagnoses, procedures, and referrals** can be created independently but must stay consistent with the patient they actually serve. Linking a diagnosis to a different patient's medical record, linking a performed procedure to a diagnosis that belongs to another patient, or a doctor referring a patient to themselves is both an **integrity problem** and a clinical **safety risk**, so these are rejected on insert and on update. The **v_medical_record_overview** view summarises each medical record and flags diagnosis mismatches and self-referrals that were recorded before the triggers existed. 262 280 263 281 === Triggers === … … 294 312 DROP TRIGGER IF EXISTS trg_diagnosis_record_consistency ON diagnosis_medical_records; 295 313 CREATE TRIGGER trg_diagnosis_record_consistency 296 BEFORE INSERT O N diagnosis_medical_records314 BEFORE INSERT OR UPDATE ON diagnosis_medical_records 297 315 FOR EACH ROW EXECUTE FUNCTION t1_diagnosis_record_consistency(); 298 316 … … 331 349 AS $$ 332 350 BEGIN 333 IF NOT EXISTS (SELECT 1 FROM medical_records WHERE record_id = NEW.record_id) THEN334 RAISE EXCEPTION 'Medical record % not found', NEW.record_id;335 END IF;336 337 351 IF NEW.from_doctor_id = NEW.to_doctor_id THEN 338 RAISE EXCEPTION 'Doctor % cannot refer to themselves', NEW.from_doctor_id;352 RAISE EXCEPTION 'Doctor % cannot refer a patient to themselves', NEW.from_doctor_id; 339 353 END IF; 340 354 … … 345 359 DROP TRIGGER IF EXISTS trg_referral_consistency ON referrals; 346 360 CREATE TRIGGER trg_referral_consistency 347 BEFORE INSERT O N referrals361 BEFORE INSERT OR UPDATE ON referrals 348 362 FOR EACH ROW EXECUTE FUNCTION t3_referral_consistency(); 349 363 }}} … … 357 371 p.patient_id, p.first_name, p.last_name, p.embg, 358 372 COUNT(DISTINCT dmr.diagnosis_id) AS diagnosis_count, 359 COUNT(DISTINCT CASE WHEN d.patient_id IS NOT NULL AND d.patient_id<> p.patient_id THEN dmr.diagnosis_id END) AS diagnosis_mismatches,373 COUNT(DISTINCT CASE WHEN d.patient_id <> p.patient_id THEN dmr.diagnosis_id END) AS diagnosis_mismatches, 360 374 COUNT(DISTINCT mrp.procedure_id) AS procedures_count, 361 375 COUNT(DISTINCT mrl.result_id) AS lab_results_count, … … 369 383 LEFT JOIN medical_record_procedures mrp ON mrp.record_id = mr.record_id 370 384 LEFT JOIN medical_record_lab_results mrl ON mrl.record_id = mr.record_id 371 LEFT JOIN referrals ref ON mr.record_id = ref.record_id372 LEFT JOIN medical_record_allergies mra ON mr .record_id = mra.record_id385 LEFT JOIN referrals ref ON ref.record_id = mr.record_id 386 LEFT JOIN medical_record_allergies mra ON mra.record_id = mr.record_id 373 387 GROUP BY mr.record_id, p.patient_id, p.first_name, p.last_name, p.embg; 374 388 }}} … … 378 392 == Billing integrity == 379 393 380 Billing records must stay in sync with their line items and payment status must follow a valid state machine. **billing.total_cost** is automatically recalculated whenever a procedure or lab test is added to or removed from a bill, rather than relying on the application to keep it in sync manually. **payment_status** may only move `PENDING → PAID` or `PENDING → CANCELLED` but never backwards once finalized and every status change is written to an audit log. Since **billing** originally had no "issued" date , a **created_at** column was added so outstanding `PENDING` bills can be tracked forfollow-up.394 Billing records must stay in sync with their line items, and payment status must follow a valid state machine. billing.total_cost is recalculated automatically whenever a procedure or lab test is added to or removed from a bill, as the sum of the list prices (`cost`) of its line items, rather than relying on the application to keep it in sync. Line items can only be changed while the bill is `PENDING`, so a `PAID` or `CANCELLED` bill can no longer change its amount. payment_status may only move `PENDING → PAID` or `PENDING → CANCELLED`, never backwards once finalized; when a bill becomes `PAID` without a payment_date, the current date is filled in. Every billing status change and every change to the calculated total caused by adding or removing a billing line item is recorded in billing_audit_log. The existing created_at column is used to track outstanding `PENDING` bills: **v_overdue_billings** lists bills that have been pending for more than 30 days (`CRITICAL` after 60 days), and job_billing_alerts reports how many need follow-up. 381 395 382 396 === Schema change === 383 397 384 398 {{{ 385 386 ALTER TABLE billing ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();387 388 399 CREATE TABLE IF NOT EXISTS billing_audit_log ( 389 400 audit_id BIGSERIAL PRIMARY KEY, … … 401 412 402 413 {{{ 403 404 405 414 CREATE OR REPLACE FUNCTION recalculate_billing_total(p_bill_id BIGINT, p_change_type TEXT) 406 415 RETURNS VOID … … 408 417 AS $$ 409 418 DECLARE 419 v_old_total DECIMAL; 420 v_status TEXT; 410 421 v_procedure_total DECIMAL; 411 422 v_lab_total DECIMAL; 412 423 v_new_total DECIMAL; 413 424 BEGIN 425 SELECT total_cost, payment_status 426 INTO v_old_total, v_status 427 FROM billing 428 WHERE bill_id = p_bill_id; 429 430 IF v_status IS NULL THEN 431 RAISE EXCEPTION 'Bill % not found', p_bill_id; 432 END IF; 433 434 IF v_status <> 'PENDING' THEN 435 RAISE EXCEPTION 'Bill % is % and its line items can no longer be changed', p_bill_id, v_status; 436 END IF; 437 414 438 SELECT COALESCE(SUM(p.cost), 0) INTO v_procedure_total 415 439 FROM billing_procedures bp … … 426 450 UPDATE billing SET total_cost = v_new_total WHERE bill_id = p_bill_id; 427 451 428 INSERT INTO billing_audit_log (bill_id, new_amount, change_type)429 VALUES (p_bill_id, v_ new_total, p_change_type);452 INSERT INTO billing_audit_log (bill_id, old_amount, new_amount, change_type) 453 VALUES (p_bill_id, v_old_total, v_new_total, p_change_type); 430 454 END; 431 455 $$; … … 440 464 WHEN p_old = 'PENDING' AND p_new IN ('PAID', 'CANCELLED') THEN TRUE 441 465 ELSE FALSE 442 END;466 END; 443 467 $$; 444 468 … … 449 473 v_overdue_count INT; 450 474 BEGIN 451 SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings WHERE days_outstanding > 30;475 SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings; 452 476 RAISE NOTICE 'Found % overdue billing records requiring follow-up', v_overdue_count; 453 477 END; … … 458 482 459 483 {{{ 460 461 484 CREATE OR REPLACE FUNCTION t1_billing_line_item_changed() 462 485 RETURNS TRIGGER … … 494 517 RAISE EXCEPTION 'Cannot transition billing status from % to %', 495 518 OLD.payment_status, NEW.payment_status; 519 END IF; 520 521 IF NEW.payment_status = 'PAID' AND NEW.payment_date IS NULL THEN 522 NEW.payment_date := CURRENT_DATE; 496 523 END IF; 497 524 … … 523 550 CASE 524 551 WHEN CURRENT_DATE - b.created_at::DATE > 60 THEN 'CRITICAL' 525 WHEN CURRENT_DATE - b.created_at::DATE > 30 THEN 'OVERDUE' 526 ELSE 'PENDING' 552 ELSE 'OVERDUE' 527 553 END AS urgency 528 554 FROM billing b … … 530 556 JOIN patients p ON mr.patient_id = p.patient_id 531 557 WHERE b.payment_status = 'PENDING' 532 AND CURRENT_DATE - b.created_at::DATE > =30558 AND CURRENT_DATE - b.created_at::DATE > 30 533 559 ORDER BY days_outstanding DESC; 534 560 }}} … … 538 564 == Custom domains for EMBG and Phone number formats == 539 565 540 Custom domains replace one-off column checks with reusable types. ** embg_format, email_format, and phone_number_format** are new or stricter than before; **non_negative_cost** consolidates a rule already repeated on three tables. non_negative_cost can be applied immediately, since it matches an existing rule. The other three need existing data checked first, since a stricter domain fails if any row doesn't already conform.566 Custom domains replace one-off column checks with reusable types. **non_negative_cost** consolidates the `cost >= 0` rule that was repeated on **procedures**, **lab_tests** and **billing**; since it matches an existing rule it is applied immediately and the three old CHECK constraints are dropped. It is created in the Custom domains block of the first section on this page, because the column types must be changed before any view that uses these columns is created (such as **v_overdue_billings** and **mv_revenue_monthly**), and PostgreSQL cannot change the type of a column that a view depends on. **embg_format** (the same rule as the existing `patients_embg_format_chk`), **email_format** and **phone_number_format** are defined for reuse but not yet applied to any column: email_format is stricter than the existing checks (for example, it requires a domain ending such as `.com`, which the admin and lab technician checks do not), so existing data has to be checked before it is applied. 541 567 542 568 === Custom domains === … … 554 580 CREATE DOMAIN phone_number_format AS TEXT 555 581 CHECK (VALUE ~ '^\+?[\d\s\-().]{7,20}$'); 556 557 CREATE DOMAIN non_negative_cost AS DECIMAL(12,2)558 CHECK (VALUE >= 0);559 560 561 ALTER TABLE procedures ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;562 ALTER TABLE lab_tests ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;563 ALTER TABLE billing ALTER COLUMN total_cost TYPE non_negative_cost USING total_cost::non_negative_cost;564 582 }}} 565 583 … … 568 586 == Revenue reporting (materialized view and background refresh job) == 569 587 570 Revenue reports need expensive joins across **billing, procedures, and lab tests**, so computing them fresh on every request is wasteful. This materialized view precomputes monthly revenue , split by procedure vs lab test. Procedure revenue is attributed down to department, since procedures carries a **doctor_id** directly. Lab revenue can't be: lab_tests has no doctor or department reference, and **billing_lab_tests** links only to the catalog **test_id**, not a specific performed instance so lab rows are reported to the clinic only.The refresh procedure was verified by manual invocation.588 Revenue reports need expensive joins across **billing, procedures, and lab tests**, so computing them fresh on every request is wasteful. This materialized view precomputes monthly revenue from `PAID` bills, split by procedure vs lab test and grouped by the month of **payment_date**. Procedure revenue is attributed down to department, since **procedures** carries a **doctor_id** directly. Lab revenue can't be: **lab_tests** has no doctor or department reference, and **billing_lab_tests** links only to the catalog **test_id**, not a specific performed instance, so lab rows are reported for the clinic as a whole. The view is refreshed with `CALL medora_job_refresh_revenue_view();`, either manually or from a scheduler such as pg_cron; the refresh procedure was verified by manual invocation. 571 589 572 590 === Views === 573 591 574 592 {{{ 593 DROP MATERIALIZED VIEW IF EXISTS mv_revenue_monthly CASCADE; 594 575 595 CREATE MATERIALIZED VIEW mv_revenue_monthly AS 576 596 SELECT … … 587 607 JOIN departments dept ON dept.department_id = doc.department_id 588 608 WHERE b.payment_status = 'PAID' 609 AND b.payment_date IS NOT NULL 589 610 GROUP BY DATE_TRUNC('month', b.payment_date), dept.department_id, dept.department_name 590 611 … … 602 623 JOIN lab_tests lt ON lt.test_id = blt.test_id 603 624 WHERE b.payment_status = 'PAID' 625 AND b.payment_date IS NOT NULL 604 626 GROUP BY DATE_TRUNC('month', b.payment_date); 605 627 … … 617 639 {{{ 618 640 CREATE OR REPLACE PROCEDURE medora_job_refresh_revenue_view() 619 LANGUAGE plpgsql641 LANGUAGE plpgsql 620 642 AS $$ 621 643 BEGIN
