diff --git a/app/models/bulk_upload.rb b/app/models/bulk_upload.rb index 70b591b91..26b988bb2 100644 --- a/app/models/bulk_upload.rb +++ b/app/models/bulk_upload.rb @@ -58,8 +58,8 @@ class BulkUpload < ApplicationRecord end year_class = case year - when 2022 - "Year2022" + when 2024 + "Year2024" when 2023 "Year2023" else diff --git a/app/models/forms/bulk_upload_sales/prepare_your_file.rb b/app/models/forms/bulk_upload_sales/prepare_your_file.rb index 04d52802f..227137d72 100644 --- a/app/models/forms/bulk_upload_sales/prepare_your_file.rb +++ b/app/models/forms/bulk_upload_sales/prepare_your_file.rb @@ -11,6 +11,8 @@ module Forms case year when 2023 "bulk_upload_sales_logs/forms/prepare_your_file_2023" + when 2024 + "bulk_upload_sales_logs/forms/prepare_your_file_2024" end end diff --git a/app/services/bulk_upload/sales/log_creator.rb b/app/services/bulk_upload/sales/log_creator.rb index 1036ac8ff..2d0888e4d 100644 --- a/app/services/bulk_upload/sales/log_creator.rb +++ b/app/services/bulk_upload/sales/log_creator.rb @@ -33,6 +33,8 @@ private @csv_parser ||= case bulk_upload.year when 2023 BulkUpload::Sales::Year2023::CsvParser.new(path:) + when 2024 + BulkUpload::Sales::Year2024::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 1a3234911..978537124 100644 --- a/app/services/bulk_upload/sales/validator.rb +++ b/app/services/bulk_upload/sales/validator.rb @@ -94,6 +94,8 @@ private @csv_parser ||= case bulk_upload.year when 2023 BulkUpload::Sales::Year2023::CsvParser.new(path:) + when 2024 + BulkUpload::Sales::Year2024::CsvParser.new(path:) else raise "csv parser not found" end diff --git a/app/services/bulk_upload/sales/year2024/csv_parser.rb b/app/services/bulk_upload/sales/year2024/csv_parser.rb new file mode 100644 index 000000000..81b7c8f61 --- /dev/null +++ b/app/services/bulk_upload/sales/year2024/csv_parser.rb @@ -0,0 +1,109 @@ +require "csv" + +class BulkUpload::Sales::Year2024::CsvParser + include CollectionTimeHelper + + MAX_COLUMNS = 142 + FORM_YEAR = 2024 + + attr_reader :path + + def initialize(path:) + @path = path + end + + def row_offset + if with_headers? + rows.find_index { |row| row[0].match(/field number/i) } + 1 + else + 0 + end + end + + def col_offset + with_headers? ? 1 : 0 + end + + def cols + @cols ||= ("A".."EK").to_a + end + + def row_parsers + @row_parsers ||= body_rows.map do |row| + stripped_row = row[col_offset..] + hash = Hash[field_numbers.zip(stripped_row)] + + BulkUpload::Sales::Year2024::RowParser.new(hash) + end + 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 + +private + + def default_field_numbers + (1..131).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_135").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? + Date.new(row_parsers.first.field_6.to_i + 2000, row_parsers.first.field_5.to_i, row_parsers.first.field_4.to_i) + else + Date.new(rows.first[5].to_i + 2000, rows.first[4].to_i, rows.first[3].to_i) + end + end +end diff --git a/app/services/bulk_upload/sales/year2024/row_parser.rb b/app/services/bulk_upload/sales/year2024/row_parser.rb new file mode 100644 index 000000000..99d45aea2 --- /dev/null +++ b/app/services/bulk_upload/sales/year2024/row_parser.rb @@ -0,0 +1,1323 @@ +class BulkUpload::Sales::Year2024::RowParser + include ActiveModel::Model + include ActiveModel::Attributes + include InterruptionScreenHelper + + 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: "Was this purchase made through an ownership scheme?", + field_9: "What is the type of shared ownership sale?", + field_10: "What is the type of discounted ownership sale?", + field_11: "What is the type of outright sale?", + + field_12: "If 'other', what is the 'other' type?", + field_13: "Is the buyer a company?", + field_14: "Will the buyers live in the property?", + field_15: "Is this a joint purchase?", + field_16: "Are there more than two joint purchasers of this property?", + field_17: "Was the buyer interviewed for any of the answers you will provide on this log?", + field_18: "Data Protection question", + field_19: "How many bedrooms does the property have?", + field_20: "What type of unit is the property?", + field_21: "Which type of bulding is the property?", + field_22: "If known, enter this property's UPRN", + field_23: "Address line 1", + + field_24: "Address line 2", + field_25: "Town or city", + field_26: "County", + field_27: "Part 1 of postcode of property", + field_28: "Part 2 of postcode of property", + field_29: "What is the local authority of the property?", + field_30: "Is the property built or adapted to wheelchair user standards?", + field_31: "Age of buyer 1", + + field_32: "Gender identity of buyer 1", + field_33: "What is buyer 1's ethnic group?", + field_34: "What is buyer 1's nationality?", + field_35: "Working situation of buyer 1", + field_36: "Will buyer 1 live in the property?", + field_37: "Relationship to buyer 1 for person 2", + field_38: "Age of person 2", + field_39: "Gender identity of person 2", + field_40: "Which of the following best describes buyer 2's ethnic background?", + + field_41: "What is buyer 2's nationality?", + field_42: "What is buyer 2 or person 2's working situation?", + field_43: "Will buyer 2 live in the property?", + field_44: "Besides the buyers, how many people will live in the property?", + field_45: "Relationship to buyer 1 for person 3", + field_46: "Age of person 3", + field_47: "Gender identity of person 3", + field_48: "Working situation of person 3", + field_49: "Relationship to buyer 1 for person 4", + + field_50: "Age of person 4", + field_51: "Gender identity of person 4", + field_52: "Working situation of person 4", + field_53: "Relationship to buyer 1 for person 5", + field_54: "Age of person 5", + field_55: "Gender identity of person 5", + field_56: "Working situation of person 5", + field_57: "Relationship to buyer 1 for person 6", + field_58: "Age of person 6", + field_59: "Gender identity of person 6", + + field_60: "Working situation of person 6", + field_61: "What was buyer 1's previous tenure?", + field_62: "Do you know the postcode of buyer 1's last settled home?", + field_63: "Part 1 of postcode of buyer 1's last settled home", + field_64: "Part 2 of postcode of buyer 1's last settled home", + field_65: "What is the local authority of buyer 1's last settled home?", + field_66: "Was the buyer registered with their PRP (HA)?", + field_67: "Was the buyer registered with another PRP (HA)?", + field_68: "Was the buyer registered with the local authority?", + field_69: "Was the buyer registered with a Help to Buy agent?", + + field_70: "At the time of purchase, was buyer 2 living at the same address as buyer 1?", + field_71: "What was buyer 2's previous tenure?", + field_72: "Has the buyer ever served in the UK Armed Forces and for how long?", + field_73: "Is the buyer still serving in the UK armed forces?", + field_74: "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_75: "Does anyone in the household consider themselves to have a disability?", + field_76: "Does anyone in the household use a wheelchair?", + field_77: "What is buyer 1's gross annual income?", + field_78: "Was buyer 1's income used for a mortgage application?", + field_79: "What is buyer 2's gross annual income?", + + field_80: "Was buyer 2's income used for a mortgage application?", + field_81: "Were the buyers receiving any of these housing-related benefits immediately before buying this property?", + field_82: "What is the total amount the buyers had in savings before they paid any deposit for the property?", + field_83: "Have any of the purchasers previously owned a property?", + field_84: "Was the previous property under shared ownership?", + field_85: "How long have the buyers been living in the property before the purchase? - Shared ownership", + field_86: "Is this a staircasing transaction?", + field_87: "What percentage of the property has been bought in this staircasing transaction?", + field_88: "What percentage of the property does the buyer now own in total?", + field_89: "Was this transaction part of a back-to-back staircasing transaction to facilitate sale of the home on the open market?", + + field_90: "Is this a resale?", + field_91: "What is the day of the exchange of contracts date?", + field_92: "What is the month of the exchange of contracts date?", + field_93: "What is the year of the exchange of contracts date?", + field_94: "What is the day of the practical completion or handover date?", + field_95: "What is the month of the practical completion or handover date?", + field_96: "What is the year of the practical completion or handover date?", + field_97: "Was the household re-housed under a local authority nominations agreement?", + field_98: "How many bedrooms did the buyer's previous property have?", + + field_99: "What was the type of the buyer's previous property?", + field_100: "What was the rent type of the buyer's previous property?", + field_101: "What was the full purchase price?", + field_102: "What was the initial percentage equity stake purchased?", + field_103: "Was a mortgage used for the purchase of this property? - Shared ownership", + field_104: "What is the mortgage amount?", + field_105: "What is the name of the mortgage lender? - Shared ownership", + field_106: "If 'other', what is the name of the mortgage lender?", + field_107: "What is the length of the mortgage in years? - Shared ownership", + field_108: "Does this include any extra borrowing?", + + field_109: "How much was the cash deposit paid on the property?", + field_110: "How much cash discount was given through Social Homebuy?", + field_111: "What is the basic monthly rent?", + field_112: "What are the total monthly leasehold charges for the property?", + field_113: "How long have the buyers been living in the property before the purchase? - Discounted ownership", + field_114: "What was the full purchase price?", + field_115: "What was the amount of any loan, grant, discount or subsidy given?", + field_116: "What was the percentage discount?", + field_117: "Was a mortgage used for the purchase of this property? - Discounted ownership", + field_118: "What is the mortgage amount?", + + field_119: "What is the name of the mortgage lender? - Discounted ownership", + field_120: "If 'other', what is the name of the mortgage lender?", + field_121: "What is the length of the mortgage in years? - Discounted ownership", + field_122: "Does this include any extra borrowing?", + field_123: "How much was the cash deposit paid on the property?", + field_124: "What are the total monthly leasehold charges for the property?", + field_125: "What is the full purchase price?", + field_126: "Was a mortgage used for the purchase of this property? - Outright sale", + field_127: "What is the mortgage amount?", + + field_128: "What is the length of the mortgage in years? - Outright sale", + field_129: "Does this include any extra borrowing?", + field_130: "How much was the cash deposit paid on the property?", + field_131: "What are the total monthly leasehold charges for the property?", + }.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, :string + 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, :integer + attribute :field_20, :integer + attribute :field_21, :integer + attribute :field_22, :string + attribute :field_23, :string + + attribute :field_24, :string + attribute :field_25, :string + attribute :field_26, :string + attribute :field_27, :string + attribute :field_28, :string + attribute :field_29, :string + attribute :field_30, :integer + attribute :field_31, :string + + attribute :field_32, :string + attribute :field_33, :integer + attribute :field_34, :integer + attribute :field_35, :integer + attribute :field_36, :integer + attribute :field_37, :string + attribute :field_38, :string + attribute :field_39, :string + attribute :field_40, :integer + + attribute :field_41, :integer + attribute :field_42, :integer + attribute :field_43, :integer + attribute :field_44, :integer + attribute :field_45, :string + attribute :field_46, :string + attribute :field_47, :string + attribute :field_48, :integer + attribute :field_49, :string + + attribute :field_50, :string + attribute :field_51, :string + attribute :field_52, :integer + attribute :field_53, :string + attribute :field_54, :string + attribute :field_55, :string + attribute :field_56, :integer + attribute :field_57, :string + attribute :field_58, :string + attribute :field_59, :string + + attribute :field_60, :integer + attribute :field_61, :integer + attribute :field_62, :integer + attribute :field_63, :string + attribute :field_64, :string + attribute :field_65, :string + attribute :field_66, :integer + attribute :field_67, :integer + attribute :field_68, :integer + attribute :field_69, :integer + + attribute :field_70, :integer + attribute :field_71, :string + attribute :field_72, :integer + attribute :field_73, :integer + attribute :field_74, :integer + attribute :field_75, :integer + attribute :field_76, :integer + attribute :field_77, :string + attribute :field_78, :integer + attribute :field_79, :string + + attribute :field_80, :integer + attribute :field_81, :integer + attribute :field_82, :string + attribute :field_83, :integer + attribute :field_84, :integer + attribute :field_85, :integer + attribute :field_86, :integer + attribute :field_87, :integer + attribute :field_88, :integer + attribute :field_89, :integer + + attribute :field_90, :integer + attribute :field_91, :integer + attribute :field_92, :integer + attribute :field_93, :integer + attribute :field_94, :integer + attribute :field_95, :integer + attribute :field_96, :integer + attribute :field_97, :integer + attribute :field_98, :integer + + attribute :field_99, :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, :string + attribute :field_107, :integer + attribute :field_108, :integer + + attribute :field_109, :integer + attribute :field_110, :integer + attribute :field_111, :decimal + attribute :field_112, :decimal + 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, :string + attribute :field_121, :integer + attribute :field_122, :integer + attribute :field_123, :integer + attribute :field_124, :integer + attribute :field_125, :integer + attribute :field_126, :integer + attribute :field_127, :integer + + attribute :field_128, :integer + attribute :field_129, :integer + attribute :field_130, :integer + attribute :field_131, :integer + + validates :field_4, + presence: { + message: I18n.t("validations.not_answered", question: "sale completion date (day)"), + category: :setup, + }, + on: :after_log + + validates :field_5, + presence: { + message: I18n.t("validations.not_answered", question: "sale completion date (month)"), + category: :setup, + }, on: :after_log + + validates :field_6, + presence: { + message: I18n.t("validations.not_answered", question: "sale completion date (year)"), + category: :setup, + }, + format: { + with: /\A\d{2}\z/, + message: I18n.t("validations.setup.saledate.year_not_two_digits"), + category: :setup, + if: proc { field_6.present? }, + }, on: :after_log + + validates :field_8, + presence: { + message: I18n.t("validations.not_answered", question: "purchase made under ownership scheme"), + 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("validations.not_answered", question: "type of shared ownership sale"), + category: :setup, + if: :shared_ownership?, + }, + on: :after_log + + validates :field_10, + inclusion: { + in: [8, 14, 27, 9, 29, 21, 22], + if: proc { field_10.present? }, + category: :setup, + question: QUESTIONS[:field_10].downcase, + }, + on: :before_log + + validates :field_10, + presence: { + message: I18n.t("validations.not_answered", question: "type of discounted ownership sale"), + category: :setup, + if: :discounted_ownership?, + }, + on: :after_log + + validates :field_11, + inclusion: { + in: [10, 12], + if: proc { field_11.present? }, + category: :setup, + question: QUESTIONS[:field_11].downcase, + }, + on: :before_log + + validates :field_11, + presence: { + message: I18n.t("validations.not_answered", question: "type of outright sale"), + category: :setup, + if: :outright_sale?, + }, + on: :after_log + + validates :field_12, + presence: { + message: I18n.t("validations.not_answered", question: "type of outright sale"), + category: :setup, + if: proc { field_11 == 12 }, + }, + on: :after_log + + validates :field_13, + inclusion: { + in: [1, 2], + if: proc { outright_sale? && field_13.present? }, + category: :setup, + question: QUESTIONS[:field_13].downcase, + }, + on: :before_log + + validates :field_13, + presence: { + message: I18n.t("validations.not_answered", question: "company buyer"), + category: :setup, + if: :outright_sale?, + }, + on: :after_log + + validates :field_14, + inclusion: { + in: [1, 2], + if: proc { outright_sale? && field_14.present? }, + category: :setup, + question: QUESTIONS[:field_14].downcase, + }, + on: :before_log + + validates :field_14, + presence: { + message: I18n.t("validations.not_answered", question: "buyers living in property"), + category: :setup, + if: :outright_sale?, + }, + on: :after_log + + validates :field_15, + presence: { + message: I18n.t("validations.not_answered", question: "joint purchase"), + category: :setup, + if: :joint_purchase_asked?, + }, + on: :after_log + + validates :field_16, + presence: { + message: I18n.t("validations.not_answered", question: "more than 2 joint buyers"), + category: :setup, + if: :joint_purchase?, + }, + on: :after_log + + validate :validate_buyer1_economic_status, on: :before_log + validate :validate_nulls, on: :after_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 if FeatureToggle.sales_managing_organisation_enabled? + validate :validate_owning_org_permitted, on: :after_log + + validate :validate_created_by_exists, on: :after_log + validate :validate_created_by_related, on: :after_log + validate :validate_managing_org_related, on: :after_log if FeatureToggle.sales_managing_organisation_enabled? + 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_data_protection_answered, on: :after_log + validate :validate_buyers_organisations, 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 + .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) + + 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? + @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_27", # postcode + "field_28", # postcode + "field_31", # age1 + "field_32", # sex1 + "field_35", # ecstat1 + ) + end + + def add_duplicate_found_in_spreadsheet_errors + spreadsheet_duplicate_hash.each_key do |field| + errors.add(field, :spreadsheet_dupe, category: :setup) + end + end + +private + + def validate_data_protection_answered + unless field_18 == 1 + errors.add(:field_18, I18n.t("validations.not_answered", question: QUESTIONS[:field_18].downcase), category: :setup) + end + end + + def validate_buyers_organisations + organisations_fields = %i[field_66 field_67 field_68 field_69] + if organisations_fields.all? { |field| attributes[field.to_s].blank? } + organisations_fields.each do |field| + errors.add(field, "At least one option must be selected of these four") + end + end + end + + def prevtenbuy2 + case field_71 + when "R" + 0 + else + field_71 + end + end + + def infer_buyer2_ethnic_group_from_ethnic + case field_40 + when 1, 2, 3, 18 + 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_40 + end + end + + def validate_uprn_exists_if_any_key_address_fields_are_blank + if field_22.blank? && (field_23.blank? || field_25.blank?) + errors.add(:field_22, I18n.t("validations.not_answered", question: "UPRN")) + end + end + + def validate_address_fields + if field_22.blank? || log.errors.attribute_names.include?(:uprn) + if field_23.blank? + errors.add(:field_23, I18n.t("validations.not_answered", question: "address line 1")) + end + + if field_25.blank? + errors.add(:field_25, I18n.t("validations.not_answered", question: "town or city")) + end + end + end + + def shared_ownership? + field_8 == 1 + end + + def discounted_ownership? + field_8 == 2 + end + + def outright_sale? + field_8 == 3 + end + + def joint_purchase? + field_114 == 1 + end + + def joint_purchase_asked? + shared_ownership? || discounted_ownership? || field_112 == 2 + end + + def field_mapping_for_errors + { + purchid: %i[field_7], + saledate: %i[field_4 field_5 field_6], + noint: %i[field_17], + age1_known: %i[field_31], + age1: %i[field_31], + age2_known: %i[field_38], + age2: %i[field_38], + age3_known: %i[field_46], + age3: %i[field_46], + age4_known: %i[field_50], + age4: %i[field_50], + age5_known: %i[field_54], + age5: %i[field_54], + age6_known: %i[field_58], + age6: %i[field_58], + sex1: %i[field_32], + sex2: %i[field_39], + sex3: %i[field_47], + + sex4: %i[field_51], + sex5: %i[field_55], + sex6: %i[field_59], + relat2: %i[field_37], + relat3: %i[field_45], + relat4: %i[field_49], + relat5: %i[field_53], + relat6: %i[field_57], + + ecstat1: %i[field_35], + ecstat2: %i[field_42], + ecstat3: %i[field_48], + + ecstat4: %i[field_52], + ecstat5: %i[field_56], + ecstat6: %i[field_60], + ethnic_group: %i[field_33], + ethnic: %i[field_33], + national: %i[field_34], + income1nk: %i[field_77], + income1: %i[field_77], + income2nk: %i[field_79], + income2: %i[field_79], + inc1mort: %i[field_78], + inc2mort: %i[field_80], + savingsnk: %i[field_82], + savings: %i[field_82], + prevown: %i[field_83], + prevten: %i[field_61], + prevloc: %i[field_65], + previous_la_known: %i[field_65], + ppcodenk: %i[field_62], + ppostcode_full: %i[field_63 field_64], + pregyrha: %i[field_66], + pregla: %i[field_68], + pregghb: %i[field_69], + pregother: %i[field_67], + disabled: %i[field_75], + + wheel: %i[field_76], + beds: %i[field_19], + proptype: %i[field_20], + builtype: %i[field_21], + la_known: %i[field_29], + la: %i[field_29], + + is_la_inferred: %i[field_29], + pcodenk: %i[field_27 field_28], + postcode_full: %i[field_27 field_28], + wchair: %i[field_30], + + type: %i[field_9 field_10 field_11 field_8], + resale: %i[field_90], + hodate: %i[field_94 field_95 field_96], + exdate: %i[field_91 field_92 field_93], + + lanomagr: %i[field_97], + frombeds: %i[field_98], + fromprop: %i[field_99], + soctenant: %i[field_98 field_99 field_100], + value: %i[field_101 field_114 field_125], + equity: %i[field_102], + mortgage: %i[field_104 field_118 field_127], + extrabor: %i[field_108 field_122 field_129], + deposit: %i[field_109 field_123 field_130], + cashdis: %i[field_110], + mrent: %i[field_111], + + has_mscharge: %i[field_112 field_124 field_131], + mscharge: %i[field_112 field_124 field_131], + grant: %i[field_115], + discount: %i[field_116], + othtype: %i[field_12], + owning_organisation_id: %i[field_1], + created_by: %i[field_3], + hhregres: %i[field_72], + hhregresstill: %i[field_73], + armedforcesspouse: %i[field_74], + + mortgagelender: %i[field_105 field_119], + mortgagelenderother: %i[field_106 field_120], + + hb: %i[field_81], + mortlen: %i[field_107 field_121 field_128], + proplen: %i[field_113 field_85], + + jointmore: %i[field_16], + staircase: %i[field_86], + privacynotice: %i[field_18], + ownershipsch: %i[field_8], + companybuy: %i[field_13], + buylivein: %i[field_14], + + jointpur: %i[field_15], + buy1livein: %i[field_36], + buy2livein: %i[field_43], + hholdcount: %i[field_44], + stairbought: %i[field_87], + stairowned: %i[field_88], + socprevten: %i[field_100], + mortgageused: %i[field_103 field_117 field_126], + + uprn: %i[field_22], + address_line1: %i[field_23], + address_line2: %i[field_24], + town_or_city: %i[field_25], + county: %i[field_26], + + ethnic_group2: %i[field_40], + ethnicbuy2: %i[field_40], + nationalbuy2: %i[field_41], + + buy2living: %i[field_70], + prevtenbuy2: %i[field_71], + + prevshared: %i[field_84], + + staircasesale: %i[field_89], + } + end + + def attributes_for_log + attributes = {} + + attributes["purchid"] = purchaser_code + attributes["saledate"] = saledate + attributes["noint"] = 2 if field_17 == 1 + + attributes["age1_known"] = age1_known? + attributes["age1"] = field_31 if attributes["age1_known"]&.zero? && field_31&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age2_known"] = age2_known? + attributes["age2"] = field_38 if attributes["age2_known"]&.zero? && field_38&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age3_known"] = age3_known? + attributes["age3"] = field_46 if attributes["age3_known"]&.zero? && field_46&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age4_known"] = age4_known? + attributes["age4"] = field_50 if attributes["age4_known"]&.zero? && field_50&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age5_known"] = age5_known? + attributes["age5"] = field_54 if attributes["age5_known"]&.zero? && field_54&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["age6_known"] = age6_known? + attributes["age6"] = field_58 if attributes["age6_known"]&.zero? && field_58&.match(/\A\d{1,3}\z|\AR\z/) + + attributes["sex1"] = field_32 + attributes["sex2"] = field_39 + attributes["sex3"] = field_47 + attributes["sex4"] = field_51 + attributes["sex5"] = field_55 + attributes["sex6"] = field_59 + + attributes["relat2"] = field_37 + attributes["relat3"] = field_45 + attributes["relat4"] = field_49 + attributes["relat5"] = field_53 + attributes["relat6"] = field_57 + + attributes["ecstat1"] = field_35 + attributes["ecstat2"] = field_42 + attributes["ecstat3"] = field_48 + attributes["ecstat4"] = field_52 + attributes["ecstat5"] = field_56 + attributes["ecstat6"] = field_60 + + 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_33 + attributes["national"] = field_34 + + attributes["income1nk"] = field_77 == "R" ? 1 : 0 + attributes["income1"] = field_77.to_i if attributes["income1nk"]&.zero? && field_77&.match(/\A\d+\z/) + + attributes["income2nk"] = field_79 == "R" ? 1 : 0 + attributes["income2"] = field_79.to_i if attributes["income2nk"]&.zero? && field_79&.match(/\A\d+\z/) + + attributes["inc1mort"] = field_78 + attributes["inc2mort"] = field_80 + + attributes["savingsnk"] = field_82 == "R" ? 1 : 0 + attributes["savings"] = field_82.to_i if attributes["savingsnk"]&.zero? && field_82&.match(/\A\d+\z/) + attributes["prevown"] = field_83 + + attributes["prevten"] = field_61 + attributes["prevloc"] = field_65 + attributes["previous_la_known"] = previous_la_known + attributes["ppcodenk"] = previous_postcode_known + attributes["ppostcode_full"] = ppostcode_full + + attributes["pregyrha"] = field_66 + attributes["pregla"] = field_68 + attributes["pregghb"] = field_69 + attributes["pregother"] = field_67 + + attributes["disabled"] = field_75 + attributes["wheel"] = field_76 + attributes["beds"] = field_19 + attributes["proptype"] = field_20 + attributes["builtype"] = field_21 + attributes["la_known"] = field_29.present? ? 1 : 0 + attributes["la"] = field_29 + attributes["is_la_inferred"] = false + attributes["pcodenk"] = 0 if postcode_full.present? + attributes["postcode_full"] = postcode_full + attributes["wchair"] = field_30 + + attributes["type"] = sale_type + attributes["resale"] = field_90 + + attributes["hodate"] = hodate + attributes["exdate"] = exdate + + attributes["lanomagr"] = field_97 + + attributes["frombeds"] = field_98 + attributes["fromprop"] = field_99 + + attributes["value"] = value + attributes["equity"] = field_102 + attributes["mortgage"] = mortgage + attributes["extrabor"] = extrabor + attributes["deposit"] = deposit + + attributes["cashdis"] = field_110 + attributes["mrent"] = field_111 + attributes["mscharge"] = mscharge if mscharge&.positive? + attributes["has_mscharge"] = attributes["mscharge"].present? ? 1 : 0 + attributes["grant"] = field_115 + attributes["discount"] = field_116 + + attributes["othtype"] = field_12 + + attributes["owning_organisation"] = owning_organisation + attributes["managing_organisation"] = managing_organisation + attributes["created_by"] = created_by || bulk_upload.user + attributes["hhregres"] = field_72 + attributes["hhregresstill"] = field_73 + attributes["armedforcesspouse"] = field_74 + + attributes["mortgagelender"] = mortgagelender + attributes["mortgagelenderother"] = mortgagelenderother + + attributes["hb"] = field_81 + + attributes["mortlen"] = mortlen + + attributes["proplen"] = proplen if proplen&.positive? + attributes["proplen_asked"] = attributes["proplen"]&.present? ? 0 : 1 + attributes["jointmore"] = field_16 + attributes["staircase"] = field_86 + attributes["privacynotice"] = field_18 + attributes["ownershipsch"] = field_8 + attributes["companybuy"] = field_13 + attributes["buylivein"] = field_14 + attributes["jointpur"] = field_15 + attributes["buy1livein"] = field_36 + attributes["buy2livein"] = field_43 + attributes["hholdcount"] = field_44 + attributes["stairbought"] = field_87 + attributes["stairowned"] = field_88 + attributes["socprevten"] = field_100 + attributes["soctenant"] = [attributes["socprevten"], attributes["frombeds"], attributes["fromprop"]].any?(&:present?) ? 1 : 0 + attributes["mortgageused"] = mortgageused + + attributes["uprn"] = field_22 + attributes["uprn_known"] = field_22.present? ? 1 : 0 + attributes["uprn_confirmed"] = 1 if field_22.present? + attributes["skip_update_uprn_confirmed"] = true + attributes["address_line1"] = field_23 + attributes["address_line2"] = field_24 + attributes["town_or_city"] = field_25 + attributes["county"] = field_26 + + attributes["ethnic_group2"] = infer_buyer2_ethnic_group_from_ethnic + attributes["ethnicbuy2"] = field_40 + attributes["nationalbuy2"] = field_41 + + attributes["buy2living"] = field_70 + attributes["prevtenbuy2"] = prevtenbuy2 + + attributes["prevshared"] = field_84 + + attributes["staircasesale"] = field_89 + + attributes + end + + def saledate + Date.new(field_6 + 2000, field_5, field_4) if field_6.present? && field_5.present? && field_4.present? + rescue Date::Error + Date.new + end + + def hodate + Date.new(field_96 + 2000, field_95, field_94) if field_96.present? && field_95.present? && field_94.present? + rescue Date::Error + Date.new + end + + def exdate + Date.new(field_93 + 2000, field_92, field_91) if field_93.present? && field_92.present? && field_91.present? + rescue Date::Error + Date.new + end + + def age1_known? + return 1 if field_31 == "R" + + 0 + end + + [ + { person: 2, field: :field_38 }, + { person: 3, field: :field_46 }, + { person: 4, field: :field_50 }, + { person: 5, field: :field_54 }, + { person: 6, field: :field_58 }, + ].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_38.present? || field_39.present? || field_37.present? + end + + def person_3_present? + field_46.present? || field_47.present? || field_45.present? + end + + def person_4_present? + field_50.present? || field_51.present? || field_49.present? + end + + def person_5_present? + field_54.present? || field_55.present? || field_53.present? + end + + def person_6_present? + field_58.present? || field_59.present? || field_57.present? + end + + def details_known?(person_n) + send("person_#{person_n}_present?") ? 1 : 2 + end + + def ethnic_group_from_ethnic + return nil if field_33.blank? + + case field_33 + when 1, 2, 3, 18 + 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_27} #{field_28}" if field_27 && field_28 + end + + def ppostcode_full + "#{field_63} #{field_64}" if field_63 && field_64 + end + + def sale_type + return field_9 if shared_ownership? + return field_10 if discounted_ownership? + return field_11 if outright_sale? + end + + def value + return field_101 if shared_ownership? + return field_114 if discounted_ownership? + return field_125 if outright_sale? + end + + def mortgage + return field_104 if shared_ownership? + return field_118 if discounted_ownership? + return field_127 if outright_sale? + end + + def extrabor + return field_108 if shared_ownership? + return field_122 if discounted_ownership? + return field_129 if outright_sale? + end + + def deposit + return field_109 if shared_ownership? + return field_123 if discounted_ownership? + return field_130 if outright_sale? + end + + def mscharge + return field_112 if shared_ownership? + return field_124 if discounted_ownership? + return field_131 if outright_sale? + end + + def mortgagelender + return field_105 if shared_ownership? + return field_119 if discounted_ownership? + end + + def mortgagelenderother + return field_106 if shared_ownership? + return field_120 if discounted_ownership? + end + + def mortlen + return field_107 if shared_ownership? + return field_121 if discounted_ownership? + return field_128 if outright_sale? + end + + def proplen + return field_85 if shared_ownership? + return field_113 if discounted_ownership? + end + + def mortgageused + return field_103 if shared_ownership? + return field_117 if discounted_ownership? + return field_126 if outright_sale? + end + + def owning_organisation + @owning_organisation ||= Organisation.find_by_id_on_multiple_fields(field_1) + end + + def created_by + @created_by ||= User.where("lower(email) = ?", field_3&.downcase).first + end + + def previous_la_known + field_65.present? ? 1 : 0 + end + + def previous_postcode_known + return 1 if field_62 == 2 + + 0 if field_62 == 1 + 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, "The owning organisation code is incorrect", category: :setup) + end + end + end + + def validate_owning_org_exists + if owning_organisation.nil? + block_log_creation! + + if errors[:field_1].blank? + errors.add(:field_1, "The owning organisation code is incorrect", 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, "The owning organisation code provided is for an organisation that does not own stock", category: :setup) + end + end + end + + def validate_owning_org_permitted + if owning_organisation && !bulk_upload.user.organisation.affiliated_stock_owners.include?(owning_organisation) + block_log_creation! + + if errors[:field_1].blank? + errors.add(:field_1, "You do not have permission to add logs for this owning organisation", category: :setup) + end + end + end + + def validate_created_by_exists + return if field_3.blank? + + unless created_by + errors.add(:field_3, "User with the specified email could not be found") + end + end + + def validate_created_by_related + return unless created_by + return if created_by.organisation == owning_organisation || created_by.organisation == managing_organisation + return if created_by.organisation == owning_organisation&.absorbing_organisation || created_by.organisation == managing_organisation&.absorbing_organisation + + block_log_creation! + errors.add(:field_3, "User must be related to owning organisation or managing organisation", category: :setup) + end + + def managing_organisation + return owning_organisation if created_by&.organisation&.absorbed_organisations&.include?(owning_organisation) + + created_by&.organisation || bulk_upload.user.organisation + 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_3].blank? + errors.add(:field_3, "This user belongs to an organisation that does not have a relationship with the owning organisation", 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| + unless errors.any? { |e| fields.include?(e.attribute) } + errors.add(field, I18n.t("validations.not_answered", question: question.error_display_label&.downcase), category: :setup) + end + end + else + fields.each do |field| + unless errors.any? { |e| fields.include?(e.attribute) } + errors.add(field, I18n.t("validations.not_answered", question: question.error_display_label&.downcase)) + 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("validations.invalid_option", question: QUESTIONS[field]), category: :setup) + end + end + else + fields.each do |field| + unless errors.any? { |e| fields.include?(e.attribute) } + errors.add(field, I18n.t("validations.invalid_option", question: 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("validations.date.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("validations.date.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("validations.date.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 = "This is a duplicate log" + + 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_27, error_message) # Postcode + errors.add(:field_28, error_message) # Postcode + errors.add(:field_31, error_message) # Buyer 1 age + errors.add(:field_32, error_message) # Buyer 1 gender + errors.add(:field_35, 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_35 == 9 + errors.add(:field_35, "Buyer 1 cannot be a child under 16") + end + end +end diff --git a/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2024.html.erb b/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2024.html.erb new file mode 100644 index 000000000..0bbca2317 --- /dev/null +++ b/app/views/bulk_upload_sales_logs/forms/prepare_your_file_2024.html.erb @@ -0,0 +1,41 @@ +<% 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 %> + + 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 }) %> before you start if you have not used bulk upload before.

+ +

Download template

+ +

Use one of these templates to upload logs for 2024/25:

+
    +
  • <%= govuk_link_to "Download the new template", @form.template_path %>: In this template, the questions are in the same order as the 2024/25 paper form and web form.
  • +
  • <%= govuk_link_to "Download the legacy template", @form.legacy_template_path %>: In this template, the questions are in the same order as the 2022/23 template, with new questions added on to the end.
  • +
+

There are 7 or 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

+
    +
  • 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 #{@form.year_combo} Bulk Upload Specification", @form.specification_path %> to check your data is in the correct format.
  • +
  • Username field: To assign a log to someone else, enter the email address they use to log into CORE.
  • +
  • If you are using the new template, keep the headers. If you are using the legacy template, you can either keep or remove the headers. If you remove the headers, you should also remove the blank column A.
  • +
+ +

Save your file

+
    +
  • Save your file as a CSV.
  • +
  • Your file should now be ready to upload.
  • +
+ + <%= f.govuk_submit %> + <% end %> +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 862528021..8838c6a80 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -44,11 +44,11 @@ en: bulk_upload/row_parser: &bulk_upload__row_parser__base inclusion: Enter a valid value for %{question} spreadsheet_dupe: This is a duplicate of a log in your file - bulk_upload/lettings/year2022/row_parser: + bulk_upload/lettings/year2024/row_parser: <<: *bulk_upload__row_parser__base bulk_upload/lettings/year2023/row_parser: <<: *bulk_upload__row_parser__base - bulk_upload/sales/year2022/row_parser: + bulk_upload/sales/year2024/row_parser: <<: *bulk_upload__row_parser__base bulk_upload/sales/year2023/row_parser: <<: *bulk_upload__row_parser__base diff --git a/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb b/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb new file mode 100644 index 000000000..e4391212a --- /dev/null +++ b/spec/services/bulk_upload/sales/year2024/csv_parser_spec.rb @@ -0,0 +1,150 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Sales::Year2024::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_2024_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2024_csv_row) + 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 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_2024_field_numbers_row(seed:)) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2024_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_2024_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_2024_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_2024_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_2024_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2024_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_2024_field_numbers_row) + file.write(BulkUpload::SalesLogToCsv.new(log:).to_2024_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/year2024/row_parser_spec.rb b/spec/services/bulk_upload/sales/year2024/row_parser_spec.rb new file mode 100644 index 000000000..9ecf31d21 --- /dev/null +++ b/spec/services/bulk_upload/sales/year2024/row_parser_spec.rb @@ -0,0 +1,1302 @@ +require "rails_helper" + +RSpec.describe BulkUpload::Sales::Year2024::RowParser do + subject(:parser) { described_class.new(attributes) } + + let(:now) { Time.zone.parse("01/05/2024") } + + let(:attributes) { { bulk_upload: } } + let(:bulk_upload) { create(:bulk_upload, :sales, user:, year: 2024) } + let(:user) { create(:user, organisation: owning_org) } + let(:owning_org) { create(:organisation, :with_old_visible_id) } + let(:setup_section_params) do + { + bulk_upload:, + field_1: owning_org.old_visible_id, # organisation + field_2: owning_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_14: "1", # will the buyers live in the property + field_15: "2", # joint purchase + field_17: "1", # noint + field_18: "1", # privacy notice + } + end + let(:valid_attributes) do + { + bulk_upload:, + field_1: owning_org.old_visible_id, + field_2: owning_org.old_visible_id, + + field_4: "12", + field_5: "5", + field_6: "24", + field_7: "test id", + field_8: "1", + field_9: "2", + field_14: "1", + field_15: "1", + field_16: "2", + field_17: "1", + field_18: "1", + field_19: "2", + field_20: "1", + field_21: "1", + field_22: "12", + field_27: "CR0", + field_28: "4BB", + field_29: "E09000008", + field_30: "3", + field_31: "32", + field_32: "M", + field_33: "12", + field_34: "18", + field_35: "1", + field_36: "1", + field_37: "R", + field_38: "32", + field_39: "F", + field_40: "17", + field_41: "13", + field_42: "2", + field_43: "1", + field_44: "0", + field_61: "1", + field_62: "1", + field_63: "A1", + field_64: "1AA", + field_65: "E09000008", + field_68: "1", + field_69: "1", + field_70: "3", + field_72: "3", + field_74: "5", + field_75: "3", + field_76: "3", + field_77: "30000", + field_78: "1", + field_79: "15000", + field_80: "1", + field_81: "4", + field_82: "20000", + field_83: "3", + field_85: "5", + field_86: "1", + field_87: "10", + field_88: "10", + field_89: "1", + field_91: "30", + field_92: "3", + field_93: "22", + field_94: "24", + field_95: "3", + field_96: "22", + field_97: "3", + field_98: "1", + field_99: "1", + field_100: "1", + field_101: "250000", + field_102: "25", + field_103: "1", + field_104: "42500", + field_105: "1", + field_107: "20", + field_108: "3", + field_109: "20000", + field_111: "800", + field_112: "200", + } + end + + around do |example| + 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 + 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_62 is 1" do + let(:attributes) do + { + bulk_upload:, + field_62: 1, + } + end + + it "sets previous postcode known to yes" do + expect(parser.log.ppcodenk).to eq(0) + end + end + + context "when field_62 is 2" do + let(:attributes) do + { + bulk_upload:, + field_62: 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_77: "R", # income 1 + field_79: "R", # income 2 + field_82: "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_77: "30000", # income 1 + field_79: "0", # income 2 + field_82: "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: {}) + + parser.valid? + 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 + 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 + 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 + 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 + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_18 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 + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_15 field_18 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_15: "1", + } + end + + it "has errors on correct setup fields" do + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_16 field_18 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 + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_10 field_15 field_18 field_4 field_5 field_6]) + end + end + + context "when setup section not complete it's discounted ownership joint purchase" do + let(:attributes) do + { + bulk_upload:, + field_17: "test id", + field_31: "2", + field_46: "8", + field_39: "1", + } + end + + it "has errors on correct setup fields" do + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_18 field_4 field_5 field_6 field_8]) + end + end + + context "when setup section not complete and type is outright sale" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + field_8: "3", + } + end + + it "has errors on correct setup fields" do + errors = parser.errors.select { |e| e.options[:category] == :setup }.map(&:attribute).sort + + expect(errors).to eql(%i[field_1 field_11 field_13 field_14 field_18 field_4 field_5 field_6]) + end + end + + context "when setup section not complete outright sale buyer is not company" do + let(:attributes) do + { + bulk_upload:, + field_7: "test id", + field_8: "3", + field_11: "12", + field_13: "2", + } + end + + it "has errors on correct setup fields" do + 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_18 field_4 field_5 field_6]) + 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 + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql(["You must answer owning organisation"]) + end + + it "blocks log creation" do + 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 + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql(["You must answer owning organisation"]) + end + + it "blocks log creation" do + 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 + expect(parser.errors.where(:field_1, category: :setup).map(&:message)).to eql(["You do not have permission to add logs for this owning organisation"]) + end + + it "blocks log creation" do + 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 + end + + describe "#field_3" do # username for created_by + context "when blank" do + let(:attributes) { setup_section_params.merge(bulk_upload:, field_3: nil) } + + it "is permitted" do + expect(parser.errors[:field_3]).to be_blank + end + end + + context "when user could not be found" do + let(:attributes) { { bulk_upload:, field_3: "idonotexist@example.com" } } + + it "is not permitted" do + 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 + expect(parser.errors.where(:field_3, category: :setup)).to be_present + end + + it "blocks log creation" do + expect(parser).to be_block_log_creation + end + end + + context "when an 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 + expect(parser.errors[:field_3]).to be_blank + 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 + 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 + 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 + 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 5 is 4 digits instead of 2" do + let(:attributes) { setup_section_params.merge({ bulk_upload:, field_6: "2023" }) } + + it "returns a setup error" do + expect(parser.errors.where(:field_6, category: :setup).map(&:message)).to include("Sale completion year must be 2 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(2024, 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: 2024) } + + it "does not return errors" do + 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 + 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 = "This is a duplicate log" + + [ + :field_1, # Owning org + :field_4, # Sale completion date + :field_5, # Sale completion date + :field_6, # Sale completion date + :field_27, # Postcode + :field_28, # Postcode + :field_31, # Buyer 1 age + :field_32, # Buyer 1 gender + :field_35, # 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.skip_update_status = true + 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_27, # Postcode + :field_28, # Postcode + :field_31, # Buyer 1 age + :field_32, # Buyer 1 gender + :field_35, # 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 + 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 + 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 + expect(parser.errors.where(:field_10, category: :setup)).to be_present + end + end + end + + describe "#field_11" do # type for outright sale + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_11: "100" }) } + + it "returns setup error" do + expect(parser.errors.where(:field_11, category: :setup)).to be_present + end + end + end + + describe "#field_12" do # type of other outright sale + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_11: "12", field_12: nil }) } + + it "returns setup error" do + expect(parser.errors.where(:field_12, category: :setup)).to be_present + end + end + end + + describe "#field_13" do # buyer a company? + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_8: "3", field_13: "100" }) } + + it "returns setup error" do + expect(parser.errors.where(:field_13).map(&:message)).to include("Enter a valid value for is the buyer a company?") + expect(parser.errors.where(:field_13, category: :setup)).to be_present + end + end + end + + describe "#field_14" do # will buyers live in the property? + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_8: "3", field_14: "100" }) } + + it "returns setup error" do + expect(parser.errors.where(:field_14).map(&:message)).to eql(["Enter a valid value for will the buyers live in the property?"]) + expect(parser.errors.where(:field_14, category: :setup)).to be_present + end + end + end + + describe "#field_15" do # joint purchase + context "when an invalid option" do + let(:attributes) { setup_section_params.merge({ field_15: "100" }) } + + it "returns a setup error" do + expect(parser.errors.where(:field_15, category: :setup)).to be_present + end + end + end + + describe "#field_16" do # more than 2 joint buyers? + context "when invalid option and must be answered" do + let(:attributes) { setup_section_params.merge({ field_15: "1", field_16: "100" }) } + + it "returns a setup error" do + expect(parser.errors.where(:field_16, category: :setup)).to be_present + end + end + end + + describe "#field_22" do # UPRN + context "when UPRN known and lookup found" do + let(:attributes) { setup_section_params.merge({ field_22: "12" }) } + + it "is valid" do + expect(parser.errors[:field_22]).to be_blank + end + + it "sets UPRN and UPRN known" 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 UPRN not known but address known" do + let(:attributes) { setup_section_params.merge({ field_22: nil, field_23: "some street", field_25: "some town", field_27: "EC1N", field_28: "2TD" }) } + + it "is valid" do + expect(parser.errors[:field_22]).to be_blank + end + end + + context "when neither UPRN or address known" do + let(:attributes) { setup_section_params.merge({ field_22: nil, field_23: nil, field_25: nil, field_27: nil, field_28: nil }) } + + it "is not valid" do + expect(parser.errors[:field_22]).to be_present + end + end + + context "when UPRN entered but no lookup found" do + let(:attributes) { setup_section_params.merge({ field_22: "1234567890123" }) } + + it "is not valid" do + expect(parser.errors[:field_22]).to be_present + end + end + end + + [ + { field: :field_23, name: "address line 1" }, + { field: :field_25, name: "town or city" }, + ].each do |data| + describe "##{data[:field]} (#{data[:name]})" do + context "when UPRN present" do + context "when UPRN valid" do + let(:attributes) { setup_section_params.merge({ field_22: "12", data[:field] => nil }) } + + it "can be blank" do + expect(parser.errors[data[:field]]).to be_blank + end + end + + context "when UPRN invalid" do + let(:attributes) { setup_section_params.merge({ field_22: "1234567890123", data[:field] => nil }) } + + it "cannot be blank" do + expect(parser.errors[data[:field]]).to be_present + end + end + end + + context "when UPRN not present" do + let(:attributes) { setup_section_params.merge({ field_22: nil, data[:field] => nil }) } + + it "cannot be blank" do + expect(parser.errors[data[:field]]).to be_present + end + end + end + end + + describe "#field_18" do # data protection + let(:attributes) { setup_section_params.merge({ field_18: nil }) } + + context "when not answered" do + it "returns a setup error" do + expect(parser.errors.where(:field_18, category: :setup)).to be_present + end + end + end + + [ + %w[age1_known details_known_1 age1 field_31 field_37 field_39], + %w[age2_known details_known_2 age2 field_38 field_37 field_39], + %w[age3_known details_known_3 age3 field_46 field_45 field_47], + %w[age4_known details_known_4 age4 field_50 field_49 field_51], + %w[age5_known details_known_5 age5 field_54 field_53 field_55], + %w[age6_known details_known_6 age6 field_58 field_57 field_59], + ].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_18: "1", field_44: "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_18: "1", field_44: "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_17: "1", field_44: "5", field_18: "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_17: "1", field_44: "5", field_18: "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_17: "1", field_44: "5", field_18: "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_35" do # ecstat1 + context "when buyer 1 is marked as a child" do + let(:attributes) { valid_attributes.merge({ field_35: "9" }) } + + it "a custom validation is applied" do + validation_message = "Buyer 1 cannot be a child under 16" + expect(parser.errors[:field_35]).to include validation_message + end + end + end + + describe "#field_36" do # will buyer1 live in property? + context "when not a possible value" do + let(:attributes) { valid_attributes.merge({ field_36: "3" }) } + + it "is not valid" do + expect(parser.errors).to include(:field_36) + end + end + end + + describe "#field_66 - 70" do # buyers organisations + context "when all nil" do + let(:attributes) { setup_section_params.merge(field_66: nil, field_67: nil, field_68: nil, field_69: nil) } + + it "returns correct errors" do + expect(parser.errors[:field_66]).to be_present + expect(parser.errors[:field_67]).to be_present + expect(parser.errors[:field_68]).to be_present + expect(parser.errors[:field_69]).to be_present + end + end + end + + describe "soft validations" do + context "when soft validation is triggered" do + let(:attributes) { valid_attributes.merge({ field_31: 22, field_35: 5 }) } + + it "adds an error to the relevant fields" do + expect(parser.errors.where(:field_31, category: :soft_validation)).to be_present + expect(parser.errors.where(:field_35, category: :soft_validation)).to be_present + end + + it "populates with correct error message" do + expect(parser.errors.where(:field_31, category: :soft_validation).first.message).to eql("You told us this person is aged 22 years and retired.") + expect(parser.errors.where(:field_31, category: :soft_validation).first.message).to eql("You told us this person is aged 22 years and retired.") + end + end + + context "when a soft validation is triggered that relates both to fields that are and are not routed to" do + let(:attributes) { valid_attributes.merge({ field_101: "300000" }) } + + it "adds errors to fields that are routed to" do + expect(parser.errors.where(:field_101, category: :soft_validation)).to be_present + end + + it "does not add errors to fields that are not routed to" do + expect(parser.errors.where(:field_112, category: :soft_validation)).not_to be_present + end + end + end + end + + describe "#log" do + describe "#uprn" do + let(:attributes) { setup_section_params.merge({ field_22: "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_22: "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_22: 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_23: "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_24: "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_25: "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_26: "some county" }) } + + it "is correctly set" do + expect(parser.log.county).to eql("some county") + end + end + + describe "#ethnic_group2" do + let(:attributes) { setup_section_params.merge({ field_40: "1" }) } + + it "is correctly set" do + expect(parser.log.ethnic_group2).to be(0) + end + end + + describe "#ethnicbuy2" do + let(:attributes) { setup_section_params.merge({ field_40: "1" }) } + + it "is correctly set" do + expect(parser.log.ethnicbuy2).to be(1) + end + end + + describe "#nationalbuy2" do + let(:attributes) { setup_section_params.merge({ field_41: "18" }) } + + it "is correctly set" do + expect(parser.log.nationalbuy2).to be(18) + end + end + + describe "#buy2living" do + let(:attributes) { setup_section_params.merge({ field_70: "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_71: "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_72: "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_73: "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_84: "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_89: "1" }) } + + it "is correctly set" do + expect(parser.log.staircasesale).to be(1) + end + end + + describe "#soctenant" do + let(:attributes) { setup_section_params.merge({ field_99: "1" }) } + + it "is correctly set" do + expect(parser.log.soctenant).to be(1) + 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_85: "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_85: "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_112: "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 + + context "when mscharge is given, but is set to 0 for outright sale" do + let(:attributes) { valid_attributes.merge(field_8: "3", field_131: "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 users organisation" do + parser.valid? + expect(parser.log.owning_organisation_id).to be(owning_org.id) + expect(parser.log.managing_organisation_id).to be(owning_org.id) + end + end + + context "when user is part of an organisation affiliated with owning org" do + let(:managing_agent) { create(:organisation) } + let(:user) { create(:user, organisation: managing_agent) } + let(:attributes) { setup_section_params } + + before do + create(:organisation_relationship, child_organisation: managing_agent, parent_organisation: owning_org) + end + + it "is not permitted as setup error" do + parser.valid? + expect(parser.log.owning_organisation_id).to be(owning_org.id) + expect(parser.log.managing_organisation_id).to be(managing_agent.id) + end + end + + context "when user is part of an organisation not affiliated with owning org" do + let(:unaffiliated_org) { create(:organisation) } + let(:user) { create(:user, organisation: unaffiliated_org) } + 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_3 }.message).to eql("This user belongs to an organisation that does not have a relationship with the owning organisation") + 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("The owning organisation code provided is for an organisation that does not own stock") + 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 diff --git a/spec/support/bulk_upload/sales_log_to_csv.rb b/spec/support/bulk_upload/sales_log_to_csv.rb index eb32bb822..efd4a289c 100644 --- a/spec/support/bulk_upload/sales_log_to_csv.rb +++ b/spec/support/bulk_upload/sales_log_to_csv.rb @@ -25,6 +25,15 @@ class BulkUpload::SalesLogToCsv end end + def to_2024_csv_row(seed: nil) + if seed + row = to_2024_row.shuffle(random: Random.new(seed)) + (row_prefix + row).flatten.join(",") + line_ending + else + (row_prefix + to_2024_row).flatten.join(",") + line_ending + end + end + def default_2022_field_numbers (1..125).to_a end @@ -45,6 +54,14 @@ class BulkUpload::SalesLogToCsv end.flatten.join(",") + line_ending end + def default_2024_field_numbers_row(seed: nil) + if seed + ["Bulk upload field number"] + default_2024_field_numbers.shuffle(random: Random.new(seed)) + else + ["Bulk upload field number"] + default_2024_field_numbers + end.flatten.join(",") + line_ending + end + def to_2023_row to_2022_row + [ log.uprn, @@ -212,6 +229,154 @@ class BulkUpload::SalesLogToCsv ] end + def to_2024_row + [ + overrides[:organisation_id] || log.owning_organisation&.old_visible_id, + overrides[:managing_organisation_id] || log.managing_organisation&.old_visible_id, + log.created_by&.email, + log.saledate&.day, + log.saledate&.month, + log.saledate&.strftime("%y"), + log.purchid, + log.ownershipsch, + log.type, # field_9: "What is the type of shared ownership sale?", + log.type, # field_10: "What is the type of discounted ownership sale?", + + log.type, # field_11: "What is the type of outright sale?", + log.othtype, + log.companybuy, + log.buylivein, + log.jointpur, + log.jointmore, + log.noint, + log.privacynotice, + log.beds, + log.proptype, # 20 + + log.builtype, + log.uprn, + log.address_line1, + log.address_line2, + log.town_or_city, + log.county, + ((log.postcode_full || "").split(" ") || [""]).first, + ((log.postcode_full || "").split(" ") || [""]).last, + log.la, + log.wchair, # 30 + + log.age1, + log.sex1, + log.ethnic, + log.national, + log.ecstat1, + log.buy1livein, + log.relat2, + log.age2, + log.sex2, + log.ethnic_group2, # 40 + + log.nationalbuy2, + log.ecstat2, + log.buy2livein, + log.hholdcount, + log.relat3, + log.age3, + log.sex3, + log.ecstat3, + log.relat4, + log.age4, # 50 + + log.sex4, + log.ecstat4, + log.relat5, + log.age5, + log.sex5, + log.ecstat5, + log.relat6, + log.age6, + log.sex6, + log.ecstat6, # 60 + + log.prevten, + log.ppcodenk, + ((log.ppostcode_full || "").split(" ") || [""]).first, + ((log.ppostcode_full || "").split(" ") || [""]).last, + log.prevloc, + log.pregyrha, + log.pregother, + log.pregla, + log.pregghb, + log.buy2living, # 70 + + log.prevtenbuy2, + hhregres, + log.hhregresstill, + log.armedforcesspouse, + log.disabled, + log.wheel, + log.income1, + log.inc1mort, + log.income2, + log.inc2mort, # 80 + + log.hb, + log.savings, + log.prevown, + log.prevshared, + log.proplen, + log.staircase, + log.stairbought, + log.stairowned, + log.staircasesale, + log.resale, # 90 + + log.exdate&.day, + log.exdate&.month, + log.exdate&.strftime("%y"), + log.hodate&.day, + log.hodate&.month, # 60 + log.hodate&.strftime("%y"), + log.lanomagr, + log.frombeds, + log.fromprop, + log.socprevten, # 100 + + log.value, + log.equity, + log.mortgageused, + log.mortgage, + log.mortgagelender, + log.mortgagelenderother, + log.mortlen, + log.extrabor, + log.deposit, + log.cashdis, # 110 + + log.mrent, + log.mscharge, + log.proplen, + log.value, + log.grant, + log.discount, + log.mortgageused, + log.mortgage, + log.mortgagelender, + log.mortgagelenderother, # 120 + + log.mortlen, + log.extrabor, + log.deposit, + log.mscharge, + log.value, + log.mortgageused, + log.mortgage, + log.mortlen, + log.extrabor, + log.deposit, # 130 + log.mscharge, + ] + end + private def default_2023_field_numbers @@ -225,4 +390,8 @@ private log.hhregres end end + + def default_2024_field_numbers + (1..131).to_a + end end