From 71c3f41bf9ad109777be9245bfd7c56b2e49fb02 Mon Sep 17 00:00:00 2001 From: kosiakkatrina <54268893+kosiakkatrina@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:03:50 +0000 Subject: [PATCH] CLDC-3803 Add lettings 25/26 BU (#2939) * Copy row and csv parser for 2025 lettings * Add 2025 BU translations file * Update row parser field number * Add "apecified accommodation" to rent type mapping * Update csv parser * Add prepare your file and connect the csv parser to log creator and validator * Update test file creation * Refactor some csv methods, add person relationship mapping * Update row parser specs * Further refactor some test helper methods * Merge update --- .../create_log_actions_component.html.erb | 11 +- .../create_log_actions_component.rb | 4 +- app/controllers/test_data_controller.rb | 34 +- .../bulk_upload/lettings_log_to_csv.rb | 259 +- .../bulk_upload/lettings/log_creator.rb | 2 + .../bulk_upload/lettings/validator.rb | 2 + .../lettings/year2025/csv_parser.rb | 122 + .../lettings/year2025/row_parser.rb | 1654 ++++++++++ .../lettings/2025/bulk_upload.en.yml | 60 + config/routes.rb | 2 + .../bulk_upload/lettings/validator_spec.rb | 4 +- .../lettings/year2023/csv_parser_spec.rb | 32 +- .../lettings/year2024/csv_parser_spec.rb | 36 +- .../lettings/year2025/csv_parser_spec.rb | 254 ++ .../lettings/year2025/row_parser_spec.rb | 2808 +++++++++++++++++ 15 files changed, 5148 insertions(+), 136 deletions(-) create mode 100644 app/services/bulk_upload/lettings/year2025/csv_parser.rb create mode 100644 app/services/bulk_upload/lettings/year2025/row_parser.rb create mode 100644 config/locales/validations/lettings/2025/bulk_upload.en.yml create mode 100644 spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb create mode 100644 spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb diff --git a/app/components/create_log_actions_component.html.erb b/app/components/create_log_actions_component.html.erb index 4b74c8901..2c3b75aea 100644 --- a/app/components/create_log_actions_component.html.erb +++ b/app/components/create_log_actions_component.html.erb @@ -1,5 +1,5 @@
"> -<% if display_actions? %> + <% if display_actions? %> <%= govuk_button_to create_button_copy, create_button_href, class: "govuk-!-margin-right-3" %> <% unless user.support? %> <%= govuk_button_link_to upload_button_copy, upload_button_href, secondary: true %> @@ -9,9 +9,10 @@ <% end %> <% if FeatureToggle.create_test_logs_enabled? %> - <%= govuk_link_to "Create test log", create_test_log_href %> - <%= govuk_link_to "Create test log (setup only)", create_setup_test_log_href %> - <%= govuk_link_to "Get test BU file (2024)", create_2024_test_bulk_upload_href %> + <%= govuk_link_to "New test log", create_test_log_href %> + <%= govuk_link_to "New test log (setup only)", create_setup_test_log_href %> + <%= govuk_link_to "24 BU test file", create_test_bulk_upload_href(2024) %> + <%= govuk_link_to "25 BU test file", create_test_bulk_upload_href(2025) %> <% end %> -<% end %> + <% end %>
diff --git a/app/components/create_log_actions_component.rb b/app/components/create_log_actions_component.rb index 0abbfd385..ae240bc91 100644 --- a/app/components/create_log_actions_component.rb +++ b/app/components/create_log_actions_component.rb @@ -42,8 +42,8 @@ class CreateLogActionsComponent < ViewComponent::Base send("create_setup_test_#{log_type}_log_path") end - def create_2024_test_bulk_upload_href - send("create_2024_test_#{log_type}_bulk_upload_path") + def create_test_bulk_upload_href(year) + send("create_#{year}_test_#{log_type}_bulk_upload_path") end def view_uploads_button_copy diff --git a/app/controllers/test_data_controller.rb b/app/controllers/test_data_controller.rb index 77e8041f0..9195b77aa 100644 --- a/app/controllers/test_data_controller.rb +++ b/app/controllers/test_data_controller.rb @@ -15,24 +15,28 @@ class TestDataController < ApplicationController redirect_to lettings_log_path(log) end - def create_2024_test_lettings_bulk_upload - return render_not_found unless FeatureToggle.create_test_logs_enabled? + %w[2024 2025].each do |year| + define_method("create_#{year}_test_lettings_bulk_upload") do + return render_not_found unless FeatureToggle.create_test_logs_enabled? - file = Tempfile.new("test_lettings_log.csv") - log = FactoryBot.create(:lettings_log, :completed, assigned_to: current_user, ppostcode_full: "SW1A 1AA") - log_to_csv = BulkUpload::LettingsLogToCsv.new(log:, line_ending: "\n", overrides: { organisation_id: "ORG#{log.owning_organisation_id}", managing_organisation_id: "ORG#{log.owning_organisation_id}" }) - file.write(log_to_csv.default_field_numbers_row) - file.write(log_to_csv.to_csv_row) - file.rewind - send_file file.path, type: "text/csv", - filename: "test_lettings_log.csv", - disposition: "attachment", - after_send: lambda { - file.close - file.unlink - } + file = Tempfile.new("#{year}_test_lettings_log.csv") + log = FactoryBot.create(:lettings_log, :completed, assigned_to: current_user, ppostcode_full: "SW1A 1AA", startdate: Time.zone.local(year.to_i, rand(4..12), rand(1..28))) + log_to_csv = BulkUpload::LettingsLogToCsv.new(log:, line_ending: "\n", overrides: { organisation_id: "ORG#{log.owning_organisation_id}", managing_organisation_id: "ORG#{log.owning_organisation_id}" }) + file.write(log_to_csv.default_field_numbers_row) + file.write(log_to_csv.to_csv_row) + file.rewind + send_file file.path, type: "text/csv", + filename: "#{year}_test_lettings_log.csv", + disposition: "attachment", + after_send: lambda { + file.close + file.unlink + } + end end + def create_2025_test_sales_bulk_upload; end + def create_test_sales_log return render_not_found unless FeatureToggle.create_test_logs_enabled? diff --git a/app/helpers/bulk_upload/lettings_log_to_csv.rb b/app/helpers/bulk_upload/lettings_log_to_csv.rb index cdf38db17..b81fec3d7 100644 --- a/app/helpers/bulk_upload/lettings_log_to_csv.rb +++ b/app/helpers/bulk_upload/lettings_log_to_csv.rb @@ -17,12 +17,8 @@ class BulkUpload::LettingsLogToCsv def to_csv_row(seed: nil) year = log.collection_start_year case year - when 2022 - to_2022_csv_row(seed:) - when 2023 - to_2023_csv_row(seed:) - when 2024 - to_2024_csv_row(seed:) + when 2022, 2023, 2024, 2025 + to_year_csv_row(year, seed:) else raise NotImplementedError "No mapping function implemented for year #{year}" end @@ -30,82 +26,32 @@ class BulkUpload::LettingsLogToCsv def to_row year = log.collection_start_year - case year - when 2022 - to_2022_row - when 2023 - to_2023_row - when 2024 - to_2024_row - else - raise NotImplementedError "No mapping function implemented for year #{year}" - end + send("to_#{year}_row") + rescue NoMethodError + raise NotImplementedError "No mapping function implemented for year #{year}" end def default_field_numbers_row(seed: nil) year = log.collection_start_year - case year - when 2022 - default_2022_field_numbers_row(seed:) - when 2023 - default_2023_field_numbers_row(seed:) - when 2024 - default_2024_field_numbers_row(seed:) - else - raise NotImplementedError "No mapping function implemented for year #{year}" - end + default_field_numbers_row_for_year(year, seed:) + rescue NoMethodError + raise NotImplementedError "No mapping function implemented for year #{year}" end def default_field_numbers year = log.collection_start_year - case year - when 2022 - default_2022_field_numbers - when 2023 - default_2023_field_numbers - when 2024 - default_2024_field_numbers - else - raise NotImplementedError "No mapping function implemented for year #{year}" - end + send("default_#{year}_field_numbers") + rescue NoMethodError + raise NotImplementedError "No mapping function implemented for year #{year}" end - def to_2022_csv_row(seed: nil) + def to_year_csv_row(year, seed: nil) + unshuffled_row = send("to_#{year}_row") if seed - row = to_2022_row.shuffle(random: Random.new(seed)) + row = unshuffled_row.shuffle(random: Random.new(seed)) (row_prefix + row).flatten.join(",") + line_ending else - (row_prefix + to_2022_row).flatten.join(",") + line_ending - end - end - - def default_2022_field_numbers - (1..134).to_a - end - - def default_2022_field_numbers_row(seed: nil) - if seed - ["Field number"] + default_2022_field_numbers.shuffle(random: Random.new(seed)) - else - ["Field number"] + default_2022_field_numbers - end.flatten.join(",") + line_ending - end - - def to_2023_csv_row(seed: nil) - if seed - row = to_2023_row.shuffle(random: Random.new(seed)) - (row_prefix + row).flatten.join(",") + line_ending - else - (row_prefix + to_2023_row).flatten.join(",") + line_ending - end - end - - def to_2024_csv_row(seed: nil) - if seed - row = to_2024_row.shuffle(random: Random.new(seed)) - (row_prefix + row).flatten.join(",") + line_ending - else - (row_prefix + to_2024_row).flatten.join(",") + line_ending + (row_prefix + unshuffled_row).flatten.join(",") + line_ending end end @@ -121,20 +67,16 @@ class BulkUpload::LettingsLogToCsv ] end - def default_2023_field_numbers_row(seed: nil) + def default_field_numbers_row_for_year(year, seed: nil) if seed - ["Field number"] + default_2023_field_numbers.shuffle(random: Random.new(seed)) + ["Field number"] + send("default_#{year}_field_numbers").shuffle(random: Random.new(seed)) else - ["Field number"] + default_2023_field_numbers + ["Field number"] + send("default_#{year}_field_numbers") end.flatten.join(",") + line_ending end - def default_2024_field_numbers_row(seed: nil) - if seed - ["Field number"] + default_2024_field_numbers.shuffle(random: Random.new(seed)) - else - ["Field number"] + default_2024_field_numbers - end.flatten.join(",") + line_ending + def default_2022_field_numbers + (1..134).to_a end def default_2023_field_numbers @@ -145,6 +87,156 @@ class BulkUpload::LettingsLogToCsv (1..130).to_a end + def default_2025_field_numbers + (1..129).to_a + end + + def to_2025_row + [ + overrides[:organisation_id] || log.owning_organisation&.old_visible_id, # 1 + overrides[:managing_organisation_id] || log.managing_organisation&.old_visible_id, + log.assigned_to&.email, + log.needstype, + log.scheme&.id ? "S#{log.scheme&.id}" : "", + log.location&.id, + renewal, + log.startdate&.day, + log.startdate&.month, + log.startdate&.strftime("%y"), # 10 + + rent_type, + log.irproduct_other, + log.tenancycode, + log.propcode, + log.declaration, + log.rsnvac, + log.unitletas, + log.uprn, + log.address_line1&.tr(",", " "), + log.address_line2&.tr(",", " "), # 20 + + log.town_or_city&.tr(",", " "), + log.county&.tr(",", " "), + ((log.postcode_full || "").split(" ") || [""]).first, + ((log.postcode_full || "").split(" ") || [""]).last, + log.la, + log.unittype_gn, + log.builtype, + log.wchair, + log.beds, + log.voiddate&.day, # 30 + + log.voiddate&.month, + log.voiddate&.strftime("%y"), + log.mrcdate&.day, + log.mrcdate&.month, + log.mrcdate&.strftime("%y"), + log.sheltered, + log.joint, + log.startertenancy, + log.tenancy, + log.tenancyother, # 40 + + log.tenancylength, + log.age1 || overrides[:age1], + log.sex1, + log.ethnic, + log.nationality_all_group, + log.ecstat1, + relat_number(log.relat2), + log.age2 || overrides[:age2], + log.sex2, + log.ecstat2, # 50 + + relat_number(log.relat3), + log.age3 || overrides[:age3], + log.sex3, + log.ecstat3, + relat_number(log.relat4), + log.age4 || overrides[:age4], + log.sex4, + log.ecstat4, + relat_number(log.relat5), + log.age5 || overrides[:age5], # 60 + + log.sex5, + log.ecstat5, + relat_number(log.relat6), + log.age6 || overrides[:age6], + log.sex6, + log.ecstat6, + relat_number(log.relat7), + log.age7 || overrides[:age7], + log.sex7, + log.ecstat7, # 70 + + relat_number(log.relat8), + log.age8 || overrides[:age8], + log.sex8, + log.ecstat8, + log.armedforces, + log.leftreg, + log.reservist, + log.preg_occ, + log.housingneeds_a, + log.housingneeds_b, # 80 + + log.housingneeds_c, + log.housingneeds_f, + log.housingneeds_g, + log.housingneeds_h, + overrides[:illness] || log.illness, + log.illness_type_1, + log.illness_type_2, + log.illness_type_3, + log.illness_type_4, + log.illness_type_5, # 90 + + log.illness_type_6, + log.illness_type_7, + log.illness_type_8, + log.illness_type_9, + log.illness_type_10, + log.layear, + log.waityear, + log.reason, + log.reasonother, + log.prevten, # 100 + + homeless, + previous_postcode_known, + ((log.ppostcode_full || "").split(" ") || [""]).first, + ((log.ppostcode_full || "").split(" ") || [""]).last, + log.prevloc, + log.reasonpref, + log.rp_homeless, + log.rp_insan_unsat, + log.rp_medwel, + log.rp_hardship, # 110 + + log.rp_dontknow, + cbl, + chr, + cap, + accessible_register, + log.referral, + net_income_known, + log.incfreq, + log.earnings, + log.hb, # 120 + + log.benefits, + log.household_charge, + log.period, + log.brent, + log.scharge, + log.pscharge, + log.supcharg, + log.hbrentshortfall, + log.tshortfall, # 129 + ] + end + def to_2024_row [ overrides[:organisation_id] || log.owning_organisation&.old_visible_id, # 1 @@ -551,4 +643,15 @@ private log.hhregres end end + + def relat_number(value) + case value + when "P" + 1 + when "R" + 3 + when "C", "X" + 2 + end + end end diff --git a/app/services/bulk_upload/lettings/log_creator.rb b/app/services/bulk_upload/lettings/log_creator.rb index 0df59b310..ab5405381 100644 --- a/app/services/bulk_upload/lettings/log_creator.rb +++ b/app/services/bulk_upload/lettings/log_creator.rb @@ -34,6 +34,8 @@ private BulkUpload::Lettings::Year2023::CsvParser.new(path:) when 2024 BulkUpload::Lettings::Year2024::CsvParser.new(path:) + when 2025 + BulkUpload::Lettings::Year2025::CsvParser.new(path:) else raise "csv parser not found" end diff --git a/app/services/bulk_upload/lettings/validator.rb b/app/services/bulk_upload/lettings/validator.rb index 291bf45e7..8cb3a1bd9 100644 --- a/app/services/bulk_upload/lettings/validator.rb +++ b/app/services/bulk_upload/lettings/validator.rb @@ -111,6 +111,8 @@ private BulkUpload::Lettings::Year2023::CsvParser.new(path:) when 2024 BulkUpload::Lettings::Year2024::CsvParser.new(path:) + when 2025 + BulkUpload::Lettings::Year2025::CsvParser.new(path:) else raise "csv parser not found" end diff --git a/app/services/bulk_upload/lettings/year2025/csv_parser.rb b/app/services/bulk_upload/lettings/year2025/csv_parser.rb new file mode 100644 index 000000000..ec6c33b6d --- /dev/null +++ b/app/services/bulk_upload/lettings/year2025/csv_parser.rb @@ -0,0 +1,122 @@ +require "csv" + +class BulkUpload::Lettings::Year2025::CsvParser + include CollectionTimeHelper + + FIELDS = 129 + MAX_COLUMNS = 130 + FORM_YEAR = 2025 + + attr_reader :path + + def initialize(path:) + @path = path + end + + def row_offset + if with_headers? + rows.find_index { |row| row[0].present? && row[0].match(/field number/i) } + 1 + else + 0 + end + end + + def col_offset + with_headers? ? 1 : 0 + end + + def cols + @cols ||= ("A".."DZ").to_a + end + + def row_parsers + @row_parsers ||= body_rows.map { |row| + next if row.empty? + + stripped_row = row[col_offset..] + + hash = Hash[field_numbers.zip(stripped_row)] + + BulkUpload::Lettings::Year2025::RowParser.new(hash) + }.compact + end + + def body_rows + rows[row_offset..] + end + + def rows + @rows ||= CSV.parse(normalised_string, row_sep:) + end + + def column_for_field(field) + cols[field_numbers.find_index(field) + col_offset] + end + + def correct_field_count? + valid_field_numbers_count = field_numbers.count { |f| f != "field_blank" } + + valid_field_numbers_count == FIELDS + end + + def too_many_columns? + return if with_headers? + + max_columns_count = body_rows.map(&:size).max - col_offset + + max_columns_count > MAX_COLUMNS + end + + def wrong_template_for_year? + collection_start_year_for_date(first_record_start_date) != FORM_YEAR + rescue Date::Error + false + end + + def missing_required_headers? + !with_headers? + end + +private + + def default_field_numbers + (1..FIELDS).map { |h| h.present? && h.to_s.match?(/^[0-9]+$/) ? "field_#{h}" : "field_blank" } + end + + def field_numbers + @field_numbers ||= if with_headers? + rows[row_offset - 1][col_offset..].map { |h| h.present? && h.match?(/^[0-9]+$/) ? "field_#{h}" : "field_blank" } + else + default_field_numbers + end + end + + def with_headers? + rows.map { |r| r[0] }.any? { |cell| cell&.match?(/field number/i) } + end + + def row_sep + "\n" + end + + def normalised_string + return @normalised_string if @normalised_string + + @normalised_string = File.read(path, encoding: "bom|utf-8") + @normalised_string.gsub!("\r\n", "\n") + @normalised_string.scrub!("") + @normalised_string.tr!("\r", "\n") + + @normalised_string + end + + def first_record_start_date + if with_headers? + year = row_parsers.first.field_10.to_s.strip.length.between?(1, 2) ? row_parsers.first.field_10.to_i + 2000 : row_parsers.first.field_10.to_i + Date.new(year, row_parsers.first.field_9.to_i, row_parsers.first.field_8.to_i) + else + year = rows.first[9].to_s.strip.length.between?(1, 2) ? rows.first[9].to_i + 2000 : rows.first[9].to_i + Date.new(year, rows.first[8].to_i, rows.first[7].to_i) + end + end +end diff --git a/app/services/bulk_upload/lettings/year2025/row_parser.rb b/app/services/bulk_upload/lettings/year2025/row_parser.rb new file mode 100644 index 000000000..e4e3710fc --- /dev/null +++ b/app/services/bulk_upload/lettings/year2025/row_parser.rb @@ -0,0 +1,1654 @@ +class BulkUpload::Lettings::Year2025::RowParser + include ActiveModel::Model + include ActiveModel::Attributes + include InterruptionScreenHelper + include FormattingHelper + + QUESTIONS = { + field_1: "Which organisation owns this property?", + field_2: "Which organisation manages this letting?", + field_3: "What is the CORE username of the account this letting log should be assigned to?", + field_4: "What is the needs type?", + field_5: "What scheme does this letting belong to?", + field_6: "Which location is this letting for?", + field_7: "Is this letting a renewal?", + field_8: "What is the tenancy start date?", + field_9: "What is the tenancy start date?", + field_10: "What is the tenancy start date?", + field_11: "What is the rent type?", + field_12: "Which 'Other' type of Intermediate Rent is this letting?", + field_13: "What is the tenant code?", + field_14: "What is the property reference?", + field_15: "Has tenant seen the MHCLG privacy notice?", + field_16: "What is the reason for the property being vacant?", + field_17: "What type was the property most recently let as?", + field_18: "If known, provide this property’s UPRN", + field_19: "Address line 1", + field_20: "Address line 2", + field_21: "Town or city", + field_22: "County", + field_23: "Part 1 of the property's postcode", + field_24: "Part 2 of the property's postcode", + field_25: "What is the property's local authority?", + field_26: "What type of unit is the property?", + field_27: "Which type of building is the property?", + field_28: "Is the property built or adapted to wheelchair-user standards?", + field_29: "How many bedrooms does the property have?", + field_30: "What is the void date?", + field_31: "What is the void date?", + field_32: "What is the void date?", + field_33: "What date were any major repairs completed on?", + field_34: "What date were any major repairs completed on?", + field_35: "What date were any major repairs completed on?", + field_36: "Is this letting sheltered accommodation?", + field_37: "Is this a joint tenancy?", + field_38: "Is this a starter tenancy?", + field_39: "What is the type of tenancy?", + field_40: "If 'Other', what is the type of tenancy?", + field_41: "What is the length of the fixed-term tenancy to the nearest year?", + field_42: "What is the lead tenant's age?", + field_43: "Which of these best describes the lead tenant's gender identity?", + field_44: "Which of these best describes the lead tenant's ethnic background?", + field_45: "What is the lead tenant's nationality?", + field_46: "Which of these best describes the lead tenant's working situation?", + field_47: "Is person 2 the partner of the lead tenant?", + field_48: "What is person 2's age?", + field_49: "Which of these best describes person 2's gender identity?", + field_50: "Which of these best describes person 2's working situation?", + field_51: "Is person 3 the partner of the lead tenant?", + field_52: "What is person 3's age?", + field_53: "Which of these best describes person 3's gender identity?", + field_54: "Which of these best describes person 3's working situation?", + field_55: "Is person 4 the partner of the lead tenant?", + field_56: "What is person 4's age?", + field_57: "Which of these best describes person 4's gender identity?", + field_58: "Which of these best describes person 4's working situation?", + field_59: "Is person 5 the partner of the lead tenant?", + field_60: "What is person 5's age?", + field_61: "Which of these best describes person 5's gender identity?", + field_62: "Which of these best describes person 5's working situation?", + field_63: "Is person 6 the partner of the lead tenant?", + field_64: "What is person 6's age?", + field_65: "Which of these best describes person 6's gender identity?", + field_66: "Which of these best describes person 6's working situation?", + field_67: "Is person 7 the partner of the lead tenant?", + field_68: "What is person 7's age?", + field_69: "Which of these best describes person 7's gender identity?", + field_70: "Which of these best describes person 7's working situation?", + field_71: "Is person 8 the partner of the lead tenant?", + field_72: "What is person 8's age?", + field_73: "Which of these best describes person 8's gender identity?", + field_74: "Which of these best describes person 8's working situation?", + field_75: "Does anybody in the household have links to the UK armed forces?", + field_76: "Is this person still serving in the UK armed forces?", + field_77: "Was this person seriously injured or ill as a result of serving in the UK armed forces?", + field_78: "Is anybody in the household pregnant?", + field_79: "Does anybody in the household have any disabled access needs?", + field_80: "Does anybody in the household have any disabled access needs?", + field_81: "Does anybody in the household have any disabled access needs?", + field_82: "Does anybody in the household have any disabled access needs?", + field_83: "Does anybody in the household have any disabled access needs?", + field_84: "Does anybody in the household have any disabled access needs?", + field_85: "Does anybody in the household have a physical or mental health condition (or other illness) expected to last 12 months or more?", + field_86: "Does this person's condition affect their dexterity?", + field_87: "Does this person's condition affect their learning or understanding or concentrating?", + field_88: "Does this person's condition affect their hearing?", + field_89: "Does this person's condition affect their memory?", + field_90: "Does this person's condition affect their mental health?", + field_91: "Does this person's condition affect their mobility?", + field_92: "Does this person's condition affect them socially or behaviourally?", + field_93: "Does this person's condition affect their stamina or breathing or fatigue?", + field_94: "Does this person's condition affect their vision?", + field_95: "Does this person's condition affect them in another way?", + field_96: "How long has the household continuously lived in the local authority area of the new letting?", + field_97: "How long has the household been on the local authority waiting list for the new letting?", + field_98: "What is the tenant’s main reason for the household leaving their last settled home?", + field_99: "If 'Other', what was the main reason for leaving their last settled home?", + field_100: "Where was the household immediately before this letting?", + field_101: "Did the household experience homelessness immediately before this letting?", + field_102: "Do you know the postcode of the household's last settled home?", + field_103: "What is the postcode of the household's last settled home?", + field_104: "What is the postcode of the household's last settled home?", + field_105: "What is the local authority of the household's last settled home?", + field_106: "Was the household given 'reasonable preference' by the local authority?", + field_107: "Reasonable preference reason They were homeless or about to lose their home (within 56 days)", + field_108: "Reasonable preference reason They were living in insanitary, overcrowded or unsatisfactory housing", + field_109: "Reasonable preference reason They needed to move on medical and welfare reasons (including disability)", + field_110: "Reasonable preference reason They needed to move to avoid hardship to themselves or others", + field_111: "Reasonable preference reason Don't know", + field_112: "Was the letting made under the Choice-Based Lettings (CBL)?", + field_113: "Was the letting made under the Common Allocation Policy (CAP)?", + field_114: "Was the letting made under the Common Housing Register (CHR)?", + field_115: "Was the letting made under the Accessible Register?", + field_116: "What was the source of referral for this letting?", + field_117: "Do you know the household's combined total income after tax?", + field_118: "How often does the household receive income?", + field_119: "How much income does the household have in total?", + field_120: "Is the tenant likely to be receiving any of these housing-related benefits?", + field_121: "How much of the household's income is from Universal Credit, state pensions or benefits?", + field_122: "Does the household pay rent or other charges for the accommodation?", + field_123: "How often does the household pay rent and other charges?", + field_124: "What is the basic rent?", + field_125: "What is the service charge?", + field_126: "What is the personal service charge?", + field_127: "What is the support charge?", + field_128: "After the household has received any housing-related benefits, will they still need to pay for rent and charges?", + field_129: "What do you expect the outstanding amount to be?", + }.freeze + + RENT_TYPE_BU_MAPPING = { + 1 => 0, + 2 => 1, + 3 => 2, + 4 => 3, + 5 => 4, + 6 => 5, + 7 => 6, + }.freeze + + ERROR_BASE_KEY = "validations.lettings.2025.bulk_upload".freeze + + attribute :bulk_upload + attribute :block_log_creation, :boolean, default: -> { false } + + attribute :field_blank + + attribute :field_1, :string + attribute :field_2, :string + attribute :field_3, :string + attribute :field_4, :integer + attribute :field_7, :integer + attribute :field_8, :integer + attribute :field_9, :integer + attribute :field_10, :integer + attribute :field_11, :integer + attribute :field_12, :string + attribute :field_13, :string + attribute :field_14, :string + attribute :field_5, :string + attribute :field_6, :string + attribute :field_18, :string + attribute :field_19, :string + attribute :field_20, :string + attribute :field_21, :string + attribute :field_22, :string + attribute :field_23, :string + attribute :field_24, :string + attribute :field_25, :string + attribute :field_17, :integer + attribute :field_16, :integer + attribute :field_26, :integer + attribute :field_27, :integer + attribute :field_28, :integer + attribute :field_29, :integer + attribute :field_30, :integer + attribute :field_31, :integer + attribute :field_32, :integer + attribute :field_33, :integer + attribute :field_34, :integer + attribute :field_35, :integer + attribute :field_37, :integer + attribute :field_38, :integer + attribute :field_39, :integer + attribute :field_40, :string + attribute :field_41, :integer + attribute :field_36, :integer + attribute :field_15, :integer + attribute :field_42, :string + attribute :field_43, :string + attribute :field_44, :integer + attribute :field_45, :integer + attribute :field_46, :integer + attribute :field_47, :integer + attribute :field_48, :string + attribute :field_49, :string + attribute :field_50, :integer + attribute :field_51, :integer + attribute :field_52, :string + attribute :field_53, :string + attribute :field_54, :integer + attribute :field_55, :integer + attribute :field_56, :string + attribute :field_57, :string + attribute :field_58, :integer + attribute :field_59, :integer + attribute :field_60, :string + attribute :field_61, :string + attribute :field_62, :integer + attribute :field_63, :integer + attribute :field_64, :string + attribute :field_65, :string + attribute :field_66, :integer + attribute :field_67, :integer + attribute :field_68, :string + attribute :field_69, :string + attribute :field_70, :integer + attribute :field_71, :integer + attribute :field_72, :string + attribute :field_73, :string + attribute :field_74, :integer + attribute :field_75, :integer + attribute :field_76, :integer + attribute :field_77, :integer + attribute :field_78, :integer + attribute :field_79, :integer + attribute :field_80, :integer + attribute :field_81, :integer + attribute :field_82, :integer + attribute :field_83, :integer + attribute :field_84, :integer + attribute :field_85, :integer + attribute :field_86, :integer + attribute :field_87, :integer + attribute :field_88, :integer + attribute :field_89, :integer + attribute :field_90, :integer + attribute :field_91, :integer + attribute :field_92, :integer + attribute :field_93, :integer + attribute :field_94, :integer + attribute :field_95, :integer + attribute :field_96, :integer + attribute :field_97, :integer + attribute :field_98, :integer + attribute :field_99, :string + attribute :field_100, :integer + attribute :field_101, :integer + attribute :field_102, :integer + attribute :field_103, :string + attribute :field_104, :string + attribute :field_105, :string + attribute :field_106, :integer + attribute :field_107, :integer + attribute :field_108, :integer + attribute :field_109, :integer + attribute :field_110, :integer + attribute :field_111, :integer + attribute :field_112, :integer + attribute :field_113, :integer + attribute :field_114, :integer + attribute :field_115, :integer + attribute :field_116, :integer + attribute :field_117, :integer + attribute :field_118, :integer + attribute :field_119, :decimal + attribute :field_120, :integer + attribute :field_121, :integer + attribute :field_122, :integer + attribute :field_123, :integer + attribute :field_124, :decimal + attribute :field_125, :decimal + attribute :field_126, :decimal + attribute :field_127, :decimal + attribute :field_128, :integer + attribute :field_129, :decimal + + validate :validate_valid_radio_option, on: :before_log + + validates :field_11, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "rent type."), + category: :setup, + }, + inclusion: { + in: (1..7).to_a, + message: I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: "rent type."), + unless: -> { field_11.blank? }, + category: :setup, + }, + on: :after_log + + validates :field_7, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "property renewal."), + category: :setup, + }, + on: :after_log + + validates :field_8, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "tenancy start date (day)."), + category: :setup, + }, + on: :after_log + + validates :field_9, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "tenancy start date (month)."), + category: :setup, + }, + on: :after_log + + validates :field_10, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "tenancy start date (year)."), + category: :setup, + }, + format: { + with: /\A(\d{2}|\d{4})\z/, + message: I18n.t("#{ERROR_BASE_KEY}.startdate.year_not_two_or_four_digits"), + category: :setup, + unless: -> { field_10.blank? }, + }, + on: :after_log + + validates :field_5, + presence: { + if: proc { supported_housing? }, + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "scheme code."), + category: :setup, + }, + on: :after_log + + validates :field_6, + presence: { + if: proc { supported_housing? }, + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "location code."), + category: :setup, + }, + on: :after_log + + validates :field_112, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "was the letting made under the Choice-Based Lettings (CBL)?"), + category: :not_answered, + }, + inclusion: { + in: [1, 2], + message: I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: "was the letting made under the Choice-Based Lettings (CBL)?"), + if: -> { field_112.present? }, + }, + on: :after_log + + validates :field_113, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "was the letting made under the Common Allocation Policy (CAP)?"), + category: :not_answered, + }, + inclusion: { + in: [1, 2], + message: I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: "was the letting made under the Common Allocation Policy (CAP)?"), + if: -> { field_113.present? }, + }, + on: :after_log + + validates :field_114, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "was the letting made under the Common Housing Register (CHR)?"), + category: :not_answered, + }, + inclusion: { + in: [1, 2], + message: I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: "was the letting made under the Common Housing Register (CHR)?"), + if: -> { field_114.present? }, + }, + on: :after_log + + validates :field_115, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "was the letting made under the Accessible Register?"), + category: :not_answered, + }, + inclusion: { + in: [1, 2], + message: I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: "was the letting made under the Accessible Register?"), + if: -> { field_115.present? }, + }, + on: :after_log + + validates :field_42, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 1) }, on: :after_log + validates :field_48, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 2) }, on: :after_log, if: proc { details_known?(2).zero? } + validates :field_52, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 3) }, on: :after_log, if: proc { details_known?(3).zero? } + validates :field_56, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 4) }, on: :after_log, if: proc { details_known?(4).zero? } + validates :field_60, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 5) }, on: :after_log, if: proc { details_known?(5).zero? } + validates :field_64, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 6) }, on: :after_log, if: proc { details_known?(6).zero? } + validates :field_68, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 7) }, on: :after_log, if: proc { details_known?(7).zero? } + validates :field_72, format: { with: /\A\d{1,3}\z|\AR\z/, message: I18n.t("#{ERROR_BASE_KEY}.age.invalid", person_num: 8) }, on: :after_log, if: proc { details_known?(8).zero? } + + validate :validate_needs_type_present, on: :after_log + validate :validate_data_types, on: :after_log + validate :validate_relevant_collection_window, on: :after_log + validate :validate_la_with_local_housing_referral, on: :after_log + validate :validate_cannot_be_la_referral_if_general_needs_and_la, on: :after_log + validate :validate_leaving_reason_for_renewal, on: :after_log + validate :validate_only_one_housing_needs_type, on: :after_log + validate :validate_no_disabled_needs_conjunction, on: :after_log + validate :validate_dont_know_disabled_needs_conjunction, on: :after_log + validate :validate_no_and_dont_know_disabled_needs_conjunction, on: :after_log + validate :validate_no_housing_needs_questions_answered, on: :after_log + validate :validate_reasonable_preference_homeless, on: :after_log + validate :validate_condition_effects, on: :after_log + validate :validate_if_log_already_exists, on: :after_log, if: -> { FeatureToggle.bulk_upload_duplicate_log_check_enabled? } + + validate :validate_owning_org_data_given, on: :after_log + validate :validate_owning_org_exists, on: :after_log + validate :validate_owning_org_owns_stock, on: :after_log + validate :validate_owning_org_permitted, on: :after_log + + validate :validate_managing_org_data_given, on: :after_log + validate :validate_managing_org_exists, on: :after_log + validate :validate_managing_org_related, on: :after_log + + validate :validate_related_scheme_exists, on: :after_log + validate :validate_related_location_exists, on: :after_log + + validate :validate_assigned_to_exists, on: :after_log + validate :validate_assigned_to_related, on: :after_log + validate :validate_assigned_to_when_support, on: :after_log + validate :validate_all_charges_given, on: :after_log + + validate :validate_address_option_found, on: :after_log, unless: -> { supported_housing? } + validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log, unless: -> { supported_housing? } + validate :validate_address_fields, on: :after_log, unless: -> { supported_housing? } + + validate :validate_incomplete_soft_validations, on: :after_log + validate :validate_nationality, on: :after_log + + validate :validate_nulls, on: :after_log + + def self.question_for_field(field) + QUESTIONS[field] + end + + def valid? + return @valid if @valid + + errors.clear + + return @valid = true if blank_row? + + super(:before_log) + @before_errors = errors.dup + + log.valid? + + super(:after_log) + errors.merge!(@before_errors) + + log.errors.each do |error| + fields = field_mapping_for_errors[error.attribute] || [] + + fields.each do |field| + next if errors.include?(field) + next if error.type == :skip_bu_error + + question = log.form.get_question(error.attribute, log) + + if question.present? && setup_question?(question) + errors.add(field, error.message, category: :setup) + else + errors.add(field, error.message) + end + end + end + + @valid = errors.blank? + end + + def blank_row? + attribute_set + .to_hash + .reject { |k, _| %w[bulk_upload block_log_creation field_blank].include?(k) } + .values + .reject(&:blank?) + .compact + .empty? + end + + def log + @log ||= LettingsLog.new(attributes_for_log) + end + + def block_log_creation! + self.block_log_creation = true + end + + def block_log_creation? + block_log_creation + end + + def tenant_code + field_13 + end + + def property_ref + field_14 + end + + def log_already_exists? + return false if blank_row? + + @log_already_exists ||= LettingsLog + .where(status: %w[not_started in_progress completed]) + .exists?(duplicate_check_fields.index_with { |field| log.public_send(field) }) + end + + def spreadsheet_duplicate_hash + hash = attributes.slice( + "field_1", # owning org + "field_8", # startdate + "field_9", # startdate + "field_10", # startdate + "field_13", # tenancycode + !general_needs? ? :field_6.to_s : nil, # location + !supported_housing? ? "field_23" : nil, # postcode + !supported_housing? ? "field_24" : nil, # postcode + "field_42", # age1 + "field_43", # sex1 + "field_46", # ecstat1 + ) + if [field_124, field_125, field_126, field_127].all?(&:present?) + hash.merge({ "tcharge" => [field_124, field_125, field_126, field_127].sum }) + else + hash + end + end + + def add_duplicate_found_in_spreadsheet_errors + spreadsheet_duplicate_hash.each_key do |field| + if field == "tcharge" + %w[field_124 field_125 field_126 field_127].each do |sub_field| + errors.add(sub_field, I18n.t("#{ERROR_BASE_KEY}.spreadsheet_dupe"), category: :setup) + end + else + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.spreadsheet_dupe"), category: :setup) + end + end + end + +private + + def validate_valid_radio_option + log.attributes.each do |question_id, _v| + question = log.form.get_question(question_id, log) + + next unless question&.type == "radio" + next if log[question_id].blank? || question.answer_options.key?(log[question_id].to_s) || !question.page.routed_to?(log, nil) + + fields = field_mapping_for_errors[question_id.to_sym] || [] + + fields.each do |field| + if setup_question?(question) + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: format_ending(QUESTIONS[field])), category: :setup) + else + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: format_ending(QUESTIONS[field]))) + end + end + end + end + + def validate_assigned_to_exists + return if field_3.blank? + + unless assigned_to + errors.add(:field_3, I18n.t("#{ERROR_BASE_KEY}.assigned_to.not_found")) + end + end + + def validate_assigned_to_when_support + if field_3.blank? && bulk_upload.user.support? + errors.add(:field_3, category: :setup, message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "what is the CORE username of the account this letting log should be assigned to?")) + end + end + + def validate_assigned_to_related + return unless assigned_to + return if assigned_to.organisation == owning_organisation || assigned_to.organisation == managing_organisation + return if assigned_to.organisation == owning_organisation&.absorbing_organisation || assigned_to.organisation == managing_organisation&.absorbing_organisation + + block_log_creation! + errors.add(:field_3, I18n.t("#{ERROR_BASE_KEY}.assigned_to.organisation_not_related")) + end + + def assigned_to + @assigned_to ||= User.where("lower(email) = ?", field_3&.downcase).first + end + + def validate_uprn_exists_if_any_key_address_fields_are_blank + if field_18.blank? && !key_address_fields_provided? + %i[field_19 field_21 field_23 field_24].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.address.not_answered")) if send(field).blank? + end + errors.add(:field_18, I18n.t("#{ERROR_BASE_KEY}.address.not_answered", question: "UPRN.")) + end + end + + def validate_address_option_found + if log.uprn.nil? && field_18.blank? && key_address_fields_provided? + error_message = if log.address_options_present? && log.address_options.size > 1 + I18n.t("#{ERROR_BASE_KEY}.address.not_determined.multiple") + elsif log.address_options_present? + I18n.t("#{ERROR_BASE_KEY}.address.not_determined.one") + else + I18n.t("#{ERROR_BASE_KEY}.address.not_found") + end + %i[field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + errors.add(field, error_message) if errors[field].blank? + end + end + end + + def key_address_fields_provided? + field_19.present? && field_21.present? && postcode_full.present? + end + + def validate_address_fields + if field_18.blank? || log.errors.attribute_names.include?(:uprn) + if field_19.blank? && errors[:field_19].blank? + errors.add(:field_19, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "address line 1.")) + end + + if field_21.blank? && errors[:field_21].blank? + errors.add(:field_21, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "town or city.")) + end + + if field_23.blank? && errors[:field_23].blank? + errors.add(:field_23, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 1 of postcode.")) + end + + if field_24.blank? && errors[:field_24].blank? + errors.add(:field_24, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 2 of postcode.")) + end + end + end + + def validate_incomplete_soft_validations + routed_to_soft_validation_questions = log.form.questions.filter { |q| q.type == "interruption_screen" && q.page.routed_to?(log, nil) }.compact + routed_to_soft_validation_questions.each do |question| + next if question.completed?(log) + + question.page.interruption_screen_question_ids.each do |interruption_screen_question_id| + next if log.form.questions.none? { |q| q.id == interruption_screen_question_id && q.page.routed_to?(log, nil) } + + field_mapping_for_errors[interruption_screen_question_id.to_sym]&.each do |field| + if errors.none? { |e| field_mapping_for_errors[interruption_screen_question_id.to_sym].include?(e.attribute) } + error_message = [display_title_text(question.page.title_text, log), display_informative_text(question.page.informative_text, log)].reject(&:empty?).join(" ") + errors.add(field, message: error_message, category: :soft_validation) + end + end + end + end + end + + def validate_nationality + if field_45.present? && !valid_nationality_options.include?(field_45.to_s) + errors.add(:field_45, I18n.t("#{ERROR_BASE_KEY}.nationality.invalid")) + end + end + + def duplicate_check_fields + [ + "startdate", + "age1", + "sex1", + "ecstat1", + "owning_organisation", + "tcharge", + !supported_housing? ? "postcode_full" : nil, + !general_needs? ? "location" : nil, + "tenancycode", + log.chcharge.present? ? "chcharge" : nil, + ].compact + end + + def validate_needs_type_present + if field_4.blank? + errors.add(:field_4, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "needs type."), category: :setup) + end + end + + def validate_no_and_dont_know_disabled_needs_conjunction + if field_83 == 1 && field_84 == 1 + errors.add(:field_83, I18n.t("#{ERROR_BASE_KEY}.housingneeds.no_and_dont_know_disabled_needs_conjunction")) + errors.add(:field_84, I18n.t("#{ERROR_BASE_KEY}.housingneeds.no_and_dont_know_disabled_needs_conjunction")) + end + end + + def validate_dont_know_disabled_needs_conjunction + if field_84 == 1 && [field_79, field_80, field_81, field_82].count(1).positive? + %i[field_84 field_79 field_80 field_81 field_82].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.housingneeds.dont_know_disabled_needs_conjunction")) if send(field) == 1 + end + end + end + + def validate_no_disabled_needs_conjunction + if field_83 == 1 && [field_79, field_80, field_81, field_82].count(1).positive? + %i[field_83 field_79 field_80 field_81 field_82].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.housingneeds.no_disabled_needs_conjunction")) if send(field) == 1 + end + end + end + + def validate_only_one_housing_needs_type + if [field_79, field_80, field_81].count(1) > 1 + %i[field_79 field_80 field_81].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.housingneeds_type.only_one_option_permitted")) if send(field) == 1 + end + end + end + + def validate_no_housing_needs_questions_answered + if [field_79, field_80, field_81, field_82, field_83, field_84].all?(&:blank?) + errors.add(:field_83, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "anybody with disabled access needs.")) + errors.add(:field_82, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "other access needs.")) + %i[field_79 field_80 field_81].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "disabled access needs type.")) + end + end + end + + def validate_reasonable_preference_homeless + reason_fields = %i[field_107 field_108 field_109 field_110 field_111] + if field_106 == 1 && reason_fields.all? { |field| attributes[field.to_s].blank? } + reason_fields.each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "reason for reasonable preference.")) + end + end + end + + def validate_condition_effects + illness_option_fields = %i[field_94 field_88 field_91 field_86 field_87 field_89 field_90 field_93 field_92 field_95] + if household_no_illness? + illness_option_fields.each do |field| + if attributes[field.to_s] == 1 + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.condition_effects.no_choices")) + end + end + elsif illness_option_fields.all? { |field| attributes[field.to_s].blank? } + illness_option_fields.each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "how is person affected by condition or illness.")) + end + end + end + + def household_no_illness? + field_85 != 1 + end + + def validate_leaving_reason_for_renewal + if field_7 == 1 && ![50, 51, 52, 53].include?(field_98) + errors.add(:field_98, I18n.t("#{ERROR_BASE_KEY}.reason.renewal_reason_needed")) + end + end + + def general_needs? + field_4 == 1 + end + + def supported_housing? + field_4 == 2 + end + + def validate_cannot_be_la_referral_if_general_needs_and_la + if field_116 == 4 && general_needs? && owning_organisation && owning_organisation.la? + errors.add :field_116, I18n.t("#{ERROR_BASE_KEY}.referral.general_needs_prp_referred_by_la") + end + end + + def validate_la_with_local_housing_referral + if field_116 == 3 && owning_organisation && owning_organisation.la? + errors.add(:field_116, I18n.t("#{ERROR_BASE_KEY}.referral.nominated_by_local_ha_but_la")) + end + end + + def validate_relevant_collection_window + return if startdate.blank? || bulk_upload.form.blank? + + unless bulk_upload.form.valid_start_date_for_form?(startdate) + errors.add(:field_8, I18n.t("#{ERROR_BASE_KEY}.startdate.outside_collection_window", year_combo: bulk_upload.year_combo, start_year: bulk_upload.year, end_year: bulk_upload.end_year), category: :setup) + errors.add(:field_9, I18n.t("#{ERROR_BASE_KEY}.startdate.outside_collection_window", year_combo: bulk_upload.year_combo, start_year: bulk_upload.year, end_year: bulk_upload.end_year), category: :setup) + errors.add(:field_10, I18n.t("#{ERROR_BASE_KEY}.startdate.outside_collection_window", year_combo: bulk_upload.year_combo, start_year: bulk_upload.year, end_year: bulk_upload.end_year), category: :setup) + end + end + + def validate_data_types + unless attribute_set["field_11"].value_before_type_cast&.match?(/^\d+\.?0*$/) + errors.add(:field_11, I18n.t("#{ERROR_BASE_KEY}.invalid_number", question: "rent type.")) + end + end + + def validate_nulls + field_mapping_for_errors.each do |error_key, fields| + question_id = error_key.to_s + question = questions.find { |q| q.id == question_id } + + next unless question + next if log.optional_fields.include?(question.id) + next if question.completed?(log) + + if setup_question?(question) + fields.each do |field| + if field.present? && errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) } + errors.add(field, question.unanswered_error_message, category: :setup) + end + end + else + fields.each do |field| + if errors.none? { |e| fields.include?(e.attribute) } && @before_errors.none? { |e| fields.include?(e.attribute) } + errors.add(field, question.unanswered_error_message) + end + end + end + end + end + + def validate_related_location_exists + if scheme && field_6.present? && location.nil? && :field_6.present? + block_log_creation! + errors.add(:field_6, I18n.t("#{ERROR_BASE_KEY}.location.must_relate_to_org"), category: :setup) + end + end + + def validate_related_scheme_exists + if field_5.present? && :field_5.present? && owning_organisation.present? && managing_organisation.present? && scheme.nil? + block_log_creation! + errors.add(:field_5, I18n.t("#{ERROR_BASE_KEY}.scheme.must_relate_to_org"), category: :setup) + end + end + + def validate_managing_org_related + if owning_organisation && managing_organisation && !owning_organisation.can_be_managed_by?(organisation: managing_organisation) + block_log_creation! + + if errors[:field_2].blank? + errors.add(:field_2, I18n.t("#{ERROR_BASE_KEY}.managing_organisation.no_relationship"), category: :setup) + end + end + end + + def validate_managing_org_exists + if managing_organisation.nil? + block_log_creation! + + if field_2.present? && errors[:field_2].blank? + errors.add(:field_2, I18n.t("#{ERROR_BASE_KEY}.managing_organisation.not_found"), category: :setup) + end + end + end + + def validate_managing_org_data_given + if field_2.blank? + block_log_creation! + errors.add(:field_2, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "managing organisation."), category: :setup) + end + end + + def validate_owning_org_owns_stock + if owning_organisation && !owning_organisation.holds_own_stock? + block_log_creation! + + if errors[:field_1].blank? + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.owning_organisation.not_stock_owner"), category: :setup) + end + end + end + + def validate_owning_org_exists + if owning_organisation.nil? + block_log_creation! + + if field_1.present? && errors[:field_1].blank? + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.owning_organisation.not_found"), category: :setup) + end + end + end + + def validate_owning_org_data_given + if field_1.blank? + block_log_creation! + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "owning organisation."), category: :setup) + end + end + + def validate_owning_org_permitted + return unless owning_organisation + return if bulk_upload_organisation.affiliated_stock_owners.include?(owning_organisation) + + block_log_creation! + + return if errors[:field_1].present? + + if bulk_upload.user.support? + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.owning_organisation.not_permitted.support", org_name: bulk_upload_organisation.name), category: :setup) + else + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.owning_organisation.not_permitted.not_support"), category: :setup) + end + end + + def validate_all_charges_given + return if supported_housing? && field_124 == 1 + + blank_charge_fields, other_charge_fields = { + field_124: "basic rent", + field_125: "service charge", + field_126: "personal service charge", + field_127: "support charge", + }.partition { |field, _| public_send(field).blank? }.map(&:to_h) + + blank_charge_fields.each do |field, charge| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.charges.missing_charges", sentence_fragment: charge)) + end + + other_charge_fields.each do |field, _charge| + blank_charge_fields.each do |_blank_field, blank_charge| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.charges.missing_charges", sentence_fragment: blank_charge)) + end + end + end + + def all_charges_given? + field_124.present? && field_125.present? && field_126.present? && field_127.present? + end + + def setup_question?(question) + log.form.setup_sections[0].subsections[0].questions.include?(question) + end + + def validate_if_log_already_exists + if log_already_exists? + error_message = I18n.t("#{ERROR_BASE_KEY}.duplicate") + + errors.add(:field_1, error_message) # owning_organisation + errors.add(:field_8, error_message) # startdate + errors.add(:field_9, error_message) # startdate + errors.add(:field_10, error_message) # startdate + errors.add(:field_13, error_message) # tenancycode + errors.add(:field_6, error_message) if !general_needs? && :field_6.present? # location + errors.add(:field_5, error_message) if !general_needs? && :field_6.blank? # add to Scheme field as unclear whether log uses New or Old CORE ids + errors.add(:field_23, error_message) unless supported_housing? # postcode_full + errors.add(:field_24, error_message) unless supported_housing? # postcode_full + errors.add(:field_25, error_message) unless supported_housing? # la + errors.add(:field_42, error_message) # age1 + errors.add(:field_43, error_message) # sex1 + errors.add(:field_46, error_message) # ecstat1 + errors.add(:field_122, error_message) unless general_needs? # household_charge + errors.add(:field_124, error_message) # brent + errors.add(:field_125, error_message) # scharge + errors.add(:field_126, error_message) # pscharge + errors.add(:field_127, error_message) # chcharge + end + end + + def field_mapping_for_errors + { + lettype: [:field_11], + tenancycode: [:field_13], + postcode_known: %i[field_25 field_23 field_24], + postcode_full: %i[field_25 field_23 field_24], + la: %i[field_25], + owning_organisation: [:field_1], + managing_organisation: [:field_2], + owning_organisation_id: [:field_1], + managing_organisation_id: [:field_2], + renewal: [:field_7], + scheme_id: (:field_5.present? ? [:field_5] : nil), + scheme: (:field_5.present? ? [:field_5] : nil), + location_id: (:field_6.present? ? [:field_6] : nil), + location: (:field_6.present? ? [:field_6] : nil), + assigned_to: [:field_3], + needstype: [:field_4], + rent_type: %i[field_11], + startdate: %i[field_8 field_9 field_10], + unittype_gn: %i[field_26], + builtype: %i[field_27], + wchair: %i[field_28], + beds: %i[field_29], + joint: %i[field_37], + startertenancy: %i[field_38], + tenancy: %i[field_39], + tenancyother: %i[field_40], + tenancylength: %i[field_41], + declaration: %i[field_15], + + age1_known: %i[field_42], + age1: %i[field_42], + age2_known: %i[field_48], + age2: %i[field_48], + age3_known: %i[field_52], + age3: %i[field_52], + age4_known: %i[field_56], + age4: %i[field_56], + age5_known: %i[field_60], + age5: %i[field_60], + age6_known: %i[field_64], + age6: %i[field_64], + age7_known: %i[field_68], + age7: %i[field_68], + age8_known: %i[field_72], + age8: %i[field_72], + + sex1: %i[field_43], + sex2: %i[field_49], + sex3: %i[field_53], + sex4: %i[field_57], + sex5: %i[field_61], + sex6: %i[field_65], + sex7: %i[field_69], + sex8: %i[field_73], + + ethnic_group: %i[field_44], + ethnic: %i[field_44], + nationality_all: %i[field_45], + nationality_all_group: %i[field_45], + + relat2: %i[field_47], + relat3: %i[field_51], + relat4: %i[field_55], + relat5: %i[field_59], + relat6: %i[field_63], + relat7: %i[field_67], + relat8: %i[field_71], + + ecstat1: %i[field_46], + ecstat2: %i[field_50], + ecstat3: %i[field_54], + ecstat4: %i[field_58], + ecstat5: %i[field_62], + ecstat6: %i[field_66], + ecstat7: %i[field_70], + ecstat8: %i[field_74], + + armedforces: %i[field_75], + leftreg: %i[field_76], + reservist: %i[field_77], + preg_occ: %i[field_78], + housingneeds: %i[field_78], + + illness: %i[field_85], + + layear: %i[field_96], + waityear: %i[field_97], + reason: %i[field_98], + reasonother: %i[field_99], + prevten: %i[field_100], + homeless: %i[field_101], + + prevloc: %i[field_105], + previous_la_known: %i[field_105], + ppcodenk: %i[field_102], + ppostcode_full: %i[field_103 field_104], + + reasonpref: %i[field_106], + rp_homeless: %i[field_107], + rp_insan_unsat: %i[field_108], + rp_medwel: %i[field_109], + rp_hardship: %i[field_110], + rp_dontknow: %i[field_111], + + cbl: %i[field_112], + cap: %i[field_113], + chr: %i[field_114], + accessible_register: %i[field_115], + letting_allocation: %i[field_112 field_113 field_114 field_115], + + referral: %i[field_116], + + net_income_known: %i[field_117], + incfreq: %i[field_118], + earnings: %i[field_119], + hb: %i[field_120], + benefits: %i[field_121], + + period: %i[field_123], + brent: %i[field_124], + scharge: %i[field_125], + pscharge: %i[field_126], + supcharg: %i[field_127], + tcharge: %i[field_124 field_125 field_126 field_127], + household_charge: %i[field_122], + hbrentshortfall: %i[field_128], + tshortfall: %i[field_129], + + unitletas: %i[field_17], + rsnvac: %i[field_16], + sheltered: %i[field_36], + + illness_type_1: %i[field_94], + illness_type_2: %i[field_88], + illness_type_3: %i[field_91], + illness_type_4: %i[field_86], + illness_type_5: %i[field_87], + illness_type_6: %i[field_89], + illness_type_7: %i[field_90], + illness_type_8: %i[field_93], + illness_type_9: %i[field_92], + illness_type_10: %i[field_95], + + irproduct_other: %i[field_12], + + propcode: %i[field_14], + + majorrepairs: %i[field_33 field_34 field_35], + mrcdate: %i[field_33 field_34 field_35], + + voiddate: %i[field_30 field_31 field_32], + + uprn: [:field_18], + address_line1: [:field_19], + address_line2: [:field_20], + town_or_city: [:field_21], + county: [:field_22], + uprn_selection: [:field_19], + }.compact + end + + def attribute_set + @attribute_set ||= instance_variable_get(:@attributes) + end + + def questions + @questions ||= log.form.subsections.flat_map { |ss| ss.applicable_questions(log) } + end + + def attributes_for_log + attributes = {} + + attributes["lettype"] = nil # should get this from rent_type + attributes["tenancycode"] = field_13 + attributes["owning_organisation"] = owning_organisation + attributes["managing_organisation"] = managing_organisation + attributes["renewal"] = renewal + attributes["scheme"] = scheme + attributes["location"] = location + attributes["assigned_to"] = assigned_to || (bulk_upload.user.support? ? nil : bulk_upload.user) + attributes["created_by"] = bulk_upload.user + attributes["needstype"] = field_4 + attributes["rent_type"] = RENT_TYPE_BU_MAPPING[field_11] + attributes["startdate"] = startdate + attributes["unittype_gn"] = field_26 + attributes["builtype"] = field_27 + attributes["wchair"] = field_28 + attributes["beds"] = field_26 == 2 ? 1 : field_29 + attributes["joint"] = field_37 + attributes["startertenancy"] = field_38 + attributes["tenancy"] = field_39 + attributes["tenancyother"] = field_40 + attributes["tenancylength"] = field_41 + attributes["declaration"] = field_15 + + attributes["age1_known"] = age1_known? + attributes["age1"] = field_42 if attributes["age1_known"]&.zero? && field_42&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age2_known"] = age2_known? + attributes["age2"] = field_48 if attributes["age2_known"]&.zero? && field_48&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age3_known"] = age3_known? + attributes["age3"] = field_52 if attributes["age3_known"]&.zero? && field_52&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age4_known"] = age4_known? + attributes["age4"] = field_56 if attributes["age4_known"]&.zero? && field_56&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age5_known"] = age5_known? + attributes["age5"] = field_60 if attributes["age5_known"]&.zero? && field_60&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age6_known"] = age6_known? + attributes["age6"] = field_64 if attributes["age6_known"]&.zero? && field_64&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age7_known"] = age7_known? + attributes["age7"] = field_68 if attributes["age7_known"]&.zero? && field_68&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age8_known"] = age8_known? + attributes["age8"] = field_72 if attributes["age8_known"]&.zero? && field_72&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["sex1"] = field_43 + attributes["sex2"] = field_49 + attributes["sex3"] = field_53 + attributes["sex4"] = field_57 + attributes["sex5"] = field_61 + attributes["sex6"] = field_65 + attributes["sex7"] = field_69 + attributes["sex8"] = field_73 + + attributes["ethnic_group"] = ethnic_group_from_ethnic + attributes["ethnic"] = field_44 + attributes["nationality_all"] = field_45 if field_45.present? && valid_nationality_options.include?(field_45.to_s) + attributes["nationality_all_group"] = nationality_group(attributes["nationality_all"]) + + attributes["relat2"] = relationship_from_input_value(field_47) + attributes["relat3"] = relationship_from_input_value(field_51) + attributes["relat4"] = relationship_from_input_value(field_55) + attributes["relat5"] = relationship_from_input_value(field_59) + attributes["relat6"] = relationship_from_input_value(field_63) + attributes["relat7"] = relationship_from_input_value(field_67) + attributes["relat8"] = relationship_from_input_value(field_71) + + attributes["ecstat1"] = field_46 + attributes["ecstat2"] = field_50 + attributes["ecstat3"] = field_54 + attributes["ecstat4"] = field_58 + attributes["ecstat5"] = field_62 + attributes["ecstat6"] = field_66 + attributes["ecstat7"] = field_70 + attributes["ecstat8"] = field_74 + + attributes["details_known_2"] = details_known?(2) + attributes["details_known_3"] = details_known?(3) + attributes["details_known_4"] = details_known?(4) + attributes["details_known_5"] = details_known?(5) + attributes["details_known_6"] = details_known?(6) + attributes["details_known_7"] = details_known?(7) + attributes["details_known_8"] = details_known?(8) + + attributes["armedforces"] = field_75 + attributes["leftreg"] = leftreg + attributes["reservist"] = field_77 + + attributes["preg_occ"] = field_78 + + attributes["housingneeds"] = housingneeds + attributes["housingneeds_type"] = housingneeds_type + attributes["housingneeds_other"] = housingneeds_other + + attributes["illness"] = field_85 + + attributes["layear"] = field_96 + attributes["waityear"] = field_97 + attributes["reason"] = field_98 + attributes["reasonother"] = field_99 if reason_is_other? + attributes["prevten"] = field_100 + attributes["homeless"] = field_101 + + attributes["prevloc"] = prevloc + attributes["previous_la_known"] = previous_la_known + attributes["ppcodenk"] = ppcodenk + attributes["ppostcode_full"] = ppostcode_full + + attributes["reasonpref"] = field_106 + attributes["rp_homeless"] = field_107 + attributes["rp_insan_unsat"] = field_108 + attributes["rp_medwel"] = field_109 + attributes["rp_hardship"] = field_110 + attributes["rp_dontknow"] = field_111 + + attributes["cbl"] = cbl + attributes["chr"] = chr + attributes["cap"] = cap + attributes["accessible_register"] = accessible_register + attributes["letting_allocation_unknown"] = letting_allocation_unknown + + attributes["referral"] = field_116 + + attributes["net_income_known"] = net_income_known + attributes["earnings"] = earnings + attributes["incfreq"] = field_118 + attributes["hb"] = field_120 + attributes["benefits"] = field_121 + + attributes["period"] = field_123 + attributes["brent"] = field_124 if all_charges_given? + attributes["scharge"] = field_125 if all_charges_given? + attributes["pscharge"] = field_126 if all_charges_given? + attributes["supcharg"] = field_127 if all_charges_given? + attributes["household_charge"] = supported_housing? ? field_122 : nil + attributes["hbrentshortfall"] = field_128 + attributes["tshortfall_known"] = tshortfall_known + attributes["tshortfall"] = field_129 + + attributes["hhmemb"] = hhmemb + + attributes["unitletas"] = field_17 + attributes["rsnvac"] = rsnvac + attributes["sheltered"] = field_36 + + attributes["illness_type_1"] = field_94 + attributes["illness_type_2"] = field_88 + attributes["illness_type_3"] = field_91 + attributes["illness_type_4"] = field_86 + attributes["illness_type_5"] = field_87 + attributes["illness_type_6"] = field_89 + attributes["illness_type_7"] = field_90 + attributes["illness_type_8"] = field_93 + attributes["illness_type_9"] = field_92 + attributes["illness_type_10"] = field_95 + + attributes["irproduct_other"] = field_12 + + attributes["propcode"] = field_14 + + attributes["majorrepairs"] = majorrepairs + + attributes["mrcdate"] = mrcdate + + attributes["voiddate"] = voiddate + + attributes["first_time_property_let_as_social_housing"] = first_time_property_let_as_social_housing + + if general_needs? + attributes["uprn_known"] = field_18.present? ? 1 : 0 + attributes["uprn_confirmed"] = 1 if field_18.present? + attributes["skip_update_uprn_confirmed"] = true + attributes["uprn"] = field_18 + attributes["address_line1"] = field_19 + attributes["address_line1_as_entered"] = field_19 + attributes["address_line2"] = field_20 + attributes["address_line2_as_entered"] = field_20 + attributes["town_or_city"] = field_21 + attributes["town_or_city_as_entered"] = field_21 + attributes["county"] = field_22 + attributes["county_as_entered"] = field_22 + attributes["postcode_full"] = postcode_full + attributes["postcode_full_as_entered"] = postcode_full + attributes["postcode_known"] = postcode_known + attributes["la"] = field_25 + attributes["la_as_entered"] = field_25 + + attributes["address_line1_input"] = address_line1_input + attributes["postcode_full_input"] = postcode_full + attributes["select_best_address_match"] = true if field_18.blank? + end + + attributes + end + + def address_line1_input + [field_19, field_20, field_21].compact.join(", ") + end + + def postcode_known + if postcode_full.present? + 1 + elsif field_25.present? + 0 + end + end + + def postcode_full + [field_23, field_24].compact_blank.join(" ") if field_23 || field_24 + end + + def owning_organisation + Organisation.find_by_id_on_multiple_fields(field_1) + end + + def managing_organisation + Organisation.find_by_id_on_multiple_fields(field_2) + end + + def renewal + case field_7 + when 1 + 1 + when 2 + 0 + else + field_7 + end + end + + def rsnvac + field_16 + end + + def scheme + return if field_5.nil? || owning_organisation.nil? || managing_organisation.nil? + + @scheme ||= Scheme.where(id: (owning_organisation.owned_schemes + managing_organisation.owned_schemes).map(&:id)).find_by_id_on_multiple_fields(field_5.strip, field_6) + end + + def location + return if scheme.nil? + + @location ||= scheme.locations.find_by_id_on_multiple_fields(field_6) + end + + def startdate + year = field_10.to_s.strip.length.between?(1, 2) ? field_10 + 2000 : field_10 + Date.new(year, field_9, field_8) if field_10.present? && field_9.present? && field_8.present? + rescue Date::Error + Date.new + end + + def ethnic_group_from_ethnic + return nil if field_44.blank? + + case field_44 + when 1, 2, 3, 18, 20 + 0 + when 4, 5, 6, 7 + 1 + when 8, 9, 10, 11, 15 + 2 + when 12, 13, 14 + 3 + when 16, 19 + 4 + when 17 + 17 + end + end + + def age1_known? + return 1 if field_42 == "R" + + 0 + end + + [ + { person: 2, field: :field_48 }, + { person: 3, field: :field_52 }, + { person: 4, field: :field_56 }, + { person: 5, field: :field_60 }, + { person: 6, field: :field_64 }, + { person: 7, field: :field_68 }, + { person: 8, field: :field_72 }, + ].each do |hash| + define_method("age#{hash[:person]}_known?") do + return 1 if public_send(hash[:field]) == "R" + return 0 if send("person_#{hash[:person]}_present?") + end + end + + def details_known?(person_n) + send("person_#{person_n}_present?") ? 0 : 1 + end + + def person_2_present? + field_47.present? || field_48.present? || field_49.present? + end + + def person_3_present? + field_51.present? || field_52.present? || field_53.present? + end + + def person_4_present? + field_55.present? || field_56.present? || field_57.present? + end + + def person_5_present? + field_59.present? || field_60.present? || field_61.present? + end + + def person_6_present? + field_63.present? || field_64.present? || field_65.present? + end + + def person_7_present? + field_67.present? || field_68.present? || field_69.present? + end + + def person_8_present? + field_71.present? || field_72.present? || field_73.present? + end + + def leftreg + field_76 + end + + def housingneeds + if field_83 == 1 + 2 + elsif field_84 == 1 + 3 + elsif field_83.blank? || field_83&.zero? + 1 + end + end + + def housingneeds_type + if field_79 == 1 + 0 + elsif field_80 == 1 + 1 + elsif field_81 == 1 + 2 + else + 3 + end + end + + def housingneeds_other + return 1 if field_82 == 1 + return 0 if [field_79, field_80, field_81].include?(1) + end + + def prevloc + field_105 + end + + def previous_la_known + prevloc.present? ? 1 : 0 + end + + def ppcodenk + case field_102 + when 1 + 0 + when 2 + 1 + end + end + + def ppostcode_full + "#{field_103} #{field_104}".strip.gsub(/\s+/, " ") + end + + def cbl + case field_112 + when 2 + 0 + when 1 + 1 + end + end + + def cap + case field_113 + when 2 + 0 + when 1 + 1 + end + end + + def chr + case field_114 + when 2 + 0 + when 1 + 1 + end + end + + def accessible_register + case field_115 + when 2 + 0 + when 1 + 1 + end + end + + def letting_allocation_unknown + [cbl, chr, cap, accessible_register].all?(0) ? 1 : 0 + end + + def net_income_known + case field_117 + when 1 + 0 + when 2 + 1 + when 3 + 2 + end + end + + def earnings + field_119.round if field_119.present? + end + + def tshortfall_known + field_128 == 1 ? 0 : 1 + end + + def hhmemb + [ + person_2_present?, + person_3_present?, + person_4_present?, + person_5_present?, + person_6_present?, + person_7_present?, + person_8_present?, + ].count(true) + 1 + end + + def majorrepairs + mrcdate.present? ? 1 : 0 + end + + def mrcdate + year = field_35.to_s.strip.length.between?(1, 2) ? field_35 + 2000 : field_35 + Date.new(year, field_34, field_33) if field_35.present? && field_34.present? && field_33.present? + rescue Date::Error + Date.new + end + + def voiddate + year = field_32.to_s.strip.length.between?(1, 2) ? field_32 + 2000 : field_32 + Date.new(year, field_31, field_30) if field_32.present? && field_31.present? && field_30.present? + rescue Date::Error + Date.new + end + + def first_time_property_let_as_social_housing + case rsnvac + when 15, 16, 17 + 1 + else + 0 + end + end + + def valid_nationality_options + %w[0] + GlobalConstants::COUNTRIES_ANSWER_OPTIONS.keys # 0 is "Prefers not to say" + end + + def nationality_group(nationality_value) + return unless nationality_value + return 0 if nationality_value.zero? + return 826 if nationality_value == 826 + + 12 + end + + def reason_is_other? + field_98 == 20 + end + + def bulk_upload_organisation + Organisation.find(bulk_upload.organisation_id) + end + + def relationship_from_input_value(value) + case value + when 1 + "P" # yes + when 2 + "X" # no + when 3 + "R" # refused + end + end +end diff --git a/config/locales/validations/lettings/2025/bulk_upload.en.yml b/config/locales/validations/lettings/2025/bulk_upload.en.yml new file mode 100644 index 000000000..7acb0726c --- /dev/null +++ b/config/locales/validations/lettings/2025/bulk_upload.en.yml @@ -0,0 +1,60 @@ +en: + validations: + lettings: + 2025: + bulk_upload: + not_answered: "You must answer %{question}" + invalid_option: "Enter a valid value for %{question}" + invalid_number: "Enter a number for %{question}" + spreadsheet_dupe: "This is a duplicate of a log in your file." + duplicate: "This is a duplicate log." + blank_file: "Template is blank - The template must be filled in for us to create the logs and check if data is correct." + wrong_template: + wrong_template: "Incorrect start dates, please ensure you have used the correct template." + no_headers: "Your file does not contain the required header rows. Add or check the header rows and upload your file again. [Read more about using the template headers](%{guidance_link})." + wrong_field_numbers_count: "Incorrect number of fields, please ensure you have used the correct template." + over_max_column_count: "Too many columns, please ensure you have used the correct template." + owning_organisation: + not_found: "The owning organisation code is incorrect." + not_stock_owner: "The owning organisation code provided is for an organisation that does not own stock." + not_permitted: + not_support: "You do not have permission to add logs for this owning organisation." + support: "This owning organisation is not affiliated with %{org_name}." + managing_organisation: + no_relationship: "This managing organisation does not have a relationship with the owning organisation." + not_found: "The managing organisation code is incorrect." + assigned_to: + not_found: "User with the specified email could not be found." + organisation_not_related: "User must be related to owning organisation or managing organisation." + startdate: + outside_collection_window: "Enter a date within the %{year_combo} collection year, which is between 1st April %{start_year} and 31st March %{end_year}." + year_not_two_or_four_digits: "Tenancy start year must be 2 or 4 digits." + housingneeds: + no_and_dont_know_disabled_needs_conjunction: "No disabled access needs and don’t know disabled access needs cannot be selected together." + dont_know_disabled_needs_conjunction: "Don’t know disabled access needs can’t be selected if you have selected fully wheelchair-accessible housing, wheelchair access to essential rooms, level access housing or other disabled access needs." + no_disabled_needs_conjunction: "No disabled access needs can’t be selected if you have selected fully wheelchair-accessible housing, wheelchair access to essential rooms, level access housing or other disabled access needs." + housingneeds_type: + only_one_option_permitted: "Only one disabled access need: fully wheelchair-accessible housing, wheelchair access to essential rooms or level access housing, can be selected." + condition_effects: + no_choices: "You cannot answer this question as you told us nobody in the household has a physical or mental health condition (or other illness) expected to last 12 months or more." + reason: + renewal_reason_needed: "The reason for leaving must be \"End of social or private sector tenancy - no fault\", \"End of social or private sector tenancy - evicted due to anti-social behaviour (ASB)\", \"End of social or private sector tenancy - evicted due to rent arrears\" or \"End of social or private sector tenancy - evicted for any other reason\"." + referral: + general_needs_prp_referred_by_la: "The source of the referral cannot be referred by local authority housing department for a general needs log." + nominated_by_local_ha_but_la: "The source of the referral cannot be Nominated by local housing authority as your organisation is a local authority." + scheme: + must_relate_to_org: "This scheme code does not belong to the owning organisation or managing organisation." + location: + must_relate_to_org: "Location code must relate to a location that is owned by the owning organisation or managing organisation." + age: + invalid: "Age of person %{person_num} must be a number or the letter R" + address: + not_found: "We could not find this address. Check the address data in your CSV file is correct and complete, or find the correct address in the service." + not_determined: + one: "There is a possible match for this address which doesn't look right. Check the address data in your CSV file is correct and complete, or confirm the address in the service." + multiple: "There are multiple matches for this address. Check the address data in your CSV file is correct and complete, or select the correct address in the service." + not_answered: "Enter either the UPRN or the full address." + nationality: + invalid: "Select a valid nationality." + charges: + missing_charges: "Please enter the %{sentence_fragment}. If there is no %{sentence_fragment}, please enter '0'." diff --git a/config/routes.rb b/config/routes.rb index ab31b6b1b..471c89578 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -400,8 +400,10 @@ Rails.application.routes.draw do get "create-test-lettings-log", to: "test_data#create_test_lettings_log" get "create-setup-test-lettings-log", to: "test_data#create_setup_test_lettings_log" get "create-2024-test-lettings-bulk-upload", to: "test_data#create_2024_test_lettings_bulk_upload" + get "create-2025-test-lettings-bulk-upload", to: "test_data#create_2025_test_lettings_bulk_upload" get "create-test-sales-log", to: "test_data#create_test_sales_log" get "create-setup-test-sales-log", to: "test_data#create_setup_test_sales_log" get "create-2024-test-sales-bulk-upload", to: "test_data#create_2024_test_sales_bulk_upload" + get "create-2025-test-sales-bulk-upload", to: "test_data#create_2025_test_sales_bulk_upload" end end diff --git a/spec/services/bulk_upload/lettings/validator_spec.rb b/spec/services/bulk_upload/lettings/validator_spec.rb index 60eb8a955..cfe654980 100644 --- a/spec/services/bulk_upload/lettings/validator_spec.rb +++ b/spec/services/bulk_upload/lettings/validator_spec.rb @@ -103,7 +103,7 @@ RSpec.describe BulkUpload::Lettings::Validator do before do values = log_to_csv.to_2024_row values[7] = nil - file.write(log_to_csv.default_2024_field_numbers_row) + file.write(log_to_csv.default_field_numbers_row_for_year(2024)) file.write(log_to_csv.to_custom_csv_row(seed: nil, field_values: values)) file.rewind end @@ -146,7 +146,7 @@ RSpec.describe BulkUpload::Lettings::Validator do before do log.needstype = nil values = log_to_csv.to_2024_row - file.write(log_to_csv.default_2024_field_numbers_row(seed:)) + file.write(log_to_csv.default_field_numbers_row_for_year(2024, seed:)) file.write(log_to_csv.to_custom_csv_row(seed:, field_values: values)) file.close end diff --git a/spec/services/bulk_upload/lettings/year2023/csv_parser_spec.rb b/spec/services/bulk_upload/lettings/year2023/csv_parser_spec.rb index 43e2f262a..f8cca52ef 100644 --- a/spec/services/bulk_upload/lettings/year2023/csv_parser_spec.rb +++ b/spec/services/bulk_upload/lettings/year2023/csv_parser_spec.rb @@ -15,8 +15,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023)) file.rewind end @@ -39,8 +39,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023)) file.rewind end @@ -64,8 +64,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row(seed:)) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row(seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023, seed:)) file.rewind end @@ -108,7 +108,7 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do context "when parsing csv without headers" do before do - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2023)) file.rewind end @@ -127,7 +127,7 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do before do file.write(bom) - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2023)) file.rewind end @@ -141,7 +141,7 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do before do file.write(invalid_sequence) - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2023)) file.rewind end @@ -158,8 +158,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\r") file.write("Type of letting the question applies to\r\n") file.write("Duplicate check field?\r") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023)) file.rewind end @@ -177,8 +177,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023)) file.rewind end @@ -190,7 +190,7 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do context "when without headers using default ordering" do before do - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2023_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2023)) file.rewind end @@ -210,8 +210,8 @@ RSpec.describe BulkUpload::Lettings::Year2023::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2023_field_numbers_row(seed:)) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2023_csv_row(seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2023, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2023, seed:)) file.rewind end diff --git a/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb b/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb index b0fcaf8b6..b736dc5e5 100644 --- a/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb +++ b/spec/services/bulk_upload/lettings/year2024/csv_parser_spec.rb @@ -15,8 +15,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024)) file.rewind end @@ -38,8 +38,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024)) file.rewind end @@ -62,8 +62,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024)) file.write("\n") file.rewind end @@ -92,8 +92,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row(seed:)) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row(seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024, seed:)) file.rewind end @@ -136,7 +136,7 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do context "when parsing csv without headers" do before do - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2024)) file.rewind end @@ -155,7 +155,7 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do before do file.write(bom) - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2024)) file.rewind end @@ -169,7 +169,7 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do before do file.write(invalid_sequence) - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2024)) file.rewind end @@ -186,8 +186,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\r") file.write("Type of letting the question applies to\r\n") file.write("Duplicate check field?\r") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024)) file.rewind end @@ -205,8 +205,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024)) file.rewind end @@ -218,7 +218,7 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do context "when without headers using default ordering" do before do - file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2024)) file.rewind end @@ -238,8 +238,8 @@ RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do file.write("Can be empty?\n") file.write("Type of letting the question applies to\n") file.write("Duplicate check field?\n") - file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row(seed:)) - file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row(seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2024, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2024, seed:)) file.rewind end diff --git a/spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb b/spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb new file mode 100644 index 000000000..dcb1cd354 --- /dev/null +++ b/spec/services/bulk_upload/lettings/year2025/csv_parser_spec.rb @@ -0,0 +1,254 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Lettings::Year2025::CsvParser do + subject(:service) { described_class.new(path:) } + + let(:file) { Tempfile.new } + let(:path) { file.path } + let(:log) { build(:lettings_log, :completed) } + + context "when parsing csv with headers" do + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025)) + file.rewind + end + + it "returns correct offsets" do + expect(service.row_offset).to eq(7) + expect(service.col_offset).to eq(1) + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when some csv headers are empty (and we don't care about them)" do + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025)) + file.rewind + end + + it "returns correct offsets" do + expect(service.row_offset).to eq(7) + expect(service.col_offset).to eq(1) + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when parsing csv with headers with extra rows" do + before do + file.write("Section\n") + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025)) + file.write("\n") + file.rewind + end + + it "returns correct offsets" do + expect(service.row_offset).to eq(8) + expect(service.col_offset).to eq(1) + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + + it "does not parse the last empty row" do + expect(service.row_parsers.count).to eq(1) + end + end + + context "when parsing csv with headers in arbitrary order" do + let(:seed) { rand } + + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025, seed:)) + file.rewind + end + + it "returns correct offsets" do + expect(service.row_offset).to eq(7) + expect(service.col_offset).to eq(1) + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when parsing csv with extra invalid headers" do + let(:seed) { rand } + let(:log_to_csv) { BulkUpload::LettingsLogToCsv.new(log:) } + let(:field_numbers) { log_to_csv.default_2025_field_numbers + %w[invalid_field_number] } + let(:field_values) { log_to_csv.to_2025_row + %w[value_for_invalid_field_number] } + + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(log_to_csv.custom_field_numbers_row(seed:, field_numbers:)) + file.write(log_to_csv.to_custom_csv_row(seed:, field_values:)) + file.rewind + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + + it "counts the number of valid field numbers correctly" do + expect(service).to be_correct_field_count + end + end + + context "when parsing csv without headers" do + before do + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2025)) + file.rewind + end + + it "returns correct offsets" do + expect(service.row_offset).to eq(0) + expect(service.col_offset).to eq(0) + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when parsing with BOM aka byte order mark" do + let(:bom) { "\uFEFF" } + + before do + file.write(bom) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2025)) + file.rewind + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when an invalid byte sequence" do + let(:invalid_sequence) { "\x81" } + + before do + file.write(invalid_sequence) + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2025)) + file.rewind + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + context "when parsing csv with carriage returns" do + before do + file.write("Question\r\n") + file.write("Additional info\r") + file.write("Values\r\n") + file.write("Can be empty?\r") + file.write("Type of letting the question applies to\r\n") + file.write("Duplicate check field?\r") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025)) + file.rewind + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_13).to eql(log.tenancycode) + end + end + + describe "#column_for_field", aggregate_failures: true do + context "when with headers using default ordering" do + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025)) + file.rewind + end + + it "returns correct column" do + expect(service.column_for_field("field_5")).to eql("F") + expect(service.column_for_field("field_22")).to eql("W") + end + end + + context "when without headers using default ordering" do + before do + file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_year_csv_row(2025)) + file.rewind + end + + it "returns correct column" do + expect(service.column_for_field("field_5")).to eql("E") + expect(service.column_for_field("field_22")).to eql("V") + end + end + + context "when with headers using custom ordering" do + let(:seed) { 123 } + + before do + file.write("Question\n") + file.write("Additional info\n") + file.write("Values\n") + file.write("Can be empty?\n") + file.write("Type of letting the question applies to\n") + file.write("Duplicate check field?\n") + file.write(BulkUpload::LettingsLogToCsv.new(log:).default_field_numbers_row_for_year(2025, seed:)) + file.write(BulkUpload::LettingsLogToCsv.new(log:).to_year_csv_row(2025, seed:)) + file.rewind + end + + it "returns correct column" do + expect(service.column_for_field("field_5")).to eql("B") + expect(service.column_for_field("field_22")).to eql("AS") + expect(service.column_for_field("field_26")).to eql("DG") + expect(service.column_for_field("field_25")).to eql("I") + end + end + end +end diff --git a/spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb b/spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb new file mode 100644 index 000000000..9882c4d8f --- /dev/null +++ b/spec/services/bulk_upload/lettings/year2025/row_parser_spec.rb @@ -0,0 +1,2808 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Lettings::Year2025::RowParser do + subject(:parser) { described_class.new(attributes) } + + let(:now) { Time.zone.local(2025, 4, 5) } + + let(:attributes) { { bulk_upload: } } + let(:bulk_upload) { create(:bulk_upload, :lettings, user:, needstype: nil, year: 2025) } + let(:user) { create(:user, organisation: owning_org) } + + let(:owning_org) { create(:organisation, :with_old_visible_id) } + let(:managing_org) { create(:organisation, :with_old_visible_id, rent_periods: [4, 1]) } + let(:scheme) { create(:scheme, :with_old_visible_id, owning_organisation: owning_org) } + let(:location) { create(:location, :with_old_visible_id, scheme:) } + + let(:setup_section_params) do + { + bulk_upload:, + field_1: owning_org.old_visible_id, + field_2: managing_org.old_visible_id, + field_4: "1", + field_7: "2", + field_8: now.day.to_s, + field_9: now.month.to_s, + field_10: now.strftime("%g"), + field_11: "1", + field_15: "1", + } + end + + before do + allow(FormHandler.instance).to receive(:lettings_in_crossover_period?).and_return(true) + create(:organisation_relationship, parent_organisation: owning_org, child_organisation: managing_org) + + LaRentRange.create!( + ranges_rent_id: "1", + la: "E09000008", + beds: 1, + lettype: 7, + soft_min: 12.41, + soft_max: 118.85, + hard_min: 9.87, + hard_max: 200.99, + start_year: 2025, + ) + end + + around do |example| + Timecop.freeze(Date.new(2025, 10, 1)) do + FormHandler.instance.use_real_forms! + example.run + end + Timecop.return + end + + describe "#blank_row?" do + context "when a new object" do + it "returns true" do + expect(parser).to be_blank_row + end + end + + context "when the only populated fields are whitespace" do + before do + parser.field_18 = " " + end + + it "returns true" do + expect(parser).to be_blank_row + end + end + + context "when any field is populated with something other than whitespace" do + before do + parser.field_1 = "1" + end + + it "returns false" do + expect(parser).not_to be_blank_row + end + end + end + + describe "validations" do + before do + stub_request(:get, /api\.postcodes\.io/) + .to_return(status: 200, body: "{\"status\":200,\"result\":{\"admin_district\":\"Manchester\", \"codes\":{\"admin_district\": \"E08000003\"}}}", headers: {}) + + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/find/) + .to_return(status: 200, body: { results: [{ DPA: { MATCH: 0.9, BUILDING_NAME: "result address line 1", POST_TOWN: "result town or city", POSTCODE: "AA1 1AA", UPRN: "12345" } }] }.to_json, headers: {}) + + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/uprn/) + .to_return(status: 200, body: '{"status":200,"results":[{"DPA":{ + "PO_BOX_NUMBER": "fake", + "ORGANISATION_NAME": "org", + "DEPARTMENT_NAME": "name", + "SUB_BUILDING_NAME": "building", + "BUILDING_NAME": "name", + "BUILDING_NUMBER": "number", + "DEPENDENT_THOROUGHFARE_NAME": "data", + "THOROUGHFARE_NAME": "thing", + "POST_TOWN": "London", + "POSTCODE": "SE2 6RT" + + }}]}', headers: {}) + end + + describe "#valid?" do + context "when the row is blank" do + let(:attributes) { { bulk_upload: } } + + it "returns true" do + expect(parser).to be_valid + end + end + + context "when calling the method multiple times" do + let(:attributes) { { bulk_upload:, field_129: 2 } } + + it "does not add keep adding errors to the pile" do + parser.valid? + expect { parser.valid? }.not_to change(parser.errors, :count) + end + end + + describe "valid/invalid attributes" do + let(:valid_attributes) do + { + bulk_upload:, + field_13: "123", + field_8: now.day.to_s, + field_9: now.month.to_s, + field_10: now.strftime("%g"), + field_23: "EC1N", + field_24: "2TD", + field_1: owning_org.old_visible_id, + field_2: managing_org.old_visible_id, + field_11: "1", + field_7: "2", + field_26: "2", + field_27: "1", + field_28: "1", + field_29: "1", + field_37: "2", + field_38: "1", + field_39: "2", + field_15: "1", + + field_42: "42", + field_48: "41", + field_52: "17", + field_56: "18", + field_60: "16", + field_64: "14", + field_68: "12", + field_72: "20", + + field_43: "F", + field_49: "M", + field_53: "F", + field_57: "M", + field_61: "F", + field_65: "M", + field_69: "F", + field_73: "M", + + field_44: "17", + field_45: "826", + + field_47: "1", + field_51: "2", + field_55: "2", + field_59: "3", + field_63: "2", + field_67: "2", + field_71: "2", + + field_46: "1", + field_50: "2", + field_54: "7", + field_58: "7", + field_62: "8", + field_66: "9", + field_70: "0", + field_74: "10", + + field_75: "1", + field_76: "4", + field_77: "1", + + field_78: "1", + + field_79: "1", + field_80: "0", + field_81: "0", + field_82: "1", + field_83: "0", + + field_85: "2", + + field_96: "11", + field_97: "2", + field_98: "31", + field_100: "3", + field_101: "11", + + field_102: "1", + field_103: "EC1N", + field_104: "2TD", + + field_106: "1", + field_107: "1", + field_108: "", + field_109: "1", + field_110: "", + field_111: "", + + field_112: "1", + field_113: "2", + field_114: "2", + field_115: "2", + + field_116: "2", + + field_117: "1", + field_118: "2", + field_119: "2300", + field_120: "1", + field_121: "4", + + field_123: "4", + field_124: "1234.56", + field_125: "43.32", + field_126: "13.14", + field_127: "101.11", + field_128: "1", + field_129: "34.56", + + field_16: "15", + field_30: now.day.to_s, + field_31: now.month.to_s, + field_32: now.strftime("%g"), + + field_4: "1", + + field_18: "12", + } + end + + context "when valid row" do + before do + allow(FeatureToggle).to receive(:bulk_upload_duplicate_log_check_enabled?).and_return(true) + end + + let(:attributes) { valid_attributes } + + it "returns true" do + expect(parser).to be_valid + end + + xit "instantiates a log with everything completed", aggregate_failures: true do + parser.valid? + + questions = parser.send(:questions).reject do |q| + parser.send(:log).optional_fields.include?(q.id) || q.completed?(parser.send(:log)) + end + + expect(questions.map(&:id).size).to eq(0) + expect(questions.map(&:id)).to eql([]) + end + + context "when a general needs log already exists in the db" do + before do + parser.log.save! + parser.instance_variable_set(:@valid, nil) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + + it "adds an error to all (and only) the fields used to determine duplicates" do + parser.valid? + + error_message = I18n.t("validations.lettings.2025.bulk_upload.duplicate") + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_13, # tenancycode + :field_23, # postcode_full + :field_24, # postcode_full + :field_25, # postcode_full + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + :field_124, # brent + :field_125, # scharge + :field_126, # pscharge + :field_127, # supcharg + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + + expect(parser.errors[:field_6]).not_to include(error_message) + end + end + + context "when a general needs log already exists in the db but has a different tcharge" do + let(:attributes) { valid_attributes.merge({ field_13: "tenant_code" }) } + + before do + parser.log.save! + saved_log = LettingsLog.find_by(tenancycode: "tenant_code") + saved_log.update!(brent: saved_log.brent + 5) + parser.instance_variable_set(:@valid, nil) + end + + it "is a valid row (and not a duplicate)" do + expect(parser).to be_valid + end + end + + context "when a supported housing log already exists in the db" do + let(:attributes) { valid_attributes.merge({ field_4: "2", field_5: "S#{scheme.id}", field_6: location.old_visible_id, field_36: 3, field_122: 0 }) } + + before do + parser.log.save! + parser.instance_variable_set(:@valid, nil) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + + it "adds an error to all the fields used to determine duplicates" do + parser.valid? + + error_message = I18n.t("validations.lettings.2025.bulk_upload.duplicate") + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_13, # tenancycode + :field_6, # location + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + :field_124, # brent + :field_125, # scharge + :field_126, # pscharge + :field_127, # supcharg + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + + expect(parser.errors[:field_23]).not_to include(error_message) + expect(parser.errors[:field_24]).not_to include(error_message) + expect(parser.errors[:field_25]).not_to include(error_message) + end + end + + context "with old core scheme and location ids" do + context "when a supported housing log already exists in the db" do + let(:attributes) { { bulk_upload:, field_4: "2", field_5: "123" } } + + before do + parser.log.save! + parser.instance_variable_set(:@valid, nil) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + + it "adds an error to all the fields used to determine duplicates" do + parser.valid? + + error_message = I18n.t("validations.lettings.2025.bulk_upload.duplicate") + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_13, # tenancycode + :field_6, # location + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + :field_124, # brent + :field_125, # scharge + :field_126, # pscharge + :field_127, # supcharg + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + + expect(parser.errors[:field_23]).not_to include(error_message) + expect(parser.errors[:field_24]).not_to include(error_message) + expect(parser.errors[:field_25]).not_to include(error_message) + end + end + end + + context "with new core scheme and location ids" do + context "when a supported housing log already exists in the db" do + let(:attributes) { { bulk_upload:, field_4: "2", field_5: "S123" } } + + before do + parser.log.save! + parser.instance_variable_set(:@valid, nil) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + + it "adds an error to all the fields used to determine duplicates" do + parser.valid? + + error_message = I18n.t("validations.lettings.2025.bulk_upload.duplicate") + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_13, # tenancycode + :field_6, # location + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + + expect(parser.errors[:field_23]).not_to include(error_message) + expect(parser.errors[:field_24]).not_to include(error_message) + expect(parser.errors[:field_25]).not_to include(error_message) + end + end + + context "when a supported housing log already exists in the db (2)" do + let(:bulk_upload) { create(:bulk_upload, :lettings, user:, needstype: 2) } + let(:attributes) do + valid_attributes.merge({ field_5: "S#{scheme.id}", + field_4: "2", + field_11: "2", + field_6: location.id, + field_1: owning_org.old_visible_id, + field_122: 0, + field_36: 4 }) + end + + before do + parser.log.save! + parser.instance_variable_set(:@valid, nil) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + + it "adds an error to all the fields used to determine duplicates" do + parser.valid? + + error_message = I18n.t("validations.lettings.2025.bulk_upload.duplicate") + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_13, # tenancycode + :field_6, # location + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + :field_122, # household_charge + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + + expect(parser.errors[:field_23]).not_to include(error_message) + expect(parser.errors[:field_24]).not_to include(error_message) + expect(parser.errors[:field_25]).not_to include(error_message) + end + end + end + + context "when the rent range validation is triggered but the log has no scheme or location id" do + let(:attributes) do + setup_section_params.merge({ field_5: nil, + field_6: nil, + field_124: 300, + field_123: 1, + field_29: 1, + field_4: 1, + field_11: "2", + field_25: "E09000008" }) + end + + it "is not a valid row" do + expect(parser).not_to be_valid + end + end + + context "when a hidden log already exists in db" do + before do + parser.log.status = "pending" + parser.log.save! + end + + it "is a valid row" do + expect(parser).to be_valid + end + + it "does not add duplicate errors" do + parser.valid? + + [ + :field_1, # owning_organisation + :field_8, # startdate + :field_9, # startdate + :field_10, # startdate + :field_6, # location + :field_23, # postcode_full + :field_24, # postcode_full + :field_25, # LA + :field_42, # age1 + :field_43, # sex1 + :field_46, # ecstat1 + ].each do |field| + expect(parser.errors[field]).to be_blank + end + end + end + end + + context "when valid row with valid decimal (integer) field_11" do + before do + allow(FeatureToggle).to receive(:bulk_upload_duplicate_log_check_enabled?).and_return(true) + end + + let(:attributes) { valid_attributes.merge(field_11: "1.00") } + + it "returns true" do + expect(parser).to be_valid + end + end + + context "when valid row with invalid decimal (non-integer) field_11" do + before do + allow(FeatureToggle).to receive(:bulk_upload_duplicate_log_check_enabled?).and_return(true) + end + + let(:attributes) { valid_attributes.merge(field_11: "1.56") } + + it "returns false" do + expect(parser).not_to be_valid + end + end + + context "with a valid ethnic value" do + context "when field_44 is 20" do + let(:attributes) { valid_attributes.merge({ field_44: "20" }) } + + it "is correctly sets ethnic and ethnic group" do + expect(parser.log.ethnic).to eq(20) + expect(parser.log.ethnic_group).to eq(0) + end + end + end + + context "when the privacy notice is not accepted" do + let(:attributes) { valid_attributes.merge({ field_15: nil }) } + + it "cannot be nulled" do + parser.valid? + expect(parser.errors[:field_15]).to eq(["You must show or give the tenant access to the MHCLG privacy notice before you can submit this log."]) + end + end + + context "when there is a :skip_bu_error error" do + let(:managing_org) { create(:organisation, :with_old_visible_id, rent_periods: [4, 1]) } + let(:attributes) { valid_attributes.merge({ field_123: 3, field_127: 80 }) } + + it "does not add that error" do + parser.valid? + + expect(parser.log.errors.map(&:attribute).sort).to eql(%i[managing_organisation_id period]) + expect(parser.errors.map(&:attribute)).to eql(%i[field_123]) + end + end + end + + describe "#validate_nulls" do + context "when non-setup questions are null" do + let(:attributes) { setup_section_params.merge({ field_43: "" }) } + + it "fetches the question's check_answer_label if it exists" do + parser.valid? + expect(parser.errors[:field_43]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "lead tenant’s gender identity.")]) + end + end + + context "when other null error is added" do + let(:attributes) { setup_section_params.merge({ field_112: nil }) } + + it "only has one error added to the field" do + parser.valid? + expect(parser.errors[:field_112]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "was the letting made under the Choice-Based Lettings (CBL)?")]) + end + end + + context "when an invalid value error has been added" do + let(:attributes) { setup_section_params.merge({ field_116: "100" }) } + + it "does not add an additional error" do + parser.valid? + expect(parser.errors[:field_116].length).to eq(1) + expect(parser.errors[:field_116]).to include(match I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "")) + end + end + end + end + + context "when setup section not complete" do + let(:attributes) { { bulk_upload:, field_13: "123" } } + + it "has errors on setup fields" do + parser.valid? + + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_10 field_11 field_15 field_2 field_4 field_7 field_8 field_9]) + end + end + + describe "#field_3" do # assigned_to + context "when blank" do + let(:attributes) { { bulk_upload:, field_3: "", field_4: 1 } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + + it "sets assigned to to bulk upload user" do + expect(parser.log.assigned_to).to eq(bulk_upload.user) + end + + it "sets created by to bulk upload user" do + expect(parser.log.created_by).to eq(bulk_upload.user) + end + end + + context "when blank and bulk upload user is support" do + let(:bulk_upload) { create(:bulk_upload, :sales, user: create(:user, :support), year: 2025) } + + let(:attributes) { setup_section_params.merge(bulk_upload:, field_3: nil) } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_present + expect(parser.errors[:field_3]).to include(I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "what is the CORE username of the account this letting log should be assigned to?")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when user could not be found" do + let(:attributes) { { bulk_upload:, field_3: "idonotexist@example.com" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_present + end + end + + context "when an unaffiliated user" do + let(:other_user) { create(:user) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_3: other_user.email, field_2: managing_org.old_visible_id } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_present + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when a user part of owning org" do + let(:other_user) { create(:user, organisation: owning_org) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_3: other_user.email, field_2: managing_org.old_visible_id } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + + it "sets assigned to to the user" do + expect(parser.log.assigned_to).to eq(other_user) + end + + it "sets created by to bulk upload user" do + expect(parser.log.created_by).to eq(bulk_upload.user) + end + end + + context "when email matches other than casing" do + let(:other_user) { create(:user, organisation: owning_org) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_3: other_user.email.upcase!, field_2: managing_org.old_visible_id } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + end + + context "when an user part of managing org" do + let(:other_user) { create(:user, organisation: managing_org) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_3: other_user.email, field_2: managing_org.old_visible_id } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + end + end + + describe "#field_5, field_6" do # scheme and location fields + context "when nullable not permitted" do + let(:attributes) { { bulk_upload:, field_4: "2", field_11: "2", field_5: nil, field_6: nil } } + + it "cannot be nulled" do + parser.valid? + expect(parser.errors[:field_5]).to eq([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "scheme code.")]) + expect(parser.errors[:field_6]).to eq([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "location code.")]) + end + end + + context "when nullable permitted" do + let(:attributes) { { bulk_upload:, field_4: "1", field_11: "1", field_5: nil, field_6: nil } } + + it "can be nulled" do + parser.valid? + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when using New CORE ids" do + let(:scheme) { create(:scheme, :with_old_visible_id, owning_organisation: owning_org) } + let!(:location) { create(:location, :with_old_visible_id, scheme:) } + + before do + parser.valid? + end + + context "when matching scheme cannot be found" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S123", field_6: location.id } } + + it "returns a setup error" do + expect(parser.errors.where(:field_5, category: :setup).map(&:message)).to eq([I18n.t("validations.lettings.2025.bulk_upload.scheme.must_relate_to_org")]) + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when missing location" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: nil } } + + it "returns a setup error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors.where(:field_6, category: :setup).map(&:message)).to eq([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "location code.")]) + expect(parser.errors[:field_6].count).to eq(1) + end + end + + context "when matching location cannot be found" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: "123" } } + + it "returns a setup error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors.where(:field_6, category: :setup).map(&:message)).to eq([I18n.t("validations.lettings.2025.bulk_upload.location.must_relate_to_org")]) + end + end + + context "when matching location exists" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: location.id } } + + it "does not return an error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when scheme ID has leading spaces" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "1", field_5: " S#{scheme.id}", field_6: location.id } } + + it "does not return an error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when location exists but not related" do + let(:other_scheme) { create(:scheme, :with_old_visible_id) } + let(:other_location) { create(:location, :with_old_visible_id, scheme: other_scheme) } + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: other_location.id } } + + it "returns a setup error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors.where(:field_6, category: :setup).map(&:message)).to eq([I18n.t("validations.lettings.2025.bulk_upload.location.must_relate_to_org")]) + end + end + + context "when scheme belongs to someone else" do + let(:other_scheme) { create(:scheme, :with_old_visible_id) } + let(:other_location) { create(:location, :with_old_visible_id, scheme: other_scheme) } + let(:attributes) { { bulk_upload:, field_4: "2", field_11: "2", field_5: "S#{other_scheme.id}", field_6: other_location.id, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id } } + + it "returns a setup error" do + expect(parser.errors.where(:field_5, category: :setup).map(&:message)).to eq([I18n.t("validations.lettings.2025.bulk_upload.scheme.must_relate_to_org")]) + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when scheme belongs to owning org" do + let(:attributes) { { bulk_upload:, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: location.id, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id } } + + it "does not return an error" do + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when scheme belongs to managing org" do + let(:managing_org_scheme) { create(:scheme, :with_old_visible_id, owning_organisation: managing_org) } + let(:managing_org_location) { create(:location, :with_old_visible_id, scheme: managing_org_scheme) } + let(:attributes) { { bulk_upload:, field_4: "2", field_11: "2", field_5: "S#{managing_org_scheme.id}", field_6: managing_org_location.id, field_2: managing_org.old_visible_id } } + + it "clears the scheme answer" do + expect(parser.errors[:field_5]).to include("You must answer scheme name.") + expect(parser.errors[:field_6]).to be_blank + end + end + + context "when matching location exists but is incomplete" do + let(:incomplete_location) { create(:location, :with_old_visible_id, :incomplete, scheme:) } + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "2", field_11: "2", field_5: "S#{scheme.id}", field_6: incomplete_location.id } } + + it "returns a setup error for scheme" do + expect(parser.errors.where(:field_5).map(&:message)).to eq(["This location is incomplete. Select another location or update this one."]) + expect(parser.errors.where(:field_6).map(&:message)).to eq(["This location is incomplete. Select another location or update this one."]) + end + end + end + end + + describe "#field_98" do # leaving reason + context "when field_7 is 1 meaning it is a renewal" do + context "when field_98 is 50" do + let(:attributes) { { bulk_upload:, field_98: "50", field_7: "1" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_98]).to be_blank + end + end + + context "when field_98 is 51" do + let(:attributes) { { bulk_upload:, field_98: "51", field_7: "1" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_98]).to be_blank + end + end + + context "when field_98 is 52" do + let(:attributes) { { bulk_upload:, field_98: "52", field_7: "1" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_98]).to be_blank + end + end + + context "when field_98 is 53" do + let(:attributes) { { bulk_upload:, field_98: "53", field_7: "1" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_98]).to be_blank + end + end + + context "when field_98 is not 50, 51, 52 or 53" do + let(:attributes) { { bulk_upload:, field_98: "1", field_7: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_98]).to include(I18n.t("validations.lettings.2025.bulk_upload.reason.renewal_reason_needed")) + end + end + end + + context "when no longer a valid option from previous year" do + let(:attributes) { setup_section_params.merge({ field_98: "7" }) } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_98]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "what is the tenant’s main reason for the household leaving their last settled home?")) + end + end + end + + describe "#field_79, #field_80, #field_81" do + context "when one item selected" do + let(:attributes) { { bulk_upload:, field_79: "1" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_79]).to be_blank + expect(parser.errors[:field_80]).to be_blank + expect(parser.errors[:field_81]).to be_blank + end + end + + context "when more than one item selected" do + let(:attributes) { { bulk_upload:, field_79: "1", field_80: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_79]).to be_present + expect(parser.errors[:field_80]).to be_present + end + end + end + + describe "#field_83" do + context "when 1 and another disability field selected" do + let(:attributes) { { bulk_upload:, field_83: "1", field_82: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_83]).to be_present + end + end + end + + describe "#field_84" do + context "when 1 and another disability field selected" do + let(:attributes) { { bulk_upload:, field_84: "1", field_82: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_84]).to be_present + end + end + end + + describe "#field_83, #field_84" do + context "when both 1" do + let(:attributes) { { bulk_upload:, field_83: "1", field_84: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_83]).to be_present + expect(parser.errors[:field_84]).to be_present + end + end + end + + describe "#field_79 - #field_84" do + context "when all blank" do + let(:attributes) { setup_section_params.merge({ field_79: nil, field_80: nil, field_81: nil, field_82: nil, field_83: nil, field_84: nil }) } + + it "adds errors to correct fields" do + parser.valid? + expect(parser.errors[:field_79]).to be_present + expect(parser.errors[:field_80]).to be_present + expect(parser.errors[:field_81]).to be_present + expect(parser.errors[:field_82]).to be_present + expect(parser.errors[:field_83]).to be_present + end + end + + context "when one item selected and field_82 is blank" do + let(:attributes) { setup_section_params.merge({ field_79: "1", field_82: nil }) } + + it "sets other disabled access needs as no" do + parser.valid? + expect(parser.errors[:field_79]).to be_blank + expect(parser.errors[:field_82]).to be_blank + expect(parser.log.housingneeds_other).to eq(0) + end + end + end + + describe "#field_85, field_94 - 99" do + context "when no illness but illnesses answered" do + let(:attributes) { { bulk_upload:, field_85: "2", field_86: "1", field_87: "1", field_88: "1" } } + + it "errors added to correct fields" do + parser.valid? + expect(parser.errors[:field_86]).to be_present + expect(parser.errors[:field_87]).to be_present + expect(parser.errors[:field_88]).to be_present + expect(parser.errors[:field_89]).not_to be_present + expect(parser.errors[:field_90]).not_to be_present + expect(parser.errors[:field_91]).not_to be_present + expect(parser.errors[:field_92]).not_to be_present + expect(parser.errors[:field_93]).not_to be_present + expect(parser.errors[:field_94]).not_to be_present + expect(parser.errors[:field_95]).not_to be_present + end + end + + context "when illness but no illnesses answered" do + let(:attributes) { { bulk_upload:, field_85: "1", field_86: nil, field_87: nil, field_88: nil, field_89: nil, field_90: nil, field_91: nil, field_92: nil, field_93: nil, field_94: nil, field_95: nil } } + + it "errors added to correct fields" do + parser.valid? + expect(parser.errors[:field_86]).to be_present + expect(parser.errors[:field_87]).to be_present + expect(parser.errors[:field_88]).to be_present + expect(parser.errors[:field_89]).to be_present + expect(parser.errors[:field_90]).to be_present + expect(parser.errors[:field_91]).to be_present + expect(parser.errors[:field_92]).to be_present + expect(parser.errors[:field_93]).to be_present + expect(parser.errors[:field_94]).to be_present + expect(parser.errors[:field_95]).to be_present + end + end + end + + describe "#field_112 - 115 (lettings allocation methods)" do + %i[field_112 field_113 field_114 field_115].each do |field| + context "when only #{field} is not given" do + let(:attributes) do + override = {} + override[field] = "" + { bulk_upload:, field_112: "2", field_113: "1", field_114: "2", field_115: "1" }.merge(override) + end + + it "adds an error to #{field}" do + parser.valid? + expect(parser.errors[field]).to be_present + end + end + end + end + + describe "#field_101, field_106 - 15" do + context "when there is a reasonable preference but none is given" do + let(:attributes) { { bulk_upload:, field_106: "1", field_107: nil, field_108: nil, field_109: nil, field_110: nil, field_111: nil } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_107]).to be_present + expect(parser.errors[:field_108]).to be_present + expect(parser.errors[:field_109]).to be_present + expect(parser.errors[:field_110]).to be_present + expect(parser.errors[:field_111]).to be_present + end + end + end + + describe "#field_116" do # referral + context "when 3 ie PRP nominated by LA and owning org is LA" do + let(:attributes) { { bulk_upload:, field_116: "3", field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_116]).to be_present + end + end + + context "when 4 ie referred by LA and is general needs and owning org is LA" do + let(:attributes) { { bulk_upload:, field_116: "4", field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_4: "1" } } + + it "is not permitted" do + parser.valid? + expect(parser.errors[:field_116]).to be_present + end + end + + context "when 4 ie referred by LA and is general needs and owning org is PRP" do + let(:owning_org) { create(:organisation, :prp, :with_old_visible_id) } + + let(:attributes) { { bulk_upload:, field_116: "4", field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_116]).to be_blank + end + end + + context "when 4 ie referred by LA and is not general needs" do + let(:bulk_upload) { create(:bulk_upload, :lettings, user:) } + let(:attributes) { { bulk_upload:, field_116: "4", field_4: "2" } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_116]).to be_blank + end + end + end + + describe "fields 7, 8, 9 => startdate" do + context "when any one of these fields is blank" do + let(:attributes) { { bulk_upload:, field_11: "1", field_8: nil, field_9: nil, field_10: nil } } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_8]).to be_present + expect(parser.errors[:field_9]).to be_present + expect(parser.errors[:field_10]).to be_present + end + end + + context "when field_10 is 4 digits instead of 2" do + let(:attributes) { setup_section_params.merge({ bulk_upload:, field_10: "2025", field_9: "4", field_8: "5" }) } + + it "correctly sets the date" do + parser.valid? + expect(parser.errors[:field_10]).to be_empty + expect(parser.log.startdate).to eq(Time.zone.local(2025, 4, 5)) + end + end + + context "when field_10 is not 4 or 2 digits" do + let(:attributes) { { bulk_upload:, field_10: "204" } } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_10]).to include(I18n.t("validations.lettings.2025.bulk_upload.startdate.year_not_two_or_four_digits")) + end + end + + context "when invalid date given" do + let(:attributes) { { bulk_upload:, field_11: "1", field_8: "a", field_9: "12", field_10: "23" } } + + it "does not raise an error" do + expect { parser.valid? }.not_to raise_error + end + end + + context "when inside of collection year" do + let(:attributes) { { bulk_upload:, field_8: "1", field_9: "10", field_10: "25" } } + let(:bulk_upload) { create(:bulk_upload, :lettings, user:, year: 2025) } + + it "does not return errors" do + parser.valid? + expect(parser.errors[:field_8]).not_to be_present + expect(parser.errors[:field_9]).not_to be_present + expect(parser.errors[:field_10]).not_to be_present + end + end + + context "when outside of collection year" do + around do |example| + Timecop.freeze(Date.new(2024, 4, 2)) do + example.run + end + end + + let(:attributes) { { bulk_upload:, field_8: "1", field_9: "1", field_10: "23" } } + + let(:bulk_upload) { create(:bulk_upload, :lettings, user:, year: 2024) } + + it "returns setup errors" do + parser.valid? + expect(parser.errors.where(:field_8, category: :setup)).to be_present + expect(parser.errors.where(:field_9, category: :setup)).to be_present + expect(parser.errors.where(:field_10, category: :setup)).to be_present + end + end + end + + describe "#field_1" do # owning org + context "when blank" do + let(:attributes) { { bulk_upload:, field_1: "", field_4: 1 } } + + it "is not permitted as setup error" do + parser.valid? + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "owning organisation.")]) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when cannot find owning org" do + let(:attributes) { { bulk_upload:, field_1: "donotexist" } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_1 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.owning_organisation.not_found")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when org is not stock owning" do + let(:owning_org) { create(:organisation, :with_old_visible_id, :does_not_own_stock) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_1 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.owning_organisation.not_stock_owner")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when not affiliated with owning org" do + let(:unaffiliated_org) { create(:organisation, :with_old_visible_id) } + + let(:attributes) { { bulk_upload:, field_1: unaffiliated_org.old_visible_id } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_1 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.owning_organisation.not_permitted.not_support")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when user's org has absorbed owning organisation" do + let(:merged_org) { create(:organisation, :with_old_visible_id, holds_own_stock: true) } + let(:merged_org_stock_owner) { create(:organisation, :with_old_visible_id, holds_own_stock: true) } + + let(:attributes) { { bulk_upload:, field_1: merged_org_stock_owner.old_visible_id, field_2: merged_org.old_visible_id, field_3: user.email } } + + before do + create(:organisation_relationship, parent_organisation: merged_org_stock_owner, child_organisation: merged_org) + merged_org.update!(absorbing_organisation: user.organisation, merge_date: Time.zone.today) + merged_org.reload + user.organisation.reload + end + + it "is permitted" do + parser = described_class.new(attributes) + + parser.valid? + expect(parser.errors.where(:field_1)).not_to be_present + expect(parser.errors.where(:field_3)).not_to be_present + end + end + + context "when user's org has absorbed owning organisation before the startdate" do + let(:merged_org) { create(:organisation, :with_old_visible_id, holds_own_stock: true) } + + let(:attributes) { setup_section_params.merge({ field_1: merged_org.old_visible_id, field_2: merged_org.old_visible_id, field_3: user.email }) } + + before do + merged_org.update!(absorbing_organisation: user.organisation, merge_date: Time.zone.today - 5.years) + merged_org.reload + user.organisation.reload + end + + it "is not permitted" do + parser = described_class.new(attributes) + + parser.valid? + expect(parser.errors[:field_1]).to include(/The owning organisation must be active on the tenancy start date/) + expect(parser.errors[:field_2]).to include(/The managing organisation must be active on the tenancy start date/) + expect(parser.errors[:field_8]).to include(/Enter a date when the owning and managing organisation was active/) + expect(parser.errors[:field_9]).to include(/Enter a date when the owning and managing organisation was active/) + expect(parser.errors[:field_10]).to include(/Enter a date when the owning and managing organisation was active/) + end + end + + context "when user is an unaffiliated non-support user and bulk upload organisation is affiliated with the owning organisation" do + let(:affiliated_org) { create(:organisation, :with_old_visible_id) } + let(:unaffiliated_user) { create(:user, organisation: create(:organisation)) } + let(:attributes) { { bulk_upload:, field_1: affiliated_org.old_visible_id } } + let(:organisation_id) { unaffiliated_user.organisation_id } + + before do + create(:organisation_relationship, parent_organisation: owning_org, child_organisation: affiliated_org) + bulk_upload.update!(organisation_id:, user: unaffiliated_user) + end + + it "blocks log creation and adds an error to field_1" do + parser = described_class.new(attributes) + parser.valid? + expect(parser).to be_block_log_creation + expect(parser.errors[:field_1]).to include(I18n.t("validations.lettings.2025.bulk_upload.owning_organisation.not_permitted.not_support")) + end + end + + context "when user is an unaffiliated support user and bulk upload organisation is affiliated with the owning organisation" do + let(:affiliated_org) { create(:organisation, :with_old_visible_id) } + let(:unaffiliated_support_user) { create(:user, :support, organisation: create(:organisation)) } + let(:attributes) { { bulk_upload:, field_1: affiliated_org.old_visible_id } } + let(:organisation_id) { affiliated_org.id } + + before do + create(:organisation_relationship, parent_organisation: owning_org, child_organisation: affiliated_org) + bulk_upload.update!(organisation_id:, user: unaffiliated_support_user) + end + + it "does not block log creation and does not add an error to field_1" do + parser = described_class.new(attributes) + parser.valid? + expect(parser.errors[:field_1]).not_to include(I18n.t("validations.lettings.2025.bulk_upload.owning_organisation.not_permitted")) + end + end + end + + describe "#field_2" do # managing org + context "when blank" do + let(:attributes) { { bulk_upload:, field_2: "", field_4: 1 } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_2 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "managing organisation.")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when cannot find managing org" do + let(:attributes) { { bulk_upload:, field_2: "donotexist" } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_2 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.managing_organisation.not_found")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + + context "when not affiliated with managing org" do + let(:unaffiliated_org) { create(:organisation, :with_old_visible_id) } + + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: unaffiliated_org.old_visible_id } } + + it "is not permitted as setup error" do + parser.valid? + + setup_errors = parser.errors.select { |e| e.options[:category] == :setup } + + expect(setup_errors.find { |e| e.attribute == :field_2 }.message).to eql(I18n.t("validations.lettings.2025.bulk_upload.managing_organisation.no_relationship")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + end + + describe "#field_4" do # needs type + context "when blank" do + let(:attributes) { { bulk_upload:, field_4: nil, field_13: "123" } } + + it "is reported as a setup error" do + parser.valid? + expect(parser.errors.where(:field_4, category: :setup).map(&:message)).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "needs type.")]) + end + end + end + + describe "#field_7" do # renewal + context "when blank" do + let(:attributes) { { bulk_upload:, field_1: owning_org.old_visible_id, field_2: owning_org.old_visible_id, field_7: "" } } + + it "has setup errors on the field" do + parser.valid? + expect(parser.errors.where(:field_7, category: :setup).map(&:message)).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "property renewal.")]) + end + end + + context "when none possible option selected" do + let(:attributes) { setup_section_params.merge({ field_7: "101" }) } + + it "adds a setup error" do + parser.valid? + expect(parser.errors.where(:field_7, category: :setup).map(&:message)).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "is this letting a renewal?")) + end + end + end + + describe "UPRN and address fields" do + context "with a general needs log" do + context "when a valid UPRN is given" do + context "and address fields are not given" do + let(:attributes) { setup_section_params.merge({ field_4: 1, field_18: "123456789012" }) } + + it "does not add errors" do + parser.valid? + %i[field_18 field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to be_empty + end + end + end + end + + context "when an invalid UPRN is given" do + context "and address fields are not given" do + let(:attributes) { setup_section_params.merge({ field_4: 1, field_18: "1234567890123" }) } + + it "adds an appropriate error to the UPRN field" do + parser.valid? + expect(parser.errors[:field_18]).to eql(["UPRN must be 12 digits or less."]) + end + + it "adds errors to missing key address fields" do + parser.valid? + expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "address line 1.")]) + expect(parser.errors[:field_21]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "town or city.")]) + expect(parser.errors[:field_23]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "part 1 of postcode.")]) + expect(parser.errors[:field_24]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "part 2 of postcode.")]) + end + end + + context "and address fields are given" do + let(:attributes) { setup_section_params.merge({ field_4: 1, field_18: "1234567890123", field_19: "address line 1", field_21: "town or city", field_23: "AA1", field_24: "1AA" }) } + + it "adds an error to the UPRN field only" do + parser.valid? + expect(parser.errors[:field_18]).to eql(["UPRN must be 12 digits or less."]) + %i[field_19 field_21 field_23 field_24].each do |field| + expect(parser.errors[field]).to be_empty + end + end + + it "does not do an address search" do + parser.valid? + expect(a_request(:any, /api\.os\.uk\/search\/places\/v1\/find/)).not_to have_been_made + end + end + end + + context "when no UPRN is given" do + context "and no address fields are given" do + let(:attributes) { setup_section_params.merge({ field_4: 1 }) } + + it "adds appropriate errors to UPRN and key address fields" do + parser.valid? + expect(parser.errors[:field_18]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_21]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_23]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_24]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + end + end + + context "and some key address field is missing" do + let(:attributes) { setup_section_params.merge({ field_4: 1, field_21: "town or city", field_23: "AA1", field_24: "1AA" }) } + + it "adds errors to UPRN and the missing key address field" do + parser.valid? + expect(parser.errors[:field_18]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_21]).to be_empty + expect(parser.errors[:field_23]).to be_empty + expect(parser.errors[:field_24]).to be_empty + end + end + + context "and all key address fields are present" do + let(:attributes) { setup_section_params.merge({ field_4: 1, field_18: nil, field_19: "address line 1", field_21: "town or city", field_23: "AA1", field_24: "1AA" }) } + + context "and an address can be found with a high enough match rating" do + before do + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/find/) + .to_return(status: 200, body: { results: [{ DPA: { MATCH: 0.7, BUILDING_NAME: "", POST_TOWN: "", POSTCODE: "AA1 1AA", UPRN: "1" } }] }.to_json, headers: {}) + end + + it "does not add errors" do + parser.valid? + %i[field_18 field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to be_empty + end + end + end + + context "when no address can be found" do + before do + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/find/) + .to_return(status: 200, body: { results: [] }.to_json, headers: {}) + end + + it "adds address not found errors to address fields only" do + parser.valid? + expect(parser.errors[:field_18]).to be_empty + %i[field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_found")]) + end + end + end + + context "when a single address with not a high enough match rating is returned" do + before do + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/find/) + .to_return(status: 200, body: { results: [{ DPA: { MATCH: 0.6, BUILDING_NAME: "", POST_TOWN: "", POSTCODE: "AA1 1AA", UPRN: "1" } }] }.to_json, headers: {}) + end + + it "adds address not found errors to address fields only" do + parser.valid? + expect(parser.errors[:field_18]).to be_empty + %i[field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_determined.one")]) + end + end + end + + context "when no addresses have a high enough match rating" do + before do + stub_request(:get, /api\.os\.uk\/search\/places\/v1\/find/) + .to_return(status: 200, body: { results: [{ DPA: { MATCH: 0.6, BUILDING_NAME: "", POST_TOWN: "", POSTCODE: "AA1 1AA", UPRN: "1" } }, { DPA: { MATCH: 0.8, BUILDING_NAME: "", POST_TOWN: "", POSTCODE: "BB2 2BB", UPRN: "2" } }] }.to_json, headers: {}) + end + + it "adds address not found errors to address fields only" do + parser.valid? + expect(parser.errors[:field_18]).to be_empty + %i[field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to eql([I18n.t("validations.lettings.2025.bulk_upload.address.not_determined.multiple")]) + end + end + end + end + end + end + + context "with a supported housing log" do + context "when neither UPRN nor address fields are provided" do + let(:attributes) { setup_section_params.merge({ field_4: 2, field_5: "S#{scheme.id}", field_6: location.old_visible_id, field_18: nil, field_19: nil, field_21: nil, field_23: nil, field_24: nil }) } + + it "does not add missing field errors" do + parser.valid? + %i[field_18 field_19 field_20 field_21 field_22 field_23 field_24].each do |field| + expect(parser.errors[field]).to be_empty + end + end + end + end + end + + describe "#field_17" do # unitletas + context "when no longer a valid option from previous year" do + let(:attributes) { setup_section_params.merge({ field_17: "4" }) } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_17]).to be_present + end + end + end + + describe "#field_27" do + context "when null" do + let(:attributes) { setup_section_params.merge({ field_27: nil }) } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_27]).to be_present + end + + it "populates with correct error message" do + parser.valid? + expect(parser.errors[:field_27]).to eql([I18n.t("validations.lettings.2025.bulk_upload.not_answered", question: "type of building.")]) + end + end + end + + describe "#field_48" do # age2 + context "when null but gender given" do + let(:attributes) { setup_section_params.merge({ field_48: "", field_49: "F" }) } + + it "returns an error" do + parser.valid? + expect(parser.errors[:field_48]).to be_present + end + end + end + + describe "#field_45" do + context "when field_45 is a 3 digit nationality code" do + let(:attributes) { setup_section_params.merge({ field_45: "036" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(36) + expect(parser.log.nationality_all_group).to be(12) + end + end + + context "when field_45 is a nationality code without the trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_45: "36" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(36) + expect(parser.log.nationality_all_group).to be(12) + end + end + + context "when field_45 is a nationality code with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_45: "0036" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(36) + expect(parser.log.nationality_all_group).to be(12) + end + end + + context "when field_45 is 0" do + let(:attributes) { setup_section_params.merge({ field_45: "0" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(0) + expect(parser.log.nationality_all_group).to be(0) + end + end + + context "when field_45 is 000" do + let(:attributes) { setup_section_params.merge({ field_45: "000" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(0) + expect(parser.log.nationality_all_group).to be(0) + end + end + + context "when field_45 is 0000" do + let(:attributes) { setup_section_params.merge({ field_45: "0000" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(0) + expect(parser.log.nationality_all_group).to be(0) + end + end + + context "when field_45 is 826" do + let(:attributes) { setup_section_params.merge({ field_45: "826" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(826) + expect(parser.log.nationality_all_group).to be(826) + end + end + + context "when field_45 is 826 with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_45: "0826" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(826) + expect(parser.log.nationality_all_group).to be(826) + end + end + + context "when field_45 is not a valid option" do + let(:attributes) { setup_section_params.merge({ field_45: "123123" }) } + + it "is correctly set" do + expect(parser.log.nationality_all).to be(nil) + expect(parser.log.nationality_all_group).to be(nil) + end + + it "adds an error to field_45" do + parser.valid? + expect(parser.errors["field_45"]).to include(I18n.t("validations.lettings.2025.bulk_upload.nationality.invalid")) + end + end + end + + describe "soft validations" do + context "when soft validation is triggered" do + let(:attributes) { setup_section_params.merge({ field_42: 22, field_46: 5 }) } + + it "adds an error to the relevant fields" do + parser.valid? + expect(parser.errors.where(:field_42, category: :soft_validation)).to be_present + expect(parser.errors.where(:field_46, category: :soft_validation)).to be_present + end + + it "populates with correct error message" do + parser.valid? + expect(parser.errors.where(:field_42, category: :soft_validation).first.message).to eql("You told us this person is aged 22 years and retired. The minimum expected retirement age in England is 66.") + expect(parser.errors.where(:field_46, category: :soft_validation).first.message).to eql("You told us this person is aged 22 years and retired. The minimum expected retirement age in England is 66.") + end + end + + context "when a soft validation is triggered that relates both to fields that are and are not routed to" do + let(:attributes) { setup_section_params.merge({ field_78: "1", field_43: "M", field_49: "M", field_53: "M" }) } + + it "adds errors to fields that are routed to" do + parser.valid? + expect(parser.errors.where(:field_49, category: :soft_validation)).to be_present + expect(parser.errors.where(:field_53, category: :soft_validation)).to be_present + end + + it "does not add errors to fields that are not routed to" do + parser.valid? + expect(parser.errors.where(:field_57, category: :soft_validation)).not_to be_present + expect(parser.errors.where(:field_61, category: :soft_validation)).not_to be_present + end + end + + context "when soft validation is triggered and not required" do + let(:attributes) { setup_section_params.merge({ field_124: 120, field_125: 120, field_126: 120, field_127: 120, field_123: 1, field_29: 1, field_4: 1, field_11: "2", field_25: "E09000008" }) } + + it "adds an error to the relevant fields" do + parser.valid? + expect(parser.errors.where(:field_124, category: :soft_validation)).to be_present + end + + it "populates with correct error message" do + parser.valid? + expect(parser.errors.where(:field_124, category: :soft_validation).count).to be(1) + expect(parser.errors.where(:field_124, category: :soft_validation).first.message).to eql("You told us the rent is £120.00 every week. This is higher than we would expect.") + end + end + + context "when an invalid ecstat1 is given" do + let(:attributes) { setup_section_params.merge({ field_46: 11, field_119: 123, field_118: 1 }) } + + it "does not run net income soft validations validation" do + parser.valid? + expect(parser.errors.where(:field_46).count).to be(1) + end + end + end + + describe "log_already_exists?" do + let(:attributes) { { bulk_upload: } } + + before do + build(:lettings_log, owning_organisation: nil, startdate: nil, tenancycode: nil, location: nil, age1: nil, sex1: nil, ecstat1: nil, brent: nil, scharge: nil, pscharge: nil, supcharg: nil).save(validate: false) + end + + it "does not add duplicate logs validation to the blank row" do + expect(parser.log_already_exists?).to eq(false) + end + end + end + + describe "#log" do + describe "#assigned_to" do + context "when blank" do + let(:attributes) { setup_section_params } + + it "takes the user that is uploading" do + expect(parser.log.assigned_to).to eql(bulk_upload.user) + end + end + + context "when email specified" do + let(:other_user) { create(:user, organisation: owning_org) } + + let(:attributes) { setup_section_params.merge(field_3: other_user.email) } + + it "sets to user with specified email" do + expect(parser.log.assigned_to).to eql(other_user) + end + end + end + + describe "#uprn" do + let(:attributes) { { bulk_upload:, field_4: 1, field_18: "12" } } + + it "sets to given value" do + expect(parser.log.uprn).to eql("12") + end + end + + describe "#uprn_known" do + context "when uprn specified" do + let(:attributes) { { bulk_upload:, field_4: 1, field_18: "12" } } + + it "sets to 1" do + expect(parser.log.uprn_known).to be(1) + expect(parser.log.uprn_confirmed).to be(1) + end + end + + context "when uprn blank" do + let(:attributes) { { bulk_upload:, field_4: 1, field_18: "" } } + + it "sets to 0" do + expect(parser.log.uprn_known).to be(0) + end + end + end + + describe "#address_line1" do + let(:attributes) { { bulk_upload:, field_4: 1, field_19: "123 Sesame Street" } } + + it "sets to given value" do + expect(parser.log.address_line1).to eql("123 Sesame Street") + end + end + + describe "#address_line2" do + let(:attributes) { { bulk_upload:, field_4: 1, field_20: "Cookie Town" } } + + it "sets to given value" do + expect(parser.log.address_line2).to eql("Cookie Town") + end + end + + describe "#town_or_city" do + let(:attributes) { { bulk_upload:, field_4: 1, field_21: "London" } } + + it "sets to given value" do + expect(parser.log.town_or_city).to eql("London") + end + end + + describe "#county" do + let(:attributes) { { bulk_upload:, field_4: 1, field_22: "Greater London" } } + + it "sets to given value" do + expect(parser.log.county).to eql("Greater London") + end + end + + describe "address related fields for supported housing logs" do + context "when address data is provided for a supported housing log" do + let(:attributes) { { bulk_upload:, field_4: 2, field_18: nil, field_19: "Flat 1", field_20: "Example Place", field_21: "London", field_22: "Greater London", field_23: "SW1A", field_24: "1AA" } } + + it "is not set on the log" do + expect(parser.log.uprn).to be_nil + expect(parser.log.uprn_known).to be_nil + expect(parser.log.address_line1).to be_nil + expect(parser.log.address_line1_as_entered).to be_nil + expect(parser.log.address_line2).to be_nil + expect(parser.log.address_line2_as_entered).to be_nil + expect(parser.log.town_or_city).to be_nil + expect(parser.log.town_or_city_as_entered).to be_nil + expect(parser.log.county).to be_nil + expect(parser.log.county_as_entered).to be_nil + expect(parser.log.postcode_full).to be_nil + expect(parser.log.postcode_full_as_entered).to be_nil + expect(parser.log.la).to be_nil + expect(parser.log.la_as_entered).to be_nil + expect(parser.log.address_line1_input).to be_nil + expect(parser.log.postcode_full_input).to be_nil + expect(parser.log.select_best_address_match).to be_nil + end + end + end + + [ + %w[age1_known details_known_1 age1 field_42 field_47 field_49], + %w[age2_known details_known_2 age2 field_48 field_47 field_49], + %w[age3_known details_known_3 age3 field_52 field_51 field_53], + %w[age4_known details_known_4 age4 field_56 field_55 field_57], + %w[age5_known details_known_5 age5 field_60 field_59 field_61], + %w[age6_known details_known_6 age6 field_64 field_63 field_65], + %w[age7_known details_known_7 age7 field_68 field_67 field_69], + %w[age8_known details_known_8 age8 field_72 field_71 field_73], + ].each do |known, details_known, age, field, relationship, gender| + describe "##{known} and ##{age}" do + context "when #{field} is blank" do + context "and person details are not given" do + let(:attributes) { { bulk_upload:, field.to_sym => nil, field_4: 1, relationship.to_sym => nil, gender.to_sym => nil } } + + it "does not set ##{known}" do + unless known == "age1_known" + expect(parser.log.public_send(known)).to be_nil + end + end + + it "sets ##{details_known} to no" do + unless details_known == "details_known_1" + expect(parser.log.public_send(details_known)).to eq(1) + end + end + + it "sets ##{age} to nil" do + expect(parser.log.public_send(age)).to be_nil + end + end + + context "and person details are given" do + let(:attributes) { { bulk_upload:, field.to_sym => nil, field_4: 1, relationship.to_sym => "C", gender.to_sym => "X" } } + + it "does not set ##{age}" do + parser.valid? + expect(parser.errors[field.to_sym]).to include(/must be a number or the letter R/) + end + end + end + + context "when #{field} is R" do + let(:attributes) { { bulk_upload:, field.to_s => "R" } } + + it "sets ##{known} 1" do + expect(parser.log.public_send(known)).to be(1) + end + + it "sets ##{age} to nil" do + expect(parser.log.public_send(age)).to be_nil + end + end + + context "when #{field} is a number" do + let(:attributes) { { bulk_upload:, field.to_s => "50" } } + + it "sets ##{known} to 0" do + expect(parser.log.public_send(known)).to be(0) + end + + it "sets ##{age} to given age" do + expect(parser.log.public_send(age)).to be(50) + end + end + end + end + + describe "#location" do + context "when lookup is via new core id" do + let(:attributes) { { bulk_upload:, field_5: "S#{scheme.id}", field_6: location.id, field_1: "ORG#{owning_org.id}", field_2: "ORG#{owning_org.id}" } } + + it "assigns the correct location" do + expect(parser.log.location).to eql(location) + end + end + end + + describe "#scheme" do + context "when lookup is via new core id" do + let(:attributes) { { bulk_upload:, field_5: "S#{scheme.id}", field_1: "ORG#{owning_org.id}", field_2: "ORG#{owning_org.id}" } } + + it "assigns the correct scheme" do + expect(parser.log.scheme).to eql(scheme) + end + end + end + + describe "#owning_organisation" do + context "when lookup is via id prefixed with ORG" do + let(:attributes) { { bulk_upload:, field_1: "ORG#{owning_org.id}" } } + + it "assigns the correct org" do + expect(parser.log.owning_organisation).to eql(owning_org) + end + end + end + + describe "#managing_organisation" do + context "when lookup is via id prefixed with ORG" do + let(:attributes) { { bulk_upload:, field_2: "ORG#{managing_org.id}" } } + + it "assigns the correct org" do + expect(parser.log.managing_organisation).to eql(managing_org) + end + end + end + + describe "#beds" do + context "when property is a bedsit" do + let(:attributes) { setup_section_params.merge({ field_26: 2, field_29: 2 }) } + + it "sets value to 1 even if field_29 contradicts this" do + expect(parser.log.beds).to be(1) + end + end + + context "when property is not a bedsit" do + let(:attributes) { setup_section_params.merge({ field_26: 1, field_29: 2 }) } + + it "sets value to field_29" do + expect(parser.log.beds).to be(2) + end + end + end + + describe "#cbl" do + context "when field_112 is yes ie 1" do + let(:attributes) { { bulk_upload:, field_112: 1 } } + + it "sets value to 1" do + expect(parser.log.cbl).to be(1) + end + end + + context "when field_112 is no ie 2" do + let(:attributes) { { bulk_upload:, field_112: 2 } } + + it "sets value to 0" do + expect(parser.log.cbl).to be(0) + end + end + + context "when field_112 is not a permitted value" do + let(:attributes) { { bulk_upload:, field_112: 3 } } + + it "adds an error" do + parser.valid? + expect(parser.errors[:field_112]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "was the letting made under the Choice-Based Lettings (CBL)?")) + end + end + end + + describe "#chr" do + context "when field_114 is yes ie 1" do + let(:attributes) { { bulk_upload:, field_114: 1 } } + + it "sets value to 1" do + expect(parser.log.chr).to be(1) + end + end + + context "when field_114 is no ie 2" do + let(:attributes) { { bulk_upload:, field_114: 2 } } + + it "sets value to 0" do + expect(parser.log.chr).to be(0) + end + end + + context "when field_114 is not a permitted value" do + let(:attributes) { { bulk_upload:, field_114: 3 } } + + it "adds an error" do + parser.valid? + expect(parser.errors[:field_114]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "was the letting made under the Common Housing Register (CHR)?")) + end + end + end + + describe "#cap" do + context "when field_113 is yes ie 1" do + let(:attributes) { { bulk_upload:, field_113: 1 } } + + it "sets value to 1" do + expect(parser.log.cap).to be(1) + end + end + + context "when field_113 is no ie 2" do + let(:attributes) { { bulk_upload:, field_113: 2 } } + + it "sets value to 0" do + expect(parser.log.cap).to be(0) + end + end + + context "when field_113 is not a permitted value" do + let(:attributes) { { bulk_upload:, field_113: 3 } } + + it "adds an error" do + parser.valid? + expect(parser.errors[:field_113]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "was the letting made under the Common Allocation Policy (CAP)?")) + end + end + end + + describe "#accessible_register" do + context "when field_115 is yes ie 1" do + let(:attributes) { { bulk_upload:, field_115: 1 } } + + it "sets value to 1" do + expect(parser.log.accessible_register).to be(1) + end + end + + context "when field_115 is no ie 2" do + let(:attributes) { { bulk_upload:, field_115: 2 } } + + it "sets value to 0" do + expect(parser.log.accessible_register).to be(0) + end + end + + context "when field_115 is not a permitted value" do + let(:attributes) { { bulk_upload:, field_115: 3 } } + + it "adds an error" do + parser.valid? + expect(parser.errors[:field_115]).to include(I18n.t("validations.lettings.2025.bulk_upload.invalid_option", question: "was the letting made under the Accessible Register?")) + end + end + end + + describe "#letting_allocation_unknown" do + context "when field_112, 113, 114, 115 are no ie 2" do + let(:attributes) { { bulk_upload:, field_112: 2, field_113: 2, field_114: 2, field_115: 2 } } + + it "sets value to 1" do + expect(parser.log.letting_allocation_unknown).to be(1) + end + end + + context "when any one of field_112, 113, 114, 115 is yes ie 1" do + let(:attributes) { { bulk_upload:, field_115: 1 } } + + it "sets value to 0" do + expect(parser.log.letting_allocation_unknown).to be(0) + end + end + end + + describe "#renewal" do + context "when field_7 is no ie 2" do + let(:attributes) { { bulk_upload:, field_7: 2 } } + + it "sets value to 0" do + expect(parser.log.renewal).to eq(0) + end + end + end + + describe "#sexN fields" do + let(:attributes) do + { + bulk_upload:, + field_43: "F", + field_49: "M", + field_53: "X", + field_57: "R", + field_61: "F", + field_65: "M", + field_69: "X", + field_73: "R", + } + end + + it "sets value from correct mapping" do + expect(parser.log.sex1).to eql("F") + expect(parser.log.sex2).to eql("M") + expect(parser.log.sex3).to eql("X") + expect(parser.log.sex4).to eql("R") + expect(parser.log.sex5).to eql("F") + expect(parser.log.sex6).to eql("M") + expect(parser.log.sex7).to eql("X") + expect(parser.log.sex8).to eql("R") + end + end + + describe "#ecstatN fields" do + let(:attributes) do + { + bulk_upload:, + field_46: "1", + field_50: "2", + field_54: "6", + field_58: "7", + field_62: "8", + field_66: "9", + field_70: "0", + field_74: "10", + } + end + + it "sets value from correct mapping", aggregate_failures: true do + expect(parser.log.ecstat1).to eq(1) + expect(parser.log.ecstat2).to eq(2) + expect(parser.log.ecstat3).to eq(6) + expect(parser.log.ecstat4).to eq(7) + expect(parser.log.ecstat5).to eq(8) + expect(parser.log.ecstat6).to eq(9) + expect(parser.log.ecstat7).to eq(0) + expect(parser.log.ecstat8).to eq(10) + end + end + + describe "#relatN fields" do + let(:attributes) do + { + bulk_upload:, + field_47: "1", + field_51: "2", + field_55: "2", + field_59: "3", + field_63: "1", + field_67: "2", + field_71: "2", + } + end + + it "sets value from correct mapping", aggregate_failures: true do + expect(parser.log.relat2).to eq("P") + expect(parser.log.relat3).to eq("X") + expect(parser.log.relat4).to eq("X") + expect(parser.log.relat5).to eq("R") + expect(parser.log.relat6).to eq("P") + expect(parser.log.relat7).to eq("X") + expect(parser.log.relat8).to eq("X") + end + end + + describe "#net_income_known" do + context "when 1" do + let(:attributes) { { bulk_upload:, field_117: "1" } } + + it "sets value from correct mapping" do + expect(parser.log.net_income_known).to eq(0) + end + end + + context "when 2" do + let(:attributes) { { bulk_upload:, field_117: "2" } } + + it "sets value from correct mapping" do + expect(parser.log.net_income_known).to eq(1) + end + end + + context "when 3" do + let(:attributes) { { bulk_upload:, field_117: "3" } } + + it "sets value from correct mapping" do + expect(parser.log.net_income_known).to eq(2) + end + end + end + + describe "#unitletas" do + let(:attributes) { { bulk_upload:, field_17: "1" } } + + it "sets value from correct mapping" do + expect(parser.log.unitletas).to eq(1) + end + end + + describe "#rsnvac" do + let(:attributes) { { bulk_upload:, field_16: "5" } } + + it "sets value from correct mapping" do + expect(parser.log.rsnvac).to eq(5) + end + end + + describe "#sheltered" do + let(:attributes) { { bulk_upload:, field_36: "1" } } + + it "sets value from correct mapping" do + expect(parser.log.sheltered).to eq(1) + end + end + + describe "illness fields" do + mapping = [ + { attribute: :illness_type_1, field: :field_94 }, + { attribute: :illness_type_2, field: :field_88 }, + { attribute: :illness_type_3, field: :field_91 }, + { attribute: :illness_type_4, field: :field_86 }, + { attribute: :illness_type_5, field: :field_87 }, + { attribute: :illness_type_6, field: :field_89 }, + { attribute: :illness_type_7, field: :field_90 }, + { attribute: :illness_type_8, field: :field_93 }, + { attribute: :illness_type_9, field: :field_92 }, + { attribute: :illness_type_10, field: :field_95 }, + ] + + mapping.each do |hash| + describe "##{hash[:attribute]}" do + context "when yes" do + let(:attributes) { { bulk_upload:, hash[:field] => "1" } } + + it "sets value from correct mapping" do + expect(parser.log.public_send(hash[:attribute])).to eq(1) + end + end + + context "when no" do + let(:attributes) { { bulk_upload:, hash[:field] => "", field_4: 1 } } + + it "sets value from correct mapping" do + expect(parser.log.public_send(hash[:attribute])).to be_nil + end + end + end + end + end + + describe "#irproduct_other" do + let(:attributes) { { bulk_upload:, field_12: "some other product" } } + + it "sets value to given free text string" do + expect(parser.log.irproduct_other).to eql("some other product") + end + end + + describe "#tenancyother" do + let(:attributes) { { bulk_upload:, field_40: "some other tenancy" } } + + it "sets value to given free text string" do + expect(parser.log.tenancyother).to eql("some other tenancy") + end + end + + describe "#tenancylength" do + let(:attributes) { { bulk_upload:, field_41: "2" } } + + it "sets value to given free text string" do + expect(parser.log.tenancylength).to eq(2) + end + end + + describe "#earnings" do + let(:attributes) { { bulk_upload:, field_119: "104.50" } } + + it "rounds to the nearest whole pound" do + expect(parser.log.earnings).to eq(105) + end + end + + describe "#reasonother" do + context "when reason is 'other'" do + let(:attributes) { { bulk_upload:, field_98: "20", field_99: "some other reason" } } + + it "is set to given free text string" do + expect(parser.log.reasonother).to eql("some other reason") + end + end + + context "when reason is not 'other'" do + let(:attributes) { { bulk_upload:, field_98: "50", field_99: "some other reason" } } + + it "is set to nil" do + expect(parser.log.reasonother).to be_nil + end + end + end + + describe "#ppcodenk" do + let(:attributes) { { bulk_upload:, field_102: "2" } } + + it "sets correct value from mapping" do + expect(parser.log.ppcodenk).to eq(1) + end + end + + describe "#household_charge" do + context "when log is general needs" do + let(:attributes) { { bulk_upload:, field_4: 1, field_122: "1" } } + + it "sets correct value from mapping" do + expect(parser.log.household_charge).to eq(nil) + end + end + + context "when log is supported housing" do + let(:attributes) { { bulk_upload:, field_4: 2, field_122: "1" } } + + it "sets correct value from mapping" do + expect(parser.log.household_charge).to eq(1) + end + end + end + + describe "#supcharg" do + let(:attributes) { setup_section_params.merge({ field_124: "330", field_125: "0", field_126: "0", field_127: "123.45" }) } + + it "sets value given" do + expect(parser.log.supcharg).to eq(123.45) + end + + context "when other charges are not given" do + let(:attributes) { setup_section_params.merge({ field_127: "123.45", field_124: nil, field_125: nil, field_126: nil }) } + + it "does not set charges values" do + parser.log.save! + expect(parser.log.tcharge).to be_nil + expect(parser.log.brent).to be_nil + expect(parser.log.supcharg).to be_nil + expect(parser.log.pscharge).to be_nil + expect(parser.log.scharge).to be_nil + end + + it "adds an error to all missing charges" do + parser.valid? + expect(parser.errors[:field_124]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "basic rent")]) + expect(parser.errors[:field_125]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "service charge")]) + expect(parser.errors[:field_126]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "personal service charge")]) + expect(parser.errors[:field_127]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "basic rent"), I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "service charge"), I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "personal service charge")]) + end + end + + context "when supscharg is not given" do + let(:attributes) { setup_section_params.merge({ field_123: 1, field_124: "350.45", field_125: "0", field_126: "0", field_127: nil }) } + + it "does not set charges values" do + parser.log.save! + expect(parser.log.period).not_to be_nil + expect(parser.log.tcharge).to be_nil + expect(parser.log.brent).to be_nil + expect(parser.log.supcharg).to be_nil + expect(parser.log.pscharge).to be_nil + expect(parser.log.scharge).to be_nil + end + + it "adds an error to all charges" do + parser.valid? + expect(parser.errors[:field_124]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "support charge")]) + expect(parser.errors[:field_125]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "support charge")]) + expect(parser.errors[:field_126]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "support charge")]) + expect(parser.errors[:field_127]).to eql([I18n.t("validations.lettings.2025.bulk_upload.charges.missing_charges", sentence_fragment: "support charge")]) + end + end + end + + describe "#pscharge" do + let(:attributes) { { bulk_upload:, field_124: "111.45", field_125: "0", field_126: "123.45", field_127: "0" } } + + it "sets value given" do + expect(parser.log.pscharge).to eq(123.45) + end + end + + describe "#scharge" do + let(:attributes) { { bulk_upload:, field_124: "111.45", field_125: "123.45", field_126: "0", field_127: "0" } } + + it "sets value given" do + expect(parser.log.scharge).to eq(123.45) + end + end + + describe "#propcode" do + let(:attributes) { { bulk_upload:, field_14: "abc123" } } + + it "sets value given" do + expect(parser.log.propcode).to eq("abc123") + end + end + + describe "#mrcdate" do + context "when valid" do + let(:attributes) { { bulk_upload:, field_33: "13", field_34: "12", field_35: "22" } } + + it "sets value given" do + expect(parser.log.mrcdate).to eq(Date.new(2022, 12, 13)) + end + end + + context "when valid (4 digit year)" do + let(:attributes) { { bulk_upload:, field_33: "13", field_34: "12", field_35: "2022" } } + + it "sets value given" do + expect(parser.log.mrcdate).to eq(Date.new(2022, 12, 13)) + end + end + + context "when invalid" do + let(:attributes) { { bulk_upload:, field_33: "13", field_34: "13", field_35: "22" } } + + it "does not raise an error" do + expect { parser.log.mrcdate }.not_to raise_error + end + end + end + + describe "#majorrepairs" do + context "when mrcdate given" do + let(:attributes) { { bulk_upload:, field_33: "13", field_34: "12", field_35: "22" } } + + it "sets #majorrepairs to 1" do + expect(parser.log.majorrepairs).to eq(1) + end + end + + context "when mrcdate not given" do + let(:attributes) { { bulk_upload:, field_33: "", field_34: "", field_35: "", field_4: 1 } } + + it "sets #majorrepairs to 0" do + expect(parser.log.majorrepairs).to eq(0) + end + end + end + + describe "#voiddate" do + context "when valid" do + let(:attributes) { { bulk_upload:, field_30: "13", field_31: "12", field_32: "22" } } + + it "sets value given" do + expect(parser.log.voiddate).to eq(Date.new(2022, 12, 13)) + end + end + + context "when valid (4 digit year)" do + let(:attributes) { { bulk_upload:, field_30: "13", field_31: "12", field_32: "2022" } } + + it "sets value given" do + expect(parser.log.voiddate).to eq(Date.new(2022, 12, 13)) + end + end + + context "when invalid" do + let(:attributes) { { bulk_upload:, field_30: "13", field_31: "13", field_32: "22" } } + + it "does not raise an error" do + expect { parser.log.voiddate }.not_to raise_error + end + end + end + + describe "#startdate" do + let(:attributes) { { bulk_upload:, field_8: now.day.to_s, field_9: now.month.to_s, field_10: now.strftime("%g") } } + + it "sets value given" do + expect(parser.log.startdate).to eq(now) + end + end + + describe "#postcode_full" do + let(:attributes) { { bulk_upload:, field_4: 1, field_23: " EC1N ", field_24: " 2TD " } } + + it "strips whitespace" do + expect(parser.log.postcode_full).to eql("EC1N 2TD") + end + + context "when a partial postcode is provided" do + let(:attributes) { { bulk_upload:, field_4: 1, field_23: "EC1N", field_24: "" } } + + it "is set to the partial value" do + expect(parser.log.postcode_full).to eql("EC1N") + end + end + end + + describe "#la" do + let(:attributes) { { bulk_upload:, field_4: 1, field_25: "E07000223" } } + + it "sets to given value" do + expect(parser.log.la).to eql("E07000223") + end + end + + describe "#prevloc" do + let(:attributes) { { bulk_upload:, field_105: "E07000223" } } + + it "sets to given value" do + expect(parser.log.prevloc).to eql("E07000223") + end + end + + describe "#previous_la_known" do + context "when known" do + let(:attributes) { { bulk_upload:, field_105: "E07000223" } } + + it "sets to 1" do + expect(parser.log.previous_la_known).to eq(1) + end + end + + context "when not known" do + let(:attributes) { { bulk_upload:, field_105: "", field_4: 1 } } + + it "sets to 0" do + expect(parser.log.previous_la_known).to eq(0) + end + end + end + + describe "#first_time_property_let_as_social_housing" do + context "when field_16 is 15, 16, or 17" do + let(:attributes) { { bulk_upload:, field_16: %w[15 16 17].sample } } + + it "sets to 1" do + expect(parser.log.first_time_property_let_as_social_housing).to eq(1) + end + end + + context "when field_16 is not 15, 16, or 17" do + let(:attributes) { { bulk_upload:, field_16: "1" } } + + it "sets to 0" do + expect(parser.log.first_time_property_let_as_social_housing).to eq(0) + end + end + end + + describe "#housingneeds" do + context "when no disabled needs" do + let(:attributes) { { bulk_upload:, field_83: "1" } } + + it "sets to 2" do + expect(parser.log.housingneeds).to eq(2) + end + end + + context "when dont know about disabled needs" do + let(:attributes) { { bulk_upload:, field_84: "1" } } + + it "sets to 3" do + expect(parser.log.housingneeds).to eq(3) + end + end + + context "when housingneeds are given" do + let(:attributes) { { bulk_upload:, field_83: "0", field_81: "1", field_82: "1" } } + + it "sets correct housingneeds" do + expect(parser.log.housingneeds).to eq(1) + expect(parser.log.housingneeds_type).to eq(2) + expect(parser.log.housingneeds_other).to eq(1) + end + end + + context "when housingneeds are given and field_82 is nil" do + let(:attributes) { { bulk_upload:, field_83: nil, field_81: "1", field_82: "1" } } + + it "sets correct housingneeds" do + expect(parser.log.housingneeds).to eq(1) + expect(parser.log.housingneeds_type).to eq(2) + expect(parser.log.housingneeds_other).to eq(1) + end + end + + context "when housingneeds are not given" do + let(:attributes) { { bulk_upload:, field_79: nil, field_80: nil, field_81: nil, field_83: nil } } + + it "sets correct housingneeds" do + expect(parser.log.housingneeds).to eq(1) + expect(parser.log.housingneeds_type).to eq(3) + end + end + + context "when housingneeds a and b are selected" do + let(:attributes) { { bulk_upload:, field_79: "1", field_80: "1" } } + + it "sets error on housingneeds a and b" do + parser.valid? + expect(parser.errors[:field_79]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_80]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_81]).to be_blank + end + end + + context "when housingneeds a and c are selected" do + let(:attributes) { { bulk_upload:, field_79: "1", field_81: "1" } } + + it "sets error on housingneeds a and c" do + parser.valid? + expect(parser.errors[:field_79]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_81]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_80]).to be_blank + end + end + + context "when housingneeds b and c are selected" do + let(:attributes) { { bulk_upload:, field_80: "1", field_81: "1" } } + + it "sets error on housingneeds b and c" do + parser.valid? + expect(parser.errors[:field_80]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_81]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds_type.only_one_option_permitted")) + expect(parser.errors[:field_79]).to be_blank + end + end + + context "when housingneeds a and g are selected" do + let(:attributes) { { bulk_upload:, field_79: "1", field_83: "1" } } + + it "sets error on housingneeds a and g" do + parser.valid? + expect(parser.errors[:field_83]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds.no_disabled_needs_conjunction")) + expect(parser.errors[:field_79]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds.no_disabled_needs_conjunction")) + expect(parser.errors[:field_80]).to be_blank + expect(parser.errors[:field_81]).to be_blank + end + end + + context "when only housingneeds g is selected" do + let(:attributes) { { bulk_upload:, field_79: "0", field_83: "1" } } + + it "does not add any housingneeds errors" do + parser.valid? + expect(parser.errors[:field_55]).to be_blank + expect(parser.errors[:field_79]).to be_blank + expect(parser.errors[:field_80]).to be_blank + expect(parser.errors[:field_81]).to be_blank + end + end + + context "when housingneeds a and h are selected" do + let(:attributes) { { bulk_upload:, field_79: "1", field_84: "1" } } + + it "sets error on housingneeds a and h" do + parser.valid? + expect(parser.errors[:field_84]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds.dont_know_disabled_needs_conjunction")) + expect(parser.errors[:field_79]).to include(I18n.t("validations.lettings.2025.bulk_upload.housingneeds.dont_know_disabled_needs_conjunction")) + expect(parser.errors[:field_80]).to be_blank + expect(parser.errors[:field_81]).to be_blank + end + end + + context "when only housingneeds h is selected" do + let(:attributes) { { bulk_upload:, field_79: "0", field_84: "1" } } + + it "does not add any housingneeds errors" do + parser.valid? + expect(parser.errors[:field_84]).to be_blank + expect(parser.errors[:field_79]).to be_blank + expect(parser.errors[:field_80]).to be_blank + expect(parser.errors[:field_81]).to be_blank + end + end + end + + describe "#housingneeds_type" do + context "when field_79 is 1" do + let(:attributes) { { bulk_upload:, field_79: "1" } } + + it "set to 0" do + expect(parser.log.housingneeds_type).to eq(0) + end + end + + context "when field_80 is 1" do + let(:attributes) { { bulk_upload:, field_80: "1" } } + + it "set to 1" do + expect(parser.log.housingneeds_type).to eq(1) + end + end + + context "when field_81 is 1" do + let(:attributes) { { bulk_upload:, field_81: "1" } } + + it "set to 2" do + expect(parser.log.housingneeds_type).to eq(2) + end + end + end + + describe "#housingneeds_other" do + context "when field_54 is 1" do + let(:attributes) { { bulk_upload:, field_82: "1" } } + + it "sets to 1" do + expect(parser.log.housingneeds_other).to eq(1) + end + end + end + end + + describe "#startdate" do + context "when year of 9 is passed to represent 2009" do + let(:attributes) { { bulk_upload:, field_8: "1", field_9: "1", field_10: "9" } } + + it "uses the year 2009" do + expect(parser.send(:startdate)).to eql(Date.new(2009, 1, 1)) + end + end + end + + describe "#spreadsheet_duplicate_hash" do + it "returns a hash" do + expect(parser.spreadsheet_duplicate_hash).to be_a(Hash) + end + end + + describe "#add_duplicate_found_in_spreadsheet_errors" do + it "adds errors" do + expect { parser.add_duplicate_found_in_spreadsheet_errors }.to change(parser.errors, :size) + end + end +end