diff --git a/.rake_tasks~ b/.rake_tasks~ new file mode 100644 index 000000000..e69de29bb diff --git a/app/helpers/bulk_upload/sales_log_to_csv.rb b/app/helpers/bulk_upload/sales_log_to_csv.rb index bc1435186..2b7f2e285 100644 --- a/app/helpers/bulk_upload/sales_log_to_csv.rb +++ b/app/helpers/bulk_upload/sales_log_to_csv.rb @@ -19,7 +19,7 @@ class BulkUpload::SalesLogToCsv case year when 2022 to_2022_csv_row - when 2023, 2024 + when 2023, 2024, 2025 to_year_csv_row(year, seed:) else raise NotImplementedError "No mapping function implemented for year #{year}" @@ -67,6 +67,8 @@ class BulkUpload::SalesLogToCsv [6, 3, 4, 5, nil, 28, 30, 38, 47, 51, 55, 59, 31, 39, 48, 52, 56, 60, 37, 46, 50, 54, 58, 35, 43, 49, 53, 57, 61, 32, 33, 78, 80, 79, 81, 83, 84, nil, 62, 66, 64, 65, 63, 67, 69, 70, 68, 76, 77, 16, 17, 18, 26, 24, 25, 27, 8, 91, 95, 96, 97, 92, 93, 94, 98, 100, 101, 103, 104, 106, 110, 111, 112, 113, 114, 9, 116, 117, 118, 120, 124, 125, 126, 10, 11, nil, 127, 129, 133, 134, 135, 1, 2, nil, 73, nil, 75, 107, 108, 121, 122, 130, 131, 82, 109, 123, 132, 115, 15, 86, 87, 29, 7, 12, 13, 14, 36, 44, 45, 88, 89, 102, 105, 119, 128, 19, 20, 21, 22, 23, 34, 40, 41, 42, 71, 72, 74, 85, 90, 99] when 2024 (1..131).to_a + when 2025 + (1..121).to_a else raise NotImplementedError "No mapping function implemented for year #{year}" end @@ -395,6 +397,141 @@ class BulkUpload::SalesLogToCsv ] end + def to_2025_row + [ + overrides[:organisation_id] || log.owning_organisation&.old_visible_id, + overrides[:managing_organisation_id] || log.managing_organisation&.old_visible_id, + log.assigned_to&.email, + log.saledate&.day, + log.saledate&.month, + log.saledate&.strftime("%y"), + log.purchid, + log.ownershipsch, + log.ownershipsch == 1 ? log.type : "", # field_9: "What is the type of shared ownership sale?", + log.staircase, # 10 + log.ownershipsch == 2 ? log.type : "", # field_11: "What is the type of discounted ownership sale?", + log.jointpur, + log.jointmore, + log.noint, + log.privacynotice, + + log.proptype, + log.beds, + log.builtype, + log.uprn, + log.address_line1&.tr(",", " "), # 20 + log.address_line2&.tr(",", " "), + log.town_or_city&.tr(",", " "), + log.county&.tr(",", " "), + ((log.postcode_full || "").split(" ") || [""]).first, + ((log.postcode_full || "").split(" ") || [""]).last, + log.la, + log.wchair, + + log.age1, + log.sex1, + log.ethnic, # 30 + log.nationality_all_group, + log.ecstat1, + log.buy1livein, + log.relat2, + log.age2, + log.sex2, + log.ethnic_group2, + log.nationality_all_buyer2_group, + log.ecstat2, + log.buy2livein, # 40 + log.hholdcount, + + log.relat3, + log.age3, + log.sex3, + log.ecstat3, + log.relat4, + log.age4, + log.sex4, + log.ecstat4, + log.relat5, # 50 + log.age5, + log.sex5, + log.ecstat5, + log.relat6, + log.age6, + log.sex6, + log.ecstat6, + + log.prevten, + log.ppcodenk, + ((log.ppostcode_full || "").split(" ") || [""]).first, # 60 + ((log.ppostcode_full || "").split(" ") || [""]).last, + log.prevloc, + log.buy2living, + log.prevtenbuy2, + + log.hhregres, + log.hhregresstill, + log.armedforcesspouse, + log.disabled, + log.wheel, + + log.income1, # 70 + log.inc1mort, + log.income2, + log.inc2mort, + log.hb, + log.savings.present? || "R", + log.prevown, + log.prevshared, + + log.resale, + log.proplen, + log.hodate&.day, # 80 + log.hodate&.month, + log.hodate&.strftime("%y"), + log.frombeds, + log.fromprop, + log.socprevten, + log.value, + log.equity, + log.mortgageused, + log.mortgage, + log.mortlen, # 90 + log.deposit, + log.cashdis, + log.mrent, + log.mscharge, + log.management_fee, + + log.stairbought, + log.stairowned, + log.staircasesale, + log.firststair, + log.initialpurchase&.day, # 100 + log.initialpurchase&.month, + log.initialpurchase&.strftime("%y"), + log.numstair, + log.lasttransaction&.day, + log.lasttransaction&.month, + log.lasttransaction&.strftime("%y"), + log.value, + log.equity, + log.mortgageused, + log.mrentprestaircasing, # 110 + log.mrent, + + log.proplen, + log.value, + log.grant, + log.discount, + log.mortgageused, + log.mortgage, + log.mortlen, + log.extrabor, + log.deposit, # 120 + log.mscharge, + ] + end + def custom_field_numbers_row(seed: nil, field_numbers: nil) if seed ["Field number"] + field_numbers.shuffle(random: Random.new(seed)) diff --git a/app/models/bulk_upload.rb b/app/models/bulk_upload.rb index 6616285b0..dd09b365b 100644 --- a/app/models/bulk_upload.rb +++ b/app/models/bulk_upload.rb @@ -104,6 +104,8 @@ class BulkUpload < ApplicationRecord end year_class = case year + when 2025 + "Year2025" when 2024 "Year2024" when 2023 diff --git a/app/models/forms/bulk_upload_form/prepare_your_file.rb b/app/models/forms/bulk_upload_form/prepare_your_file.rb index 911daa4fe..1838e754f 100644 --- a/app/models/forms/bulk_upload_form/prepare_your_file.rb +++ b/app/models/forms/bulk_upload_form/prepare_your_file.rb @@ -13,6 +13,8 @@ module Forms case year when 2024 "bulk_upload_#{log_type}_logs/forms/prepare_your_file_2024" + when 2025 + "bulk_upload_#{log_type}_logs/forms/prepare_your_file_2025" end end diff --git a/app/services/bulk_upload/sales/log_creator.rb b/app/services/bulk_upload/sales/log_creator.rb index 69f1580a0..a21e7a31a 100644 --- a/app/services/bulk_upload/sales/log_creator.rb +++ b/app/services/bulk_upload/sales/log_creator.rb @@ -33,6 +33,8 @@ private BulkUpload::Sales::Year2023::CsvParser.new(path:) when 2024 BulkUpload::Sales::Year2024::CsvParser.new(path:) + when 2025 + BulkUpload::Sales::Year2025::CsvParser.new(path:) else raise "csv parser not found" end diff --git a/app/services/bulk_upload/sales/validator.rb b/app/services/bulk_upload/sales/validator.rb index 7ad9638d7..0b2d68bc5 100644 --- a/app/services/bulk_upload/sales/validator.rb +++ b/app/services/bulk_upload/sales/validator.rb @@ -108,6 +108,8 @@ private BulkUpload::Sales::Year2023::CsvParser.new(path:) when 2024 BulkUpload::Sales::Year2024::CsvParser.new(path:) + when 2025 + BulkUpload::Sales::Year2025::CsvParser.new(path:) else raise "csv parser not found" end diff --git a/app/services/bulk_upload/sales/year2025/csv_parser.rb b/app/services/bulk_upload/sales/year2025/csv_parser.rb new file mode 100644 index 000000000..49113e1f4 --- /dev/null +++ b/app/services/bulk_upload/sales/year2025/csv_parser.rb @@ -0,0 +1,124 @@ +require "csv" + +class BulkUpload::Sales::Year2025::CsvParser + include CollectionTimeHelper + + FIELDS = 121 + MAX_COLUMNS = 142 + 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".."DR").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::Sales::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 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 + + def correct_field_count? + valid_field_numbers_count = field_numbers.count { |f| f != "field_blank" } + + valid_field_numbers_count == FIELDS + end + +private + + def default_field_numbers + (1..FIELDS).map do |number| + if number.to_s.match?(/^[0-9]+$/) + "field_#{number}" + else + "field_blank" + end + end + end + + def field_numbers + @field_numbers ||= if with_headers? + rows[row_offset - 1][col_offset..].map { |number| number.to_s.match?(/^[0-9]+$/) ? "field_#{number}" : "field_blank" } + else + default_field_numbers + end + end + + def headers + @headers ||= ("field_1".."field_#{FIELDS}").to_a + end + + def with_headers? + # we will eventually want to validate that headers exist for this year + 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_6.to_s.strip.length.between?(1, 2) ? row_parsers.first.field_6.to_i + 2000 : row_parsers.first.field_6.to_i + Date.new(year, row_parsers.first.field_5.to_i, row_parsers.first.field_4.to_i) + else + year = rows.first[5].to_s.strip.length.between?(1, 2) ? rows.first[5].to_i + 2000 : rows.first[5].to_i + Date.new(year, rows.first[4].to_i, rows.first[3].to_i) + end + end +end diff --git a/app/services/bulk_upload/sales/year2025/row_parser.rb b/app/services/bulk_upload/sales/year2025/row_parser.rb new file mode 100644 index 000000000..47db7de29 --- /dev/null +++ b/app/services/bulk_upload/sales/year2025/row_parser.rb @@ -0,0 +1,2037 @@ +class BulkUpload::Sales::Year2025::RowParser + include ActiveModel::Model + include ActiveModel::Attributes + include InterruptionScreenHelper + include FormattingHelper + + QUESTIONS = { + field_1: "Which organisation owned this property before the sale?", + field_2: "Which organisation is reporting this sale?", + field_3: "Username", + field_4: "What is the day of the sale completion date? - DD", + field_5: "What is the month of the sale completion date? - MM", + field_6: "What is the year of the sale completion date? - YY", + field_7: "What is the purchaser code?", + field_8: "What is the sale type?", + field_9: "What is the type of shared ownership sale?", + field_10: "Is this a staircasing transaction?", + field_11: "What is the type of discounted ownership sale?", + field_12: "Is this a joint purchase?", + field_13: "Are there more than two joint purchasers of this property?", + field_14: "Was the buyer interviewed for any of the answers you will provide on this log?", + field_15: "Data Protection question", + + field_16: "What type of unit is the property?", + field_17: "How many bedrooms does the property have?", + field_18: "Which type of building is the property?", + field_19: "If known, enter this property's UPRN", + field_20: "Address line 1", + field_21: "Address line 2", + field_22: "Town or city", + field_23: "County", + field_24: "Part 1 of postcode of property", + field_25: "Part 2 of postcode of property", + field_26: "What is the local authority of the property?", + field_27: "Is the property built or adapted to wheelchair user standards?", + + field_28: "Age of buyer 1", + field_29: "Gender identity of buyer 1", + field_30: "What is buyer 1's ethnic group?", + field_31: "What is buyer 1's nationality?", + field_32: "Working situation of buyer 1", + field_33: "Will buyer 1 live in the property?", + field_34: "Is buyer 2 or person 2 the partner of buyer 1?", + field_35: "Age of person 2", + field_36: "Gender identity of person 2", + field_37: "Which of the following best describes buyer 2's ethnic background?", + field_38: "What is buyer 2's nationality?", + field_39: "What is buyer 2 or person 2's working situation?", + field_40: "Will buyer 2 live in the property?", + field_41: "Besides the buyers, how many people will live in the property?", + + field_42: "Is person 3 the partner of buyer 1?", + field_43: "Age of person 3", + field_44: "Gender identity of person 3", + field_45: "Working situation of person 3", + field_46: "Is person 4 the partner of buyer 1?", + field_47: "Age of person 4", + field_48: "Gender identity of person 4", + field_49: "Working situation of person 4", + field_50: "Is person 5 the partner of buyer 1?", + field_51: "Age of person 5", + field_52: "Gender identity of person 5", + field_53: "Working situation of person 5", + field_54: "Is person 6 the partner of buyer 1?", + field_55: "Age of person 6", + field_56: "Gender identity of person 6", + field_57: "Working situation of person 6", + + field_58: "What was buyer 1's previous tenure?", + field_59: "Do you know the postcode of buyer 1's last settled home?", + field_60: "Part 1 of postcode of buyer 1's last settled home", + field_61: "Part 2 of postcode of buyer 1's last settled home", + field_62: "What is the local authority of buyer 1's last settled home?", + field_63: "At the time of purchase, was buyer 2 living at the same address as buyer 1?", + field_64: "What was buyer 2's previous tenure?", + + field_65: "Has the buyer ever served in the UK Armed Forces and for how long?", + field_66: "Is the buyer still serving in the UK armed forces?", + field_67: "Are any of the buyers a spouse or civil partner of a UK Armed Forces regular who died in service within the last 2 years?", + field_68: "Does anyone in the household consider themselves to have a disability?", + field_69: "Does anyone in the household use a wheelchair?", + + field_70: "What is buyer 1's gross annual income?", + field_71: "Was buyer 1's income used for a mortgage application?", + field_72: "What is buyer 2's gross annual income?", + field_73: "Was buyer 2's income used for a mortgage application?", + field_74: "Were the buyers receiving any of these housing-related benefits immediately before buying this property?", + field_75: "What is the total amount the buyers had in savings before they paid any deposit for the property?", + field_76: "Have any of the purchasers previously owned a property?", + field_77: "Was the previous property under shared ownership?", + + field_78: "Is this a resale?", + field_79: "How long have the buyers been living in the property before the purchase? - Shared ownership", + field_80: "What is the day of the practical completion or handover date?", + field_81: "What is the month of the practical completion or handover date?", + field_82: "What is the year of the practical completion or handover date?", + field_83: "How many bedrooms did the buyer's previous property have?", + field_84: "What was the type of the buyer's previous property?", + field_85: "What was the rent type of the buyer's previous property?", + field_86: "What was the full purchase price?", + field_87: "What was the initial percentage share purchased?", + field_88: "Was a mortgage used for the purchase of this property? - Shared ownership", + field_89: "What is the mortgage amount?", + field_90: "What is the length of the mortgage in years? - Shared ownership", + field_91: "How much was the cash deposit paid on the property?", + field_92: "How much cash discount was given through Social Homebuy?", + field_93: "What is the basic monthly rent?", + field_94: "What are the total monthly service charges for the property?", + field_95: "What are the total monthly estate management fees for the property?", + + field_96: "What percentage of the property has been bought in this staircasing transaction?", + field_97: "What percentage of the property does the buyer now own in total?", + field_98: "Was this transaction part of a back-to-back staircasing transaction to facilitate sale of the home on the open market?", + field_99: "Is this the first time the buyer has engaged in staircasing in the home?", + field_100: "What was the day of the initial purchase of a share in the property? DD", + field_101: "What was the month of the initial purchase of a share in the property? MM", + field_102: "What was the year of the initial purchase of a share in the property? YYYY", + field_103: "Including this time, how many times has the shared owner engaged in staircasing in the home?", + field_104: "What was the day of the last staircasing transaction? DD", + field_105: "What was the month of the last staircasing transaction? MM", + field_106: "What was the year of the last staircasing transaction? YYYY", + field_107: "What is the full purchase price for this staircasing transaction?", + field_108: "What was the percentage share purchased in the initial transaction?", + field_109: "Was a mortgage used for this staircasing transaction?", + field_110: "What was the basic monthly rent prior to staircasing?", + field_111: "What is the basic monthly rent after staircasing?", + + field_112: "How long have the buyers been living in the property before the purchase? - Discounted ownership", + field_113: "What was the full purchase price?", + field_114: "What was the amount of any loan, grant, discount or subsidy given?", + field_115: "What was the percentage discount?", + field_116: "Was a mortgage used for the purchase of this property? - Discounted ownership", + field_117: "What is the mortgage amount?", + field_118: "What is the length of the mortgage in years? - Discounted ownership", + field_119: "Does this include any extra borrowing?", + field_120: "How much was the cash deposit paid on the property?", + field_121: "What are the total monthly leasehold charges for the property?", + }.freeze + + ERROR_BASE_KEY = "validations.sales.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_5, :integer + attribute :field_6, :integer + attribute :field_7, :string + attribute :field_8, :integer + attribute :field_9, :integer + attribute :field_10, :integer + attribute :field_11, :integer + attribute :field_12, :integer + attribute :field_13, :integer + attribute :field_14, :integer + attribute :field_15, :integer + + attribute :field_16, :integer + attribute :field_17, :integer + attribute :field_18, :integer + 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_26, :string + attribute :field_27, :integer + + attribute :field_28, :string + attribute :field_29, :string + attribute :field_30, :integer + attribute :field_31, :integer + attribute :field_32, :integer + attribute :field_33, :integer + attribute :field_34, :integer + attribute :field_35, :string + attribute :field_36, :string + attribute :field_37, :integer + attribute :field_38, :integer + attribute :field_39, :integer + attribute :field_40, :integer + attribute :field_41, :integer + + attribute :field_42, :integer + attribute :field_43, :string + attribute :field_44, :string + attribute :field_45, :integer + attribute :field_46, :integer + attribute :field_47, :string + attribute :field_48, :string + attribute :field_49, :integer + attribute :field_49, :integer + attribute :field_51, :string + attribute :field_52, :string + attribute :field_53, :integer + attribute :field_54, :integer + attribute :field_55, :string + attribute :field_56, :string + attribute :field_57, :integer + + attribute :field_58, :integer + attribute :field_59, :integer + attribute :field_60, :string + attribute :field_61, :string + attribute :field_62, :string + attribute :field_63, :integer + attribute :field_64, :string + + attribute :field_65, :integer + attribute :field_66, :integer + attribute :field_67, :integer + attribute :field_68, :integer + attribute :field_69, :integer + + attribute :field_70, :string + attribute :field_71, :integer + attribute :field_72, :string + attribute :field_73, :integer + attribute :field_74, :integer + attribute :field_75, :string + 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, :decimal + + attribute :field_96, :integer + attribute :field_97, :integer + attribute :field_98, :integer + attribute :field_99, :integer + attribute :field_100, :integer + attribute :field_101, :integer + attribute :field_102, :integer + attribute :field_103, :integer + attribute :field_104, :integer + attribute :field_105, :integer + attribute :field_106, :integer + attribute :field_107, :integer + attribute :field_108, :integer + attribute :field_109, :integer + attribute :field_110, :integer + attribute :field_111, :decimal + + 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, :integer + attribute :field_120, :integer + attribute :field_121, :integer + + + validates :field_4, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (day)."), + category: :setup, + }, + on: :after_log + + validates :field_5, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (month)."), + category: :setup, + }, on: :after_log + + validates :field_6, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "sale completion date (year)."), + category: :setup, + }, + format: { + with: /\A(\d{2}|\d{4})\z/, + message: I18n.t("#{ERROR_BASE_KEY}.saledate.year_not_two_or_four_digits"), + category: :setup, + if: proc { field_6.present? }, + }, on: :after_log + + validates :field_8, + inclusion: { + in: [1, 2], + if: proc { field_8.present? }, + category: :setup, + question: QUESTIONS[:field_8].downcase, + }, + on: :before_log + + validates :field_8, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "shared ownership sale type."), + category: :setup, + }, + on: :after_log + + validates :field_9, + inclusion: { + in: [2, 30, 18, 16, 24, 28, 31, 32], + if: proc { field_9.present? }, + category: :setup, + question: QUESTIONS[:field_9].downcase, + }, + on: :before_log + + validates :field_9, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "type of shared ownership sale."), + category: :setup, + if: :shared_ownership?, + }, + on: :after_log + + validates :field_10, + inclusion: { + in: [1, 2], + if: proc { field_10.present? }, + category: :setup, + question: QUESTIONS[:field_10].downcase, + }, + on: :before_log + + validates :field_10, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "staircasing transaction."), + category: :setup, + if: :shared_ownership?, + }, + on: :after_log + + validates :field_11, + inclusion: { + in: [8, 14, 27, 9, 29, 21, 22], + if: proc { field_11.present? }, + category: :setup, + question: QUESTIONS[:field_11].downcase, + }, + on: :before_log + + validates :field_11, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "type of discounted ownership sale."), + category: :setup, + if: :discounted_ownership?, + }, + on: :after_log + + validates :field_115, + numericality: { + message: I18n.t("#{ERROR_BASE_KEY}.numeric.within_range", field: "Percentage discount", min: "0%", max: "70%"), + greater_than_or_equal_to: 0, + less_than_or_equal_to: 70, + if: :discounted_ownership?, + }, + on: :before_log + + validates :field_12, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "joint purchase."), + category: :setup, + if: :joint_purchase_asked?, + }, + on: :after_log + + validates :field_13, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "more than 2 joint buyers."), + category: :setup, + if: :joint_purchase?, + }, + on: :after_log + + validates :field_18, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "type of building."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_27, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "meets wheelchair standards."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_30, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1 ethnicity."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_31, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1 nationality."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_32, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1 working situation."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_33, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether buyer 1 will live in the property."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_37, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 2 ethnic background."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_38, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 2 nationality."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_39, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 2 working situation."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_40, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether buyer 2 will live in the property."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_41, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "number of other people living in the property."), + category: :setup, + unless: :staircasing?, + }, + on: :after_log + + validates :field_58, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1's previous tenure."), + category: :setup, + unless: :staircasing?, + }, + on: :after_log + + validates :field_59, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1's last settled accommodation."), + category: :setup, + unless: :discounted_ownership || :staircasing?, + }, + on: :after_log + + validates :field_63, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether buyer 2 was living with buyer 1."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_64, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 2's previous tenure."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing? && two_buyers_share_address, + }, + on: :after_log + + validates :field_65, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether a buyer has served in the armed forces."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_66, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether a buyer is currently in the armed forces."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_67, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether a buyer's spouse recently died in service."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_68, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether any buyer has a disability."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_69, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether any buyer uses a wheelchair."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_70, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 1's annual income."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_72, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer 2's annual income."), + category: :setup, + if: :joint_purchase && !:staircasing?, + }, + on: :after_log + + validates :field_74, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether buyers were receiving housing benefits."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_75, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "the buyers' total savings."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_76, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether the buyers have previously owned a property."), + category: :setup, + unless: staircasing?, + }, + on: :after_log + + validates :field_78, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether this is a resale."), + category: :setup, + if: :shared_or_discounted_but_not_staircasing?, + }, + on: :after_log + + validates :field_79, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "how long the buyer lived there prior to purchase."), + category: :setup, + if: :shared_ownership_initial_purchase? && :not_resale?, + }, + on: :after_log + + validates :field_80, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "day of practical completion or handover."), + category: :setup, + if: :shared_ownership_initial_purchase? && :not_resale?, + }, + on: :after_log + + validates :field_81, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "month of practical completion or handover."), + category: :setup, + if: :shared_ownership_initial_purchase? && :not_resale?, + }, + on: :after_log + + validates :field_82, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "year of practical completion or handover."), + category: :setup, + if: :shared_ownership_initial_purchase? && :not_resale?, + }, + on: :after_log + + validates :field_83, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "number of bedrooms in previous property."), + category: :setup, + if: :shared_ownership_initial_purchase? & :buyer_1_previous_tenure_not_1_or_2?, + }, + on: :after_log + + validates :field_84, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "previous property type."), + category: :setup, + if: :shared_ownership_initial_purchase? & :buyer_1_previous_tenure_not_1_or_2?, + }, + on: :after_log + + validates :field_85, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "buyer's previous tenure."), + category: :setup, + if: :shared_ownership_initial_purchase? & :buyer_1_previous_tenure_not_1_or_2?, + }, + on: :after_log + + validates :field_86, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "full purchase price."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_87, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "initial percentage share purchased."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_88, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether a mortgage was used."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_88, + inclusion: { + in: [1, 2], + if: proc { field_88.present? }, + category: :setup, + question: QUESTIONS[:field_88].downcase, + }, + on: :before_log + + validates :field_89, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "mortgage amount."), + category: :setup, + if: :shared_ownership_initial_purchase? & :mortgage_used?, + }, + on: :after_log + + validates :field_90, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "mortgage length."), + category: :setup, + if: :shared_ownership_initial_purchase? & :mortgage_used?, + }, + on: :after_log + + validates :field_91, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "deposit amount."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_92, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "deposit amount."), + category: :setup, + if: :shared_ownership_initial_purchase? & !:social_homebuy?, + }, + on: :after_log + + validates :field_93, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "basic monthly rent."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_94, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "monthly service charges."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_95, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "monthly estate management fees."), + category: :setup, + if: :shared_ownership_initial_purchase?, + }, + on: :after_log + + validates :field_96, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "percentage of property bought this transaction."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_97, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "total percentage of property buyers now own."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_98, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether transaction is part of a back to back staircasing transaction."), + category: :setup, + if: :staircasing? && :buyers_own_all?, + }, + on: :after_log + + validates :field_99, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether it is the first time the buyer has engaged in staircasing."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_99, + inclusion: { + in: [1, 2], + if: proc { field_99.present? }, + category: :setup, + question: QUESTIONS[:field_99].downcase, + }, + on: :before_log + + validates :field_100, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "day of the initial purchase of a share."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_101, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "month of the initial purchase of a share."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_102, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "year of the initial purchase of a share."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_103, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "number of times buyer has engaged in staircasing."), + category: :setup, + if: :staircasing? && :buyer_staircased_before?, + }, + on: :after_log + + validates :field_103, + inclusion: { + in: [2, 3, 4, 5, 6, 7, 8, 9, 10 ], + if: proc { field_103.present? }, + category: :setup, + question: QUESTIONS[:field_103].downcase, + }, + on: :before_log + + validates :field_104, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "day of last staircasing transaction."), + category: :setup, + if: :staircasing? && :buyer_staircased_before?, + }, + on: :after_log + + validates :field_105, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "month of last staircasing transaction."), + category: :setup, + if: :staircasing? && :buyer_staircased_before?, + }, + on: :after_log + + validates :field_106, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "year of last staircasing transaction."), + category: :setup, + if: :staircasing? && :buyer_staircased_before?, + }, + on: :after_log + + validates :field_107, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "full purchase price."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_108, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "percentage share purchased initially."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_109, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether a mortgage was used."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_110, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "basic monthly rent before staircasing."), + category: :setup, + if: :staircasing?, + }, + on: :after_log + + validates :field_111, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "basic monthly rent after staircasing."), + category: :setup, + if: :staircasing? && !:buyers_own_all?, + }, + on: :after_log + + validates :field_112, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "how long buyers lived in property before purchasing."), + category: :setup, + if: :discounted_ownership?, + }, + on: :after_log + + validates :field_113, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "full purchase price."), + category: :setup, + if: :discounted_ownership?, + }, + on: :after_log + + validates :field_114, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "amount of loan or subsidy."), + category: :setup, + if: :discounted_ownership? && !:rtb_like_sale_type?, + }, + on: :after_log + + validates :field_115, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "percentage discount."), + category: :setup, + if: :discounted_ownership? && :rtb_like_sale_type?, + }, + on: :after_log + + validates :field_117, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "mortgage amount."), + category: :setup, + if: :discounted_ownership? && :mortgage_used?, + }, + on: :after_log + + validates :field_118, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "mortgage duration."), + category: :setup, + if: :discounted_ownership? && :mortgage_used?, + }, + on: :after_log + + validates :field_119, + presence: { + message: I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "whether any extra borrowing is included."), + category: :setup, + if: :discounted_ownership? && :mortgage_used?, + }, + on: :after_log + + validate :validate_buyer1_economic_status, on: :before_log + validate :validate_address_option_found, on: :after_log + validate :validate_buyer2_economic_status, on: :before_log + validate :validate_valid_radio_option, on: :before_log + + 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_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_managing_org_related, on: :after_log + validate :validate_relevant_collection_window, on: :after_log + validate :validate_incomplete_soft_validations, on: :after_log + + validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log + validate :validate_address_fields, on: :after_log + validate :validate_if_log_already_exists, on: :after_log, if: -> { FeatureToggle.bulk_upload_duplicate_log_check_enabled? } + + validate :validate_nationality, on: :after_log + validate :validate_buyer_2_nationality, on: :after_log + + validate :validate_nulls, on: :after_log + + def self.question_for_field(field) + QUESTIONS[field] + end + + def attribute_set + @attribute_set ||= instance_variable_get(:@attributes) + end + + def blank_row? + attribute_set + .to_hash + .reject { |k, _| %w[bulk_upload block_log_creation].include?(k) } + .values + .reject(&:blank?) + .compact + .empty? + end + + def log + @log ||= SalesLog.new(attributes_for_log) + end + + def valid? + errors.clear + + return 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 + + errors.blank? + end + + def block_log_creation? + block_log_creation + end + + def inspect + "#" + end + + def log_already_exists? + return false if blank_row? + + @log_already_exists ||= SalesLog + .where(status: %w[not_started in_progress completed]) + .exists?(duplicate_check_fields.index_with { |field| log.public_send(field) }) + end + + def purchaser_code + field_7 + end + + def spreadsheet_duplicate_hash + attributes.slice( + "field_1", # owning org + "field_4", # saledate + "field_5", # saledate + "field_6", # saledate + "field_7", # purchaser_code + "field_24", # postcode + "field_25", # postcode + "field_28", # age1 + "field_29", # sex1 + "field_32", # ecstat1 + ) + end + + def add_duplicate_found_in_spreadsheet_errors + spreadsheet_duplicate_hash.each_key do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.spreadsheet_dupe"), category: :setup) + end + end + +private + + def prevtenbuy2 + case field_64 + when "R" + 0 + else + field_64 + end + end + + def infer_buyer2_ethnic_group_from_ethnic + case field_37 + 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 + else + field_37 + end + end + + def validate_uprn_exists_if_any_key_address_fields_are_blank + if field_19.blank? && !key_address_fields_provided? + %i[field_20 field_22 field_24 field_25].each do |field| + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.address.not_answered")) if send(field).blank? + end + errors.add(:field_19, I18n.t("#{ERROR_BASE_KEY}.address.not_answered", question: "UPRN.")) + end + end + + def validate_address_option_found + if log.uprn.nil? && field_19.blank? && key_address_fields_provided? + error_message = if log.address_options_present? + I18n.t("#{ERROR_BASE_KEY}.address.not_determined") + else + I18n.t("#{ERROR_BASE_KEY}.address.not_found") + end + %i[field_20 field_21 field_22 field_23 field_24 field_25].each do |field| + errors.add(field, error_message) if errors[field].blank? + end + end + end + + def key_address_fields_provided? + field_20.present? && field_22.present? && postcode_full.present? + end + + def validate_address_fields + if field_19.blank? || log.errors.attribute_names.include?(:uprn) + if field_20.blank? && errors[:field_20].blank? + errors.add(:field_20, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "address line 1.")) + end + + if field_22.blank? && errors[:field_22].blank? + errors.add(:field_22, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "town or city.")) + end + + if field_24.blank? && errors[:field_24].blank? + errors.add(:field_24, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 1 of postcode.")) + end + + if field_25.blank? && errors[:field_25].blank? + errors.add(:field_25, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 2 of postcode.")) + end + end + end + + def shared_ownership? + field_8 == 1 + end + + def discounted_ownership? + field_8 == 2 + end + + def joint_purchase? + field_12 == 1 + end + + def joint_purchase_asked? + shared_ownership? || discounted_ownership? || field_13 == 2 + end + + def shared_or_discounted_but_not_staircasing? + (shared_ownership? || discounted_ownership?) && field_10 != 1 + end + + def shared_ownership_initial_purchase? + field_8 == 1 && field_10 != 1 + end + + def staircasing? + field_8 == 1 && field_10 == 1 + end + + def two_buyers_share_address + field_63 == 2 + end + + def not_resale + field_78 == 2 + end + + def buyer_1_previous_tenure_not_1_or_2 + field_58 != 1 && field_58 != 2 + end + + def mortgage_used + field_88 == 2 + end + + def social_homebuy + field_9 == 18 + end + + def buyers_own_all + field_97 == 100 + end + + def buyer_staircased_before + field_99 == 1 + end + + def rtb_like_sale_type + [9, 14, 27, 29].includes(field_11) + end + + def field_mapping_for_errors + { + purchid: %i[field_7], + saledate: %i[field_4 field_5 field_6], + noint: %i[field_14], + age1_known: %i[field_28], + age1: %i[field_28], + age2_known: %i[field_35], + age2: %i[field_35], + age3_known: %i[field_43], + age3: %i[field_43], + age4_known: %i[field_47], + age4: %i[field_47], + age5_known: %i[field_51], + age5: %i[field_51], + age6_known: %i[field_55], + age6: %i[field_55], + sex1: %i[field_29], + sex2: %i[field_36], + sex3: %i[field_44], + + sex4: %i[field_48], + sex5: %i[field_52], + sex6: %i[field_56], + relat2: %i[field_34], + relat3: %i[field_42], + relat4: %i[field_46], + relat5: %i[field_49], + relat6: %i[field_54], + + ecstat1: %i[field_32], + ecstat2: %i[field_39], + ecstat3: %i[field_45], + + ecstat4: %i[field_49], + ecstat5: %i[field_53], + ecstat6: %i[field_57], + ethnic_group: %i[field_30], + ethnic: %i[field_30], + nationality_all: %i[field_31], + nationality_all_group: %i[field_31], + income1nk: %i[field_70], + income1: %i[field_70], + income2nk: %i[field_72], + income2: %i[field_72], + inc1mort: %i[field_71], + inc2mort: %i[field_73], + savingsnk: %i[field_75], + savings: %i[field_75], + prevown: %i[field_76], + prevten: %i[field_58], + prevloc: %i[field_62], + previous_la_known: %i[field_62], + ppcodenk: %i[field_59], + ppostcode_full: %i[field_60 field_61], + disabled: %i[field_68], + + wheel: %i[field_69], + beds: %i[field_17], + proptype: %i[field_16], + builtype: %i[field_18], + la_known: %i[field_26], + la: %i[field_26], + + is_la_inferred: %i[field_26], + pcodenk: %i[field_24 field_25], + postcode_full: %i[field_24 field_25], + wchair: %i[field_27], + + type: %i[field_9 field_11 field_8], + resale: %i[field_78], + hodate: %i[field_80 field_81 field_82], + + frombeds: %i[field_83], + fromprop: %i[field_84], + value: value_fields, + equity: equity_fields, + mortgage: mortgage_fields, + extrabor: extrabor_fields, + deposit: deposit_fields, + cashdis: %i[field_92], + mrent: mrent_fields, + + has_mscharge: mscharge_fields, + mscharge: mscharge_fields, + grant: %i[field_114], + discount: %i[field_115], + owning_organisation_id: %i[field_1], + managing_organisation_id: [:field_2], + assigned_to: %i[field_3], + hhregres: %i[field_65], + hhregresstill: %i[field_66], + armedforcesspouse: %i[field_67], + + mortgagelenderother: mortgagelenderother_fields, + + hb: %i[field_74], + mortlen: mortlen_fields, + proplen: proplen_fields, + + jointmore: %i[field_13], + staircase: %i[field_10], + privacynotice: %i[field_15], + ownershipsch: %i[field_8], + + jointpur: %i[field_12], + buy1livein: %i[field_33], + buy2livein: %i[field_40], + hholdcount: %i[field_41], + stairbought: %i[field_96], + stairowned: %i[field_97], + socprevten: %i[field_85], + mortgageused: mortgageused_fields, + + uprn: %i[field_19], + address_line1: %i[field_20], + address_line2: %i[field_21], + town_or_city: %i[field_22], + county: %i[field_23], + uprn_selection: [:field_20], + + ethnic_group2: %i[field_37], + ethnicbuy2: %i[field_37], + nationality_all_buyer2: %i[field_38], + nationality_all_buyer2_group: %i[field_38], + + buy2living: %i[field_63], + prevtenbuy2: %i[field_64], + + prevshared: %i[field_77], + + staircasesale: %i[field_98], + firststair: %i[field_99], + numstair: %i[field_103], + mrentprestaircasing: %i[field_110], + lasttransaction: %i[field_104 field_105 field_106], + initialpurchase: %i[field_100 field_101 field_102], + + } + end + + def attributes_for_log + attributes = {} + + attributes["purchid"] = purchaser_code + attributes["saledate"] = saledate + attributes["noint"] = field_14 + + attributes["age1_known"] = age1_known? + attributes["age1"] = field_28 if attributes["age1_known"]&.zero? && field_28&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age2_known"] = age2_known? + attributes["age2"] = field_35 if attributes["age2_known"]&.zero? && field_35&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age3_known"] = age3_known? + attributes["age3"] = field_43 if attributes["age3_known"]&.zero? && field_43&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age4_known"] = age4_known? + attributes["age4"] = field_47 if attributes["age4_known"]&.zero? && field_47&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age5_known"] = age5_known? + attributes["age5"] = field_51 if attributes["age5_known"]&.zero? && field_51&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age6_known"] = age6_known? + attributes["age6"] = field_55 if attributes["age6_known"]&.zero? && field_55&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["sex1"] = field_29 + attributes["sex2"] = field_36 + attributes["sex3"] = field_44 + attributes["sex4"] = field_48 + attributes["sex5"] = field_52 + attributes["sex6"] = field_56 + + attributes["relat2"] = field_34 == 1 ? "P" : (field_34 == 2 ? "X" : "R") + attributes["relat3"] = field_42 == 1 ? "P" : (field_42 == 2 ? "X" : "R") + attributes["relat4"] = field_46 == 1 ? "P" : (field_46 == 2 ? "X" : "R") + attributes["relat5"] = field_49 == 1 ? "P" : (field_49 == 2 ? "X" : "R") + attributes["relat6"] = field_54 == 1 ? "P" : (field_54 == 2 ? "X" : "R") + + attributes["ecstat1"] = field_32 + attributes["ecstat2"] = field_39 + attributes["ecstat3"] = field_45 + attributes["ecstat4"] = field_49 + attributes["ecstat5"] = field_53 + attributes["ecstat6"] = field_57 + + 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["ethnic_group"] = ethnic_group_from_ethnic + attributes["ethnic"] = field_30 + attributes["nationality_all"] = field_31 if field_31.present? && valid_nationality_options.include?(field_31.to_s) + attributes["nationality_all_group"] = nationality_group(attributes["nationality_all"]) + + attributes["income1nk"] = field_70 == "R" ? 1 : 0 + attributes["income1"] = field_70.to_i if attributes["income1nk"]&.zero? && field_70&.match(/\A\d+\z/) + + attributes["income2nk"] = field_72 == "R" ? 1 : 0 + attributes["income2"] = field_72.to_i if attributes["income2nk"]&.zero? && field_72&.match(/\A\d+\z/) + + attributes["inc1mort"] = field_71 + attributes["inc2mort"] = field_73 + + attributes["savingsnk"] = field_75 == "R" ? 1 : 0 + attributes["savings"] = field_75.to_i if attributes["savingsnk"]&.zero? && field_75&.match(/\A\d+\z/) + attributes["prevown"] = field_76 + + attributes["prevten"] = field_58 + attributes["prevloc"] = field_62 + attributes["previous_la_known"] = previous_la_known + attributes["ppcodenk"] = previous_postcode_known + attributes["ppostcode_full"] = ppostcode_full + + attributes["disabled"] = field_68 + attributes["wheel"] = field_69 + attributes["beds"] = field_17 + attributes["proptype"] = field_16 + attributes["builtype"] = field_18 + attributes["la_known"] = field_26.present? ? 1 : 0 + attributes["la"] = field_26 + attributes["la_as_entered"] = field_26 + attributes["is_la_inferred"] = false + attributes["pcodenk"] = 0 if postcode_full.present? + attributes["postcode_full"] = postcode_full + attributes["postcode_full_as_entered"] = postcode_full + attributes["wchair"] = field_27 + + attributes["type"] = sale_type + attributes["resale"] = field_78 + + attributes["hodate"] = hodate + + attributes["frombeds"] = field_83 + attributes["fromprop"] = field_84 + + attributes["value"] = value + attributes["equity"] = equity + attributes["mortgage"] = mortgage + attributes["extrabor"] = extrabor + attributes["deposit"] = deposit + + attributes["cashdis"] = field_92 + attributes["mrent"] = mrent + attributes["mscharge"] = mscharge if mscharge&.positive? + attributes["has_mscharge"] = attributes["mscharge"].present? ? 1 : 0 + attributes["grant"] = field_114 + attributes["discount"] = field_115 + + attributes["owning_organisation"] = owning_organisation + attributes["managing_organisation"] = managing_organisation + attributes["assigned_to"] = assigned_to || (bulk_upload.user.support? ? nil : bulk_upload.user) + attributes["created_by"] = bulk_upload.user + attributes["hhregres"] = field_65 + attributes["hhregresstill"] = field_66 + attributes["armedforcesspouse"] = field_67 + + attributes["mortgagelender"] = mortgagelender + attributes["mortgagelenderother"] = mortgagelenderother + + attributes["hb"] = field_74 + + attributes["mortlen"] = mortlen + + attributes["proplen"] = proplen if proplen&.positive? + attributes["proplen_asked"] = attributes["proplen"]&.present? ? 0 : 1 + attributes["jointmore"] = field_13 + attributes["staircase"] = field_10 + attributes["privacynotice"] = field_15 + attributes["ownershipsch"] = field_8 + attributes["jointpur"] = field_12 + attributes["buy1livein"] = field_33 + attributes["buy2livein"] = field_40 + attributes["hholdcount"] = field_41 + attributes["stairbought"] = field_96 + attributes["stairowned"] = field_97 + attributes["socprevten"] = field_85 + attributes["soctenant"] = infer_soctenant_from_prevten_and_prevtenbuy2 + attributes["mortgageused"] = mortgageused + + attributes["uprn"] = field_19 + attributes["uprn_known"] = field_19.present? ? 1 : 0 + attributes["uprn_confirmed"] = 1 if field_19.present? + attributes["skip_update_uprn_confirmed"] = true + attributes["address_line1"] = field_20 + attributes["address_line1_as_entered"] = field_20 + attributes["address_line2"] = field_21 + attributes["address_line2_as_entered"] = field_21 + attributes["town_or_city"] = field_22 + attributes["town_or_city_as_entered"] = field_22 + attributes["county"] = field_23 + attributes["county_as_entered"] = field_23 + attributes["address_line1_input"] = address_line1_input + attributes["postcode_full_input"] = postcode_full + attributes["select_best_address_match"] = true if field_19.blank? + + attributes["ethnic_group2"] = infer_buyer2_ethnic_group_from_ethnic + attributes["ethnicbuy2"] = field_37 + attributes["nationality_all_buyer2"] = field_38 if field_38.present? && valid_nationality_options.include?(field_38.to_s) + attributes["nationality_all_buyer2_group"] = nationality_group(attributes["nationality_all_buyer2"]) + + attributes["buy2living"] = field_63 + attributes["prevtenbuy2"] = prevtenbuy2 + + attributes["prevshared"] = field_77 + + attributes["staircasesale"] = field_98 + + attributes["firststair"] = field_99 + attributes["numstair"] = field_103 + attributes["mrentprestaircasing"] = field_110 + attributes["lasttransaction"] = lasttransaction + attributes["initialpurchase"] = initialpurchase + + attributes["management_fee"] = field_95 + attributes["has_management_fee"] = field_95.present? && field_95 > 0 ? 1 : 0 + end + + def address_line1_input + [field_20, field_21, field_22].compact.join(", ") + end + + def saledate + year = field_6.to_s.strip.length.between?(1, 2) ? field_6 + 2000 : field_6 + Date.new(year, field_5, field_4) if field_6.present? && field_5.present? && field_4.present? + rescue Date::Error + Date.new + end + + def hodate + year = field_82.to_s.strip.length.between?(1, 2) ? field_82 + 2000 : field_82 + Date.new(year, field_81, field_80) if field_82.present? && field_81.present? && field_80.present? + rescue Date::Error + Date.new + end + + def lasttransaction + year = field_106.to_s.strip.length.between?(1, 2) ? field_106 + 2000 : field_106 + Date.new(year, field_105, field_104) if field_106.present? && field_105.present? && field_104.present? + rescue Date::Error + Date.new + end + + def initialpurchase + year = field_102.to_s.strip.length.between?(1, 2) ? field_102 + 2000 : field_102 + Date.new(year, field_101, field_100) if field_102.present? && field_101.present? && field_100.present? + rescue Date::Error + Date.new + end + + def age1_known? + return 1 if field_28 == "R" + + 0 + end + + [ + { person: 2, field: :field_35 }, + { person: 3, field: :field_43 }, + { person: 4, field: :field_47 }, + { person: 5, field: :field_51 }, + { person: 6, field: :field_55 }, + ].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 person_2_present? + field_35.present? || field_36.present? || field_34.present? + end + + def person_3_present? + field_43.present? || field_44.present? || field_42.present? + end + + def person_4_present? + field_47.present? || field_48.present? || field_46.present? + end + + def person_5_present? + field_51.present? || field_52.present? || field_49.present? + end + + def person_6_present? + field_55.present? || field_56.present? || field_54.present? + end + + def details_known?(person_n) + send("person_#{person_n}_present?") ? 1 : 2 + end + + def ethnic_group_from_ethnic + return nil if field_30.blank? + + case field_30 + 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 postcode_full + [field_24, field_25].compact_blank.join(" ") if field_24 || field_25 + end + + def ppostcode_full + "#{field_60} #{field_61}" if field_60 && field_61 + end + + def sale_type + return field_9 if shared_ownership? + return field_11 if discounted_ownership? + end + + def value + return field_86 if shared_ownership_initial_purchase? + return field_113 if discounted_ownership? + return field_107 if staircasing? + end + + def equity + return field_87 if shared_ownership_initial_purchase? + return field_108 if staircasing? + end + + def mortgage + return field_89 if shared_ownership? + return field_117 if discounted_ownership? + end + + def extrabor + return field_119 if discounted_ownership? + end + + def deposit + return field_91 if shared_ownership? + return field_120 if discounted_ownership? + end + + def mrent + return field_93 if shared_ownership_initial_purchase? + return field_111 if staircasing? + end + def mscharge + return field_94 if shared_ownership? + return field_121 if discounted_ownership? + end + + def mortlen + return field_90 if shared_ownership? + return field_118 if discounted_ownership? + end + + def proplen + return field_79 if shared_ownership? + return field_112 if discounted_ownership? + end + + def mortgageused + return field_88 if shared_ownership_initial_purchase? + return field_116 if discounted_ownership? + return field_109 if staircasing? + end + + def value_fields + return [:field_86] if shared_ownership_initial_purchase? + return [:field_113] if discounted_ownership? + return [:field_107] if staircasing? + + %i[field_86 field_113 field_107] + end + + def equity_fields + return [:field_87] if shared_ownership_initial_purchase? + return [:field_108] if staircasing? + + %i[field_87 field_108] + end + + def mortgage_fields + return [:field_89] if shared_ownership? + return [:field_117] if discounted_ownership? + + %i[field_89 field_117] + end + + def extrabor_fields + return [:field_119] if discounted_ownership? + + %i[field_119] + end + + def deposit_fields + return [:field_91] if shared_ownership? + return [:field_120] if discounted_ownership? + + %i[field_91 field_120] + end + + def mrent_fields + return [:field_93] if shared_ownership_initial_purchase? + return [:field_111] if staircasing? + + %i[field_93 field_111] + end + + def mscharge_fields + return [:field_94] if shared_ownership? + return [:field_121] if discounted_ownership? + + %i[field_94 field_121] + end + + def mortlen_fields + return [:field_90] if shared_ownership? + return [:field_118] if discounted_ownership? + + %i[field_90 field_118] + end + + def proplen_fields + return [:field_79] if shared_ownership? + return [:field_112] if discounted_ownership? + + %i[field_79 field_112] + end + + def mortgageused_fields + return [:field_88] if shared_ownership_initial_purchase? + return [:field_116] if discounted_ownership? + return [:field_109] if staircasing? + + %i[field_88 field_116 field_109] + end + + def owning_organisation + @owning_organisation ||= Organisation.find_by_id_on_multiple_fields(field_1) + end + + def assigned_to + @assigned_to ||= User.where("lower(email) = ?", field_3&.downcase).first + end + + def previous_la_known + field_62.present? ? 1 : 0 + end + + def previous_postcode_known + return 1 if field_59 == 2 + + 0 if field_59 == 1 + end + + def infer_soctenant_from_prevten_and_prevtenbuy2 + return unless shared_ownership? + + if [1, 2].include?(field_58) || [1, 2].include?(field_64.to_i) + 1 + else + 2 + end + end + + def block_log_creation! + self.block_log_creation = true + end + + def questions + @questions ||= log.form.subsections.flat_map { |ss| ss.applicable_questions(log) } + end + + def duplicate_check_fields + %w[ + saledate + age1 + sex1 + ecstat1 + owning_organisation + postcode_full + purchid + ] + end + + def validate_owning_org_data_given + if field_1.blank? + block_log_creation! + + if errors[:field_1].blank? + errors.add(:field_1, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "owning organisation."), 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_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_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", 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_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 sales 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"), category: :setup) + end + + def managing_organisation + Organisation.find_by_id_on_multiple_fields(field_2) + 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 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}.assigned_to.managing_organisation_not_related"), category: :setup) + end + end + end + + def setup_question?(question) + log.form.setup_sections[0].subsections[0].questions.include?(question) + 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 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_valid_radio_option + log.attributes.each do |question_id, _v| + question = log.form.get_question(question_id, log) + + next if question_id == "type" + + 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] || [] + + if setup_question?(question) + fields.each do |field| + if errors[field].none? + block_log_creation! + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: format_ending(QUESTIONS[field])), category: :setup) + end + end + else + fields.each do |field| + unless errors.any? { |e| fields.include?(e.attribute) } + errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: format_ending(QUESTIONS[field]))) + end + end + end + end + end + + def validate_relevant_collection_window + return if saledate.blank? || bulk_upload.form.blank? + return if errors.key?(:field_4) || errors.key?(:field_5) || errors.key?(:field_6) + + unless bulk_upload.form.valid_start_date_for_form?(saledate) + errors.add(:field_4, I18n.t("#{ERROR_BASE_KEY}.saledate.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_5, I18n.t("#{ERROR_BASE_KEY}.saledate.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_6, I18n.t("#{ERROR_BASE_KEY}.saledate.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_if_log_already_exists + if log_already_exists? + error_message = I18n.t("#{ERROR_BASE_KEY}.duplicate") + + errors.add(:field_1, error_message) # Owning org + errors.add(:field_4, error_message) # Sale completion date + errors.add(:field_5, error_message) # Sale completion date + errors.add(:field_6, error_message) # Sale completion date + errors.add(:field_24, error_message) # Postcode + errors.add(:field_25, error_message) # Postcode + errors.add(:field_28, error_message) # Buyer 1 age + errors.add(:field_29, error_message) # Buyer 1 gender + errors.add(:field_32, error_message) # Buyer 1 working situation + errors.add(:field_7, error_message) # Purchaser code + 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| e.options[:category] == :soft_validation && 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_buyer1_economic_status + if field_32 == 9 + if field_28.present? && field_28.to_i >= 16 + errors.add(:field_32, I18n.t("#{ERROR_BASE_KEY}.ecstat1.buyer_cannot_be_over_16_and_child")) + errors.add(:field_28, I18n.t("#{ERROR_BASE_KEY}.age1.buyer_cannot_be_over_16_and_child")) + else + errors.add(:field_32, I18n.t("#{ERROR_BASE_KEY}.ecstat1.buyer_cannot_be_child")) + end + end + end + + def validate_buyer2_economic_status + return unless joint_purchase? + + if field_39 == 9 + if field_35.present? && field_35.to_i >= 16 + errors.add(:field_39, I18n.t("#{ERROR_BASE_KEY}.ecstat2.buyer_cannot_be_over_16_and_child")) + errors.add(:field_35, I18n.t("#{ERROR_BASE_KEY}.age2.buyer_cannot_be_over_16_and_child")) + else + errors.add(:field_39, I18n.t("#{ERROR_BASE_KEY}.ecstat2.buyer_cannot_be_child")) + end + end + end + + def validate_nationality + if field_31.present? && !valid_nationality_options.include?(field_31.to_s) + errors.add(:field_31, I18n.t("#{ERROR_BASE_KEY}.nationality.invalid")) + end + end + + def validate_buyer_2_nationality + if field_38.present? && !valid_nationality_options.include?(field_38.to_s) + errors.add(:field_38, I18n.t("#{ERROR_BASE_KEY}.nationality.invalid")) + end + end + + def valid_nationality_options + %w[0] + GlobalConstants::COUNTRIES_ANSWER_OPTIONS.keys # 0 is "Prefers not to say" + end + + def bulk_upload_organisation + Organisation.find(bulk_upload.organisation_id) + end +end diff --git a/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2025.html.erb b/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2025.html.erb new file mode 100644 index 000000000..fd5c623da --- /dev/null +++ b/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2025.html.erb @@ -0,0 +1,36 @@ +<% content_for :before_content do %> + <%= govuk_back_link href: @form.back_path %> +<% end %> + +
+
+ <%= form_with model: @form, scope: :form, url: bulk_upload_sales_log_path(id: "prepare-your-file"), method: :patch do |f| %> + <%= f.hidden_field :year %> + <%= f.hidden_field :organisation_id %> + + Upload sales logs in bulk (<%= @form.year_combo %>) +

Prepare your file

+

<%= govuk_link_to "Read the full guidance", bulk_upload_sales_log_path(id: "guidance", form: { year: @form.year }, referrer: "prepare-your-file") %> before you start if you have not used bulk upload before.

+ +

Download template

+ +

Use one of these templates to upload logs for 2025/26:

+

<%= govuk_link_to "Download the sales bulk upload template (2025 to 2026)", @form.template_path %>: In this template, the questions are in the same order as the 2025/26 paper form and web form.

+

There are 8 rows of content in the templates. These rows are called the ‘headers’. They contain the CORE form questions and guidance about which questions are required and how to format your answers.

+ +

Create your file

+ <%= govuk_list [ + "Fill in the template with data from your housing management system. Your data should go below the headers, with one row per log. The bulk upload fields start at column B. Leave column A blank.", + "Make sure each column of your data aligns with the matching headers above. You may need to reorder your data.", + "Use the #{govuk_link_to 'Sales bulk upload Specification (2025 to 2026)', @form.specification_path} to check your data is in the correct format.".html_safe, + "Username field: To assign a log to someone else, enter the email address they use to log into CORE.".html_safe, + "If you have reordered the headers, keep the headers in the file.", + ], type: :bullet %> + +

Save your file

+ <%= govuk_list ["Save your file as a CSV.", "Your file should now be ready to upload."], type: :bullet %> + + <%= f.govuk_submit %> + <% end %> +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 6ca3ea322..3e35a32ce 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -57,6 +57,8 @@ en: <<: *bulk_upload__row_parser__base bulk_upload/lettings/year2023/row_parser: <<: *bulk_upload__row_parser__base + bulk_upload/sales/year2025/row_parser: + <<: *bulk_upload__row_parser__base bulk_upload/sales/year2024/row_parser: <<: *bulk_upload__row_parser__base bulk_upload/sales/year2023/row_parser: diff --git a/config/locales/validations/sales/2025/bulk_upload.en.yml b/config/locales/validations/sales/2025/bulk_upload.en.yml new file mode 100644 index 000000000..4a2d88b46 --- /dev/null +++ b/config/locales/validations/sales/2025/bulk_upload.en.yml @@ -0,0 +1,46 @@ +en: + validations: + sales: + 2024: + bulk_upload: + not_answered: "You must answer %{question}" + invalid_option: "Enter a valid value 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: + over_max_column_count: "Too many columns, 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." + wrong_template: "Incorrect sale dates, please ensure you have used the correct template." + numeric: + within_range: "%{field} must be between %{min} and %{max}." + 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: + support: "This owning organisation is not affiliated with %{name}." + not_support: "You do not have permission to add logs for this owning organisation." + 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." + managing_organisation_not_related: "This organisation does not have a relationship with the owning organisation." + saledate: + 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: "Sale completion year must be 2 or 4 digits." + ecstat1: + buyer_cannot_be_over_16_and_child: "Buyer 1's age cannot be 16 or over if their working situation is child under 16." + buyer_cannot_be_child: "Buyer 1 cannot have a working situation of child under 16." + age1: + buyer_cannot_be_over_16_and_child: "Buyer 1's age cannot be 16 or over if their working situation is child under 16." + ecstat2: + buyer_cannot_be_over_16_and_child: "Buyer 2's age cannot be 16 or over if their working situation is child under 16." + buyer_cannot_be_child: "Buyer 2 cannot have a working situation of child under 16." + age2: + buyer_cannot_be_over_16_and_child: "Buyer 2's age cannot be 16 or over if their working situation is child under 16." + address: + not_found: "We could not find this address. Check the address data in your CSV file is correct and complete, or select the correct address using the CORE site." + not_determined: "There are multiple matches for this address. Either select the correct address manually or correct the UPRN in the CSV file." + not_answered: "Enter either the UPRN or the full address." + nationality: + invalid: "Select a valid nationality." diff --git a/session-manager-plugin.deb b/session-manager-plugin.deb new file mode 100644 index 000000000..3befd8f9c Binary files /dev/null and b/session-manager-plugin.deb differ diff --git a/spec/models/bulk_upload_spec.rb b/spec/models/bulk_upload_spec.rb index 03342b627..61e16920e 100644 --- a/spec/models/bulk_upload_spec.rb +++ b/spec/models/bulk_upload_spec.rb @@ -41,6 +41,7 @@ RSpec.describe BulkUpload, type: :model do [ { year: 2023, expected_value: "2023 to 2024" }, { year: 2024, expected_value: "2024 to 2025" }, + { year: 2025, expected_value: "2025 to 2026" }, ].each do |test_case| context "when the bulk upload year is #{test_case[:year]}" do let(:bulk_upload) { build(:bulk_upload, year: test_case[:year]) } diff --git a/spec/services/bulk_upload/sales/log_creator_spec.rb b/spec/services/bulk_upload/sales/log_creator_spec.rb index 2353a74c6..78d78eadc 100644 --- a/spec/services/bulk_upload/sales/log_creator_spec.rb +++ b/spec/services/bulk_upload/sales/log_creator_spec.rb @@ -6,13 +6,13 @@ RSpec.describe BulkUpload::Sales::LogCreator do let(:owning_org) { create(:organisation, old_visible_id: 123) } let(:user) { create(:user, organisation: owning_org) } - let(:bulk_upload) { create(:bulk_upload, :sales, user:, year: 2024) } - let(:csv_parser) { instance_double(BulkUpload::Sales::Year2024::CsvParser) } - let(:row_parser) { instance_double(BulkUpload::Sales::Year2024::RowParser) } + let(:bulk_upload) { create(:bulk_upload, :sales, user:, year: 2025) } + let(:csv_parser) { instance_double(BulkUpload::Sales::Year2025::CsvParser) } + let(:row_parser) { instance_double(BulkUpload::Sales::Year2025::RowParser) } let(:log) { build(:sales_log, :completed, assigned_to: user, owning_organisation: owning_org, managing_organisation: owning_org) } before do - allow(BulkUpload::Sales::Year2024::CsvParser).to receive(:new).and_return(csv_parser) + allow(BulkUpload::Sales::Year2025::CsvParser).to receive(:new).and_return(csv_parser) allow(csv_parser).to receive(:row_parsers).and_return([row_parser]) allow(row_parser).to receive(:log).and_return(log) allow(row_parser).to receive(:bulk_upload=).and_return(true) diff --git a/spec/services/bulk_upload/sales/validator_spec.rb b/spec/services/bulk_upload/sales/validator_spec.rb index 71a94a725..557386cd2 100644 --- a/spec/services/bulk_upload/sales/validator_spec.rb +++ b/spec/services/bulk_upload/sales/validator_spec.rb @@ -58,6 +58,22 @@ RSpec.describe BulkUpload::Sales::Validator do end end + context "when trying to upload 2024 logs for 2025 bulk upload" do + let(:bulk_upload) { create(:bulk_upload, user:, year: 2025) } + let(:log) { build(:sales_log, :completed, saledate: Time.zone.local(2024, 10, 10), assigned_to: user) } + + before do + file.write(log_to_csv.default_2025_field_numbers_row) + file.write(log_to_csv.to_2025_csv_row) + file.rewind + end + + it "is not valid" do + expect(validator).not_to be_valid + expect(validator.errors["base"]).to eql([I18n.t("validations.sales.2025.bulk_upload.wrong_template.wrong_template")]) + end + end + [ { line_ending: "\n", name: "unix" }, { line_ending: "\r\n", name: "windows" }, diff --git a/spec/services/bulk_upload/sales/year2025/csv_parser_spec.rb b/spec/services/bulk_upload/sales/year2025/csv_parser_spec.rb new file mode 100644 index 000000000..d0f27cbbf --- /dev/null +++ b/spec/services/bulk_upload/sales/year2025/csv_parser_spec.rb @@ -0,0 +1,191 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Sales::Year2025::CsvParser do + subject(:service) { described_class.new(path:) } + + let(:file) { Tempfile.new } + let(:path) { file.path } + let(:log) { build(:sales_log, :completed, :with_uprn) } + + 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::SalesLogToCsv.new(log:).default_2025_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2025_csv_row) + file.write("\n") + 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_22).to eql(log.uprn) + end + + it "counts the number of valid field numbers correctly" do + expect(service).to be_correct_field_count + end + + it "does not parse the last empty row" do + expect(service.row_parsers.count).to eq(1) + 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::SalesLogToCsv.new(log:).default_2025_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2025_csv_row) + file.write("\n") + 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_22).to eql(log.uprn) + end + + it "counts the number of valid field numbers correctly" do + expect(service).to be_correct_field_count + 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::SalesLogToCsv.new(log:).default_2025_field_numbers_row(seed:)) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2025_csv_row(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_22).to eql(log.uprn) + end + end + + context "when parsing csv without headers" do + let(:file) { Tempfile.new } + let(:path) { file.path } + let(:log) { build(:sales_log, :completed, :with_uprn) } + + before do + file.write(BulkUpload::SalesLogToCsv.new(log:, col_offset: 0).to_2025_csv_row) + 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_22).to eql(log.uprn) + end + end + + context "when parsing with BOM aka byte order mark" do + let(:file) { Tempfile.new } + let(:path) { file.path } + let(:log) { build(:sales_log, :completed, :with_uprn) } + let(:bom) { "\uFEFF" } + + before do + file.write(bom) + file.write(BulkUpload::SalesLogToCsv.new(log:, col_offset: 0).to_2025_csv_row) + file.close + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_22).to eql(log.uprn) + end + end + + context "when an invalid byte sequence" do + let(:file) { Tempfile.new } + let(:path) { file.path } + let(:log) { build(:sales_log, :completed, :with_uprn) } + let(:invalid_sequence) { "\x81" } + + before do + file.write(invalid_sequence) + file.write(BulkUpload::SalesLogToCsv.new(log:, col_offset: 0).to_2025_csv_row) + file.close + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_22).to eql(log.uprn) + end + end + + describe "#column_for_field", aggregate_failures: true do + context "when headers present" 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::SalesLogToCsv.new(log:).default_2025_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2025_csv_row) + file.rewind + end + + it "returns correct column" do + expect(service.column_for_field("field_1")).to eql("B") + expect(service.column_for_field("field_99")).to eql("CV") + end + 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::SalesLogToCsv.new(log:).default_2025_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2025_csv_row) + file.rewind + end + + it "parses csv correctly" do + expect(service.row_parsers[0].field_22).to eql(log.uprn) + end + end +end diff --git a/spec/services/bulk_upload/sales/year2025/row_parser_spec.rb b/spec/services/bulk_upload/sales/year2025/row_parser_spec.rb new file mode 100644 index 000000000..4a73e3215 --- /dev/null +++ b/spec/services/bulk_upload/sales/year2025/row_parser_spec.rb @@ -0,0 +1,1912 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Sales::Year2025::RowParser do + subject(:parser) { described_class.new(attributes) } + + let(:now) { Time.zone.parse("01/05/2025") } + + let(:attributes) { { bulk_upload: } } + let(:bulk_upload) { create(:bulk_upload, :sales, user:, 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) } + + let(:setup_section_params) do + { + bulk_upload:, + field_1: owning_org.old_visible_id, # organisation + field_2: managing_org.old_visible_id, # organisation + field_3: user.email, # user + field_4: now.day.to_s, # sale day + field_5: now.month.to_s, # sale month + field_6: now.strftime("%g"), # sale year + field_7: "test id", # purchase id + field_8: "1", # owhershipsch + field_9: "2", # shared ownership sale type + field_10: "1", # staircasing + field_12: "2", # joint purchase + field_14: "1", # noint + field_15: "1", # privacy notice + } + end + let(:valid_attributes) do + { + bulk_upload:, + field_1: owning_org.old_visible_id, + field_2: managing_org.old_visible_id, + + field_4: "12", + field_5: "5", + field_6: "24", + field_7: "test id", + field_8: "1", + field_9: "2", + field_10: "1", + field_12: "1", + field_13: "2", + field_14: "1", + field_15: "1", + field_16: "1", + field_17: "2", + field_18: "1", + field_19: "12", + field_20: "Address line 1", + field_24: "CR0", + field_25: "4BB", + field_26: "E09000008", + field_27: "3", + field_28: "32", + field_29: "M", + field_30: "12", + field_31: "28", + field_32: "1", + field_33: "1", + field_34: "R", + field_35: "32", + field_36: "F", + field_37: "17", + field_38: "28", + field_39: "2", + field_40: "1", + field_41: "0", + field_58: "1", + field_59: "1", + field_60: "A1", + field_61: "1AA", + field_62: "E09000008", + field_63: "3", + field_65: "3", + field_67: "5", + field_68: "3", + field_69: "3", + field_70: "30000", + field_71: "1", + field_72: "15000", + field_73: "1", + field_74: "4", + field_75: "20000", + field_76: "3", + field_79: "5", + field_80: "24", + field_81: "3", + field_82: "2022", + field_83: "1", + field_84: "1", + field_85: "1", + field_86: "250000", + field_87: "25", + field_88: "1", + field_89: "5000", + field_90: "20", + field_91: "30", + field_92: "3", + field_93: "2022", + field_96: "10", + field_97: "40", + field_98: "1", + field_94: "200", + field_120: "20000", + } + end + + around do |example| + create(:organisation_relationship, parent_organisation: owning_org, child_organisation: managing_org) + + Timecop.freeze(Time.zone.local(2025, 2, 22)) do + Singleton.__init__(FormHandler) + example.run + end + 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 any field is populated" do + before do + parser.field_1 = "1" + end + + it "returns false" do + expect(parser).not_to be_blank_row + end + end + + context "when the only populated fields are empty strings or whitespace" do + before do + parser.field_6 = " " + parser.field_17 = "" + end + + it "returns true" do + expect(parser).to be_blank_row + end + end + end + + describe "purchaser_code" do + before do + def purch_id_field + described_class::QUESTIONS.key("What is the purchaser code?").to_s + end + end + + let(:attributes) do + { + bulk_upload:, + purch_id_field => "some purchaser code", + } + end + + it "is linked to the correct field" do + expect(parser.purchaser_code).to eq("some purchaser code") + end + end + + describe "previous postcode known" do + context "when field_59 is 1" do + let(:attributes) do + { + bulk_upload:, + field_59: 1, + } + end + + it "sets previous postcode known to yes" do + expect(parser.log.ppcodenk).to eq(0) + end + end + + context "when field_59 is 2" do + let(:attributes) do + { + bulk_upload:, + field_59: 2, + } + end + + it "sets previous postcode known to no" do + expect(parser.log.ppcodenk).to eq(1) + end + end + end + + describe "income and savings fields" do + context "when set to R" do + let(:attributes) do + { + bulk_upload:, + field_70: "R", # income 1 + field_72: "R", # income 2 + field_75: "R", # savings + } + end + + it "sets the not known field as not known" do + expect(parser.log.income1nk).to be(1) + expect(parser.log.income2nk).to be(1) + expect(parser.log.savingsnk).to be(1) + end + + it "leaves the value field nil" do + expect(parser.log.income1).to be_nil + expect(parser.log.income2).to be_nil + expect(parser.log.savings).to be_nil + end + end + + context "when set to a number" do + let(:attributes) do + { + bulk_upload:, + field_70: "30000", # income 1 + field_72: "0", # income 2 + field_75: "12420", # savings + } + end + + it "sets the not known field as known" do + expect(parser.log.income1nk).to be(0) + expect(parser.log.income2nk).to be(0) + expect(parser.log.savingsnk).to be(0) + end + + it "sets the values" do + expect(parser.log.income1).to be(30_000) + expect(parser.log.income2).to be(0) + expect(parser.log.savings).to be(12_420) + 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/) + .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: {}) + 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_8: 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 + + context "when valid row" do + let(:attributes) { valid_attributes } + + it "returns true" do + expect(parser).to be_valid + end + + it "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 + end + + describe "#validate_nulls" do + context "when non-setup questions are null" do + let(:attributes) { setup_section_params.merge({ field_29: "" }) } + + it "fetches the question's check_answer_label if it exists" do + parser.valid? + expect(parser.errors[:field_29]).to eql([I18n.t("validations.not_answered", question: "buyer 1’s gender identity.")]) + end + end + + context "when other null error is added" do + let(:attributes) { setup_section_params.merge({ field_20: nil }) } + + it "only has one error added to the field" do + parser.valid? + expect(parser.errors[:field_20]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + end + end + + context "when an invalid value error has been added" do + let(:attributes) { setup_section_params.merge({ field_32: "100" }) } + + it "does not add an additional error" do + parser.valid? + expect(parser.errors[:field_32].length).to eq(1) + expect(parser.errors[:field_32]).to include(match I18n.t("validations.sales.2025.bulk_upload.invalid_option", question: "")) + end + end + end + end + + context "when setup section not complete and type is not given" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + } + end + + it "has errors on correct 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_14 field_15 field_2 field_4 field_5 field_6 field_8]) + end + end + + context "when setup section not complete and type is shared ownership" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + field_8: "1", + } + end + + it "has errors on correct 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_12 field_14 field_15 field_2 field_4 field_5 field_6 field_9]) + end + end + + context "when setup section not complete it's shared ownership joint purchase" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + field_8: "1", + field_9: "2", + field_12: "1", + } + end + + it "has errors on correct 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_13 field_14 field_15 field_2 field_4 field_5 field_6]) + end + end + + context "when setup section not complete and type is discounted ownership" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + field_8: "2", + field_10: nil, + } + end + + it "has errors on correct 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_12 field_14 field_15 field_2 field_4 field_5 field_6]) + end + end + + context "when setup section not complete and it's discounted ownership joint purchase" do + let(:attributes) do + { + bulk_upload:, + field_28: "2", + field_43: "8", + field_36: "1", + } + end + + it "has errors on correct 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_14 field_15 field_2 field_4 field_5 field_6 field_8]) + end + end + + describe "#field_1" do # owning org + context "when no data given" do + let(:attributes) { setup_section_params.merge(field_1: nil) } + + 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.sales.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 a setup error" do + parser.valid? + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql([I18n.t("validations.sales.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 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? + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql([I18n.t("validations.sales.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 with stock owners" 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 } } + + 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.valid? + expect(parser.errors.where(:field_1)).not_to be_present + 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(:attributes) { { bulk_upload:, field_1: merged_org.old_visible_id, field_3: user.email } } + + before do + merged_org.update!(absorbing_organisation: user.organisation, merge_date: Time.zone.today) + merged_org.reload + user.organisation.reload + user.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_3: user.email }) } + + before do + merged_org.update!(absorbing_organisation: user.organisation, merge_date: Time.zone.today - 3.years) + merged_org.reload + user.organisation.reload + user.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 sale completion date/) + expect(parser.errors[:field_4]).to include(/Enter a date when the owning organisation was active/) + expect(parser.errors[:field_5]).to include(/Enter a date when the owning organisation was active/) + expect(parser.errors[:field_6]).to include(/Enter a date when the owning 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.sales.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.sales.2025.bulk_upload.owning_organisation.not_permitted.not_support")) + end + end + end + + describe "#field_3" do # username for assigned_to + context "when blank" do + let(:attributes) { setup_section_params.merge(bulk_upload:, field_3: nil) } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + + it "sets assigned to to bulk upload user" do + parser.valid? + expect(parser.log.assigned_to).to eq(bulk_upload.user) + end + + it "sets created by to bulk upload user" do + parser.valid? + 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.sales.2025.bulk_upload.not_answered", question: "what is the CORE username of the account this sales 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 } } + + it "is not permitted as a setup error" do + parser.valid? + expect(parser.errors.where(:field_3, category: :setup)).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 } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + + it "sets assigned to to the user" do + parser.valid? + expect(parser.log.assigned_to).to eq(other_user) + end + + it "sets created by to bulk upload user" do + parser.valid? + 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! } } + + it "is permitted" do + parser.valid? + expect(parser.errors[:field_3]).to be_blank + end + end + end + + describe "fields 3, 4, 5 => saledate" do + context "when all of these fields are blank" do + let(:attributes) { setup_section_params.merge({ field_4: nil, field_5: nil, field_6: nil }) } + + it "returns them as setup errors" do + parser.valid? + expect(parser.errors.where(:field_4, category: :setup)).to be_present + expect(parser.errors.where(:field_5, category: :setup)).to be_present + expect(parser.errors.where(:field_6, category: :setup)).to be_present + end + end + + context "when one of these fields is blank" do + let(:attributes) { setup_section_params.merge({ field_4: "1", field_5: "1", field_6: nil }) } + + it "returns an error only on blank field as setup error" do + parser.valid? + expect(parser.errors[:field_4]).to be_blank + expect(parser.errors[:field_5]).to be_blank + expect(parser.errors.where(:field_6, category: :setup)).to be_present + end + end + + context "when field 6 is 4 digits instead of 2" do + let(:attributes) { setup_section_params.merge({ bulk_upload:, field_6: "2025" }) } + + it "correctly sets the date" do + parser.valid? + expect(parser.errors.where(:field_6, category: :setup)).to be_empty + expect(parser.log.saledate).to eq(Time.zone.local(2025, 5, 1)) + end + end + + context "when field 5 is not 2 or 4 digits" do + let(:attributes) { setup_section_params.merge({ bulk_upload:, field_6: "202" }) } + + it "returns a setup error" do + parser.valid? + expect(parser.errors.where(:field_6, category: :setup).map(&:message)).to include(I18n.t("validations.sales.2025.bulk_upload.saledate.year_not_two_or_four_digits")) + end + end + + context "when invalid date given" do + let(:attributes) { setup_section_params.merge({ field_4: "a", field_5: "12", field_6: "2023" }) } + + it "does not raise an error" do + expect { parser.valid? }.not_to raise_error + end + end + + context "when inside of collection year" do + around do |example| + Timecop.freeze(Date.new(2025, 10, 1)) do + example.run + end + end + + let(:attributes) { setup_section_params.merge({ field_4: "1", field_5: "10", field_6: "24" }) } + + let(:bulk_upload) { create(:bulk_upload, :sales, user:, year: 2025) } + + it "does not return errors" do + parser.valid? + expect(parser.errors[:field_4]).not_to be_present + expect(parser.errors[:field_5]).not_to be_present + expect(parser.errors[:field_6]).not_to be_present + end + end + + context "when outside of collection year" do + around do |example| + Timecop.freeze(Date.new(2023, 4, 2)) do + example.run + end + end + + let(:attributes) { setup_section_params.merge({ field_4: "1", field_5: "1", field_6: "22" }) } + + let(:bulk_upload) { create(:bulk_upload, :sales, user:, year: 2023) } + + it "returns setup errors" do + parser.valid? + expect(parser.errors.where(:field_4, category: :setup)).to be_present + expect(parser.errors.where(:field_5, category: :setup)).to be_present + expect(parser.errors.where(:field_6, category: :setup)).to be_present + end + end + end + + context "when the log already exists in the db" do + let(:attributes) { valid_attributes } + + 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.sales.2025.bulk_upload.duplicate") + + [ + :field_1, # Owning org + :field_4, # Sale completion date + :field_5, # Sale completion date + :field_6, # Sale completion date + :field_24, # Postcode + :field_25, # Postcode + :field_28, # Buyer 1 age + :field_29, # Buyer 1 gender + :field_32, # Buyer 1 working situation + :field_7, # Purchaser code + ].each do |field| + expect(parser.errors[field]).to include(error_message) + end + 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 org + :field_4, # Sale completion date + :field_5, # Sale completion date + :field_6, # Sale completion date + :field_24, # Postcode + :field_25, # Postcode + :field_28, # Buyer 1 age + :field_29, # Buyer 1 gender + :field_32, # Buyer 1 working situation + :field_7, # Purchaser code + ].each do |field| + expect(parser.errors[field]).to be_blank + end + end + end + + describe "#field_8" do # ownership scheme + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_8: "100" }) } + + it "returns setup error" do + parser.valid? + expect(parser.errors.where(:field_8, category: :setup)).to be_present + end + end + end + + describe "#field_9" do # type for shared ownership sale + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_9: "100" }) } + + it "returns setup error" do + parser.valid? + expect(parser.errors.where(:field_9, category: :setup)).to be_present + end + end + end + + describe "#field_10" do # type for discounted sale + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_10: "100" }) } + + it "returns setup error" do + parser.valid? + expect(parser.errors.where(:field_10, category: :setup)).to be_present + end + end + end + + describe "#field_116" do # percentage discount + context "when percentage discount over 70" do + let(:attributes) { valid_attributes.merge({ field_8: "2", field_116: "71" }) } + + it "returns correct error" do + parser.valid? + expect(parser.errors.where(:field_116).map(&:message)).to include(I18n.t("validations.sales.2025.bulk_upload.numeric.within_range", field: "Percentage discount", min: "0%", max: "70%")) + end + end + + context "when percentage discount not over 70" do + let(:attributes) { valid_attributes.merge({ field_8: "2", field_116: "70" }) } + + it "does not return error" do + parser.valid? + expect(parser.errors.where(:field_116)).not_to be_present + end + end + + context "when percentage less than 0" do + let(:attributes) { valid_attributes.merge({ field_8: "2", field_116: "-1" }) } + + it "returns correct error" do + parser.valid? + expect(parser.errors.where(:field_116).map(&:message)).to include(I18n.t("validations.sales.2025.bulk_upload.numeric.within_range", field: "Percentage discount", min: "0%", max: "70%")) + end + end + end + + describe "#field_12" do # joint purchase + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_12: "100" }) } + + it "returns a setup error" do + parser.valid? + expect(parser.errors.where(:field_12, category: :setup)).to be_present + end + end + end + + describe "#field_13" do # more than 2 joint buyers? + context "when invalid option and must be answered" do + let(:attributes) { setup_section_params.merge({ field_12: "1", field_13: "100" }) } + + it "returns a setup error" do + parser.valid? + expect(parser.errors.where(:field_13, category: :setup)).to be_present + end + end + end + + describe "UPRN and address fields" do + context "when a valid UPRN is given" do + let(:attributes) { setup_section_params.merge({ field_19: "12" }) } + + it "does not add errors" do + parser.valid? + %i[field_19 field_20 field_21 field_22 field_23 field_24 field_25].each do |field| + expect(parser.errors[field]).to be_empty + end + end + + it "sets UPRN, UPRN known, and UPRN confirmed" do + expect(parser.log.uprn).to eq("12") + expect(parser.log.uprn_known).to eq(1) + expect(parser.log.uprn_confirmed).to eq(1) + 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_19: "1234567890123" }) } + + it "adds an appropriate error to the UPRN field" do + parser.valid? + expect(parser.errors[:field_19]).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_20]).to eql([I18n.t("validations.sales.2025.bulk_upload.not_answered", question: "address line 1.")]) + expect(parser.errors[:field_22]).to eql([I18n.t("validations.sales.2025.bulk_upload.not_answered", question: "town or city.")]) + expect(parser.errors[:field_24]).to eql([I18n.t("validations.sales.2025.bulk_upload.not_answered", question: "part 1 of postcode.")]) + expect(parser.errors[:field_25]).to eql([I18n.t("validations.sales.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_19: "1234567890123", field_20: "address line 1", field_22: "town or city", field_24: "AA1", field_25: "1AA" }) } + + it "adds an error to the UPRN field only" do + parser.valid? + expect(parser.errors[:field_19]).to eql(["UPRN must be 12 digits or less."]) + %i[field_20 field_22 field_24 field_25].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 } + + it "adds appropriate errors to UPRN and key address fields" do + parser.valid? + expect(parser.errors[:field_19]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_20]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_22]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_24]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_25]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + end + end + + context "and some key address field is missing" do + let(:attributes) { setup_section_params.merge({ field_22: "town or city", field_24: "AA1", field_25: "1AA" }) } + + it "adds errors to UPRN and the missing key address field" do + parser.valid? + expect(parser.errors[:field_19]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_20]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_answered")]) + expect(parser.errors[:field_22]).to be_empty + expect(parser.errors[:field_24]).to be_empty + expect(parser.errors[:field_25]).to be_empty + end + end + + context "and all key address fields are present" do + let(:attributes) { setup_section_params.merge({ field_19: nil, field_20: "address line 1", field_22: "town or city", field_24: "AA1", field_25: "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_19 field_20 field_21 field_22 field_23 field_24 field_25].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_19]).to be_empty + %i[field_20 field_21 field_22 field_23 field_24 field_25].each do |field| + expect(parser.errors[field]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_found")]) + end + end + end + + context "when no address has 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" } }] }.to_json, headers: {}) + end + + it "adds address not found errors to address fields only" do + parser.valid? + expect(parser.errors[:field_19]).to be_empty + %i[field_20 field_21 field_22 field_23 field_24 field_25].each do |field| + expect(parser.errors[field]).to eql([I18n.t("validations.sales.2025.bulk_upload.address.not_determined")]) + end + end + end + end + end + end + + describe "#field_15" do # data protection + let(:attributes) { setup_section_params.merge({ field_15: nil }) } + + before do + parser.valid? + end + + context "when not answered" do + it "returns a setup error" do + expect(parser.errors.where(:field_15, category: :setup)).to be_present + end + end + + context "when the privacy notice is not accepted" do + it "cannot be nulled" do + expect(parser.errors[:field_15]).to eq(["You must show or give the buyer access to the MHCLG privacy notice before you can submit this log."]) + end + end + end + + [ + %w[age1_known details_known_1 age1 field_28 field_34 field_36], + %w[age2_known details_known_2 age2 field_35 field_34 field_36], + %w[age3_known details_known_3 age3 field_43 field_42 field_44], + %w[age4_known details_known_4 age4 field_47 field_46 field_48], + %w[age5_known details_known_5 age5 field_51 field_50 field_52], + %w[age6_known details_known_6 age6 field_55 field_54 field_56], + ].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 blank" do + let(:attributes) { setup_section_params.merge({ field.to_s => nil, relationship.to_sym => nil, gender.to_sym => nil, field_15: "1", field_41: "5" }) } + + 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(2) + 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) { setup_section_params.merge({ field.to_sym => nil, relationship.to_sym => "C", gender.to_sym => "X", field_15: "1", field_41: "5" }) } + + it "does not set ##{age}" do + parser.valid? + expect(parser.errors[field.to_sym]).to include(/You must answer/) + end + end + end + + context "when #{field} is R" do + let(:attributes) { setup_section_params.merge({ field.to_s => "R", field_14: "1", field_41: "5", field_15: "1" }) } + + 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) { setup_section_params.merge({ field.to_s => "50", field_14: "1", field_41: "5", field_15: "1" }) } + + 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 + + context "when #{field} is a non-sensical value" do + let(:attributes) { setup_section_params.merge({ field.to_s => "A", field_14: "1", field_41: "5", field_15: "1" }) } + + it "sets ##{known} to 0" do + expect(parser.log.public_send(known)).to be(0) + end + + it "sets ##{age} to nil" do + expect(parser.log.public_send(age)).to be_nil + end + end + end + end + + describe "field_39" do # ecstat2 + context "when buyer 2 has no age but has ecstat as child" do + let(:attributes) { valid_attributes.merge({ field_35: nil, field_39: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + expect(parser.errors[:field_39]).to include I18n.t("validations.sales.2025.bulk_upload.ecstat2.buyer_cannot_be_child") + end + end + + context "when buyer 2 is under 16" do + let(:attributes) { valid_attributes.merge({ field_35: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + validation_message = "Buyer 2’s age must be between 16 and 110." + expect(parser.errors[:field_35]).to include validation_message + end + end + + context "when buyer 2 is over 16 but has ecstat as child" do + let(:attributes) { valid_attributes.merge({ field_35: "17", field_39: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + expect(parser.errors[:field_39]).to include I18n.t("validations.sales.2025.bulk_upload.ecstat2.buyer_cannot_be_over_16_and_child") + expect(parser.errors[:field_35]).to include I18n.t("validations.sales.2025.bulk_upload.age2.buyer_cannot_be_over_16_and_child") + end + end + + context "when person 2 a child but not a buyer" do + let(:attributes) { valid_attributes.merge({ field_12: 2, field_35: "10", field_39: "9" }) } + + it "does not add errors to their age and ecstat fields" do + parser.valid? + expect(parser.errors[:field_35]).to be_empty + expect(parser.errors[:field_39]).to be_empty + end + end + end + + describe "field_32" do # ecstat1 + context "when buyer 1 has no age but has ecstat as child" do + let(:attributes) { valid_attributes.merge({ field_28: nil, field_32: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + expect(parser.errors[:field_32]).to include I18n.t("validations.sales.2025.bulk_upload.ecstat1.buyer_cannot_be_child") + end + end + + context "when buyer 1 is under 16" do + let(:attributes) { valid_attributes.merge({ field_28: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + validation_message = "Buyer 1’s age must be between 16 and 110." + expect(parser.errors[:field_28]).to include validation_message + end + end + + context "when buyer 1 is over 16 but has ecstat as child" do + let(:attributes) { valid_attributes.merge({ field_28: "17", field_32: "9" }) } + + it "a custom validation is applied" do + parser.valid? + + expect(parser.errors[:field_32]).to include I18n.t("validations.sales.2025.bulk_upload.ecstat1.buyer_cannot_be_over_16_and_child") + expect(parser.errors[:field_28]).to include I18n.t("validations.sales.2025.bulk_upload.age1.buyer_cannot_be_over_16_and_child") + end + end + end + + describe "#field_33" do # will buyer1 live in property? + context "when not a possible value" do + let(:attributes) { valid_attributes.merge({ field_33: "3" }) } + + it "is not valid" do + parser.valid? + expect(parser.errors).to include(:field_33) + end + end + end + + describe "#field_88" do # shared ownership mortgageused + context "when invalid value" do + let(:attributes) { setup_section_params.merge(field_88: "4") } + + it "returns correct errors" do + parser.valid? + expect(parser.errors[:field_88]).to include(I18n.t("validations.sales.2025.bulk_upload.invalid_option", question: "was a mortgage used for the purchase of this property? - Shared ownership.")) + + parser.log.blank_invalid_non_setup_fields! + parser.log.save! + expect(parser.log.mortgageused).to be_nil + end + end + + context "when value is 3 and stairowned is not 100" do + let(:attributes) { setup_section_params.merge(field_88: "3", field_10: "1", field_96: "50", field_97: "99", field_120: nil) } + + it "returns correct errors" do + parser.valid? + expect(parser.errors[:field_88]).to include("The percentage owned has to be 100% if the mortgage used is 'Don’t know'") + + parser.log.blank_invalid_non_setup_fields! + parser.log.save! + expect(parser.log.mortgageused).to be_nil + end + end + + context "when value is 3 and stairowned is not answered" do + let(:attributes) { setup_section_params.merge(field_88: "3", field_10: "1", field_96: "50", field_97: nil, field_120: nil) } + + it "does not add errors" do + parser.valid? + expect(parser.errors[:field_88]).to be_empty + expect(parser.errors[:field_88]).to be_empty + end + end + + context "when it is not a staircasing transaction" do + context "when value is 3 and stairowned is not answered" do + let(:attributes) { setup_section_params.merge(field_88: "3", field_10: "2", field_96: "50", field_97: nil, field_120: nil) } + + it "returns correct errors" do + parser.valid? + expect(parser.errors[:field_88]).to include(I18n.t("validations.invalid_option", question: "was a mortgage used for the purchase of this property?")) + + parser.log.blank_invalid_non_setup_fields! + parser.log.save! + expect(parser.log.mortgageused).to be_nil + end + end + + context "when value is 3 and stairowned is 100" do + let(:attributes) { setup_section_params.merge(field_88: "3", field_10: "2", field_96: "50", field_97: "100", field_120: nil) } + + it "returns correct errors" do + parser.valid? + expect(parser.errors[:field_88]).to include(I18n.t("validations.invalid_option", question: "was a mortgage used for the purchase of this property?")) + + parser.log.blank_invalid_non_setup_fields! + parser.log.save! + expect(parser.log.mortgageused).to be_nil + end + end + end + + context "when value is 3 and stairowned is 100" do + let(:attributes) { setup_section_params.merge(field_88: "3", field_10: "1", field_96: "50", field_97: "100", field_120: nil) } + + it "does not add errors and sets mortgage used to 3" do + parser.valid? + expect(parser.log.mortgageused).to eq(3) + expect(parser.log.stairowned).to eq(100) + expect(parser.log.deposit).to be_nil + expect(parser.errors[:field_88]).to be_empty + expect(parser.errors[:field_120]).to be_empty + end + end + + context "with non staircasing mortgage error" do + let(:attributes) { setup_section_params.merge(field_9: "30", field_88: "1", field_89: "10000", field_120: "5000", field_86: "30000", field_87: "28", field_10: "2") } + + it "does not add a BU error on type (because it's a setup field and would block log creation)" do + parser.valid? + expect(parser.errors[:field_9]).to be_empty + end + + it "includes errors on other related fields" do + parser.valid? + expect(parser.errors[:field_89]).to include("The mortgage (£10,000.00) and cash deposit (£5,000.00) added together is £15,000.00.

The full purchase price (£30,000.00) multiplied by the percentage equity stake purchased (28.0%) is £8,400.00.

These two amounts should be the same.") + expect(parser.errors[:field_120]).to include("The mortgage (£10,000.00) and cash deposit (£5,000.00) added together is £15,000.00.

The full purchase price (£30,000.00) multiplied by the percentage equity stake purchased (28.0%) is £8,400.00.

These two amounts should be the same.") + expect(parser.errors[:field_86]).to include("The mortgage (£10,000.00) and cash deposit (£5,000.00) added together is £15,000.00.

The full purchase price (£30,000.00) multiplied by the percentage equity stake purchased (28.0%) is £8,400.00.

These two amounts should be the same.") + expect(parser.errors[:field_87]).to include("The mortgage (£10,000.00) and cash deposit (£5,000.00) added together is £15,000.00.

The full purchase price (£30,000.00) multiplied by the percentage equity stake purchased (28.0%) is £8,400.00.

These two amounts should be the same.") + end + + it "does not add errors to other ownership type fields" do + parser.valid? + expect(parser.errors[:field_117]).to be_empty + expect(parser.errors[:field_126]).to be_empty + expect(parser.errors[:field_118]).to be_empty + expect(parser.errors[:field_127]).to be_empty + expect(parser.errors[:field_123]).to be_empty + expect(parser.errors[:field_130]).to be_empty + expect(parser.errors[:field_114]).to be_empty + expect(parser.errors[:field_125]).to be_empty + end + end + end + + describe "#field_117" do + let(:attributes) { valid_attributes.merge({ field_8: "2", field_10: "9", field_117: "3" }) } + + it "does not allow 3 (don't know) as an option for discounted ownership" do + parser.valid? + expect(parser.errors[:field_117]).to include(I18n.t("validations.invalid_option", question: "was a mortgage used for the purchase of this property?")) + + parser.log.blank_invalid_non_setup_fields! + parser.log.save! + expect(parser.log.mortgageused).to be_nil + end + + context "when validate_discounted_ownership_value is triggered" do + let(:attributes) { setup_section_params.merge(field_114: 100, field_123: 100, field_8: 2, field_10: 9, field_117: 2, field_116: 10) } + + it "only adds errors to the discounted ownership field" do + parser.valid? + expect(parser.errors[:field_88]).to be_empty + expect(parser.errors[:field_117]).to include("The mortgage and cash deposit (£100.00) added together is £100.00.

The full purchase price (£100.00) subtracted by the sum of the full purchase price (£100.00) multiplied by the percentage discount (10.0%) is £90.00.

These two amounts should be the same.") + expect(parser.errors[:field_126]).to be_empty + end + end + end + + + describe "soft validations" do + context "when soft validation is triggered" do + let(:attributes) { valid_attributes.merge({ field_28: 22, field_32: 5 }) } + + it "adds an error to the relevant fields" do + parser.valid? + expect(parser.errors.where(:field_28, category: :soft_validation)).to be_present + expect(parser.errors.where(:field_32, category: :soft_validation)).to be_present + end + + it "populates with correct error message" do + parser.valid? + expect(parser.errors.where(:field_28, 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_32, 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 + end + + describe "log_already_exists?" do + let(:attributes) { { bulk_upload: } } + + before do + build(:sales_log, owning_organisation: nil, saledate: nil, purchid: nil, age1: nil, sex1: nil, ecstat1: 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 "#noint" do + context "when field is set to 1" do + let(:attributes) { valid_attributes.merge({ field_14: 1 }) } + + it "is correctly set" do + expect(parser.log.noint).to be(1) + end + end + + context "when field is set to 2" do + let(:attributes) { valid_attributes.merge({ field_14: 2 }) } + + it "is correctly set" do + expect(parser.log.noint).to be(2) + end + end + end + + describe "#uprn" do + let(:attributes) { setup_section_params.merge({ field_19: "12" }) } + + it "is correctly set" do + expect(parser.log.uprn).to eql("12") + end + end + + describe "#uprn_known" do + context "when uprn known" do + let(:attributes) { setup_section_params.merge({ field_19: "12" }) } + + it "is correctly set" do + expect(parser.log.uprn_known).to be(1) + end + end + + context "when uprn not known" do + let(:attributes) { setup_section_params.merge({ field_19: nil }) } + + it "is correctly set" do + expect(parser.log.uprn_known).to be(0) + end + end + end + + describe "#address_line1" do + let(:attributes) { setup_section_params.merge({ field_20: "some street" }) } + + it "is correctly set" do + expect(parser.log.address_line1).to eql("some street") + end + end + + describe "#address_line2" do + let(:attributes) { setup_section_params.merge({ field_21: "some other street" }) } + + it "is correctly set" do + expect(parser.log.address_line2).to eql("some other street") + end + end + + describe "#town_or_city" do + let(:attributes) { setup_section_params.merge({ field_22: "some town" }) } + + it "is correctly set" do + expect(parser.log.town_or_city).to eql("some town") + end + end + + describe "#county" do + let(:attributes) { setup_section_params.merge({ field_23: "some county" }) } + + it "is correctly set" do + expect(parser.log.county).to eql("some county") + end + end + + describe "#ethnic_group" do + context "when field_30 is 20" do + let(:attributes) { setup_section_params.merge({ field_30: "20" }) } + + it "is correctly set" do + expect(parser.log.ethnic_group).to be(0) + end + end + end + + describe "#ethnic_group2" do + let(:attributes) { setup_section_params.merge({ field_37: "1" }) } + + it "is correctly set" do + expect(parser.log.ethnic_group2).to be(0) + end + + context "when field_37 is 20" do + let(:attributes) { setup_section_params.merge({ field_37: "20" }) } + + it "is correctly set" do + expect(parser.log.ethnic_group2).to be(0) + end + end + end + + describe "#ethnicbuy2" do + let(:attributes) { setup_section_params.merge({ field_37: "1" }) } + + it "is correctly set" do + expect(parser.log.ethnicbuy2).to be(1) + end + end + + describe "#nationality_all" do + context "when field_31 is a 3 digit nationality code" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is a nationality code without the trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is a nationality code with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is 0" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is 000" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is 0000" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is 826" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is 826 with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_31: "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_31 is not a valid option" do + let(:attributes) { setup_section_params.merge({ field_31: "123123" }) } + + it "is correctly set" do + parser.valid? + expect(parser.log.nationality_all).to be(nil) + expect(parser.log.nationality_all_group).to be(nil) + expect(parser.errors["field_31"]).to include(I18n.t("validations.sales.2025.bulk_upload.nationality.invalid")) + end + end + end + + describe "#nationality_all_buyer2" do + context "when field_38 is a 3 digit nationality code" do + let(:attributes) { setup_section_params.merge({ field_38: "036" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(36) + expect(parser.log.nationality_all_buyer2_group).to be(12) + end + end + + context "when field_38 is a nationality code without the trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_38: "36" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(36) + expect(parser.log.nationality_all_buyer2_group).to be(12) + end + end + + context "when field_38 is a nationality code with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_38: "0036" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(36) + expect(parser.log.nationality_all_buyer2_group).to be(12) + end + end + + context "when field_38 is 0" do + let(:attributes) { setup_section_params.merge({ field_38: "0" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(0) + expect(parser.log.nationality_all_buyer2_group).to be(0) + end + end + + context "when field_38 is 000" do + let(:attributes) { setup_section_params.merge({ field_38: "000" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(0) + expect(parser.log.nationality_all_buyer2_group).to be(0) + end + end + + context "when field_38 is 0000" do + let(:attributes) { setup_section_params.merge({ field_38: "0000" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(0) + expect(parser.log.nationality_all_buyer2_group).to be(0) + end + end + + context "when field_38 is 826" do + let(:attributes) { setup_section_params.merge({ field_38: "826" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(826) + expect(parser.log.nationality_all_buyer2_group).to be(826) + end + end + + context "when field_38 is 826 with trailing 0s" do + let(:attributes) { setup_section_params.merge({ field_38: "0826" }) } + + it "is correctly set" do + expect(parser.log.nationality_all_buyer2).to be(826) + expect(parser.log.nationality_all_buyer2_group).to be(826) + end + end + + context "when field_38 is not a valid option" do + let(:attributes) { setup_section_params.merge({ field_38: "123123" }) } + + it "is correctly set" do + parser.valid? + expect(parser.log.nationality_all_buyer2).to be(nil) + expect(parser.log.nationality_all_buyer2_group).to be(nil) + expect(parser.errors["field_38"]).to include(I18n.t("validations.sales.2025.bulk_upload.nationality.invalid")) + end + end + end + + describe "#buy2living" do + let(:attributes) { setup_section_params.merge({ field_63: "1" }) } + + it "is correctly set" do + expect(parser.log.buy2living).to be(1) + end + end + + describe "#prevtenbuy2" do + let(:attributes) { setup_section_params.merge({ field_64: "R" }) } + + it "is correctly set" do + expect(parser.log.prevtenbuy2).to be(0) + end + end + + describe "#hhregres" do + let(:attributes) { setup_section_params.merge({ field_65: "1" }) } + + it "is correctly set" do + expect(parser.log.hhregres).to be(1) + end + end + + describe "#hhregresstill" do + let(:attributes) { setup_section_params.merge({ field_66: "4" }) } + + it "is correctly set" do + expect(parser.log.hhregresstill).to be(4) + end + end + + describe "#prevshared" do + let(:attributes) { setup_section_params.merge({ field_77: "3" }) } + + it "is correctly set" do + expect(parser.log.prevshared).to be(3) + end + end + + describe "#staircasesale" do + let(:attributes) { setup_section_params.merge({ field_98: "1" }) } + + it "is correctly set" do + expect(parser.log.staircasesale).to be(1) + end + end + + describe "#soctenant" do + context "when discounted ownership" do + let(:attributes) { valid_attributes.merge({ field_8: "2" }) } + + it "is set to nil" do + expect(parser.log.soctenant).to be_nil + end + end + + context "when shared ownership" do + context "when prevten is a social housing type" do + let(:attributes) { valid_attributes.merge({ field_8: "1", field_58: "1" }) } + + it "is set to yes" do + expect(parser.log.soctenant).to be(1) + end + end + + context "when prevten is not a social housing type" do + context "and prevtenbuy2 is a social housing type" do + let(:attributes) { valid_attributes.merge({ field_8: "1", field_58: "3", field_64: "2" }) } + + it "is set to yes" do + expect(parser.log.soctenant).to be(1) + end + end + + context "and prevtenbuy2 is not a social housing type" do + let(:attributes) { valid_attributes.merge({ field_8: "1", field_58: "3", field_64: "4" }) } + + it "is set to no" do + expect(parser.log.soctenant).to be(2) + end + end + + context "and prevtenbuy2 is blank" do + let(:attributes) { valid_attributes.merge({ field_8: "1", field_58: "3", field_64: nil }) } + + it "is set to no" do + expect(parser.log.soctenant).to be(2) + end + end + end + end + end + + describe "with living before purchase years for shared ownership more than 0" do + let(:attributes) { setup_section_params.merge({ field_8: "1", field_79: "1" }) } + + it "is sets living before purchase asked to yes and sets the correct living before purchase years" do + expect(parser.log.proplen_asked).to be(0) + expect(parser.log.proplen).to be(1) + end + end + + describe "with living before purchase years for discounted ownership more than 0" do + let(:attributes) { setup_section_params.merge({ field_8: "2", field_113: "1" }) } + + it "is sets living before purchase asked to yes and sets the correct living before purchase years" do + expect(parser.log.proplen_asked).to be(0) + expect(parser.log.proplen).to be(1) + end + end + + describe "with living before purchase years for shared ownership set to 0" do + let(:attributes) { setup_section_params.merge({ field_8: "1", field_79: "0" }) } + + it "is sets living before purchase asked to no" do + expect(parser.log.proplen_asked).to be(1) + expect(parser.log.proplen).to be_nil + end + end + + describe "with living before purchase 0 years for discounted ownership set to 0" do + let(:attributes) { setup_section_params.merge({ field_8: "2", field_113: "0" }) } + + it "is sets living before purchase asked to no" do + expect(parser.log.proplen_asked).to be(1) + expect(parser.log.proplen).to be_nil + end + end + + context "when mscharge is given, but is set to 0 for shared ownership" do + let(:attributes) { valid_attributes.merge(field_94: "0") } + + it "does not override variables correctly" do + log = parser.log + expect(log["has_mscharge"]).to eq(0) # no + expect(log["mscharge"]).to be_nil + end + end + + context "when mscharge is given, but is set to 0 for discounted ownership" do + let(:attributes) { valid_attributes.merge(field_8: "2", field_124: "0") } + + it "does not override variables correctly" do + log = parser.log + expect(log["has_mscharge"]).to eq(0) # no + expect(log["mscharge"]).to be_nil + end + end + + describe "shared ownership sale type" do + context "when 32 is selected for shared ownership type" do + let(:attributes) { valid_attributes.merge(field_9: "32") } + + it "sets the value correctly" do + log = parser.log + expect(log.type).to eq(32) + end + end + end + + describe "#managing_organisation_id" do + let(:attributes) { setup_section_params } + + context "when user is part of the owning organisation" do + it "sets managing organisation to the correct organisation" do + parser.valid? + expect(parser.log.owning_organisation_id).to be(owning_org.id) + expect(parser.log.managing_organisation_id).to be(managing_org.id) + end + end + + context "when blank" do + let(:attributes) { { bulk_upload:, field_2: "", field_6: "not blank" } } + + 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.not_answered", question: "reported by.")) + 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.not_answered", question: "reported by.")) + 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.sales.2025.bulk_upload.assigned_to.managing_organisation_not_related")) + end + + it "blocks log creation" do + parser.valid? + expect(parser).to be_block_log_creation + end + end + end + end + + describe "#owning_organisation_id" do + let(:attributes) { setup_section_params } + + context "when owning organisation does not own stock" do + let(:owning_org) { create(:organisation, :with_old_visible_id, holds_own_stock: false) } + let(:attributes) { setup_section_params } + + 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.sales.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 + 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