DeveloperCare plansCare planBackend
LHR Data Migration Rake Tasks
Complete Data Migration Tasks
Main Migration Task
# lib/tasks/lhr_migration.rake
namespace :lhr do
desc "Complete migration of existing data to LHR FHIR structure"
task migrate_all: :environment do |task, args|
puts "🚀 Starting complete LHR migration..."
start_time = Time.current
# Run all migration subtasks
Rake::Task['lhr:migrate_patient_problems'].invoke
Rake::Task['lhr:migrate_clinical_impressions'].invoke
Rake::Task['lhr:migrate_clinical_observations'].invoke
Rake::Task['lhr:migrate_diagnostic_reports'].invoke
Rake::Task['lhr:backfill_fhir_resources'].invoke
Rake::Task['lhr:create_initial_care_plans'].invoke
Rake::Task['lhr:validate_migration'].invoke
duration = Time.current - start_time
puts "✅ LHR migration completed in #{duration.round(2)} seconds!"
end
desc "Migrate patient problems from historical prescription diagnoses"
task migrate_patient_problems: :environment do
puts "📋 Migrating patient problems from prescription diagnoses..."
problems_created = 0
problems_updated = 0
# Process each clinic separately for performance
Clinic.where(deleted: false).find_each do |clinic|
puts " Processing clinic: #{clinic.name} (ID: #{clinic.id})"
# Get all patients for this clinic with prescriptions
clinic_patients = ClinicPatient.includes(
:patient,
prescriptions: [:prescription_complaints, :doctor]
).where(clinic_id: clinic.id)
clinic_patients.find_each do |clinic_patient|
patient = clinic_patient.patient
problems_map = {}
# Process prescriptions chronologically
prescriptions = clinic_patient.prescriptions
.where(is_final: true)
.where.not(diagnosis: [nil, []])
.order(:created_at)
prescriptions.each do |prescription|
prescription.diagnosis.each do |diagnosis|
diagnosis_normalized = diagnosis.strip.downcase
if problems_map[diagnosis_normalized]
# Update existing problem with latest encounter
problems_map[diagnosis_normalized][:last_encounter_id] = prescription.event_id
problems_map[diagnosis_normalized][:clinical_notes] += "\\n#{prescription.created_at.strftime('%Y-%m-%d')}: #{diagnosis}"
else
# Create new problem entry
complaint_text = prescription.prescription_complaints.pluck(:name).join(', ')
problems_map[diagnosis_normalized] = {
clinic_id: clinic.id,
patient_id: patient.id,
doctor_id: prescription.doctor_id,
problem_code: extract_icd_code(diagnosis),
problem_name: diagnosis,
problem_category: categorize_diagnosis(diagnosis),
severity: determine_severity(diagnosis),
status: determine_status(diagnosis),
onset_date: prescription.prescription_date || prescription.created_at.to_date,
first_encounter_id: prescription.event_id,
last_encounter_id: prescription.event_id,
source_prescription_id: prescription.id,
source_complaint_text: complaint_text,
clinical_notes: "#{prescription.created_at.strftime('%Y-%m-%d')}: #{diagnosis}",
metadata: {
migration_source: 'prescription_diagnosis',
original_prescription_id: prescription.id
}
}
end
end
end
# Create patient problems
problems_map.values.each do |problem_data|
begin
existing_problem = PatientProblem.find_by(
clinic_id: problem_data[:clinic_id],
patient_id: problem_data[:patient_id],
problem_name: problem_data[:problem_name]
)
if existing_problem
existing_problem.update!(
last_encounter_id: problem_data[:last_encounter_id],
clinical_notes: problem_data[:clinical_notes]
)
problems_updated += 1
else
PatientProblem.create!(problem_data)
problems_created += 1
end
rescue ActiveRecord::RecordInvalid => e
puts " ❌ Failed to create/update problem '#{problem_data[:problem_name]}' for patient #{patient.id}: #{e.message}"
end
end
end
end
puts " ✅ Created #{problems_created} new patient problems"
puts " ✅ Updated #{problems_updated} existing patient problems"
end
desc "Migrate clinical impressions from prescription diagnoses and observations"
task migrate_clinical_impressions: :environment do
puts "🩺 Migrating clinical impressions from prescriptions..."
impressions_created = 0
Prescription.where(is_final: true)
.where('diagnosis IS NOT NULL OR observations IS NOT NULL')
.where.not(diagnosis: [nil, []])
.includes(:doctor)
.find_each do |prescription|
begin
# Only create if we have meaningful clinical data
next if prescription.diagnosis.blank? && prescription.observations.blank?
summary_parts = []
if prescription.prescription_complaints.any?
complaints = prescription.prescription_complaints.pluck(:name).join(', ')
summary_parts << "Chief Complaints: #{complaints}"
end
if prescription.observations.present?
summary_parts << "Clinical Findings: #{prescription.observations}"
end
if prescription.diagnosis.any?
summary_parts << "Assessment: #{prescription.diagnosis.join(', ')}"
end
# Extract ICD-10 codes
finding_codes = prescription.diagnosis.map do |diagnosis|
extract_icd_code(diagnosis)
end.compact
ClinicalImpression.create!(
clinic_id: prescription.clinic_id,
patient_id: prescription.patient_id,
doctor_id: prescription.doctor_id,
encounter_id: prescription.event_id,
prescription_id: prescription.id,
assessment_date: prescription.created_at,
summary: summary_parts.join("\\n\\n"),
finding_codes: finding_codes,
finding_display: prescription.diagnosis || [],
clinical_notes: prescription.observations,
status: 'completed',
metadata: {
migration_source: 'prescription_diagnosis_observations',
original_prescription_id: prescription.id
}
)
impressions_created += 1
rescue ActiveRecord::RecordInvalid => e
puts " ❌ Failed to create clinical impression for prescription #{prescription.id}: #{e.message}"
end
end
puts " ✅ Created #{impressions_created} clinical impressions"
end
desc "Migrate clinical observations from prescription observations and appointment vitals"
task migrate_clinical_observations: :environment do
puts "🔬 Migrating clinical observations..."
observations_created = 0
# Migrate from prescription observations
Prescription.where(is_final: true)
.where.not(observations: [nil, ''])
.includes(:doctor)
.find_each do |prescription|
begin
ClinicalObservation.create!(
clinic_id: prescription.clinic_id,
patient_id: prescription.patient_id,
doctor_id: prescription.doctor_id,
encounter_id: prescription.event_id,
prescription_id: prescription.id,
category: 'exam',
code: 'CLINICAL-EXAM',
display_name: 'Clinical Examination',
value_type: 'string',
value_string: prescription.observations,
interpretation: 'normal', # Default, could be enhanced
notes: 'Migrated from prescription observations',
metadata: {
migration_source: 'prescription_observations',
original_prescription_id: prescription.id
}
)
observations_created += 1
rescue ActiveRecord::RecordInvalid => e
puts " ❌ Failed to create observation for prescription #{prescription.id}: #{e.message}"
end
end
# Migrate from appointment vitals
Appointment.where.not(vitals: [nil, '{}', ''])
.includes(:clinic_doctor)
.find_each do |appointment|
begin
vitals_data = appointment.vitals.is_a?(String) ? JSON.parse(appointment.vitals) : appointment.vitals
doctor_id = appointment.clinic_doctor&.doctor_id
vitals_data.each do |vital_key, vital_value|
next unless vital_value.present?
standardized_key = standardize_vital_key(vital_key)
next unless standardized_key
# Parse value and determine type
parsed_value = parse_vital_value_for_observation(vital_key, vital_value)
ClinicalObservation.create!(
clinic_id: appointment.clinic_id,
patient_id: appointment.clinic_patient.patient_id,
doctor_id: doctor_id,
encounter_id: appointment.id,
category: 'vital-signs',
code: get_loinc_code(standardized_key),
display_name: get_vital_display_name(standardized_key),
value_type: parsed_value[:type],
value_quantity: parsed_value[:quantity],
value_string: parsed_value[:string],
value_unit: get_vital_unit(standardized_key),
reference_range: get_normal_range(standardized_key),
interpretation: is_vital_abnormal?(vital_key, vital_value) ? 'abnormal' : 'normal',
metadata: {
migration_source: 'appointment_vitals',
original_appointment_id: appointment.id,
original_vital_key: vital_key
}
)
observations_created += 1
end
rescue JSON::ParserError, ActiveRecord::RecordInvalid => e
puts " ❌ Failed to process vitals for appointment #{appointment.id}: #{e.message}"
end
end
puts " ✅ Created #{observations_created} clinical observations"
end
desc "Create diagnostic reports from prescription diagnoses"
task migrate_diagnostic_reports: :environment do
puts "📊 Creating diagnostic reports from prescription diagnoses..."
reports_created = 0
Prescription.where(is_final: true)
.where.not(diagnosis: [nil, []])
.includes(:doctor)
.find_each do |prescription|
begin
DiagnosticReport.create!(
clinic_id: prescription.clinic_id,
patient_id: prescription.patient_id,
doctor_id: prescription.doctor_id,
encounter_id: prescription.event_id,
prescription_id: prescription.id,
category: 'general',
code: 'CLINICAL-ASSESSMENT',
display_name: 'Clinical Assessment',
conclusion: prescription.diagnosis.join('; '),
coded_diagnosis: prescription.diagnosis_codes || [],
clinical_significance: determine_clinical_significance(prescription.diagnosis),
clinical_notes: prescription.notes,
status: 'final',
metadata: {
migration_source: 'prescription_diagnosis',
original_prescription_id: prescription.id
}
)
reports_created += 1
rescue ActiveRecord::RecordInvalid => e
puts " ❌ Failed to create diagnostic report for prescription #{prescription.id}: #{e.message}"
end
end
puts " ✅ Created #{reports_created} diagnostic reports"
end
desc "Backfill FHIR resources for finalized prescriptions"
task backfill_fhir_resources: :environment do
puts "⚡ Backfilling FHIR resources for finalized prescriptions..."
jobs_queued = 0
# Only process prescriptions from last 6 months to avoid overwhelming the system
Prescription.where(is_final: true)
.where('created_at > ?', 6.months.ago)
.find_each do |prescription|
# Check if FHIR resources already exist
has_medication_requests = MedicationRequest.exists?(prescription_id: prescription.id)
has_service_requests = ServiceRequest.exists?(prescription_id: prescription.id)
unless has_medication_requests && has_service_requests
Prescriptions::CreateFhirResourcesJob.perform_later(prescription.id)
jobs_queued += 1
end
end
puts " ✅ Queued #{jobs_queued} background jobs for FHIR resource creation"
end
desc "Create initial care plans for patients with chronic conditions"
task create_initial_care_plans: :environment do
puts "📋 Creating initial care plans for chronic conditions..."
care_plans_created = 0
# Find patients with chronic problems
chronic_problems = PatientProblem.where(
status: ['chronic', 'active'],
severity: ['high', 'critical']
).group_by { |p| [p.clinic_id, p.patient_id] }
chronic_problems.each do |(clinic_id, patient_id), problems|
next if problems.empty?
# Check if care plan already exists
existing_plan = CarePlan.find_by(
clinic_id: clinic_id,
patient_id: patient_id,
status: 'active'
)
next if existing_plan
# Get primary doctor (most recent prescriptions)
primary_doctor_id = Prescription.where(
clinic_id: clinic_id,
patient_id: patient_id
).order(created_at: :desc).first&.doctor_id
next unless primary_doctor_id
begin
care_plan = CarePlan.create!(
clinic_id: clinic_id,
patient_id: patient_id,
doctor_id: primary_doctor_id,
status: 'active',
intent: 'plan',
title: 'Chronic Disease Management Plan',
description: \"Care plan for managing #{problems.map(&:problem_name).join(', ')}\",
period_start: Date.current,
addresses_problems: problems.map(&:id),
goals: {
'primary' => 'Stabilize chronic conditions',
'secondary' => 'Prevent complications',
'tertiary' => 'Improve quality of life'
},
activities: {
'monitoring' => 'Regular vital signs and lab monitoring',
'medication' => 'Medication adherence and optimization',
'lifestyle' => 'Diet and exercise counseling'
},
care_team: [
{
'role' => 'Primary Doctor',
'name' => problems.first.doctor.first_name + ' ' + problems.first.doctor.last_name
}
],
metadata: {
migration_source: 'chronic_problems_aggregation',
problem_count: problems.length
}
)
care_plans_created += 1
rescue ActiveRecord::RecordInvalid => e
puts " ❌ Failed to create care plan for patient #{patient_id} in clinic #{clinic_id}: #{e.message}"
end
end
puts " ✅ Created #{care_plans_created} initial care plans"
end
desc "Validate migration data integrity"
task validate_migration: :environment do
puts "🔍 Validating migration data integrity..."
# Check prescription -> FHIR resource mapping
finalized_prescriptions = Prescription.where(is_final: true).count
medication_requests = MedicationRequest.count
service_requests = ServiceRequest.count
puts " 📊 Migration Statistics:"
puts " Finalized Prescriptions: #{finalized_prescriptions}"
puts " Patient Problems: #{PatientProblem.count}"
puts " Clinical Impressions: #{ClinicalImpression.count}"
puts " Clinical Observations: #{ClinicalObservation.count}"
puts " Diagnostic Reports: #{DiagnosticReport.count}"
puts " Medication Requests: #{medication_requests}"
puts " Service Requests: #{service_requests}"
puts " Care Plans: #{CarePlan.count}"
# Validate data consistency
orphaned_clinical_impressions = ClinicalImpression.left_joins(:prescription).where(prescriptions: { id: nil }).count
orphaned_medication_requests = MedicationRequest.left_joins(:prescription).where(prescriptions: { id: nil }).count
orphaned_service_requests = ServiceRequest.left_joins(:prescription).where(prescriptions: { id: nil }).count
orphaned_problems = PatientProblem.left_joins(:patient).where(patients: { id: nil }).count
if orphaned_clinical_impressions > 0 || orphaned_medication_requests > 0 || orphaned_service_requests > 0 || orphaned_problems > 0
puts " ⚠️ Data integrity issues found:"
puts " Orphaned Clinical Impressions: #{orphaned_clinical_impressions}" if orphaned_clinical_impressions > 0
puts " Orphaned Medication Requests: #{orphaned_medication_requests}" if orphaned_medication_requests > 0
puts " Orphaned Service Requests: #{orphaned_service_requests}" if orphaned_service_requests > 0
puts " Orphaned Patient Problems: #{orphaned_problems}" if orphaned_problems > 0
else
puts " ✅ Data integrity validation passed"
end
end
desc "Rollback migration (USE WITH CAUTION)"
task rollback_migration: :environment do
puts "🚨 Rolling back LHR migration..."
print "Are you sure you want to delete all migrated LHR data? (yes/no): "
confirmation = STDIN.gets.chomp
if confirmation.downcase == 'yes'
deleted_counts = {}
deleted_counts['Medication Requests'] = MedicationRequest.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Service Requests'] = ServiceRequest.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Patient Problems'] = PatientProblem.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Clinical Impressions'] = ClinicalImpression.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Clinical Observations'] = ClinicalObservation.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Diagnostic Reports'] = DiagnosticReport.where("metadata->>'migration_source' IS NOT NULL").delete_all
deleted_counts['Care Plans'] = CarePlan.where("metadata->>'migration_source' IS NOT NULL").delete_all
puts " ✅ Rollback completed:"
deleted_counts.each { |type, count| puts " #{type}: #{count} records deleted" }
else
puts " Rollback cancelled"
end
end
# Helper methods for the tasks
def categorize_diagnosis(diagnosis)
case diagnosis.downcase
when /diabetes|glucose|insulin/ then 'Endocrine'
when /hypertension|blood pressure|cardiac|heart/ then 'Cardiovascular'
when /infection|fever|bacteria|virus/ then 'Infectious'
when /pain|arthritis|joint|muscle/ then 'Musculoskeletal'
when /respiratory|lung|asthma|copd/ then 'Respiratory'
when /gastro|stomach|digestive/ then 'Gastrointestinal'
when /mental|depression|anxiety/ then 'Mental Health'
when /skin|dermat/ then 'Dermatological'
else 'General'
end
end
def determine_severity(diagnosis)
case diagnosis.downcase
when /cancer|tumor|malignant|stroke|heart attack|myocardial/ then 'critical'
when /diabetes|hypertension|copd|asthma|chronic/ then 'high'
when /infection|acute|severe/ then 'medium'
else 'low'
end
end
def determine_status(diagnosis)
chronic_keywords = ['diabetes', 'hypertension', 'copd', 'asthma', 'arthritis', 'chronic']
if chronic_keywords.any? { |keyword| diagnosis.downcase.include?(keyword) }
'chronic'
else
'active'
end
end
def extract_icd_code(diagnosis)
# Simple ICD-10 code mapping (can be enhanced with proper medical coding)
case diagnosis.downcase
when /type.*2.*diabetes/ then 'E11.9'
when /diabetes/ then 'E10.9'
when /hypertension/ then 'I10'
when /hyperlipidemia/ then 'E78.5'
when /asthma/ then 'J45.9'
when /copd/ then 'J44.1'
else nil
end
end
def standardize_vital_key(key)
case key.to_s.downcase
when 'bp', 'blood_pressure', 'bloodpressure' then 'bloodPressure'
when 'hr', 'heart_rate', 'heartrate', 'pulse' then 'heartRate'
when 'temp', 'temperature' then 'temperature'
when 'weight', 'wt' then 'weight'
when 'spo2', 'oxygen_saturation', 'o2sat' then 'oxygenSaturation'
when 'rr', 'respiratory_rate', 'resp_rate' then 'respiratoryRate'
when 'bmi' then 'bmi'
else nil
end
end
def get_loinc_code(vital_type)
# Standard LOINC codes for vital signs
{
'bloodPressure' => '85354-9',
'heartRate' => '8867-4',
'temperature' => '8310-5',
'weight' => '29463-7',
'oxygenSaturation' => '2708-6',
'respiratoryRate' => '9279-1',
'bmi' => '39156-5'
}[vital_type] || 'unknown'
end
def get_vital_display_name(vital_type)
{
'bloodPressure' => 'Blood Pressure',
'heartRate' => 'Heart Rate',
'temperature' => 'Body Temperature',
'weight' => 'Weight',
'oxygenSaturation' => 'Oxygen Saturation',
'respiratoryRate' => 'Respiratory Rate',
'bmi' => 'Body Mass Index'
}[vital_type] || vital_type.humanize
end
def get_vital_unit(vital_type)
{
'bloodPressure' => 'mmHg',
'heartRate' => 'bpm',
'temperature' => '°C',
'weight' => 'kg',
'oxygenSaturation' => '%',
'respiratoryRate' => 'breaths/min',
'bmi' => 'kg/m²'
}[vital_type] || ''
end
def parse_vital_value_for_observation(key, value)
standardized_key = standardize_vital_key(key)
if standardized_key == 'bloodPressure' && value.to_s.include?('/')
# Store as string for blood pressure
{ type: 'string', string: value.to_s, quantity: nil }
else
# Store as quantity for numeric values
numeric_value = value.to_s.gsub(/[^\d.]/, '').to_f
{ type: 'quantity', quantity: numeric_value, string: nil }
end
end
def get_normal_range(vital_type)
{
'bloodPressure' => { 'low' => '90/60', 'high' => '140/90' },
'heartRate' => { 'low' => 60, 'high' => 100 },
'temperature' => { 'low' => 36.0, 'high' => 37.5 },
'oxygenSaturation' => { 'low' => 95, 'high' => 100 },
'respiratoryRate' => { 'low' => 12, 'high' => 20 }
}[vital_type] || {}
end
def is_vital_abnormal?(key, value)
standardized_key = standardize_vital_key(key)
normal_ranges = get_normal_range(standardized_key)
return false if normal_ranges.empty?
if standardized_key == 'bloodPressure' && value.to_s.include?('/')
systolic, diastolic = value.to_s.split('/').map(&:to_i)
min_sys, min_dia = normal_ranges['low'].split('/').map(&:to_i)
max_sys, max_dia = normal_ranges['high'].split('/').map(&:to_i)
systolic < min_sys || systolic > max_sys || diastolic < min_dia || diastolic > max_dia
else
numeric_value = value.to_s.gsub(/[^\d.]/, '').to_f
numeric_value < normal_ranges['low'] || numeric_value > normal_ranges['high']
end
end
def determine_clinical_significance(diagnoses)
critical_conditions = ['stroke', 'heart attack', 'cancer', 'myocardial infarction']
abnormal_conditions = ['diabetes', 'hypertension', 'copd', 'asthma']
diagnoses_text = diagnoses.join(' ').downcase
if critical_conditions.any? { |condition| diagnoses_text.include?(condition) }
'critical'
elsif abnormal_conditions.any? { |condition| diagnoses_text.include?(condition) }
'abnormal'
else
'normal'
end
end
endUsage Instructions
1. Initial Migration
# Run complete migration
cd backend
bundle exec rake lhr:migrate_all
# Or run individual steps
bundle exec rake lhr:migrate_patient_problems
bundle exec rake lhr:migrate_clinical_observations
bundle exec rake lhr:migrate_diagnostic_reports
bundle exec rake lhr:backfill_fhir_resources
bundle exec rake lhr:create_initial_care_plans2. Validation
# Validate migration results
bundle exec rake lhr:validate_migration3. Ongoing Operations
# Backfill FHIR resources for new finalized prescriptions
bundle exec rake lhr:backfill_fhir_resources
# Create care plans for new chronic patients
bundle exec rake lhr:create_initial_care_plans4. Emergency Rollback (USE WITH CAUTION)
# Only if migration needs to be completely reversed
bundle exec rake lhr:rollback_migrationMigration Safety
Pre-Migration Checklist
- Backup database
- Run on staging environment first
- Verify sufficient disk space
- Ensure background job workers are running
- Check that all required tables exist
Post-Migration Verification
- Run validation task
- Check data integrity
- Verify API endpoints return expected data
- Test frontend LHR dashboard
- Monitor background job completion
Performance Considerations
- Tasks process data in batches to avoid memory issues
- Background jobs queued for FHIR resource creation
- Indexes created for optimal query performance
- Migration can be run multiple times (idempotent)
Rollback Strategy
- All migrated records tagged with
metadata.migration_source - Rollback only deletes records created by migration
- Original prescription data remains unchanged
- Frontend continues working with existing data structure