| | 1 | = Advanced Database Development = |
| | 2 | |
| | 3 | |
| | 4 | ---- |
| | 5 | |
| | 6 | == Allergy–prescription safety enforcement == |
| | 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. |
| | 9 | |
| | 10 | === Custom domains === |
| | 11 | |
| | 12 | {{{ |
| | 13 | CREATE DOMAIN non_negative_currency AS DECIMAL(12,2) |
| | 14 | CHECK (VALUE >= 0); |
| | 15 | }}} |
| | 16 | |
| | 17 | === Triggers === |
| | 18 | |
| | 19 | {{{ |
| | 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 TIMESTAMP |
| | 29 | ); |
| | 30 | |
| | 31 | CREATE OR REPLACE FUNCTION t1_prescription_allergy_check() |
| | 32 | RETURNS TRIGGER |
| | 33 | LANGUAGE plpgsql |
| | 34 | AS $$ |
| | 35 | DECLARE |
| | 36 | v_patient_id BIGINT; |
| | 37 | v_conflict_allergy_id BIGINT; |
| | 38 | v_allergy_name TEXT; |
| | 39 | BEGIN |
| | 40 | SELECT patient_id INTO v_patient_id |
| | 41 | FROM medical_records |
| | 42 | WHERE record_id = NEW.record_id; |
| | 43 | |
| | 44 | IF v_patient_id IS NULL THEN |
| | 45 | RAISE EXCEPTION 'Medical record % not found', NEW.record_id; |
| | 46 | END IF; |
| | 47 | |
| | 48 | SELECT apr.allergy_id, a.name |
| | 49 | INTO v_conflict_allergy_id, v_allergy_name |
| | 50 | FROM prescription_restriction pr_rest |
| | 51 | 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 |
| | 54 | WHERE pr_rest.prescription_id = NEW.prescription_id |
| | 55 | AND mra.record_id = NEW.record_id |
| | 56 | LIMIT 1; |
| | 57 | |
| | 58 | 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; |
| | 61 | END IF; |
| | 62 | |
| | 63 | RETURN NEW; |
| | 64 | END; |
| | 65 | $$; |
| | 66 | |
| | 67 | DROP TRIGGER IF EXISTS trg_prescription_allergy_check ON prescription_medical_records; |
| | 68 | CREATE TRIGGER trg_prescription_allergy_check |
| | 69 | BEFORE INSERT |
| | 70 | ON prescription_medical_records |
| | 71 | FOR EACH ROW |
| | 72 | EXECUTE FUNCTION t1_prescription_allergy_check(); |
| | 73 | }}} |
| | 74 | |
| | 75 | === Views === |
| | 76 | |
| | 77 | {{{ |
| | 78 | CREATE OR REPLACE VIEW v_prescription_allergy_conflicts AS |
| | 79 | SELECT |
| | 80 | mr.record_id, |
| | 81 | p.patient_id, |
| | 82 | p.first_name, |
| | 83 | p.last_name, |
| | 84 | pmr.prescription_id, |
| | 85 | pr.medication_name, |
| | 86 | a.allergy_id, |
| | 87 | a.name AS allergy_name, |
| | 88 | a.allergy_severity, |
| | 89 | 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 |
| | 95 | FROM prescription_medical_records pmr |
| | 96 | JOIN medical_records mr ON pmr.record_id = mr.record_id |
| | 97 | JOIN patients p ON mr.patient_id = p.patient_id |
| | 98 | JOIN prescriptions pr ON pmr.prescription_id = pr.prescription_id |
| | 99 | JOIN prescription_restriction pr_rest ON pr.prescription_id = pr_rest.prescription_id |
| | 100 | JOIN allergy_prescription_restrictions apr ON pr_rest.restriction_id = apr.restriction_id |
| | 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; |
| | 106 | }}} |
| | 107 | |
| | 108 | ---- |
| | 109 | |
| | 110 | == Appointment scheduling integrity == |
| | 111 | |
| | 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 | |
| | 118 | ALTER TABLE appointments DROP CONSTRAINT appointments_status_chk; |
| | 119 | ALTER TABLE appointments ADD CONSTRAINT appointments_status_chk |
| | 120 | CHECK (status IN ('SCHEDULED','COMPLETED','CANCELLED','IN_PROGRESS','NO_SHOW')); |
| | 121 | }}} |
| | 122 | |
| | 123 | === Triggers === |
| | 124 | |
| | 125 | {{{ |
| | 126 | CREATE OR REPLACE FUNCTION is_valid_appointment_transition(p_old TEXT, p_new TEXT) |
| | 127 | RETURNS BOOLEAN |
| | 128 | LANGUAGE sql |
| | 129 | IMMUTABLE |
| | 130 | AS $$ |
| | 131 | SELECT CASE |
| | 132 | WHEN p_old = p_new THEN TRUE |
| | 133 | WHEN p_old = 'SCHEDULED' AND p_new IN ('IN_PROGRESS', 'COMPLETED', 'CANCELLED') THEN TRUE |
| | 134 | WHEN p_old = 'IN_PROGRESS' AND p_new IN ('COMPLETED', 'CANCELLED') THEN TRUE |
| | 135 | ELSE FALSE |
| | 136 | END; |
| | 137 | $$; |
| | 138 | |
| | 139 | CREATE OR REPLACE FUNCTION trigger_appointments_enforce() |
| | 140 | RETURNS TRIGGER |
| | 141 | LANGUAGE plpgsql |
| | 142 | AS $$ |
| | 143 | DECLARE |
| | 144 | v_combined_datetime TIMESTAMP; |
| | 145 | BEGIN |
| | 146 | v_combined_datetime := NEW.appointment_date::TIMESTAMP + NEW.appointment_time; |
| | 147 | |
| | 148 | IF TG_OP = 'INSERT' AND v_combined_datetime < NOW() THEN |
| | 149 | RAISE EXCEPTION 'Cannot schedule appointment in the past (appointment_date=%, appointment_time=%)', |
| | 150 | NEW.appointment_date, NEW.appointment_time; |
| | 151 | END IF; |
| | 152 | |
| | 153 | IF TG_OP = 'UPDATE' THEN |
| | 154 | IF NOT is_valid_appointment_transition(OLD.status, NEW.status) THEN |
| | 155 | RAISE EXCEPTION 'Appointment status cannot transition from % to %', |
| | 156 | OLD.status, NEW.status; |
| | 157 | 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; |
| | 163 | END IF; |
| | 164 | |
| | 165 | RETURN NEW; |
| | 166 | END; |
| | 167 | $$; |
| | 168 | |
| | 169 | DROP TRIGGER IF EXISTS trigger_appointments_enforce ON appointments; |
| | 170 | CREATE TRIGGER trigger_appointments_enforce |
| | 171 | BEFORE INSERT OR UPDATE |
| | 172 | ON appointments |
| | 173 | FOR EACH ROW |
| | 174 | EXECUTE FUNCTION trigger_appointments_enforce(); |
| | 175 | |
| | 176 | CREATE OR REPLACE FUNCTION t1_appointments_no_overlap() |
| | 177 | RETURNS TRIGGER |
| | 178 | LANGUAGE plpgsql |
| | 179 | AS $$ |
| | 180 | DECLARE |
| | 181 | v_new_start TIMESTAMP; |
| | 182 | v_new_end TIMESTAMP; |
| | 183 | BEGIN |
| | 184 | IF NEW.status NOT IN ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED') THEN |
| | 185 | RETURN NEW; |
| | 186 | END IF; |
| | 187 | |
| | 188 | v_new_start := NEW.appointment_date::TIMESTAMP + NEW.appointment_time; |
| | 189 | v_new_end := v_new_start + INTERVAL '30 minutes'; |
| | 190 | |
| | 191 | IF EXISTS ( |
| | 192 | SELECT 1 FROM appointments a |
| | 193 | WHERE a.doctor_id = NEW.doctor_id |
| | 194 | AND a.status IN ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED') |
| | 195 | AND (a.appointment_date::TIMESTAMP + a.appointment_time) < v_new_end |
| | 196 | AND v_new_start < (a.appointment_date::TIMESTAMP + a.appointment_time + INTERVAL '30 minutes') |
| | 197 | AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id) |
| | 198 | ) THEN |
| | 199 | RAISE EXCEPTION 'Doctor % has overlapping appointment at %', NEW.doctor_id, NEW.appointment_date; |
| | 200 | END IF; |
| | 201 | |
| | 202 | IF EXISTS ( |
| | 203 | SELECT 1 FROM appointments a |
| | 204 | WHERE a.patient_id = NEW.patient_id |
| | 205 | AND a.status IN ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED') |
| | 206 | AND (a.appointment_date::TIMESTAMP + a.appointment_time) < v_new_end |
| | 207 | AND v_new_start < (a.appointment_date::TIMESTAMP + a.appointment_time + INTERVAL '30 minutes') |
| | 208 | AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id) |
| | 209 | ) THEN |
| | 210 | RAISE EXCEPTION 'Patient % has overlapping appointment at %', NEW.patient_id, NEW.appointment_date; |
| | 211 | END IF; |
| | 212 | |
| | 213 | RETURN NEW; |
| | 214 | END; |
| | 215 | $$; |
| | 216 | |
| | 217 | DROP TRIGGER IF EXISTS trigger_appointments_no_overlap ON appointments; |
| | 218 | CREATE TRIGGER trigger_appointments_no_overlap |
| | 219 | BEFORE INSERT OR UPDATE |
| | 220 | ON appointments |
| | 221 | FOR EACH ROW |
| | 222 | EXECUTE FUNCTION t1_appointments_no_overlap(); |
| | 223 | }}} |
| | 224 | |
| | 225 | === Stored procedures/functions === |
| | 226 | |
| | 227 | {{{ |
| | 228 | CREATE OR REPLACE PROCEDURE job_mark_no_show() |
| | 229 | LANGUAGE plpgsql |
| | 230 | AS $$ |
| | 231 | BEGIN |
| | 232 | UPDATE appointments |
| | 233 | SET status = 'NO_SHOW' |
| | 234 | WHERE status = 'SCHEDULED' |
| | 235 | AND (appointment_date::TIMESTAMP + appointment_time) < (NOW() - INTERVAL '45 minutes'); |
| | 236 | END; |
| | 237 | $$; |
| | 238 | }}} |
| | 239 | |
| | 240 | === Views === |
| | 241 | |
| | 242 | {{{ |
| | 243 | CREATE OR REPLACE VIEW v_overdue_appointments AS |
| | 244 | SELECT |
| | 245 | a.appointment_id, a.patient_id, p.first_name, p.last_name, |
| | 246 | a.doctor_id, d.first_name AS doctor_first_name, d.last_name AS doctor_last_name, |
| | 247 | a.appointment_date, a.appointment_time, a.status, |
| | 248 | NOW() - (a.appointment_date::TIMESTAMP + a.appointment_time) AS time_overdue |
| | 249 | FROM appointments a |
| | 250 | JOIN patients p ON a.patient_id = p.patient_id |
| | 251 | JOIN doctors d ON a.doctor_id = d.doctor_id |
| | 252 | WHERE a.status = 'SCHEDULED' |
| | 253 | AND (a.appointment_date::TIMESTAMP + a.appointment_time) < (NOW() - INTERVAL '45 minutes') |
| | 254 | ORDER BY time_overdue DESC; |
| | 255 | }}} |
| | 256 | |
| | 257 | ---- |
| | 258 | |
| | 259 | == Medical record consistency == |
| | 260 | |
| | 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**. |
| | 262 | |
| | 263 | === Triggers === |
| | 264 | |
| | 265 | {{{ |
| | 266 | CREATE OR REPLACE FUNCTION t1_diagnosis_record_consistency() |
| | 267 | RETURNS TRIGGER |
| | 268 | LANGUAGE plpgsql |
| | 269 | AS $$ |
| | 270 | DECLARE |
| | 271 | v_record_patient BIGINT; |
| | 272 | v_diagnosis_patient BIGINT; |
| | 273 | BEGIN |
| | 274 | SELECT patient_id INTO v_record_patient FROM medical_records WHERE record_id = NEW.record_id; |
| | 275 | SELECT patient_id INTO v_diagnosis_patient FROM diagnosis WHERE diagnosis_id = NEW.diagnosis_id; |
| | 276 | |
| | 277 | IF v_record_patient IS NULL THEN |
| | 278 | RAISE EXCEPTION 'Medical record % not found', NEW.record_id; |
| | 279 | END IF; |
| | 280 | |
| | 281 | IF v_diagnosis_patient IS NULL THEN |
| | 282 | RAISE EXCEPTION 'Diagnosis % not found', NEW.diagnosis_id; |
| | 283 | END IF; |
| | 284 | |
| | 285 | IF v_record_patient <> v_diagnosis_patient THEN |
| | 286 | RAISE EXCEPTION 'Diagnosis % belongs to patient %, but medical record % belongs to patient %', |
| | 287 | NEW.diagnosis_id, v_diagnosis_patient, NEW.record_id, v_record_patient; |
| | 288 | END IF; |
| | 289 | |
| | 290 | RETURN NEW; |
| | 291 | END; |
| | 292 | $$; |
| | 293 | |
| | 294 | DROP TRIGGER IF EXISTS trg_diagnosis_record_consistency ON diagnosis_medical_records; |
| | 295 | CREATE TRIGGER trg_diagnosis_record_consistency |
| | 296 | BEFORE INSERT ON diagnosis_medical_records |
| | 297 | FOR EACH ROW EXECUTE FUNCTION t1_diagnosis_record_consistency(); |
| | 298 | |
| | 299 | CREATE OR REPLACE FUNCTION t2_procedure_diagnosis_consistency() |
| | 300 | RETURNS TRIGGER |
| | 301 | LANGUAGE plpgsql |
| | 302 | AS $$ |
| | 303 | DECLARE |
| | 304 | v_diagnosis_patient BIGINT; |
| | 305 | BEGIN |
| | 306 | IF NEW.diagnosis_id IS NOT NULL THEN |
| | 307 | SELECT patient_id INTO v_diagnosis_patient FROM diagnosis WHERE diagnosis_id = NEW.diagnosis_id; |
| | 308 | |
| | 309 | IF v_diagnosis_patient IS NULL THEN |
| | 310 | RAISE EXCEPTION 'Diagnosis % not found', NEW.diagnosis_id; |
| | 311 | END IF; |
| | 312 | |
| | 313 | IF NEW.patient_id <> v_diagnosis_patient THEN |
| | 314 | RAISE EXCEPTION 'Procedure belongs to patient %, but diagnosis % belongs to patient %', |
| | 315 | NEW.patient_id, NEW.diagnosis_id, v_diagnosis_patient; |
| | 316 | END IF; |
| | 317 | END IF; |
| | 318 | |
| | 319 | RETURN NEW; |
| | 320 | END; |
| | 321 | $$; |
| | 322 | |
| | 323 | DROP TRIGGER IF EXISTS trg_procedure_diagnosis_consistency ON performed_procedures; |
| | 324 | CREATE TRIGGER trg_procedure_diagnosis_consistency |
| | 325 | BEFORE INSERT OR UPDATE ON performed_procedures |
| | 326 | FOR EACH ROW EXECUTE FUNCTION t2_procedure_diagnosis_consistency(); |
| | 327 | |
| | 328 | CREATE OR REPLACE FUNCTION t3_referral_consistency() |
| | 329 | RETURNS TRIGGER |
| | 330 | LANGUAGE plpgsql |
| | 331 | AS $$ |
| | 332 | BEGIN |
| | 333 | IF NOT EXISTS (SELECT 1 FROM medical_records WHERE record_id = NEW.record_id) THEN |
| | 334 | RAISE EXCEPTION 'Medical record % not found', NEW.record_id; |
| | 335 | END IF; |
| | 336 | |
| | 337 | IF NEW.from_doctor_id = NEW.to_doctor_id THEN |
| | 338 | RAISE EXCEPTION 'Doctor % cannot refer to themselves', NEW.from_doctor_id; |
| | 339 | END IF; |
| | 340 | |
| | 341 | RETURN NEW; |
| | 342 | END; |
| | 343 | $$; |
| | 344 | |
| | 345 | DROP TRIGGER IF EXISTS trg_referral_consistency ON referrals; |
| | 346 | CREATE TRIGGER trg_referral_consistency |
| | 347 | BEFORE INSERT ON referrals |
| | 348 | FOR EACH ROW EXECUTE FUNCTION t3_referral_consistency(); |
| | 349 | }}} |
| | 350 | |
| | 351 | === Views === |
| | 352 | |
| | 353 | {{{ |
| | 354 | CREATE OR REPLACE VIEW v_medical_record_overview AS |
| | 355 | SELECT |
| | 356 | mr.record_id, |
| | 357 | p.patient_id, p.first_name, p.last_name, p.embg, |
| | 358 | 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, |
| | 360 | COUNT(DISTINCT mrp.procedure_id) AS procedures_count, |
| | 361 | COUNT(DISTINCT mrl.result_id) AS lab_results_count, |
| | 362 | COUNT(DISTINCT ref.referral_id) AS referrals_count, |
| | 363 | COUNT(DISTINCT mra.allergy_id) AS allergies_count, |
| | 364 | COUNT(DISTINCT CASE WHEN ref.from_doctor_id = ref.to_doctor_id THEN ref.referral_id END) AS self_referrals_detected |
| | 365 | FROM medical_records mr |
| | 366 | JOIN patients p ON mr.patient_id = p.patient_id |
| | 367 | LEFT JOIN diagnosis_medical_records dmr ON dmr.record_id = mr.record_id |
| | 368 | LEFT JOIN diagnosis d ON d.diagnosis_id = dmr.diagnosis_id |
| | 369 | LEFT JOIN medical_record_procedures mrp ON mrp.record_id = mr.record_id |
| | 370 | 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_id |
| | 372 | LEFT JOIN medical_record_allergies mra ON mr.record_id = mra.record_id |
| | 373 | GROUP BY mr.record_id, p.patient_id, p.first_name, p.last_name, p.embg; |
| | 374 | }}} |
| | 375 | |
| | 376 | ---- |
| | 377 | |
| | 378 | == Billing integrity == |
| | 379 | |
| | 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 for follow-up. |
| | 381 | |
| | 382 | === Schema change === |
| | 383 | |
| | 384 | {{{ |
| | 385 | |
| | 386 | ALTER TABLE billing ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW(); |
| | 387 | |
| | 388 | CREATE TABLE IF NOT EXISTS billing_audit_log ( |
| | 389 | audit_id BIGSERIAL PRIMARY KEY, |
| | 390 | bill_id BIGINT NOT NULL REFERENCES billing(bill_id), |
| | 391 | old_amount DECIMAL(12,2), |
| | 392 | new_amount DECIMAL(12,2), |
| | 393 | old_status TEXT, |
| | 394 | new_status TEXT, |
| | 395 | change_type TEXT CHECK (change_type IN ('INSERT', 'UPDATE', 'LINE_ITEM_ADD', 'LINE_ITEM_REMOVE')), |
| | 396 | changed_at TIMESTAMP DEFAULT NOW() |
| | 397 | ); |
| | 398 | }}} |
| | 399 | |
| | 400 | === Stored procedures/functions === |
| | 401 | |
| | 402 | {{{ |
| | 403 | |
| | 404 | |
| | 405 | CREATE OR REPLACE FUNCTION recalculate_billing_total(p_bill_id BIGINT, p_change_type TEXT) |
| | 406 | RETURNS VOID |
| | 407 | LANGUAGE plpgsql |
| | 408 | AS $$ |
| | 409 | DECLARE |
| | 410 | v_procedure_total DECIMAL; |
| | 411 | v_lab_total DECIMAL; |
| | 412 | v_new_total DECIMAL; |
| | 413 | BEGIN |
| | 414 | SELECT COALESCE(SUM(p.cost), 0) INTO v_procedure_total |
| | 415 | FROM billing_procedures bp |
| | 416 | JOIN procedures p ON p.procedure_id = bp.procedure_id |
| | 417 | WHERE bp.bill_id = p_bill_id; |
| | 418 | |
| | 419 | SELECT COALESCE(SUM(lt.cost), 0) INTO v_lab_total |
| | 420 | FROM billing_lab_tests blt |
| | 421 | JOIN lab_tests lt ON lt.test_id = blt.test_id |
| | 422 | WHERE blt.bill_id = p_bill_id; |
| | 423 | |
| | 424 | v_new_total := v_procedure_total + v_lab_total; |
| | 425 | |
| | 426 | UPDATE billing SET total_cost = v_new_total WHERE bill_id = p_bill_id; |
| | 427 | |
| | 428 | INSERT INTO billing_audit_log (bill_id, new_amount, change_type) |
| | 429 | VALUES (p_bill_id, v_new_total, p_change_type); |
| | 430 | END; |
| | 431 | $$; |
| | 432 | |
| | 433 | CREATE OR REPLACE FUNCTION is_valid_billing_transition(p_old TEXT, p_new TEXT) |
| | 434 | RETURNS BOOLEAN |
| | 435 | LANGUAGE sql |
| | 436 | IMMUTABLE |
| | 437 | AS $$ |
| | 438 | SELECT CASE |
| | 439 | WHEN p_old = p_new THEN TRUE |
| | 440 | WHEN p_old = 'PENDING' AND p_new IN ('PAID', 'CANCELLED') THEN TRUE |
| | 441 | ELSE FALSE |
| | 442 | END; |
| | 443 | $$; |
| | 444 | |
| | 445 | CREATE OR REPLACE PROCEDURE job_billing_alerts() |
| | 446 | LANGUAGE plpgsql |
| | 447 | AS $$ |
| | 448 | DECLARE |
| | 449 | v_overdue_count INT; |
| | 450 | BEGIN |
| | 451 | SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings WHERE days_outstanding > 30; |
| | 452 | RAISE NOTICE 'Found % overdue billing records requiring follow-up', v_overdue_count; |
| | 453 | END; |
| | 454 | $$; |
| | 455 | }}} |
| | 456 | |
| | 457 | === Triggers === |
| | 458 | |
| | 459 | {{{ |
| | 460 | -- one shared trigger function, reused for both billing_procedures and billing_lab_tests |
| | 461 | -- (both tables have a bill_id column, so NEW.bill_id / OLD.bill_id works either way) |
| | 462 | CREATE OR REPLACE FUNCTION t1_billing_line_item_changed() |
| | 463 | RETURNS TRIGGER |
| | 464 | LANGUAGE plpgsql |
| | 465 | AS $$ |
| | 466 | BEGIN |
| | 467 | IF TG_OP = 'DELETE' THEN |
| | 468 | PERFORM recalculate_billing_total(OLD.bill_id, 'LINE_ITEM_REMOVE'); |
| | 469 | RETURN OLD; |
| | 470 | ELSE |
| | 471 | PERFORM recalculate_billing_total(NEW.bill_id, 'LINE_ITEM_ADD'); |
| | 472 | RETURN NEW; |
| | 473 | END IF; |
| | 474 | END; |
| | 475 | $$; |
| | 476 | |
| | 477 | DROP TRIGGER IF EXISTS trg_billing_procedures_update_total ON billing_procedures; |
| | 478 | CREATE TRIGGER trg_billing_procedures_update_total |
| | 479 | AFTER INSERT OR DELETE ON billing_procedures |
| | 480 | FOR EACH ROW |
| | 481 | EXECUTE FUNCTION t1_billing_line_item_changed(); |
| | 482 | |
| | 483 | DROP TRIGGER IF EXISTS trg_billing_lab_tests_update_total ON billing_lab_tests; |
| | 484 | CREATE TRIGGER trg_billing_lab_tests_update_total |
| | 485 | AFTER INSERT OR DELETE ON billing_lab_tests |
| | 486 | FOR EACH ROW |
| | 487 | EXECUTE FUNCTION t1_billing_line_item_changed(); |
| | 488 | |
| | 489 | CREATE OR REPLACE FUNCTION t2_billing_status_transition() |
| | 490 | RETURNS TRIGGER |
| | 491 | LANGUAGE plpgsql |
| | 492 | AS $$ |
| | 493 | BEGIN |
| | 494 | IF NOT is_valid_billing_transition(OLD.payment_status, NEW.payment_status) THEN |
| | 495 | RAISE EXCEPTION 'Cannot transition billing status from % to %', |
| | 496 | OLD.payment_status, NEW.payment_status; |
| | 497 | END IF; |
| | 498 | |
| | 499 | IF OLD.payment_status <> NEW.payment_status THEN |
| | 500 | INSERT INTO billing_audit_log (bill_id, old_status, new_status, change_type) |
| | 501 | VALUES (NEW.bill_id, OLD.payment_status, NEW.payment_status, 'UPDATE'); |
| | 502 | END IF; |
| | 503 | |
| | 504 | RETURN NEW; |
| | 505 | END; |
| | 506 | $$; |
| | 507 | |
| | 508 | DROP TRIGGER IF EXISTS trg_billing_status_transition ON billing; |
| | 509 | CREATE TRIGGER trg_billing_status_transition |
| | 510 | BEFORE UPDATE ON billing |
| | 511 | FOR EACH ROW |
| | 512 | EXECUTE FUNCTION t2_billing_status_transition(); |
| | 513 | }}} |
| | 514 | |
| | 515 | === Views === |
| | 516 | |
| | 517 | {{{ |
| | 518 | CREATE OR REPLACE VIEW v_overdue_billings AS |
| | 519 | SELECT |
| | 520 | b.bill_id, |
| | 521 | p.patient_id, p.first_name, p.last_name, |
| | 522 | b.total_cost, b.payment_status, b.created_at, |
| | 523 | CURRENT_DATE - b.created_at::DATE AS days_outstanding, |
| | 524 | CASE |
| | 525 | WHEN CURRENT_DATE - b.created_at::DATE > 60 THEN 'CRITICAL' |
| | 526 | WHEN CURRENT_DATE - b.created_at::DATE > 30 THEN 'OVERDUE' |
| | 527 | ELSE 'PENDING' |
| | 528 | END AS urgency |
| | 529 | FROM billing b |
| | 530 | JOIN medical_records mr ON b.record_id = mr.record_id |
| | 531 | JOIN patients p ON mr.patient_id = p.patient_id |
| | 532 | WHERE b.payment_status = 'PENDING' |
| | 533 | AND CURRENT_DATE - b.created_at::DATE >= 30 |
| | 534 | ORDER BY days_outstanding DESC; |
| | 535 | }}} |
| | 536 | |
| | 537 | ---- |
| | 538 | |
| | 539 | == Custom domains for EMBG and Phone number formats == |
| | 540 | |
| | 541 | 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. |
| | 542 | |
| | 543 | === Custom domains === |
| | 544 | |
| | 545 | {{{ |
| | 546 | CREATE DOMAIN embg_format AS TEXT |
| | 547 | CHECK (VALUE ~ '^\d{13}$'); |
| | 548 | |
| | 549 | CREATE DOMAIN email_format AS TEXT |
| | 550 | CHECK ( |
| | 551 | VALUE ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' |
| | 552 | AND LENGTH(VALUE) <= 254 |
| | 553 | ); |
| | 554 | |
| | 555 | CREATE DOMAIN phone_number_format AS TEXT |
| | 556 | CHECK (VALUE ~ '^\+?[\d\s\-().]{7,20}$'); |
| | 557 | |
| | 558 | CREATE DOMAIN non_negative_cost AS DECIMAL(12,2) |
| | 559 | CHECK (VALUE >= 0); |
| | 560 | |
| | 561 | |
| | 562 | ALTER TABLE procedures ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost; |
| | 563 | ALTER TABLE lab_tests ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost; |
| | 564 | ALTER TABLE billing ALTER COLUMN total_cost TYPE non_negative_cost USING total_cost::non_negative_cost; |
| | 565 | |
| | 566 | -- run these BEFORE altering embg / email / phone columns — every one of these |
| | 567 | -- should return ZERO rows, otherwise the corresponding ALTER will fail: |
| | 568 | -- SELECT patient_id, embg FROM patients WHERE embg !~ '^\d{13}$'; |
| | 569 | -- SELECT doctor_id, email_address FROM doctors WHERE email_address !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; |
| | 570 | -- SELECT patient_id, email_address FROM patients WHERE email_address IS NOT NULL AND email_address !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; |
| | 571 | -- SELECT patient_id, phone_number FROM patients WHERE phone_number IS NOT NULL AND phone_number !~ '^\+?[\d\s\-().]{7,20}$'; |
| | 572 | }}} |
| | 573 | |
| | 574 | ---- |
| | 575 | |
| | 576 | == Revenue reporting (materialized view and background refresh job) == |
| | 577 | |
| | 578 | 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. |
| | 579 | |
| | 580 | === Views === |
| | 581 | |
| | 582 | {{{ |
| | 583 | CREATE MATERIALIZED VIEW mv_revenue_monthly AS |
| | 584 | SELECT |
| | 585 | DATE_TRUNC('month', b.payment_date)::DATE AS month, |
| | 586 | dept.department_id, |
| | 587 | dept.department_name, |
| | 588 | 'PROCEDURE' AS revenue_type, |
| | 589 | SUM(p.cost) AS revenue, |
| | 590 | COUNT(DISTINCT b.bill_id) AS transaction_count |
| | 591 | FROM billing b |
| | 592 | JOIN billing_procedures bp ON bp.bill_id = b.bill_id |
| | 593 | JOIN procedures p ON p.procedure_id = bp.procedure_id |
| | 594 | JOIN doctors doc ON doc.doctor_id = p.doctor_id |
| | 595 | JOIN departments dept ON dept.department_id = doc.department_id |
| | 596 | WHERE b.payment_status = 'PAID' |
| | 597 | GROUP BY DATE_TRUNC('month', b.payment_date), dept.department_id, dept.department_name |
| | 598 | |
| | 599 | UNION ALL |
| | 600 | |
| | 601 | SELECT |
| | 602 | DATE_TRUNC('month', b.payment_date)::DATE AS month, |
| | 603 | NULL AS department_id, |
| | 604 | NULL AS department_name, |
| | 605 | 'LAB' AS revenue_type, |
| | 606 | SUM(lt.cost) AS revenue, |
| | 607 | COUNT(DISTINCT b.bill_id) AS transaction_count |
| | 608 | FROM billing b |
| | 609 | JOIN billing_lab_tests blt ON blt.bill_id = b.bill_id |
| | 610 | JOIN lab_tests lt ON lt.test_id = blt.test_id |
| | 611 | WHERE b.payment_status = 'PAID' |
| | 612 | GROUP BY DATE_TRUNC('month', b.payment_date); |
| | 613 | |
| | 614 | CREATE INDEX idx_mv_revenue_monthly_month ON mv_revenue_monthly (month, revenue_type); |
| | 615 | |
| | 616 | CREATE OR REPLACE VIEW v_current_month_revenue AS |
| | 617 | SELECT month, department_id, department_name, revenue_type, revenue, transaction_count |
| | 618 | FROM mv_revenue_monthly |
| | 619 | WHERE month = DATE_TRUNC('month', CURRENT_DATE)::DATE |
| | 620 | ORDER BY revenue_type, revenue DESC; |
| | 621 | }}} |
| | 622 | |
| | 623 | === Stored procedures/functions === |
| | 624 | |
| | 625 | {{{ |
| | 626 | CREATE OR REPLACE PROCEDURE medora_job_refresh_revenue_view() |
| | 627 | LANGUAGE plpgsql |
| | 628 | AS $$ |
| | 629 | BEGIN |
| | 630 | REFRESH MATERIALIZED VIEW mv_revenue_monthly; |
| | 631 | END; |
| | 632 | $$; |
| | 633 | }}} |