Class: MOCO::Sync

Inherits:
Object
  • Object
show all
Defined in:
lib/moco/sync.rb

Overview

Match and map projects and tasks between MOCO instances and sync activities

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source_client, target_client, **args) ⇒ Sync

Returns a new instance of Sync.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/moco/sync.rb', line 13

def initialize(source_client, target_client, **args)
  @source = source_client
  @target = target_client
  @project_match_threshold = args.fetch(:project_match_threshold, 0.8)
  @task_match_threshold = args.fetch(:task_match_threshold, 0.45)
  @filters = args.fetch(:filters, {})
  @dry_run = args.fetch(:dry_run, false)
  @debug = args.fetch(:debug, false)
  @default_task_name = args.fetch(:default_task_name, nil)

  @project_mapping = {}
  @task_mapping = {}
  @default_task_cache = {} # Cache default tasks per project

  fetch_assigned_projects
  build_initial_mappings
  create_missing_tasks_for_activities
end

Instance Attribute Details

#debugObject

Returns the value of attribute debug.



11
12
13
# File 'lib/moco/sync.rb', line 11

def debug
  @debug
end

#dry_runObject

Returns the value of attribute dry_run.



11
12
13
# File 'lib/moco/sync.rb', line 11

def dry_run
  @dry_run
end

#project_mappingObject (readonly)

Returns the value of attribute project_mapping.



10
11
12
# File 'lib/moco/sync.rb', line 10

def project_mapping
  @project_mapping
end

#project_match_thresholdObject

Returns the value of attribute project_match_threshold.



11
12
13
# File 'lib/moco/sync.rb', line 11

def project_match_threshold
  @project_match_threshold
end

#source_projectsObject (readonly)

Returns the value of attribute source_projects.



10
11
12
# File 'lib/moco/sync.rb', line 10

def source_projects
  @source_projects
end

#target_projectsObject (readonly)

Returns the value of attribute target_projects.



10
11
12
# File 'lib/moco/sync.rb', line 10

def target_projects
  @target_projects
end

#task_mappingObject (readonly)

Returns the value of attribute task_mapping.



10
11
12
# File 'lib/moco/sync.rb', line 10

def task_mapping
  @task_mapping
end

#task_match_thresholdObject

Returns the value of attribute task_match_threshold.



11
12
13
# File 'lib/moco/sync.rb', line 11

def task_match_threshold
  @task_match_threshold
end

Instance Method Details

#sync(&callbacks) ⇒ Object

rubocop:todo Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/moco/sync.rb', line 33

def sync(&callbacks)
  results = []

  source_activity_filters = @filters.fetch(:source, {})
  source_activities_r = @source.activities.where(source_activity_filters).all
  debug_log "Fetched #{source_activities_r.size} source activities"

  # Log source activities for debugging
  debug_log "Source activities:"
  source_activities_r.each do |activity|
    debug_log "  Source Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"

    # Also log the expected target activity for each source activity
    begin
      expected = get_expected_target_activity(activity)
      if expected
        project_id = expected.project&.id rescue "N/A"
        task_id = expected.task&.id rescue "N/A"
        remote_id = expected.instance_variable_get(:@attributes)[:remote_id] rescue "N/A"
        debug_log "    Expected Target: Project: #{project_id}, Task: #{task_id}, Remote ID: #{remote_id}"
      end
    rescue => e
      debug_log "    Error getting expected target: #{e.message}"
    end
  end

  target_activity_filters = @filters.fetch(:target, {})
  target_activities_r = @target.activities.where(target_activity_filters).all
  debug_log "Fetched #{target_activities_r.size} target activities"

  # Log target activities for debugging
  debug_log "Target activities:"
  target_activities_r.each do |activity|
    debug_log "  Target Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"
  end

  # Group activities by date and then by project_id for consistent lookups
  source_activities_grouped = source_activities_r.group_by(&:date).transform_values do |activities|
    activities.group_by { |a| a.project&.id } # Group by project ID
  end
  target_activities_grouped = target_activities_r.group_by(&:date).transform_values do |activities|
    activities.group_by { |a| a.project&.id } # Group by project ID
  end

  used_source_activities = []
  used_target_activities = []

  debug_log "Starting main sync loop..."
  source_activities_grouped.each do |date, activities_by_project_id|
    debug_log "Processing date: #{date}"
    activities_by_project_id.each do |source_project_id, source_activities|
      debug_log "  Processing source project ID: #{source_project_id} (#{source_activities.count} activities)"
      # Find the corresponding target project ID using the mapping
      target_project_object = @project_mapping[source_project_id]
      unless target_project_object
        debug_log "    Skipping - Source project ID #{source_project_id} not mapped."
        next
      end

      target_project_id = target_project_object.id
      # Fetch target activities using the target project ID
      target_activities = target_activities_grouped.fetch(date, {}).fetch(target_project_id, [])
      debug_log "    Found #{target_activities.count} target activities for target project ID: #{target_project_id}"

      if source_activities.empty? || target_activities.empty?
        debug_log "    Skipping - No source or target activities for this date/project pair."
        next
      end

      matches = calculate_matches(source_activities, target_activities)
      debug_log "    Calculated #{matches.count} potential matches."
      matches.sort_by! { |match| -match[:score] }

      debug_log "    Entering matches loop..."
      matches.each do |match|
        source_activity, target_activity = match[:activity]
        score = match[:score]
        debug_log "      Match Pair: Score=#{score}, Source=#{source_activity.id}, Target=#{target_activity.id}"

        if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
          debug_log "        Skipping match pair - already used: Source used=#{used_source_activities.include?(source_activity)}, Target used=#{used_target_activities.include?(target_activity)}"
          next
        end

        best_score = score # Since we sorted, this is the best score for this unused pair
        best_match = target_activity
        expected_target_activity = get_expected_target_activity(source_activity)
        debug_log "        Processing best score #{best_score} for Source=#{source_activity.id}"

        case best_score
        when 100
          debug_log "          Case 100: Equal"
          # 100 - perfect match found, nothing needs doing
          callbacks&.call(:equal, source_activity, expected_target_activity)
          # Mark both as used
          debug_log "            Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
          used_source_activities << source_activity
          used_target_activities << target_activity
        when 60...100
          debug_log "          Case 60-99: Update"
          # >=60 <100 - match with some differences
          expected_target_activity.to_h.except(:id, :user, :customer).each do |k, v|
            debug_log "            Updating attribute #{k} on Target=#{target_activity.id}"
            best_match.send("#{k}=", v)
          end
          callbacks&.call(:update, source_activity, best_match)
          unless @dry_run
            debug_log "            Executing API update for Target=#{target_activity.id}"
            results << @target.activities.update(best_match.id, best_match.attributes) # Pass ID and attributes
            callbacks&.call(:updated, source_activity, best_match, results.last)
          end
          # Mark both as used
          debug_log "            Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
          used_source_activities << source_activity
          used_target_activities << target_activity
        when 0...60
          debug_log "          Case 0-59: Low score, doing nothing for this pair."
          # <60 - Low score for this specific pair. Do nothing here.
          # Creation is handled later if source_activity remains unused.
          nil # Explicitly do nothing
        end
        # Only mark activities as used if score >= 60 (handled within the case branches above)
      end
      debug_log "    Finished matches loop."
    end
    debug_log "  Finished processing project IDs for date #{date}."
  end
  debug_log "Finished main sync loop."

  # Second loop: Create source activities that were never used (i.e., had no match >= 60)
  debug_log "Starting creation loop..."
  source_activities_r.each do |source_activity|
    if used_source_activities.include?(source_activity)
      debug_log "  Skipping creation for Source=#{source_activity.id} - already used."
      next
    end
    # Use safe navigation in case project is nil
    source_project_id = source_activity.project&.id
    unless @project_mapping[source_project_id]
      debug_log "  Skipping creation for Source=#{source_activity.id} - project #{source_project_id} not mapped."
      next
    end

    debug_log "  Processing creation for Source=#{source_activity.id}"
    expected_target_activity = get_expected_target_activity(source_activity)
    callbacks&.call(:create, source_activity, expected_target_activity)
    next if @dry_run

    debug_log "    Executing API create."
    # Pass attributes hash to create
    created_activity = @target.activities.create(expected_target_activity.attributes)
    results << created_activity
    # Pass the actual created activity object to the callback
    callbacks&.call(:created, source_activity, created_activity, results.last)
  end
  debug_log "Finished creation loop."

  results
end