Skip to content

Commit 07e9217

Browse files
authored
v3.0.3: keep Harvest import names distinct (#2432)
* fix: keep Harvest import names distinct * chore: release v3.0.3 * fix: stabilize Harvest names across imports * fix: normalize Harvest import identities * fix: preserve legacy Harvest import identities
1 parent d67ae76 commit 07e9217

5 files changed

Lines changed: 156 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
- Mailer layout branding now uses asset helper URLs with production `action_mailer.asset_host` to keep email logo assets resolvable in production mail clients
2727
- Razorpay Payment Settings now copies the production webhook URLs with the required event names for faster dashboard setup
2828

29+
## 3.0.3 - 2026-09-07
30+
31+
### Fixed
32+
33+
- Harvest imports now keep long client and project names distinct when their 30-character prefixes collide
34+
2935
## 3.0.2 - 2026-09-07
3036

3137
### Fixed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.0.2
1+
3.0.3

app/services/imports/harvest_time_entries_importer.rb

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def load_workspace_records
118118
.includes(:client)
119119
.to_a
120120
.index_by { |project| [project.client_id, key(project.name)] }
121+
load_legacy_names
121122
end
122123

123124
def build_plan
@@ -169,12 +170,17 @@ def build_entity_plans
169170
client_key, client_plan = build_client_plan(row)
170171

171172
original_project_name = row[:project_name]
172-
project_name = original_project_name[0, 30]
173+
project_name = import_name(original_project_name)
174+
existing_project = client_plan[:existing] && @existing_projects[[client_plan[:existing].id, key(project_name)]]
175+
if existing_project.nil? && (legacy_name = legacy_import_name(original_project_name, @legacy_project_names))
176+
existing_project = client_plan[:existing] && @existing_projects[[client_plan[:existing].id, key(legacy_name)]]
177+
project_name = legacy_name if existing_project
178+
end
173179
@warnings << "Project name truncated: #{original_project_name.truncate(80)}" if original_project_name.length > 30
174180
project_key = [client_key, key(project_name)]
175-
existing_project = client_plan[:existing] && @existing_projects[[client_plan[:existing].id, key(project_name)]]
176181
row[:project_plan] = @project_plans[project_key] ||= {
177182
name: project_name,
183+
source_name: original_project_name,
178184
client: client_plan,
179185
existing: existing_project,
180186
billable: false,
@@ -189,7 +195,10 @@ def build_entity_plans
189195

190196
def build_client_plan(row)
191197
original_name = row[:client_name]
192-
client_name = original_name[0, 30]
198+
client_name = import_name(original_name)
199+
if !@existing_clients.key?(key(client_name)) && (legacy_name = legacy_import_name(original_name, @legacy_client_names))
200+
client_name = legacy_name if @existing_clients.key?(key(legacy_name))
201+
end
193202
@warnings << "Client name truncated: #{original_name.truncate(80)}" if original_name.length > 30
194203
client_key = key(client_name)
195204
client_plan = @client_plans[client_key] ||= {
@@ -201,6 +210,36 @@ def build_client_plan(row)
201210
[client_key, client_plan]
202211
end
203212

213+
def import_name(name)
214+
return name if name.length <= 30
215+
216+
suffix = "-#{Digest::SHA256.hexdigest(key(name)).first(6)}"
217+
"#{name.first(30 - suffix.length)}#{suffix}"
218+
end
219+
220+
def load_legacy_names
221+
warnings = company.data_imports.where(source: "harvest", kind: "time_entries", status: "completed", dry_run: false)
222+
.pluck(:summary)
223+
.flat_map { |summary| Array(summary["warnings"]) }
224+
@legacy_client_names = legacy_names(warnings, "Client")
225+
@legacy_project_names = legacy_names(warnings, "Project")
226+
end
227+
228+
def legacy_names(warnings, entity)
229+
prefix = "#{entity} name truncated: "
230+
names = warnings.filter_map do |warning|
231+
warning.delete_prefix(prefix) if warning.start_with?(prefix) && !warning.end_with?("...")
232+
end
233+
names.group_by { |name| key(name.first(30)) }.transform_values do |matches|
234+
matches.first if matches.map { |name| key(name) }.uniq.one?
235+
end.compact
236+
end
237+
238+
def legacy_import_name(name, legacy_names)
239+
legacy_name = legacy_names[key(name.first(30))]
240+
name.first(30) if legacy_name && key(legacy_name) == key(name)
241+
end
242+
204243
def plan_row(row)
205244
error = row_error(row)
206245
return add_row_error(row, error) if error
@@ -381,13 +420,20 @@ def create_clients_and_projects
381420
plan[:record] = plan[:existing] || plan[:client][:record].projects.create!(
382421
name: plan[:name],
383422
billable: plan[:billable],
384-
description: plan[:code] && "Harvest project code: #{plan[:code]}"
423+
description: project_description(plan)
385424
)
386425
plan[:record].undiscard! if plan[:record].discarded?
387426
end
388427
end
389428
end
390429

430+
def project_description(plan)
431+
[
432+
("Harvest project: #{plan[:source_name]}" if plan[:source_name] != plan[:name]),
433+
("Harvest project code: #{plan[:code]}" if plan[:code])
434+
].compact.join("\n").presence
435+
end
436+
391437
def create_project_members
392438
pairs = @planned_rows.each_with_object({}) do |row, result|
393439
result[[row[:project_plan][:record].id, row[:user].id]] ||= row

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@saeloun/miru-web",
3-
"version": "3.0.2",
3+
"version": "3.0.3",
44
"dependencies": {
55
"@fontsource-variable/inter": "^5.2.6",
66
"@fontsource/plus-jakarta-sans": "^4.5.0",

spec/services/imports/harvest_time_entries_importer_spec.rb

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
failed_rows: 0,
2929
skipped_rows: 1
3030
)
31-
expect(company.clients.kept.pluck(:name)).to contain_exactly("Acme LLC", "Very Long Client Name That Exc")
31+
expect(company.clients.kept.pluck(:name)).to contain_exactly("Acme LLC", "Very Long Client Name T-715152")
3232
expect(company.projects.kept.count).to eq(3)
3333
expect(ProjectMember.kept.joins(project: :client).where(clients: { company_id: company.id }).count).to eq(3)
3434

@@ -43,7 +43,7 @@
4343
internal = company.projects.kept.find_by!(name: "Internal")
4444
expect(internal).not_to be_billable
4545
expect(internal.timesheet_entries.kept).to all(be_non_billable)
46-
expect(company.clients.kept.find_by!(name: "Very Long Client Name That Exc").currency).to eq("USD")
46+
expect(company.clients.kept.find_by!(name: "Very Long Client Name T-715152").currency).to eq("USD")
4747
expect(website.project_members.kept.find_by!(user: paul).hourly_rate).to eq(150)
4848
end
4949

@@ -153,11 +153,11 @@
153153
"zero_hour_skipped" => 1,
154154
"duplicates_skipped" => 0
155155
)
156-
expect(data_import.summary.dig("clients", "to_create")).to contain_exactly("Acme LLC", "Very Long Client Name That Exc")
156+
expect(data_import.summary.dig("clients", "to_create")).to contain_exactly("Acme LLC", "Very Long Client Name T-715152")
157157
expect(data_import.summary.dig("projects", "to_create")).to contain_exactly(
158158
"Acme LLC / Internal",
159159
"Acme LLC / Website",
160-
"Very Long Client Name That Exc / Advisory"
160+
"Very Long Client Name T-715152 / Advisory"
161161
)
162162
expect(data_import.summary.dig("date_range")).to eq("from" => "2026-01-02", "to" => "2026-01-11")
163163
expect(data_import.summary).not_to have_key("row_errors")
@@ -188,6 +188,88 @@
188188
expect(TimesheetEntry.kept.last.work_date).to eq(Date.new(2026, 1, 7))
189189
end
190190

191+
it "keeps project names distinct when their 30-character prefixes collide" do
192+
attach_csv_contents(data_import, <<~CSV)
193+
Date,Client,Project,Project Code,Hours,First Name,Last Name
194+
2026-01-01,Acme LLC,Acme 1H 2025 Committed Hours (PC),PC,1,Paul,Connors
195+
CSV
196+
197+
described_class.new(data_import).process
198+
199+
second_import = create(:data_import, company:, user: actor)
200+
attach_csv_contents(second_import, <<~CSV)
201+
Date,Client,Project,Hours,First Name,Last Name
202+
2026-01-02,Acme LLC,Acme 1H 2025 Committed Hours (LW),1,Paul,Connors
203+
CSV
204+
described_class.new(second_import).process
205+
206+
projects = company.projects.kept.order(:name)
207+
expect(projects.pluck(:name).uniq.size).to eq(2)
208+
expect(projects.pluck(:name).map(&:length)).to all(be <= 30)
209+
expect(projects.pluck(:description)).to contain_exactly(
210+
"Harvest project: Acme 1H 2025 Committed Hours (LW)",
211+
"Harvest project: Acme 1H 2025 Committed Hours (PC)\nHarvest project code: PC"
212+
)
213+
expect(projects.flat_map { |project| project.timesheet_entries.kept.pluck(:work_date) }).to contain_exactly(
214+
Date.new(2026, 1, 1),
215+
Date.new(2026, 1, 2)
216+
)
217+
expect(data_import.reload.summary["warnings"]).to include(match(/Project name truncated:/))
218+
219+
reimport = create(:data_import, company:, user: actor)
220+
attach_csv_contents(reimport, <<~CSV)
221+
Date,Client,Project,Hours,First Name,Last Name
222+
2026-01-02,Acme LLC,Acme 1H 2025 Committed Hours (LW),1,Paul,Connors
223+
2026-01-01,Acme LLC,Acme 1H 2025 Committed Hours (PC),1,Paul,Connors
224+
CSV
225+
226+
expect do
227+
described_class.new(reimport).process
228+
end.not_to change { [company.projects.kept.count, TimesheetEntry.kept.count] }
229+
expect(reimport.reload).to have_attributes(imported_rows: 0, skipped_rows: 2)
230+
end
231+
232+
it "keeps client names distinct when their 30-character prefixes collide" do
233+
attach_csv_contents(data_import, <<~CSV)
234+
Date,Client,Project,Hours,First Name,Last Name
235+
2026-01-01,Acme Corporate Legal Services Alpha,Website,1,Paul,Connors
236+
CSV
237+
238+
described_class.new(data_import).process
239+
240+
second_import = create(:data_import, company:, user: actor)
241+
attach_csv_contents(second_import, <<~CSV)
242+
Date,Client,Project,Hours,First Name,Last Name
243+
2026-01-02,Acme Corporate Legal Services Bravo,Website,1,Paul,Connors
244+
CSV
245+
described_class.new(second_import).process
246+
247+
expect(company.clients.kept.pluck(:name).uniq.size).to eq(2)
248+
expect(company.projects.kept.count).to eq(2)
249+
expect(data_import.reload.summary["warnings"]).to include(match(/Client name truncated:/))
250+
end
251+
252+
it "reuses unambiguous client and project names created by the legacy importer" do
253+
client_name = "Acme Corporate Legal Services Alpha"
254+
project_name = "Acme 1H 2025 Committed Hours (PC)"
255+
create(:data_import, company:, user: actor, status: "completed", summary: {
256+
"warnings" => ["Client name truncated: #{client_name}", "Project name truncated: #{project_name}"]
257+
})
258+
legacy_client = create(:client, company:, name: client_name.first(30))
259+
legacy_project = create(:project, client: legacy_client, name: project_name.first(30))
260+
create(:timesheet_entry, project: legacy_project, user: paul, work_date: Date.new(2026, 1, 1), duration: 60, note: "", source: "import")
261+
attach_csv_contents(data_import, <<~CSV)
262+
Date,Client,Project,Hours,First Name,Last Name
263+
2026-01-01,#{client_name},#{project_name},1,Paul,Connors
264+
CSV
265+
266+
expect do
267+
described_class.new(data_import).process
268+
end.not_to change { [company.clients.count, company.projects.count, TimesheetEntry.count] }
269+
270+
expect(data_import.reload).to have_attributes(imported_rows: 0, skipped_rows: 1)
271+
end
272+
191273
it "records invalid hours, date, and blank client rows without creating entries" do
192274
attach_csv_contents(data_import, <<~CSV)
193275
Date,Client,Project,Hours,First Name,Last Name
@@ -238,21 +320,30 @@
238320
)
239321
end
240322

241-
it "truncates project names and reuses a matching project" do
323+
it "shortens long project names deterministically" do
242324
client = create(:client, company:, name: "Acme LLC")
243-
project = create(:project, client:, name: "A Project Name Longer Than Thi", billable: true)
244325
attach_csv_contents(data_import, <<~CSV)
245326
Date,Client,Project,Hours,First Name,Last Name,Billable?
246327
2026-01-01,Acme LLC,A Project Name Longer Than Thirty Characters,1,Paul,Connors,Yes
247328
CSV
248329

249330
described_class.new(data_import).process
250331

251-
expect(client.projects.count).to eq(1)
332+
project = client.projects.find_by!(name: "A Project Name Longer T-ae2f52")
252333
expect(project.timesheet_entries.kept).to exist
253334
expect(data_import.reload.summary["warnings"]).to include(
254335
"Project name truncated: A Project Name Longer Than Thirty Characters"
255336
)
337+
338+
reimport = create(:data_import, company:, user: actor)
339+
attach_csv_contents(reimport, <<~CSV)
340+
Date,Client,Project,Hours,First Name,Last Name,Billable?
341+
2026-01-01,Acme LLC,A PROJECT NAME LONGER THAN THIRTY CHARACTERS,1,Paul,Connors,Yes
342+
CSV
343+
344+
expect do
345+
described_class.new(reimport).process
346+
end.not_to change { [client.projects.count, TimesheetEntry.kept.count] }
256347
end
257348

258349
it "restores an archived client before importing entries" do

0 commit comments

Comments
 (0)