Browse Source

Merge branch 'main' into CLDC-3899-fix-deactivation-periods

pull/2995/head
kosiakkatrina 1 month ago committed by GitHub
parent
commit
8366eda2b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      Gemfile.lock
  2. 10
      app/controllers/address_search_controller.rb
  3. 11
      app/models/derived_variables/lettings_log_variables.rb
  4. 1
      app/models/export.rb
  5. 2
      app/models/form/lettings/questions/previous_let_type.rb
  6. 4
      app/models/form/lettings/questions/reason.rb
  7. 2
      app/models/form/lettings/subsections/property_information.rb
  8. 4
      app/models/form/sales/pages/previous_bedrooms.rb
  9. 4
      app/models/form/sales/pages/previous_property_type.rb
  10. 4
      app/models/form/sales/pages/previous_tenure.rb
  11. 12
      app/models/lettings_log.rb
  12. 6
      app/models/validations/sales/financial_validations.rb
  13. 4
      app/models/validations/sales/property_validations.rb
  14. 4
      app/models/validations/sales/sale_information_validations.rb
  15. 7
      app/models/validations/soft_validations.rb
  16. 32
      app/services/bulk_upload/sales/year2024/row_parser.rb
  17. 91
      app/services/bulk_upload/sales/year2025/row_parser.rb
  18. 12
      app/services/csv/lettings_log_csv_service.rb
  19. 7
      app/services/csv/sales_log_csv_service.rb
  20. 13
      app/services/exports/export_service.rb
  21. 5
      app/services/exports/lettings_log_export_constants.rb
  22. 3
      app/services/exports/lettings_log_export_service.rb
  23. 146
      app/services/exports/sales_log_export_constants.rb
  24. 156
      app/services/exports/sales_log_export_service.rb
  25. 2
      app/services/exports/user_export_service.rb
  26. 4
      app/services/feature_toggle.rb
  27. 2
      app/services/merge/merge_organisations_service.rb
  28. 11
      app/services/uprn_client.rb
  29. 2
      app/views/form/_address_search_question.html.erb
  30. 3
      app/views/form/_date_question.html.erb
  31. 1
      config/routes.rb
  32. 5
      db/migrate/20250305092900_add_values_updated_at_to_user.rb
  33. 3
      db/schema.rb
  34. BIN
      docs/images/architecture_diagram.png
  35. BIN
      docs/images/context_diagram.png
  36. 3
      docs/infrastructure.md
  37. 207
      lib/tasks/update_manual_address_entry_selected_prexisting_logs.rake
  38. 35
      spec/factories/lettings_log.rb
  39. 167
      spec/factories/sales_log.rb
  40. 176
      spec/fixtures/exports/general_needs_log_25_26.xml
  41. 154
      spec/fixtures/exports/sales_log.xml
  42. 154
      spec/fixtures/exports/sales_log_2024.xml
  43. 3
      spec/fixtures/files/lettings_log_csv_export_codes_25.csv
  44. 3
      spec/fixtures/files/lettings_log_csv_export_labels_25.csv
  45. 3
      spec/fixtures/files/lettings_log_csv_export_non_support_codes_25.csv
  46. 3
      spec/fixtures/files/lettings_log_csv_export_non_support_labels_25.csv
  47. 4
      spec/helpers/tab_nav_helper_spec.rb
  48. 211
      spec/lib/tasks/update_manual_address_entry_selected_prexisting_logs_spec.rb
  49. 2
      spec/models/form/lettings/questions/previous_let_type_spec.rb
  50. 16
      spec/models/form/lettings/subsections/property_information_spec.rb
  51. 4
      spec/models/form/sales/pages/previous_bedrooms_spec.rb
  52. 4
      spec/models/form/sales/pages/previous_property_type_spec.rb
  53. 4
      spec/models/form/sales/pages/previous_tenure_spec.rb
  54. 186
      spec/models/lettings_log_spec.rb
  55. 36
      spec/models/validations/soft_validations_spec.rb
  56. 112
      spec/requests/address_search_controller_spec.rb
  57. 2
      spec/requests/bulk_upload_lettings_results_controller_spec.rb
  58. 4
      spec/requests/bulk_upload_sales_results_controller_spec.rb
  59. 48
      spec/services/bulk_upload/sales/year2025/row_parser_spec.rb
  60. 193
      spec/services/csv/lettings_log_csv_service_spec.rb
  61. 247
      spec/services/exports/export_service_spec.rb
  62. 32
      spec/services/exports/lettings_log_export_service_spec.rb
  63. 425
      spec/services/exports/sales_log_export_service_spec.rb
  64. 21
      spec/services/exports/user_export_service_spec.rb
  65. 2
      spec/services/merge/merge_organisations_service_spec.rb
  66. 28
      spec/services/uprn_client_spec.rb
  67. 77
      yarn.lock

2
Gemfile.lock

@ -343,7 +343,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.11)
rack (3.1.12)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1)

10
app/controllers/address_search_controller.rb

@ -5,7 +5,9 @@ class AddressSearchController < ApplicationController
def index
query = params[:query]
if query.match?(/\A\d+\z/) && query.length > 5
if query.nil?
render json: { error: "Query cannot be blank." }, status: :bad_request
elsif query.match?(/\A\d+\z/) && query.length > 5
# Query is all numbers and greater than 5 digits, assume it's a UPRN
service = UprnClient.new(query)
service.call
@ -16,8 +18,8 @@ class AddressSearchController < ApplicationController
presenter = UprnDataPresenter.new(service.result)
render json: [{ text: presenter.address, value: presenter.uprn }]
end
elsif query.match?(/[a-zA-Z]/)
# Query contains letters, assume it's an address
elsif query.match?(/\D/)
# Query contains any non-digit characters, assume it's an address
service = AddressClient.new(query, { minmatch: 0.2 })
service.call
@ -38,7 +40,7 @@ class AddressSearchController < ApplicationController
address_service.call
uprn_service.call
results = ([uprn_service.result] || []) + (address_service.result || [])
results = [uprn_service.result, *address_service.result].compact
if address_service.error.present? && uprn_service.error.present?
render json: { error: "Address and UPRN are not recognised." }, status: :not_found

11
app/models/derived_variables/lettings_log_variables.rb

@ -18,7 +18,7 @@ module DerivedVariables::LettingsLogVariables
3 => 6, # "Rent to Buy" => "Rent to Buy basis"
4 => 7, # "London Living Rent" => "London Living Rent basis"
5 => 8, # "Other intermediate rent product" => "Another Intermediate Rent basis"
6 => 9, # "Specified accommodation - exempt accommodation, managed properties, refuges and local authority hostels" => "Specified accommodation - exempt accommodation, manged properties, refuges and local authority hostels"
6 => 9, # "Specified accommodation - exempt accommodation, managed properties, refuges and local authority hostels" => "Specified accommodation - exempt accommodation, managed properties, refuges and local authority hostels"
}.freeze
RENTTYPE_DETAIL_MAPPING = {
@ -83,12 +83,19 @@ module DerivedVariables::LettingsLogVariables
end
set_housingneeds_fields if housingneeds?
if form.start_year_2025_or_later? && is_general_needs?
if changed_to_newbuild? && uprn.nil?
self.manual_address_entry_selected = true
end
self.manual_address_entry_selected = address_answered_without_uprn? if changed_from_newbuild?
end
self.uprn_known = 0 if address_answered_without_uprn?
if uprn_known&.zero?
self.uprn = nil
if uprn_known_was == 1
if uprn_known_was == 1 && (rsnvac != 15 || !form.start_year_2025_or_later?)
self.address_line1 = nil
self.address_line2 = nil
self.town_or_city = nil

1
app/models/export.rb

@ -1,5 +1,6 @@
class Export < ApplicationRecord
scope :lettings, -> { where(collection: "lettings") }
scope :sales, -> { where(collection: "sales") }
scope :organisations, -> { where(collection: "organisations") }
scope :users, -> { where(collection: "users") }
end

2
app/models/form/lettings/questions/previous_let_type.rb

@ -36,7 +36,7 @@ class Form::Lettings::Questions::PreviousLetType < ::Form::Question
"6" => { "value" => "Rent to Buy basis" },
"7" => { "value" => "London Living Rent basis" },
"8" => { "value" => "Another Intermediate Rent basis" },
"9" => { "value" => "Specified accommodation - exempt accommodation, manged properties, refuges and local authority hostels" },
"9" => { "value" => "Specified accommodation - exempt accommodation, managed properties, refuges and local authority hostels" },
"divider" => { "value" => true },
"3" => { "value" => "Don’t know" },
}.freeze

4
app/models/form/lettings/questions/reason.rb

@ -46,9 +46,9 @@ class Form::Lettings::Questions::Reason < ::Form::Question
"18" => { "value" => "To move to accommodation with support" },
"19" => { "value" => "To move to independent accommodation" },
"20" => { "value" => "Other" },
"28" => { "value" => "Don’t know" },
"divider" => { "value" => true },
"47" => { "value" => "Tenant prefers not to say" },
"divider" => { "value" => true },
"28" => { "value" => "Don’t know" },
}.freeze
end

2
app/models/form/lettings/subsections/property_information.rb

@ -56,6 +56,8 @@ class Form::Lettings::Subsections::PropertyInformation < ::Form::Subsection
end
def displayed_in_tasklist?(log)
return true if form.start_year_2025_or_later?
!(log.is_supported_housing? && log.is_renewal?)
end
end

4
app/models/form/sales/pages/previous_bedrooms.rb

@ -9,6 +9,10 @@ class Form::Sales::Pages::PreviousBedrooms < ::Form::Page
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
]
end

4
app/models/form/sales/pages/previous_property_type.rb

@ -11,6 +11,10 @@ class Form::Sales::Pages::PreviousPropertyType < ::Form::Page
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
]
end

4
app/models/form/sales/pages/previous_tenure.rb

@ -12,6 +12,10 @@ class Form::Sales::Pages::PreviousTenure < ::Form::Page
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
]
end

12
app/models/lettings_log.rb

@ -148,7 +148,7 @@ class LettingsLog < Log
OPTIONAL_FIELDS = %w[tenancycode propcode chcharge].freeze
RENT_TYPE_MAPPING_LABELS = { 1 => "Social Rent", 2 => "Affordable Rent", 3 => "Intermediate Rent", 4 => "Specified accommodation" }.freeze
HAS_BENEFITS_OPTIONS = [1, 6, 8, 7].freeze
NUM_OF_WEEKS_FROM_PERIOD = { 2 => 26, 3 => 13, 4 => 12, 5 => 50, 6 => 49, 7 => 48, 8 => 47, 9 => 46, 1 => 52, 10 => 53 }.freeze
NUM_OF_WEEKS_FROM_PERIOD = { 2 => 26, 3 => 13, 4 => 12, 5 => 50, 6 => 49, 7 => 48, 8 => 47, 9 => 46, 11 => 51, 1 => 52, 10 => 53 }.freeze
SUFFIX_FROM_PERIOD = { 2 => "every 2 weeks", 3 => "every 4 weeks", 4 => "every month" }.freeze
DUPLICATE_LOG_ATTRIBUTES = %w[owning_organisation_id tenancycode startdate age1_known age1 sex1 ecstat1 tcharge household_charge chcharge].freeze
RENT_TYPE = {
@ -626,7 +626,7 @@ class LettingsLog < Log
end
def rent_and_charges_paid_weekly?
[1, 5, 6, 7, 8, 9, 10].include? period
[1, 5, 6, 7, 8, 9, 10, 11].include? period
end
def rent_and_charges_paid_every_4_weeks?
@ -740,6 +740,14 @@ class LettingsLog < Log
"lettings_log"
end
def changed_to_newbuild?
rsnvac == 15 && rsnvac_was != 15
end
def changed_from_newbuild?
rsnvac != 15 && rsnvac_was == 15
end
private
def reset_invalid_unresolved_log_fields!

6
app/models/validations/sales/financial_validations.rb

@ -75,7 +75,7 @@ module Validations::Sales::FinancialValidations
if threshold && record.stairbought < threshold
shared_ownership_type = record.form.get_question("type", record).label_from_value(record.type).downcase
record.errors.add :stairbought, I18n.t("validations.sales.financial.stairbought.percentage_bought_must_be_at_least_threshold", threshold:, shared_ownership_type:)
record.errors.add :type, I18n.t("validations.sales.financial.type.percentage_bought_must_be_at_least_threshold", threshold:, shared_ownership_type:)
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sales.financial.type.percentage_bought_must_be_at_least_threshold", threshold:, shared_ownership_type:)
end
end
@ -96,10 +96,10 @@ module Validations::Sales::FinancialValidations
return unless (range = ranges[record.type])
if record.equity < range.min
record.errors.add :type, I18n.t("validations.sales.financial.type.equity_under_min", min_equity: range.min)
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sales.financial.type.equity_under_min", min_equity: range.min)
record.errors.add :equity, :under_min, message: I18n.t("validations.sales.financial.equity.equity_under_min", min_equity: range.min)
elsif !record.is_resale? && record.equity > range.max
record.errors.add :type, I18n.t("validations.sales.financial.type.equity_over_max", max_equity: range.max)
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sales.financial.type.equity_over_max", max_equity: range.max)
record.errors.add :equity, :over_max, message: I18n.t("validations.sales.financial.equity.equity_over_max", max_equity: range.max)
record.errors.add :resale, I18n.t("validations.sales.financial.resale.equity_over_max", max_equity: range.max)
end

4
app/models/validations/sales/property_validations.rb

@ -49,9 +49,9 @@ module Validations::Sales::PropertyValidations
record.errors.add :uprn_confirmation, I18n.t("validations.sales.property_information.uprn_confirmation.not_in_england")
record.errors.add :uprn_selection, I18n.t("validations.sales.property_information.uprn_selection.not_in_england")
if record.uprn.present?
record.errors.add :saledate, I18n.t("validations.sales.property_information.saledate.address_not_in_england")
record.errors.add :saledate, :skip_bu_error, message: I18n.t("validations.sales.property_information.saledate.address_not_in_england")
else
record.errors.add :saledate, I18n.t("validations.sales.property_information.saledate.postcode_not_in_england")
record.errors.add :saledate, :skip_bu_error, message: I18n.t("validations.sales.property_information.saledate.postcode_not_in_england")
end
end
end

4
app/models/validations/sales/sale_information_validations.rb

@ -108,7 +108,7 @@ module Validations::Sales::SaleInformationValidations
if record.shared_ownership_scheme? && !record.old_persons_shared_ownership? && record.mrent > 9999
record.errors.add :mrent, I18n.t("validations.sales.sale_information.mrent.monthly_rent_higher_than_expected")
record.errors.add :type, I18n.t("validations.sales.sale_information.type.monthly_rent_higher_than_expected")
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sales.sale_information.type.monthly_rent_higher_than_expected")
end
end
@ -136,7 +136,7 @@ module Validations::Sales::SaleInformationValidations
if max_stairbought && record.stairbought > max_stairbought
record.errors.add :stairbought, I18n.t("validations.sales.sale_information.stairbought.stairbought_over_max", max_stairbought:, type: record.form.get_question("type", record).answer_label(record))
record.errors.add :type, I18n.t("validations.sales.sale_information.type.stairbought_over_max", max_stairbought:, type: record.form.get_question("type", record).answer_label(record))
record.errors.add :type, :skip_bu_error, message: I18n.t("validations.sales.sale_information.type.stairbought_over_max", max_stairbought:, type: record.form.get_question("type", record).answer_label(record))
end
end

7
app/models/validations/soft_validations.rb

@ -103,13 +103,16 @@ module Validations::SoftValidations
TWO_YEARS_IN_DAYS = 730
TEN_YEARS_IN_DAYS = 3650
TWENTY_YEARS_IN_DAYS = 7300
def major_repairs_date_in_soft_range?
mrcdate.present? && startdate.present? && mrcdate.between?(startdate.to_date - TEN_YEARS_IN_DAYS, startdate.to_date - TWO_YEARS_IN_DAYS)
upper_limit = form.start_year_2025_or_later? ? TWENTY_YEARS_IN_DAYS : TEN_YEARS_IN_DAYS
mrcdate.present? && startdate.present? && mrcdate.between?(startdate.to_date - upper_limit, startdate.to_date - TWO_YEARS_IN_DAYS)
end
def voiddate_in_soft_range?
voiddate.present? && startdate.present? && voiddate.between?(startdate.to_date - TEN_YEARS_IN_DAYS, startdate.to_date - TWO_YEARS_IN_DAYS)
upper_limit = form.start_year_2025_or_later? ? TWENTY_YEARS_IN_DAYS : TEN_YEARS_IN_DAYS
voiddate.present? && startdate.present? && voiddate.between?(startdate.to_date - upper_limit, startdate.to_date - TWO_YEARS_IN_DAYS)
end
def net_income_higher_or_lower_text

32
app/services/bulk_upload/sales/year2024/row_parser.rb

@ -252,8 +252,8 @@ class BulkUpload::Sales::Year2024::RowParser
attribute :field_84, :integer
attribute :field_85, :integer
attribute :field_86, :integer
attribute :field_87, :integer
attribute :field_88, :integer
attribute :field_87, :decimal
attribute :field_88, :decimal
attribute :field_89, :integer
attribute :field_90, :integer
@ -268,40 +268,40 @@ class BulkUpload::Sales::Year2024::RowParser
attribute :field_99, :integer
attribute :field_100, :integer
attribute :field_101, :integer
attribute :field_102, :integer
attribute :field_101, :decimal
attribute :field_102, :decimal
attribute :field_103, :integer
attribute :field_104, :integer
attribute :field_104, :decimal
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_109, :decimal
attribute :field_110, :decimal
attribute :field_111, :decimal
attribute :field_112, :decimal
attribute :field_113, :integer
attribute :field_114, :integer
attribute :field_114, :decimal
attribute :field_115, :integer
attribute :field_116, :integer
attribute :field_116, :decimal
attribute :field_117, :integer
attribute :field_118, :integer
attribute :field_118, :decimal
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_123, :decimal
attribute :field_124, :decimal
attribute :field_125, :decimal
attribute :field_126, :integer
attribute :field_127, :integer
attribute :field_127, :decimal
attribute :field_128, :integer
attribute :field_129, :integer
attribute :field_130, :integer
attribute :field_131, :integer
attribute :field_130, :decimal
attribute :field_131, :decimal
validates :field_4,
presence: {

91
app/services/bulk_upload/sales/year2025/row_parser.rb

@ -236,19 +236,19 @@ class BulkUpload::Sales::Year2025::RowParser
attribute :field_83, :integer
attribute :field_84, :integer
attribute :field_85, :integer
attribute :field_86, :integer
attribute :field_87, :integer
attribute :field_86, :decimal
attribute :field_87, :decimal
attribute :field_88, :integer
attribute :field_89, :integer
attribute :field_89, :decimal
attribute :field_90, :integer
attribute :field_91, :integer
attribute :field_92, :integer
attribute :field_91, :decimal
attribute :field_92, :decimal
attribute :field_93, :decimal
attribute :field_94, :decimal
attribute :field_95, :decimal
attribute :field_96, :integer
attribute :field_97, :integer
attribute :field_96, :decimal
attribute :field_97, :decimal
attribute :field_98, :integer
attribute :field_99, :integer
attribute :field_100, :integer
@ -258,22 +258,22 @@ class BulkUpload::Sales::Year2025::RowParser
attribute :field_104, :integer
attribute :field_105, :integer
attribute :field_106, :integer
attribute :field_107, :integer
attribute :field_108, :integer
attribute :field_107, :decimal
attribute :field_108, :decimal
attribute :field_109, :integer
attribute :field_110, :integer
attribute :field_110, :decimal
attribute :field_111, :decimal
attribute :field_112, :integer
attribute :field_113, :integer
attribute :field_113, :decimal
attribute :field_114, :integer
attribute :field_115, :integer
attribute :field_115, :decimal
attribute :field_116, :integer
attribute :field_117, :integer
attribute :field_117, :decimal
attribute :field_118, :integer
attribute :field_119, :integer
attribute :field_120, :integer
attribute :field_121, :integer
attribute :field_120, :decimal
attribute :field_121, :decimal
validates :field_1,
presence: {
@ -393,6 +393,15 @@ class BulkUpload::Sales::Year2025::RowParser
},
on: :after_log
validates :field_103,
numericality: {
greater_than_or_equal_to: 2,
less_than_or_equal_to: 10,
message: I18n.t("#{ERROR_BASE_KEY}.numeric.within_range", field: "Number of staircasing transactions", min: "2", max: "10"),
allow_blank: true,
},
on: :before_log
validate :validate_buyer1_economic_status, on: :before_log
validate :validate_buyer2_economic_status, on: :before_log
validate :validate_valid_radio_option, on: :before_log
@ -806,31 +815,11 @@ private
attributes["sex5"] = field_52
attributes["sex6"] = field_56
attributes["relat2"] = if field_34 == 1
"P"
else
(field_34 == 2 ? "X" : "R")
end
attributes["relat3"] = if field_42 == 1
"P"
else
(field_42 == 2 ? "X" : "R")
end
attributes["relat4"] = if field_46 == 1
"P"
else
(field_46 == 2 ? "X" : "R")
end
attributes["relat5"] = if field_50 == 1
"P"
else
(field_50 == 2 ? "X" : "R")
end
attributes["relat6"] = if field_54 == 1
"P"
else
(field_54 == 2 ? "X" : "R")
end
attributes["relat2"] = relationship_from_is_partner(field_34)
attributes["relat3"] = relationship_from_is_partner(field_42)
attributes["relat4"] = relationship_from_is_partner(field_46)
attributes["relat5"] = relationship_from_is_partner(field_50)
attributes["relat6"] = relationship_from_is_partner(field_54)
attributes["ecstat1"] = field_32
attributes["ecstat2"] = field_39
@ -1043,6 +1032,17 @@ private
field_55.present? || field_56.present? || field_54.present?
end
def relationship_from_is_partner(is_partner)
case is_partner
when 1
"P"
when 2
"X"
when 3
"R"
end
end
def details_known?(person_n)
send("person_#{person_n}_present?") ? 1 : 2
end
@ -1482,6 +1482,17 @@ private
%w[0] + GlobalConstants::COUNTRIES_ANSWER_OPTIONS.keys # 0 is "Prefers not to say"
end
def validate_relat_fields
%i[field_34 field_42 field_46 field_50 field_54].each do |field|
value = send(field)
next if value.blank?
unless (1..3).cover?(value)
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.invalid_option", question: format_ending(QUESTIONS[field])))
end
end
end
def bulk_upload_organisation
Organisation.find(bulk_upload.organisation_id)
end

12
app/services/csv/lettings_log_csv_service.rb

@ -177,6 +177,10 @@ module Csv
10 => "Intermediate rent supported housing private registered provider",
11 => "Intermediate rent general needs local authority",
12 => "Intermediate rent supported housing local authority",
13 => "Specified accommodation general needs private registered provider",
14 => "Specified accommodation supported housing private registered provider",
15 => "Specified accommodation general needs local authority",
16 => "Specified accommodation supported housing local authority",
}.freeze
IRPRODUCT_LABELS = {
@ -206,6 +210,7 @@ module Csv
1 => "Social Rent",
2 => "Affordable Rent",
3 => "Intermediate Rent",
4 => "Specified accommodation",
}.freeze
UPRN_KNOWN_LABELS = {
@ -284,13 +289,12 @@ module Csv
end
def lettings_log_definitions
CsvVariableDefinition.lettings.group_by { |record| [record.variable, record.definition] }
.map do |_, options|
CsvVariableDefinition.lettings.group_by(&:variable).map { |_, options|
exact_match = options.find { |definition| definition.year == @year }
next exact_match if exact_match
options.max_by(&:year)
end
options.select { |opt| opt.year < @year }.max_by(&:year)
}.compact
end
def insert_derived_and_related_attributes(ordered_questions)

7
app/services/csv/sales_log_csv_service.rb

@ -179,13 +179,12 @@ module Csv
end
def sales_log_definitions
CsvVariableDefinition.sales.group_by { |record| [record.variable, record.definition] }
.map do |_, options|
CsvVariableDefinition.sales.group_by(&:variable).map { |_, options|
exact_match = options.find { |definition| definition.year == @year }
next exact_match if exact_match
options.max_by(&:year)
end
options.select { |opt| opt.year < @year }.max_by(&:year)
}.compact
end
def insert_derived_and_related_attributes(ordered_questions)

13
app/services/exports/export_service.rb

@ -11,6 +11,7 @@ module Exports
start_time = Time.zone.now
daily_run_number = get_daily_run_number
lettings_archives_for_manifest = {}
sales_archives_for_manifest = {}
users_archives_for_manifest = {}
organisations_archives_for_manifest = {}
@ -20,16 +21,19 @@ module Exports
users_archives_for_manifest = get_user_archives(start_time, full_update)
when "organisations"
organisations_archives_for_manifest = get_organisation_archives(start_time, full_update)
else
when "lettings"
lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year)
when "sales"
sales_archives_for_manifest = get_sales_archives(start_time, full_update, year)
end
else
users_archives_for_manifest = get_user_archives(start_time, full_update)
organisations_archives_for_manifest = get_organisation_archives(start_time, full_update)
lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year)
sales_archives_for_manifest = get_sales_archives(start_time, full_update, year) if FeatureToggle.sales_export_enabled?
end
write_master_manifest(daily_run_number, lettings_archives_for_manifest.merge(users_archives_for_manifest).merge(organisations_archives_for_manifest))
write_master_manifest(daily_run_number, lettings_archives_for_manifest.merge(sales_archives_for_manifest).merge(users_archives_for_manifest).merge(organisations_archives_for_manifest))
end
private
@ -74,5 +78,10 @@ module Exports
lettings_export_service = Exports::LettingsLogExportService.new(@storage_service, start_time)
lettings_export_service.export_xml_lettings_logs(full_update:, collection_year:)
end
def get_sales_archives(start_time, full_update, collection_year)
sales_export_service = Exports::SalesLogExportService.new(@storage_service, start_time)
sales_export_service.export_xml_sales_logs(full_update:, collection_year:)
end
end
end

5
app/services/exports/lettings_log_export_constants.rb

@ -193,5 +193,10 @@ module Exports::LettingsLogExportConstants
PRE_2024_EXPORT_FIELDS = Set[
"national",
"offered"
]
PRE_2025_EXPORT_FIELDS = Set[
"carehome_charges_value_check",
"chcharge"
]
end

3
app/services/exports/lettings_log_export_service.rb

@ -142,7 +142,8 @@ module Exports
pattern_age.match(field_name) ||
!EXPORT_FIELDS.include?(field_name) ||
(lettings_log.form.start_year_2024_or_later? && PRE_2024_EXPORT_FIELDS.include?(field_name)) ||
(!lettings_log.form.start_year_2024_or_later? && POST_2024_EXPORT_FIELDS.include?(field_name))
(!lettings_log.form.start_year_2024_or_later? && POST_2024_EXPORT_FIELDS.include?(field_name)) ||
(lettings_log.form.start_year_2025_or_later? && PRE_2025_EXPORT_FIELDS.include?(field_name))
end
def build_export_xml(lettings_logs)

146
app/services/exports/sales_log_export_constants.rb

@ -0,0 +1,146 @@
module Exports::SalesLogExportConstants
MAX_XML_RECORDS = 10_000
LOG_ID_OFFSET = 300_000_000_000
EXPORT_MODE = {
xml: 1,
csv: 2,
}.freeze
EXPORT_FIELDS = Set["ID",
"STATUS",
"DAY",
"MONTH",
"YEAR",
"DUPLICATESET",
"CREATEDDATE",
"UPLOADDATE",
"OWNINGORGID",
"OWNINGORGNAME",
"MANINGORGID",
"MANINGORGNAME",
"USERNAME",
"USERNAMEID",
"PURCHID",
"TYPE",
"OWNERSHIP",
"COLLECTIONYEAR",
"JOINTMORE",
"JOINT",
"BEDS",
"ETHNIC",
"ETHNICGROUP1",
"LIVEINBUYER1",
"BUILTYPE",
"PROPTYPE",
"NOINT",
"LIVEINBUYER2",
"PRIVACYNOTICE",
"WHEEL",
"HHOLDCOUNT",
"LA",
"INCOME1",
"INC1NK",
"INC1MORT",
"INCOME2",
"INC2NK",
"SAVINGSNK",
"SAVINGS",
"PREVOWN",
"AMENDEDBY",
"AMENDEDBYID",
"MORTGAGE",
"INC2MORT",
"HB",
"FROMBEDS",
"STAIRCASE",
"STAIRBOUGHT",
"STAIROWNED",
"MRENT",
"MRENTPRESTAIRCASING",
"RESALE",
"DEPOSIT",
"CASHDIS",
"DISABLED",
"VALUE",
"EQUITY",
"DISCOUNT",
"GRANT",
"PPCODENK",
"PPOSTC1",
"PPOSTC2",
"PREVLOC",
"PREVLOCNAME",
"PREVIOUSLAKNOWN",
"HHREGRES",
"HHREGRESSTILL",
"PROPLEN",
"HASMSCHARGE",
"MSCHARGE",
"PREVTEN",
"MORTGAGEUSED",
"WCHAIR",
"ARMEDFORCESSPOUSE",
"HODAY",
"HOMONTH",
"HOYEAR",
"FROMPROP",
"SOCPREVTEN",
"MORTLEN1",
"EXTRABOR",
"HHTYPE",
"POSTCODE",
"ISLAINFERRED",
"BULKUPLOADID",
"VALUE_VALUE_CHECK",
"PREVSHARED",
"STAIRCASETOSALE",
"ETHNICGROUP2",
"ETHNIC2",
"BUY2LIVING",
"PREVTEN2",
"UPRN",
"ADDRESS1",
"ADDRESS2",
"TOWNCITY",
"COUNTY",
"LANAME",
"CREATIONMETHOD",
"NATIONALITYALL1",
"NATIONALITYALL2",
"MSCHARGE_VALUE_CHECK",
"ADDRESS1INPUT",
"POSTCODEINPUT",
"ADDRESS_SEARCH_VALUE_CHECK",
"UPRNSELECTED",
"BULKADDRESS1",
"BULKADDRESS2",
"BULKTOWNCITY",
"BULKCOUNTY",
"BULKPOSTCODE",
"BULKLA",
"CREATEDBY",
"CREATEDBYID",
"HASESTATEFEE",
"ESTATEFEE",
"FIRSTSTAIR",
"NUMSTAIR",
"STAIRLASTDAY",
"STAIRLASTMONTH",
"STAIRLASTYEAR",
"STAIRINITIALYEAR",
"STAIRINITIALMONTH",
"STAIRINITIALDAY",
"HASSERVICECHARGES",
"SERVICECHARGES",]
(1..6).each do |index|
EXPORT_FIELDS << "AGE#{index}"
EXPORT_FIELDS << "ECSTAT#{index}"
EXPORT_FIELDS << "SEX#{index}"
end
(2..6).each do |index|
EXPORT_FIELDS << "RELAT#{index}"
end
end

156
app/services/exports/sales_log_export_service.rb

@ -0,0 +1,156 @@
module Exports
class SalesLogExportService < Exports::XmlExportService
include Exports::SalesLogExportConstants
include CollectionTimeHelper
def export_xml_sales_logs(full_update: false, collection_year: nil)
archives_for_manifest = {}
collection_years_to_export(collection_year).each do |year|
recent_export = Export.sales.where(year:).order("started_at").last
base_number = Export.sales.where(empty_export: false, year:).maximum(:base_number) || 1
export = build_export_run("sales", base_number, full_update, year)
archives = write_export_archive(export, year, recent_export, full_update)
archives_for_manifest.merge!(archives)
export.empty_export = archives.empty?
export.save!
end
archives_for_manifest
end
private
def get_archive_name(year, base_number, increment)
return unless year
base_number_str = "f#{base_number.to_s.rjust(4, '0')}"
increment_str = "inc#{increment.to_s.rjust(4, '0')}"
"core_sales_#{year}_#{year + 1}_apr_mar_#{base_number_str}_#{increment_str}".downcase
end
def retrieve_resources(recent_export, full_update, year)
if !full_update && recent_export
params = { from: recent_export.started_at, to: @start_time }
SalesLog.exportable.where("(updated_at >= :from AND updated_at <= :to) OR (values_updated_at IS NOT NULL AND values_updated_at >= :from AND values_updated_at <= :to)", params).filter_by_year(year)
else
params = { to: @start_time }
SalesLog.exportable.where("updated_at <= :to", params).filter_by_year(year)
end
end
def apply_cds_transformation(sales_log, _export_mode)
attribute_hash = sales_log.attributes_before_type_cast
attribute_hash["day"] = sales_log.saledate&.day
attribute_hash["month"] = sales_log.saledate&.month
attribute_hash["year"] = sales_log.saledate&.year
attribute_hash["createddate"] = sales_log.created_at&.iso8601
attribute_hash["createdby"] = sales_log.created_by&.email
attribute_hash["createdbyid"] = sales_log.created_by_id
attribute_hash["username"] = sales_log.assigned_to&.email
attribute_hash["usernameid"] = sales_log.assigned_to_id
attribute_hash["uploaddate"] = sales_log.updated_at&.iso8601
attribute_hash["amendedby"] = sales_log.updated_by&.email
attribute_hash["amendedbyid"] = sales_log.updated_by_id
attribute_hash["owningorgid"] = sales_log.owning_organisation&.id
attribute_hash["owningorgname"] = sales_log.owning_organisation&.name
attribute_hash["maningorgid"] = sales_log.managing_organisation&.id
attribute_hash["maningorgname"] = sales_log.managing_organisation&.name
attribute_hash["creationmethod"] = sales_log.creation_method_before_type_cast
attribute_hash["bulkuploadid"] = sales_log.bulk_upload_id
attribute_hash["collectionyear"] = sales_log.form.start_date.year
attribute_hash["ownership"] = sales_log.ownershipsch
attribute_hash["joint"] = sales_log.jointpur
attribute_hash["ethnicgroup1"] = sales_log.ethnic_group
attribute_hash["ethnicgroup2"] = sales_log.ethnic_group2
attribute_hash["previouslaknown"] = sales_log.previous_la_known
attribute_hash["hasmscharge"] = sales_log.discounted_ownership_sale? ? sales_log.has_mscharge : nil
attribute_hash["mscharge"] = sales_log.discounted_ownership_sale? ? sales_log.mscharge : nil
attribute_hash["hasservicecharges"] = sales_log.shared_ownership_scheme? ? sales_log.has_mscharge : nil
attribute_hash["servicecharges"] = sales_log.shared_ownership_scheme? ? sales_log.mscharge : nil
attribute_hash["hoday"] = sales_log.hodate&.day
attribute_hash["homonth"] = sales_log.hodate&.month
attribute_hash["hoyear"] = sales_log.hodate&.year
attribute_hash["inc1nk"] = sales_log.income1nk
attribute_hash["inc2nk"] = sales_log.income2nk
attribute_hash["postcode"] = sales_log.postcode_full
attribute_hash["islainferred"] = sales_log.is_la_inferred
attribute_hash["mortlen1"] = sales_log.mortlen
attribute_hash["ethnic2"] = sales_log.ethnicbuy2
attribute_hash["prevten2"] = sales_log.prevtenbuy2
attribute_hash["address1"] = sales_log.address_line1
attribute_hash["address2"] = sales_log.address_line2
attribute_hash["towncity"] = sales_log.town_or_city
attribute_hash["laname"] = LocalAuthority.find_by(code: sales_log.la)&.name
attribute_hash["address1input"] = sales_log.address_line1_input
attribute_hash["postcodeinput"] = sales_log.postcode_full_input
attribute_hash["uprnselected"] = sales_log.uprn_selection
attribute_hash["bulkaddress1"] = sales_log.address_line1_as_entered
attribute_hash["bulkaddress2"] = sales_log.address_line2_as_entered
attribute_hash["bulktowncity"] = sales_log.town_or_city_as_entered
attribute_hash["bulkcounty"] = sales_log.county_as_entered
attribute_hash["bulkpostcode"] = sales_log.postcode_full_as_entered
attribute_hash["bulkla"] = sales_log.la_as_entered
attribute_hash["nationalityall1"] = sales_log.nationality_all
attribute_hash["nationalityall2"] = sales_log.nationality_all_buyer2
attribute_hash["prevlocname"] = LocalAuthority.find_by(code: sales_log.prevloc)&.name
attribute_hash["liveinbuyer1"] = sales_log.buy1livein
attribute_hash["liveinbuyer2"] = sales_log.buy2livein
attribute_hash["hasestatefee"] = sales_log.has_management_fee
attribute_hash["estatefee"] = sales_log.management_fee
attribute_hash["stairlastday"] = sales_log.lasttransaction&.day
attribute_hash["stairlastmonth"] = sales_log.lasttransaction&.month
attribute_hash["stairlastyear"] = sales_log.lasttransaction&.year
attribute_hash["stairinitialday"] = sales_log.initialpurchase&.day
attribute_hash["stairinitialmonth"] = sales_log.initialpurchase&.month
attribute_hash["stairinitialyear"] = sales_log.initialpurchase&.year
attribute_hash["mscharge_value_check"] = sales_log.monthly_charges_value_check
attribute_hash["duplicateset"] = sales_log.duplicate_set_id
attribute_hash["staircasetosale"] = sales_log.staircasesale
attribute_hash.transform_keys!(&:upcase)
attribute_hash
end
def is_omitted_field?(field_name, _sales_log)
!EXPORT_FIELDS.include?(field_name)
end
def build_export_xml(sales_logs)
doc = Nokogiri::XML("<forms/>")
sales_logs.each do |sales_log|
attribute_hash = apply_cds_transformation(sales_log, EXPORT_MODE[:xml])
form = doc.create_element("form")
doc.at("forms") << form
attribute_hash.each do |key, value|
if is_omitted_field?(key, sales_log)
next
else
form << doc.create_element(key, value)
end
end
end
xml_doc_to_temp_file(doc)
end
def collection_years_to_export(collection_year)
return [collection_year] if collection_year.present?
FormHandler.instance.sales_forms.values.map { |f| f.start_date.year }.uniq.select { |year| year > 2024 }
end
end
end

2
app/services/exports/user_export_service.rb

@ -28,7 +28,7 @@ module Exports
def retrieve_resources(recent_export, full_update, _year)
if !full_update && recent_export
params = { from: recent_export.started_at, to: @start_time }
User.where("(updated_at >= :from AND updated_at <= :to)", params)
User.where("(updated_at >= :from AND updated_at <= :to) OR (values_updated_at IS NOT NULL AND values_updated_at >= :from AND values_updated_at <= :to)", params)
else
params = { to: @start_time }
User.where("updated_at <= :to", params)

4
app/services/feature_toggle.rb

@ -30,4 +30,8 @@ class FeatureToggle
def self.create_test_logs_enabled?
Rails.env.development? || Rails.env.review?
end
def self.sales_export_enabled?
Time.zone.now >= Time.zone.local(2025, 4, 1) || (Rails.env.review? || Rails.env.staging?)
end
end

2
app/services/merge/merge_organisations_service.rb

@ -62,7 +62,7 @@ private
def merge_users(merging_organisation)
users_to_merge = users_to_merge(merging_organisation)
@merged_users[merging_organisation.name] = users_to_merge.map { |user| { name: user.name, email: user.email } }
users_to_merge.update_all(organisation_id: @absorbing_organisation.id)
users_to_merge.update_all(organisation_id: @absorbing_organisation.id, values_updated_at: Time.zone.now)
end
def merge_schemes_and_locations(merging_organisation)

11
app/services/uprn_client.rb

@ -20,7 +20,16 @@ class UprnClient
end
def result
@result ||= JSON.parse(response.body).dig("results", 0, "DPA") || JSON.parse(response.body).dig("results", 0, "LPI")
@result ||= if response.is_a?(Net::HTTPSuccess)
parsed_response = JSON.parse(response.body)
parsed_response.dig("results", 0, "DPA") || parsed_response.dig("results", 0, "LPI")
else
@error = "UPRN client failed to return a valid result, try again later."
Sentry.capture_message("UPRN client failed to return a valid result with error code: #{response.code}.")
Rails.logger.error("UPRN client failed to return a valid result with error code: #{response.code}.")
Rails.logger.error("Response body: #{response.body}")
nil
end
end
private

2
app/views/form/_address_search_question.html.erb

@ -17,7 +17,7 @@
<%= question.answer_selected?(@log, answer) ? "selected" : "" %>><%= answer.name || answer.resource %></option>
<% end %>
<% else %>
<option value="" disabled>Javascript is disabled. Please enter the address manually.</option>
<option value="" disabled>Javascript is disabled, please enter the address manually.</option>
<% end %>
<% end %>

3
app/views/form/_date_question.html.erb

@ -1,9 +1,10 @@
<%= render partial: "form/guidance/#{question.top_guidance_partial}" if question.top_guidance? %>
<% legend = legend(question, page_header, conditional) %>
<%= render partial: "components/date_picker", locals:
{
resource: @log,
question_id: question.id,
legend: { text: legend(question, page_header, conditional)[:text], size: "l", caption: caption(caption_text, page_header, conditional) },
legend: { text: legend[:text], size: legend[:size], caption: caption(caption_text, page_header, conditional) },
resource_type: @log.log_type,
hint: (question.hint_text.blank? ? "" : (question.hint_text.html_safe + "</br></br>".html_safe)) + "For example, #{date_mid_collection_year_formatted(@log.startdate).tr(' ', '/')}",
f:,

1
config/routes.rb

@ -40,7 +40,6 @@ Rails.application.routes.draw do
get "/service-moved", to: "maintenance#service_moved"
get "/service-unavailable", to: "maintenance#service_unavailable"
get "/address-search", to: "address_search#index"
get "/address-search/current", to: "address_search#current"
get "/address-search/manual-input/:log_type/:log_id", to: "address_search#manual_input", as: "address_manual_input"
get "/address-search/search-input/:log_type/:log_id", to: "address_search#search_input", as: "address_search_input"

5
db/migrate/20250305092900_add_values_updated_at_to_user.rb

@ -0,0 +1,5 @@
class AddValuesUpdatedAtToUser < ActiveRecord::Migration[7.2]
def change
add_column :users, :values_updated_at, :datetime
end
end

3
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_02_25_180643) do
ActiveRecord::Schema[7.2].define(version: 2025_03_05_092900) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -853,6 +853,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_25_180643) do
t.boolean "reactivate_with_organisation"
t.datetime "discarded_at"
t.string "phone_extension"
t.datetime "values_updated_at"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["encrypted_otp_secret_key"], name: "index_users_on_encrypted_otp_secret_key", unique: true

BIN
docs/images/architecture_diagram.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

BIN
docs/images/context_diagram.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

3
docs/infrastructure.md

@ -118,3 +118,6 @@ Things to watch out for when destroying/creating infra:
- After destroying the db, you’ll need to make sure the ad hoc ECS task which seeds the database gets run in order to set up the database correctly.
- SNS
- When creating an email subscription in an environment, Terraform will look up the email to use as the subscription endpoint from Secrets Manager. If you haven’t already created this (e.g. by running terraform apply -target="module.monitoring" -var="create_secrets_first=true") then this will lead to the subscription creation erroring, because it can’t retrieve the value of the secret (because it doesn’t exist yet). If this happens, remember you’ll need to go to Secrets Manager in the console and enter the desired email (as plaintext, no quotation marks or anything else required) as the value of the secret (which is most likely called MONITORING_EMAIL). Then run another apply with Terraform and this time it should succeed.
![Architecture Diagram](https://raw.githubusercontent.com/communitiesuk/submit-social-housing-lettings-and-sales-data/main/docs/images/architecture_diagram.png)
![Context Diagram](https://raw.githubusercontent.com/communitiesuk/submit-social-housing-lettings-and-sales-data/main/docs/images/context_diagram.png)

207
lib/tasks/update_manual_address_entry_selected_prexisting_logs.rake

@ -0,0 +1,207 @@
namespace :bulk_update do
desc "Update logs with specific criteria and set manual_address_entry_selected to true"
task update_manual_address_entry_selected: :environment do
updated_lettings_logs_count = 0
lettings_postcode_fixed_count = 0
lettings_postcode_fixed_status_changed_count = 0
lettings_postcode_not_fixed_status_changed_count = 0
lettings_postcode_fixed_status_changed_ids = []
lettings_postcode_not_fixed_status_changed_ids = []
lettings_updated_without_issue = 0
updated_sales_logs_count = 0
sales_postcode_fixed_count = 0
sales_postcode_fixed_status_changed_count = 0
sales_postcode_not_fixed_status_changed_count = 0
sales_postcode_fixed_status_changed_ids = []
sales_postcode_not_fixed_status_changed_ids = []
sales_updated_without_issue = 0
lettings_logs = LettingsLog.filter_by_year(2024)
.where(status: %w[in_progress completed])
.where(needstype: 1, manual_address_entry_selected: false, uprn: nil)
.where("(address_line1 IS NOT NULL AND address_line1 != '') OR (address_line2 IS NOT NULL AND address_line2 != '') OR (town_or_city IS NOT NULL AND town_or_city != '') OR (county IS NOT NULL AND county != '') OR (postcode_full IS NOT NULL AND postcode_full != '')")
lettings_logs.find_each do |log|
status_pre_change = log.status
log.manual_address_entry_selected = true
if log.save
updated_lettings_logs_count += 1
Rails.logger.info "manual_address_entry_selected updated for lettings log #{log.id}"
else
Rails.logger.info "Could not save manual_address_entry_selected changes to lettings log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
postcode_fixed = false
if log.postcode_full.nil? && log.address_line1 == log.address_line1_input
log.postcode_full = log.postcode_full_input
if log.save
lettings_postcode_fixed_count += 1
Rails.logger.info "postcode_full updated by address_line1_input for lettings log #{log.id}"
postcode_fixed = true
else
Rails.logger.info "Could not save postcode_full changes to lettings log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
end
if log.postcode_full.nil? && log.creation_method == "bulk upload" && log.address_line1 == log.address_line1_as_entered
log.postcode_full = log.postcode_full_as_entered
if log.save
lettings_postcode_fixed_count += 1
Rails.logger.info "postcode_full updated by address_line1_as_entered for lettings log #{log.id}"
postcode_fixed = true
else
Rails.logger.info "Could not save postcode_full changes to lettings log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
end
status_post_change = log.status
if status_pre_change != status_post_change
if postcode_fixed
lettings_postcode_fixed_status_changed_count += 1
lettings_postcode_fixed_status_changed_ids << log.id
else
lettings_postcode_not_fixed_status_changed_count += 1
lettings_postcode_not_fixed_status_changed_ids << log.id
end
else
lettings_updated_without_issue += 1
end
end
sales_logs = SalesLog.filter_by_year(2024)
.where(status: %w[in_progress completed])
.where(manual_address_entry_selected: false, uprn: nil)
.where("(address_line1 IS NOT NULL AND address_line1 != '') OR (address_line2 IS NOT NULL AND address_line2 != '') OR (town_or_city IS NOT NULL AND town_or_city != '') OR (county IS NOT NULL AND county != '') OR (postcode_full IS NOT NULL AND postcode_full != '')")
sales_logs.find_each do |log|
status_pre_change = log.status
log.manual_address_entry_selected = true
if log.save
updated_sales_logs_count += 1
Rails.logger.info "manual_address_entry_selected updated for sales log #{log.id}"
else
Rails.logger.info "Could not save manual_address_entry_selected changes to sales log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
postcode_fixed = false
if log.postcode_full.nil? && log.address_line1 == log.address_line1_input
log.postcode_full = log.postcode_full_input
if log.save
sales_postcode_fixed_count += 1
Rails.logger.info "postcode_full updated by address_line1_input for sales log #{log.id}"
postcode_fixed = true
else
Rails.logger.info "Could not save postcode_full changes to sales log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
end
if log.postcode_full.nil? && log.creation_method == "bulk upload" && log.address_line1 == log.address_line1_as_entered
log.postcode_full = log.postcode_full_as_entered
if log.save
sales_postcode_fixed_count += 1
Rails.logger.info "postcode_full updated by address_line1_as_entered for sales log #{log.id}"
postcode_fixed = true
else
Rails.logger.info "Could not save postcode_full changes to sales log #{log.id} : #{log.errors.full_messages.join(', ')}"
end
end
status_post_change = log.status
if status_pre_change != status_post_change
if postcode_fixed
sales_postcode_fixed_status_changed_count += 1
sales_postcode_fixed_status_changed_ids << log.id
else
sales_postcode_not_fixed_status_changed_count += 1
sales_postcode_not_fixed_status_changed_ids << log.id
end
else
sales_updated_without_issue += 1
end
end
puts "#{updated_lettings_logs_count} lettings logs were updated."
puts "#{lettings_updated_without_issue} lettings logs were updated without issue."
puts "#{lettings_postcode_fixed_count} lettings logs where postcode fix was applied."
puts "#{lettings_postcode_fixed_status_changed_count} lettings logs with postcode fix and status changed."
puts "#{lettings_postcode_not_fixed_status_changed_count} lettings logs without postcode fix and status changed."
puts "IDs of lettings logs with postcode fix and status changed: [#{lettings_postcode_fixed_status_changed_ids.join(', ')}]"
puts "IDs of lettings logs without postcode fix and status changed: [#{lettings_postcode_not_fixed_status_changed_ids.join(', ')}]"
lettings_postcode_fixed_org_counts = LettingsLog.where(id: lettings_postcode_fixed_status_changed_ids).group(:owning_organisation_id).count
lettings_postcode_fixed_org_counts.each do |org_id, count|
puts "Org #{org_id}: #{count} logs with postcode fix and status changed."
end
lettings_postcode_not_fixed_org_counts = LettingsLog.where(id: lettings_postcode_not_fixed_status_changed_ids).group(:owning_organisation_id).count
lettings_postcode_not_fixed_org_counts.each do |org_id, count|
puts "Org #{org_id}: #{count} logs without postcode fix and status changed."
end
puts "#{updated_sales_logs_count} sales logs were updated."
puts "#{sales_updated_without_issue} sales logs were updated without issue."
puts "#{sales_postcode_fixed_count} sales logs where postcode fix was applied."
puts "#{sales_postcode_fixed_status_changed_count} sales logs with postcode fix and status changed."
puts "#{sales_postcode_not_fixed_status_changed_count} sales logs without postcode fix and status changed."
puts "IDs of sales logs with postcode fix and status changed: [#{sales_postcode_fixed_status_changed_ids.join(', ')}]"
puts "IDs of sales logs without postcode fix and status changed: [#{sales_postcode_not_fixed_status_changed_ids.join(', ')}]"
sales_postcode_fixed_org_counts = SalesLog.where(id: sales_postcode_fixed_status_changed_ids).group(:owning_organisation_id).count
sales_postcode_fixed_org_counts.each do |org_id, count|
puts "Org #{org_id}: #{count} logs with postcode fix and status changed."
end
sales_postcode_not_fixed_org_counts = SalesLog.where(id: sales_postcode_not_fixed_status_changed_ids).group(:owning_organisation_id).count
sales_postcode_not_fixed_org_counts.each do |org_id, count|
puts "Org #{org_id}: #{count} logs without postcode fix and status changed."
end
end
desc "Find logs to fix and update postcode_full if conditions are met"
task update_postcode_full_preexisting_manual_entry_logs: :environment do
updated_count = 0
fixed_count = 0
not_updated_count = 0
not_updated_ids = []
updated_but_not_fixed_ids = []
logs_to_fix = LettingsLog.filter_by_year(2024).where(manual_address_entry_selected: true, uprn: nil, status: "in_progress", postcode_full: nil, updated_at: Time.zone.parse("2025-03-19 16:00:00")..Time.zone.parse("2025-03-19 17:00:00"))
logs_to_fix.find_each do |log|
previous_version = log.versions[-2]
previous_status = previous_version&.reify&.status
if log.address_line1 == log.address_line1_input
log.postcode_full = log.postcode_full_input
elsif log.creation_method == "bulk upload" && log.address_line1 == log.address_line1_as_entered
log.postcode_full = log.postcode_full_as_entered
end
if log.postcode_full.present?
if log.save
Rails.logger.info "Updated postcode_full for lettings log #{log.id}"
updated_count += 1
if log.status == previous_status
fixed_count += 1
else
updated_but_not_fixed_ids << log.id
end
else
Rails.logger.info "Could not save changes to lettings log #{log.id}: #{log.errors.full_messages.join(', ')}"
not_updated_count += 1
not_updated_ids << log.id
end
else
not_updated_count += 1
not_updated_ids << log.id
end
end
puts "#{updated_count} logs updated."
puts "#{fixed_count} logs fixed."
puts "#{not_updated_count} logs not updated."
puts "IDs of logs not updated: [#{not_updated_ids.join(', ')}]"
puts "IDs of logs updated but not fixed: [#{updated_but_not_fixed_ids.join(', ')}]"
end
end

35
spec/factories/lettings_log.rb

@ -172,7 +172,7 @@ FactoryBot.define do
if log.startdate >= Time.zone.local(2024, 4, 1)
log.nationality_all_group = 826
log.uprn = evaluator.uprn || "10033558653"
log.uprn_selection = evaluator.uprn_selection || "10033558653"
log.uprn_selection = evaluator.uprn_selection || evaluator.uprn || "10033558653"
end
end
end
@ -220,5 +220,38 @@ FactoryBot.define do
instance.save!(validate: false)
end
end
trait :completed_without_uprn do
completed
manual_address_entry_selected { false }
after(:build) do |log|
log.uprn = nil
log.uprn_selection = nil
log.uprn_known = 0
end
after(:build) do |log|
log.address_line1 = "1 Test Street"
log.address_line2 = "Testville"
log.county = "Testshire"
log.town_or_city = "Testford"
log.postcode_full = "SW1 1AA"
end
end
trait :inprogress_without_address_fields do
completed
manual_address_entry_selected { false }
after(:build) do |log|
log.uprn = nil
log.uprn_selection = nil
log.uprn_known = 0
log.address_line1 = nil
log.address_line2 = nil
log.county = nil
log.town_or_city = nil
log.postcode_full = nil
log.address_line1_input = nil
log.postcode_full_input = nil
end
end
end
end

167
spec/factories/sales_log.rb

@ -173,7 +173,7 @@ FactoryBot.define do
log.nationality_all_group = 826
log.nationality_all_buyer2_group = 826
log.uprn = evaluator.uprn || "10033558653"
log.uprn_selection = evaluator.uprn_selection || "10033558653"
log.uprn_selection = evaluator.uprn_selection || evaluator.uprn || "10033558653"
end
if log.saledate >= Time.zone.local(2025, 4, 1)
log.relat2 = "X" if log.relat2 == "C"
@ -184,6 +184,39 @@ FactoryBot.define do
end
end
end
trait :discounted_2025_completed do
completed
discount { 20 }
end
trait :shared_2025_completed do
completed
shared_ownership
staircase { 2 }
resale { 2 }
frombeds { 2 }
fromprop { 1 }
socprevten { 3 }
mrent { 900 }
equity { 30 }
ppostcode_full { "SW1A 1AA" }
hodate { Time.zone.today }
end
trait :staircasing_2025_completed do
completed
shared_ownership
staircase { 1 }
stairbought { 10 }
stairowned { 60 }
staircasesale { 2 }
firststair { 2 }
initialpurchase { Time.zone.today - 2.years }
numstair { 2 }
lasttransaction { Time.zone.today - 1.year }
mrentprestaircasing { 1000 }
mrent { 900 }
equity { 30 }
ppostcode_full { "SW1A 1AA" }
end
trait :with_uprn do
uprn { rand(999_999_999_999).to_s }
uprn_known { 1 }
@ -202,5 +235,137 @@ FactoryBot.define do
instance.save!(validate: false)
end
end
trait :completed_without_uprn do
completed
manual_address_entry_selected { false }
after(:build) do |log|
log.uprn = nil
log.uprn_selection = nil
log.uprn_known = 0
end
after(:build) do |log|
log.address_line1 = "1 Test Street"
log.address_line2 = "Testville"
log.county = "Testshire"
log.town_or_city = "Testford"
log.postcode_full = "SW1 1AA"
end
end
trait :inprogress_without_address_fields do
completed
manual_address_entry_selected { false }
after(:build) do |log|
log.uprn = nil
log.uprn_selection = nil
log.uprn_known = 0
log.address_line1 = nil
log.address_line2 = nil
log.county = nil
log.town_or_city = nil
log.postcode_full = nil
log.address_line1_input = nil
log.postcode_full_input = nil
end
end
trait :export do
purchid { "123" }
ownershipsch { 2 }
type { 8 }
saledate_today
jointpur { 1 }
beds { 2 }
jointmore { 1 }
noint { 2 }
privacynotice { 1 }
age1_known { 0 }
age1 { 27 }
sex1 { "F" }
national { 18 }
buy1livein { 1 }
relat2 { "P" }
proptype { 1 }
age2_known { 0 }
age2 { 33 }
builtype { 1 }
ethnic { 3 }
ethnic_group { 17 }
sex2 { "X" }
buy2livein { "1" }
ecstat1 { "1" }
ecstat2 { "1" }
hholdcount { "4" }
wheel { 1 }
details_known_3 { 1 }
age3_known { 0 }
age3 { 14 }
details_known_4 { 1 }
age4_known { 0 }
age4 { 18 }
details_known_5 { 1 }
age5_known { 0 }
age5 { 40 }
details_known_6 { 1 }
age6_known { 0 }
age6 { 40 }
income1nk { 0 }
income1 { 10_000 }
inc1mort { 1 }
income2nk { 0 }
income2 { 10_000 }
inc2mort { 1 }
uprn_known { 0 }
address_line1 { "Address line 1" }
town_or_city { "City" }
la_known { 1 }
la { "E09000003" }
savingsnk { 1 }
prevown { 1 }
prevshared { 2 }
sex3 { "F" }
sex4 { "X" }
sex5 { "M" }
sex6 { "X" }
mortgage { 20_000 }
ecstat3 { 9 }
ecstat4 { 3 }
ecstat5 { 2 }
ecstat6 { 1 }
disabled { 1 }
deposit { 80_000 }
value { 110_000 }
value_value_check { 0 }
grant { 10_000 }
hhregres { 7 }
ppcodenk { 1 }
prevten { 1 }
previous_la_known { 0 }
relat3 { "X" }
relat4 { "X" }
relat5 { "R" }
relat6 { "R" }
hb { 4 }
mortgageused { 1 }
wchair { 1 }
armedforcesspouse { 5 }
has_mscharge { 1 }
mscharge { 100 }
mortlen { 10 }
pcodenk { 0 }
postcode_full { "SW1A 1AA" }
is_la_inferred { false }
mortgagelender { 5 }
extrabor { 1 }
ethnic_group2 { 17 }
nationalbuy2 { 13 }
buy2living { 3 }
proplen_asked { 1 }
address_line1_input { "Address line 1" }
postcode_full_input { "SW1A 1AA" }
nationality_all_group { 826 }
nationality_all_buyer2_group { 826 }
uprn { "10033558653" }
uprn_selection { 1 }
end
end
end

176
spec/fixtures/exports/general_needs_log_25_26.xml vendored

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<status>2</status>
<tenancycode>BZ737</tenancycode>
<age1>35</age1>
<sex1>F</sex1>
<ethnic>2</ethnic>
<prevten>6</prevten>
<ecstat1>0</ecstat1>
<hhmemb>2</hhmemb>
<age2>32</age2>
<sex2>M</sex2>
<ecstat2>6</ecstat2>
<age3/>
<sex3/>
<ecstat3/>
<age4/>
<sex4/>
<ecstat4/>
<age5/>
<sex5/>
<ecstat5/>
<age6/>
<sex6/>
<ecstat6/>
<age7/>
<sex7/>
<ecstat7/>
<age8/>
<sex8/>
<ecstat8/>
<homeless>1</homeless>
<underoccupation_benefitcap>4</underoccupation_benefitcap>
<leftreg>4</leftreg>
<reservist>1</reservist>
<illness>1</illness>
<preg_occ>2</preg_occ>
<startertenancy>1</startertenancy>
<tenancylength>5</tenancylength>
<tenancy>4</tenancy>
<ppostcode_full>A1 1AA</ppostcode_full>
<rsnvac>6</rsnvac>
<unittype_gn>7</unittype_gn>
<beds>3</beds>
<wchair>1</wchair>
<earnings>268</earnings>
<incfreq>1</incfreq>
<benefits>1</benefits>
<period>2</period>
<layear>2</layear>
<waityear>7</waityear>
<postcode_full>AA1 1AA</postcode_full>
<reasonpref>1</reasonpref>
<cbl>0</cbl>
<chr>1</chr>
<cap>0</cap>
<reasonother/>
<housingneeds_a>1</housingneeds_a>
<housingneeds_b>0</housingneeds_b>
<housingneeds_c>0</housingneeds_c>
<housingneeds_f>0</housingneeds_f>
<housingneeds_g>0</housingneeds_g>
<housingneeds_h>0</housingneeds_h>
<illness_type_1>0</illness_type_1>
<illness_type_2>1</illness_type_2>
<illness_type_3>0</illness_type_3>
<illness_type_4>0</illness_type_4>
<illness_type_8>0</illness_type_8>
<illness_type_5>0</illness_type_5>
<illness_type_6>0</illness_type_6>
<illness_type_7>0</illness_type_7>
<illness_type_9>0</illness_type_9>
<illness_type_10>0</illness_type_10>
<rp_homeless>0</rp_homeless>
<rp_insan_unsat>1</rp_insan_unsat>
<rp_medwel>0</rp_medwel>
<rp_hardship>0</rp_hardship>
<rp_dontknow>0</rp_dontknow>
<tenancyother/>
<net_income_value_check/>
<irproduct_other/>
<reason>4</reason>
<propcode>123</propcode>
<la>E09000033</la>
<prevloc>E07000105</prevloc>
<hb>6</hb>
<hbrentshortfall>1</hbrentshortfall>
<mrcdate>2022-05-05T10:36:49+01:00</mrcdate>
<incref>0</incref>
<startdate>2025-04-03T00:00:00+01:00</startdate>
<armedforces>1</armedforces>
<unitletas>2</unitletas>
<builtype>1</builtype>
<voiddate>2021-11-03T00:00:00+00:00</voiddate>
<renttype>2</renttype>
<needstype>1</needstype>
<lettype>7</lettype>
<totchild>0</totchild>
<totelder>0</totelder>
<totadult>2</totadult>
<nocharge>0</nocharge>
<referral>2</referral>
<brent>200.0</brent>
<scharge>50.0</scharge>
<pscharge>40.0</pscharge>
<supcharg>35.0</supcharg>
<tcharge>325.0</tcharge>
<tshortfall>12.0</tshortfall>
<ppcodenk>0</ppcodenk>
<has_benefits>1</has_benefits>
<renewal>0</renewal>
<wrent>100.0</wrent>
<wscharge>25.0</wscharge>
<wpschrge>20.0</wpschrge>
<wsupchrg>17.5</wsupchrg>
<wtcharge>162.5</wtcharge>
<wtshortfall>6.0</wtshortfall>
<refused>0</refused>
<housingneeds>1</housingneeds>
<wchchrg/>
<newprop>2</newprop>
<relat2>P</relat2>
<relat3/>
<relat4/>
<relat5/>
<relat6/>
<relat7/>
<relat8/>
<rent_value_check/>
<lar>2</lar>
<irproduct/>
<joint>3</joint>
<sheltered/>
<hhtype>4</hhtype>
<new_old>2</new_old>
<vacdays>1064</vacdays>
<bulk_upload_id>1</bulk_upload_id>
<uprn>1</uprn>
<uprn_known>1</uprn_known>
<uprn_confirmed>1</uprn_confirmed>
<address_line1>1, Test Street</address_line1>
<address_line2/>
<town_or_city>Test Town</town_or_city>
<county/>
<discarded_at/>
<creation_method>2</creation_method>
<supcharg_value_check/>
<scharge_value_check/>
<pscharge_value_check/>
<duplicate_set_id/>
<accessible_register>0</accessible_register>
<nationality_all>826</nationality_all>
<address_line1_as_entered>address line 1 as entered</address_line1_as_entered>
<address_line2_as_entered>address line 2 as entered</address_line2_as_entered>
<town_or_city_as_entered>town or city as entered</town_or_city_as_entered>
<county_as_entered>county as entered</county_as_entered>
<postcode_full_as_entered>AB1 2CD</postcode_full_as_entered>
<la_as_entered>la as entered</la_as_entered>
<formid>{id}</formid>
<owningorgid>{owning_org_id}</owningorgid>
<owningorgname>{owning_org_name}</owningorgname>
<hcnum>1234</hcnum>
<maningorgid>{managing_org_id}</maningorgid>
<maningorgname>{managing_org_name}</maningorgname>
<manhcnum>1234</manhcnum>
<createddate>2025-04-03T00:00:00+01:00</createddate>
<uploaddate>2025-04-03T00:00:00+01:00</uploaddate>
<log_id>{log_id}</log_id>
<assigned_to>test1@example.com</assigned_to>
<created_by>test1@example.com</created_by>
<amended_by/>
<renttype_detail>2</renttype_detail>
<providertype>1</providertype>
</form>
</forms>

154
spec/fixtures/exports/sales_log.xml vendored

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>1</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEX1>F</SEX1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEX2>X</SEX2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEX3>F</SEX3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEX4>X</SEX4>
<SEX5>M</SEX5>
<SEX6>X</SEX6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<DAY>1</DAY>
<MONTH>3</MONTH>
<YEAR>2026</YEAR>
<CREATEDDATE>2026-03-01T00:00:00+00:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2026-03-01T00:00:00+00:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2025</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

154
spec/fixtures/exports/sales_log_2024.xml vendored

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>2</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEX1>F</SEX1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEX2>X</SEX2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEX3>F</SEX3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEX4>X</SEX4>
<SEX5>M</SEX5>
<SEX6>X</SEX6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<DAY>3</DAY>
<MONTH>4</MONTH>
<YEAR>2024</YEAR>
<CREATEDDATE>2024-04-03T00:00:00+01:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2024-04-03T00:00:00+01:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2024</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

3
spec/fixtures/files/lettings_log_csv_export_codes_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/lettings_log_csv_export_labels_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/lettings_log_csv_export_non_support_codes_25.csv vendored

File diff suppressed because one or more lines are too long

3
spec/fixtures/files/lettings_log_csv_export_non_support_labels_25.csv vendored

File diff suppressed because one or more lines are too long

4
spec/helpers/tab_nav_helper_spec.rb

@ -16,7 +16,7 @@ RSpec.describe TabNavHelper do
describe "#org_cell" do
it "returns the users org name and role separated by a newline character" do
expected_html = "#{organisation.name}\n<span class=\"app-!-colour-muted\">Data provider</span>"
expect(org_cell(current_user)).to match(expected_html)
expect(CGI.unescapeHTML(org_cell(current_user))).to match(expected_html)
end
end
@ -30,7 +30,7 @@ RSpec.describe TabNavHelper do
describe "#scheme_cell" do
it "returns the scheme link service name and primary user group separated by a newline character" do
expected_html = "<a class=\"govuk-link\" href=\"/schemes/#{scheme.id}\">#{scheme.service_name}</a>\n<span class=\"govuk-visually-hidden\">Scheme</span>"
expect(scheme_cell(scheme)).to match(expected_html)
expect(CGI.unescapeHTML(scheme_cell(scheme))).to match(expected_html)
end
end
end

211
spec/lib/tasks/update_manual_address_entry_selected_prexisting_logs_spec.rb

@ -0,0 +1,211 @@
require "rails_helper"
require "rake"
RSpec.describe "update_manual_address_entry_selected_preexisting_logs_spec", type: :task do
before do
Rake.application.rake_require("tasks/update_manual_address_entry_selected_prexisting_logs")
Rake::Task.define_task(:environment)
task.reenable
end
describe "bulk_update:update_manual_address_entry_selected" do
let(:task) { Rake::Task["bulk_update:update_manual_address_entry_selected"] }
let(:lettings_log_uprn_entered) do
build(:lettings_log, :completed, startdate: Time.zone.local(2024, 6, 1), needstype: 1, manual_address_entry_selected: false)
end
let(:lettings_log_uprn_found) do
build(:lettings_log, :completed, startdate: Time.zone.local(2024, 9, 1), needstype: 1, manual_address_entry_selected: false, address_line1_input: "1 Test Street", postcode_full_input: "SW1 1AA")
end
let(:lettings_log_address_fields_not_entered) do
build(:lettings_log, :inprogress_without_address_fields, startdate: Time.zone.local(2024, 9, 1), needstype: 1)
end
let(:lettings_log_address_manually_entered) do
build(:lettings_log, :completed_without_uprn, startdate: Time.zone.local(2024, 12, 1), needstype: 1)
end
let(:sales_log_uprn_entered) do
build(:sales_log, :completed, saledate: Time.zone.local(2024, 12, 1), manual_address_entry_selected: false)
end
let(:sales_log_uprn_found) do
build(:sales_log, :completed, saledate: Time.zone.local(2024, 7, 1), manual_address_entry_selected: false, address_line1_input: "1 Test Street", postcode_full_input: "SW1 1AA")
end
let(:sales_log_address_fields_not_entered) do
build(:sales_log, :inprogress_without_address_fields, saledate: Time.zone.local(2024, 12, 30))
end
let(:sales_log_address_manually_entered) do
build(:sales_log, :completed_without_uprn, saledate: Time.zone.local(2024, 12, 30))
end
context "when running the task" do
context "when logs do not meet the criteria" do
before do
lettings_log_uprn_found.save!(validate: false)
lettings_log_uprn_entered.save!(validate: false)
lettings_log_address_fields_not_entered.save!(validate: false)
sales_log_uprn_found.save!(validate: false)
sales_log_uprn_entered.save!(validate: false)
sales_log_address_fields_not_entered.save!(validate: false)
end
it "does not update logs with a UPRN entered" do
task.invoke
lettings_log_uprn_entered.reload
sales_log_uprn_entered.reload
expect(lettings_log_uprn_entered.manual_address_entry_selected).to be false
expect(lettings_log_uprn_entered.uprn).to eq("10033558653")
expect(sales_log_uprn_entered.manual_address_entry_selected).to be false
expect(sales_log_uprn_entered.uprn).to eq("10033558653")
end
it "does not update logs with a UPRN found" do
task.invoke
lettings_log_uprn_found.reload
sales_log_uprn_found.reload
expect(lettings_log_uprn_found.manual_address_entry_selected).to be false
expect(lettings_log_uprn_found.uprn).to eq("10033558653")
expect(sales_log_uprn_found.manual_address_entry_selected).to be false
expect(sales_log_uprn_found.uprn).to eq("10033558653")
end
it "does not update logs with no UPRN or address fields entered" do
task.invoke
lettings_log_address_fields_not_entered.reload
sales_log_address_fields_not_entered.reload
expect(lettings_log_address_fields_not_entered.manual_address_entry_selected).to be false
expect(sales_log_address_fields_not_entered.manual_address_entry_selected).to be false
end
end
context "when logs do meet the criteria" do
before do
lettings_log_address_manually_entered.manual_address_entry_selected = false
lettings_log_address_manually_entered.save!(validate: false)
sales_log_address_manually_entered.manual_address_entry_selected = false
sales_log_address_manually_entered.save!(validate: false)
end
it "updates logs with an address manually entered" do
expect(lettings_log_address_manually_entered.manual_address_entry_selected).to be false
expect(lettings_log_address_manually_entered.address_line1).to eq("1 Test Street")
expect(lettings_log_address_manually_entered.address_line2).to eq("Testville")
expect(lettings_log_address_manually_entered.town_or_city).to eq("Testford")
expect(lettings_log_address_manually_entered.postcode_full).to eq("SW1 1AA")
expect(sales_log_address_manually_entered.manual_address_entry_selected).to be false
expect(sales_log_address_manually_entered.address_line1).to eq("1 Test Street")
expect(sales_log_address_manually_entered.address_line2).to eq("Testville")
expect(sales_log_address_manually_entered.town_or_city).to eq("Testford")
expect(sales_log_address_manually_entered.postcode_full).to eq("SW1 1AA")
task.invoke
lettings_log_address_manually_entered.reload
sales_log_address_manually_entered.reload
expect(lettings_log_address_manually_entered.manual_address_entry_selected).to be true
expect(lettings_log_address_manually_entered.address_line1).to eq("1 Test Street")
expect(lettings_log_address_manually_entered.address_line2).to eq("Testville")
expect(lettings_log_address_manually_entered.town_or_city).to eq("Testford")
expect(lettings_log_address_manually_entered.postcode_full).to eq("SW1 1AA")
expect(sales_log_address_manually_entered.manual_address_entry_selected).to be true
expect(sales_log_address_manually_entered.address_line1).to eq("1 Test Street")
expect(sales_log_address_manually_entered.address_line2).to eq("Testville")
expect(sales_log_address_manually_entered.town_or_city).to eq("Testford")
expect(sales_log_address_manually_entered.postcode_full).to eq("SW1 1AA")
end
end
end
end
describe "bulk_update:update_postcode_full_preexisting_manual_entry_logs" do
let(:task) { Rake::Task["bulk_update:update_postcode_full_preexisting_manual_entry_logs"] }
let(:lettings_log_to_fix) do
build(:lettings_log, :inprogress_without_address_fields, startdate: Time.zone.local(2024, 6, 1), updated_at: Time.zone.parse("2025-03-19 16:30:00"))
end
let(:bu_lettings_log_to_fix) do
build(:lettings_log, :inprogress_without_address_fields, startdate: Time.zone.local(2024, 6, 1), creation_method: "bulk upload", updated_at: Time.zone.parse("2025-03-19 16:30:00"))
end
let(:lettings_log_not_to_fix) do
build(:lettings_log, :inprogress_without_address_fields, startdate: Time.zone.local(2024, 6, 1), updated_at: Time.zone.parse("2025-03-19 15:30:00"))
end
before do
lettings_log_to_fix.manual_address_entry_selected = true
lettings_log_to_fix.address_line1 = "1 Test Street"
lettings_log_to_fix.address_line2 = "Testville"
lettings_log_to_fix.town_or_city = "Testford"
lettings_log_to_fix.postcode_full = nil
lettings_log_to_fix.address_line1_input = "1 Test Street"
lettings_log_to_fix.postcode_full_input = "SW1 2BB"
lettings_log_to_fix.save!(validate: false)
bu_lettings_log_to_fix.manual_address_entry_selected = true
bu_lettings_log_to_fix.address_line1 = "1 Test Street"
bu_lettings_log_to_fix.address_line2 = "Testville"
bu_lettings_log_to_fix.town_or_city = "Testford"
bu_lettings_log_to_fix.postcode_full = nil
bu_lettings_log_to_fix.address_line1_as_entered = "1 Test Street"
bu_lettings_log_to_fix.postcode_full_as_entered = "SW1 2BB"
bu_lettings_log_to_fix.save!(validate: false)
lettings_log_not_to_fix.postcode_full = nil
lettings_log_not_to_fix.save!(validate: false)
end
context "when running the task" do
it "updates logs that meet the criteria" do
expect(lettings_log_to_fix.postcode_full).to be_nil
expect(lettings_log_to_fix.address_line1).to eq("1 Test Street")
expect(lettings_log_to_fix.address_line2).to eq("Testville")
expect(lettings_log_to_fix.town_or_city).to eq("Testford")
expect(lettings_log_to_fix.address_line1_input).to eq("1 Test Street")
expect(lettings_log_to_fix.postcode_full_input).to eq("SW1 2BB")
expect(bu_lettings_log_to_fix.postcode_full).to be_nil
expect(bu_lettings_log_to_fix.address_line1_input).to be_nil
expect(bu_lettings_log_to_fix.address_line1).to eq("1 Test Street")
expect(bu_lettings_log_to_fix.address_line2).to eq("Testville")
expect(bu_lettings_log_to_fix.town_or_city).to eq("Testford")
expect(bu_lettings_log_to_fix.address_line1_as_entered).to eq("1 Test Street")
expect(bu_lettings_log_to_fix.postcode_full_as_entered).to eq("SW1 2BB")
task.invoke
lettings_log_to_fix.reload
bu_lettings_log_to_fix.reload
expect(lettings_log_to_fix.postcode_full).to eq(lettings_log_to_fix.postcode_full_input)
expect(lettings_log_to_fix.postcode_full).to eq("SW1 2BB")
expect(bu_lettings_log_to_fix.postcode_full).to eq(bu_lettings_log_to_fix.postcode_full_as_entered)
expect(bu_lettings_log_to_fix.postcode_full).to eq("SW1 2BB")
end
it "does not update logs that do not meet the criteria" do
task.invoke
lettings_log_not_to_fix.reload
expect(lettings_log_not_to_fix.postcode_full).to be_nil
end
end
end
end

2
spec/models/form/lettings/questions/previous_let_type_spec.rb

@ -83,7 +83,7 @@ RSpec.describe Form::Lettings::Questions::PreviousLetType, type: :model do
"6" => { "value" => "Rent to Buy basis" },
"7" => { "value" => "London Living Rent basis" },
"8" => { "value" => "Another Intermediate Rent basis" },
"9" => { "value" => "Specified accommodation - exempt accommodation, manged properties, refuges and local authority hostels" },
"9" => { "value" => "Specified accommodation - exempt accommodation, managed properties, refuges and local authority hostels" },
"divider" => { "value" => true },
"3" => { "value" => "Don’t know" },
})

16
spec/models/form/lettings/subsections/property_information_spec.rb

@ -84,6 +84,14 @@ RSpec.describe Form::Lettings::Subsections::PropertyInformation, type: :model do
],
)
end
context "when it is supported housing and a renewal" do
let(:log) { FactoryBot.build(:lettings_log, needstype: 2, renewal: 1) }
it "is not displayed in tasklist" do
expect(property_information.displayed_in_tasklist?(log)).to eq(false)
end
end
end
context "when 2025" do
@ -118,6 +126,14 @@ RSpec.describe Form::Lettings::Subsections::PropertyInformation, type: :model do
],
)
end
context "when it is supported housing and a renewal" do
let(:log) { FactoryBot.build(:lettings_log, needstype: 2, renewal: 1) }
it "is displayed in tasklist" do
expect(property_information.displayed_in_tasklist?(log)).to eq(true)
end
end
end
end

4
spec/models/form/sales/pages/previous_bedrooms_spec.rb

@ -31,6 +31,10 @@ RSpec.describe Form::Sales::Pages::PreviousBedrooms, type: :model do
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
])
end
end

4
spec/models/form/sales/pages/previous_property_type_spec.rb

@ -31,6 +31,10 @@ RSpec.describe Form::Sales::Pages::PreviousPropertyType, type: :model do
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
])
end
end

4
spec/models/form/sales/pages/previous_tenure_spec.rb

@ -31,6 +31,10 @@ RSpec.describe Form::Sales::Pages::PreviousTenure, type: :model do
{
"soctenant" => 0,
},
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevten" => 2 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 1 },
{ "soctenant_is_inferred?" => true, "ownershipsch" => 1, "prevtenbuy2" => 2 },
])
end
end

186
spec/models/lettings_log_spec.rb

@ -849,6 +849,192 @@ RSpec.describe LettingsLog do
expect(lettings_log.reload.is_la_inferred).to eq(false)
end
end
context "when the log changes from new build to not new build" do
before do
allow(FormHandler.instance).to receive(:current_lettings_form).and_call_original
Timecop.freeze(2025, 5, 1)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
end
context "and the address is entered" do
let(:address_lettings_log) do
create(:lettings_log,
:setup_completed,
startdate: Time.zone.yesterday,
mrcdate: nil,
rsnvac: 15,
manual_address_entry_selected: true,
first_time_property_let_as_social_housing: 1,
address_line1: "Address line 1",
address_line2: "Address line 2",
town_or_city: "Town",
postcode_full: "AA1 1AA")
end
it "keeps the manually entered address" do
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.uprn_selection).to eq(nil)
expect(address_lettings_log.uprn_known).to eq(0)
expect(address_lettings_log.uprn).to eq(nil)
expect(address_lettings_log.address_line1).to eq("Address line 1")
expect(address_lettings_log.address_line2).to eq("Address line 2")
expect(address_lettings_log.town_or_city).to eq("Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
address_lettings_log.update!(rsnvac: 16)
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.address_line1).to eq("Address line 1")
expect(address_lettings_log.address_line2).to eq("Address line 2")
expect(address_lettings_log.town_or_city).to eq("Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
end
end
context "and the address is not entered" do
let(:address_lettings_log) do
create(:lettings_log,
:setup_completed,
startdate: Time.zone.yesterday,
mrcdate: nil,
rsnvac: 15,
manual_address_entry_selected: true,
first_time_property_let_as_social_housing: 1,
address_line1: nil,
address_line2: nil,
town_or_city: nil,
postcode_full: nil)
end
it "routes to the uprn question" do
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.uprn_selection).to eq(nil)
expect(address_lettings_log.uprn_known).to eq(0)
expect(address_lettings_log.uprn).to eq(nil)
expect(address_lettings_log.address_line1).to eq(nil)
expect(address_lettings_log.address_line2).to eq(nil)
expect(address_lettings_log.town_or_city).to eq(nil)
expect(address_lettings_log.postcode_full).to eq(nil)
address_lettings_log.update!(rsnvac: 16)
expect(address_lettings_log.manual_address_entry_selected).to eq(false)
expect(address_lettings_log.uprn_selection).to eq(nil)
expect(address_lettings_log.uprn_known).to eq(nil)
end
end
end
context "when the log changes from not new build to new build" do
before do
allow(FormHandler.instance).to receive(:current_lettings_form).and_call_original
Timecop.freeze(2025, 5, 1)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
end
context "and the uprn is selected" do
let(:address_lettings_log) do
create(:lettings_log,
:setup_completed,
startdate: Time.zone.yesterday,
mrcdate: nil,
rsnvac: 17,
manual_address_entry_selected: false,
first_time_property_let_as_social_housing: 1,
uprn_selection: "1",
uprn_confirmed: "1",
uprn_known: "1",
uprn: "1")
end
it "keeps the uprn" do
expect(address_lettings_log.manual_address_entry_selected).to eq(false)
expect(address_lettings_log.uprn).to eq("1")
expect(address_lettings_log.address_line1).to eq("1, Test Street")
expect(address_lettings_log.town_or_city).to eq("Test Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
address_lettings_log.update!(rsnvac: 15)
expect(address_lettings_log.manual_address_entry_selected).to eq(false)
expect(address_lettings_log.address_line1).to eq("1, Test Street")
expect(address_lettings_log.town_or_city).to eq("Test Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
expect(address_lettings_log.uprn_selection).to eq("1")
expect(address_lettings_log.uprn).to eq("1")
expect(address_lettings_log.uprn_known).to eq(1)
end
end
context "and the address is manually entered" do
let(:address_lettings_log) do
create(:lettings_log,
:setup_completed,
startdate: Time.zone.yesterday,
mrcdate: nil,
rsnvac: 16,
manual_address_entry_selected: true,
first_time_property_let_as_social_housing: 1,
uprn_selection: "uprn_not_listed",
address_line1: "Address line 1",
address_line2: "Address line 2",
town_or_city: "Town",
postcode_full: "AA1 1AA")
end
it "keeps the manually entered address" do
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.address_line1).to eq("Address line 1")
expect(address_lettings_log.address_line2).to eq("Address line 2")
expect(address_lettings_log.town_or_city).to eq("Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
address_lettings_log.update!(rsnvac: 15)
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.address_line1).to eq("Address line 1")
expect(address_lettings_log.address_line2).to eq("Address line 2")
expect(address_lettings_log.town_or_city).to eq("Town")
expect(address_lettings_log.postcode_full).to eq("AA1 1AA")
end
end
context "and the address is not entered" do
let(:address_lettings_log) do
create(:lettings_log,
:setup_completed,
startdate: Time.zone.yesterday,
mrcdate: nil,
rsnvac: 17,
manual_address_entry_selected: false,
first_time_property_let_as_social_housing: 1,
address_line1: nil,
address_line2: nil,
town_or_city: nil,
postcode_full: nil)
end
it "routes to the manual address questions" do
expect(address_lettings_log.manual_address_entry_selected).to eq(false)
expect(address_lettings_log.uprn_selection).to eq(nil)
expect(address_lettings_log.address_line1).to eq(nil)
expect(address_lettings_log.address_line2).to eq(nil)
expect(address_lettings_log.town_or_city).to eq(nil)
expect(address_lettings_log.postcode_full).to eq(nil)
address_lettings_log.update!(rsnvac: 15)
expect(address_lettings_log.manual_address_entry_selected).to eq(true)
expect(address_lettings_log.uprn_selection).to eq(nil)
expect(address_lettings_log.uprn).to eq(nil)
expect(address_lettings_log.uprn_known).to eq(0)
end
end
end
end
describe "optional fields" do

36
spec/models/validations/soft_validations_spec.rb

@ -265,6 +265,24 @@ RSpec.describe Validations::SoftValidations do
expect(record.major_repairs_date_in_soft_range?).to be false
end
end
context "with 2025 logs" do
context "when the void date is within 20 years of the tenancy start date" do
it "shows the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.mrcdate = Time.zone.local(2007, 2, 1)
expect(record.major_repairs_date_in_soft_range?).to be true
end
end
context "when the void date is less than 2 years before the tenancy start date" do
it "does not show the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.mrcdate = Time.zone.local(2025, 2, 1)
expect(record.major_repairs_date_in_soft_range?).to be false
end
end
end
end
describe "void date soft validations" do
@ -283,6 +301,24 @@ RSpec.describe Validations::SoftValidations do
expect(record.voiddate_in_soft_range?).to be false
end
end
context "with 2025 logs" do
context "when the void date is within 20 years of the tenancy start date" do
it "shows the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.voiddate = Time.zone.local(2007, 2, 1)
expect(record.voiddate_in_soft_range?).to be true
end
end
context "when the void date is less than 2 years before the tenancy start date" do
it "does not show the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.voiddate = Time.zone.local(2025, 2, 1)
expect(record.voiddate_in_soft_range?).to be false
end
end
end
end
describe "old persons shared ownership soft validations" do

112
spec/requests/address_search_controller_spec.rb

@ -25,7 +25,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(sales_log.town_or_city).to eq(nil)
expect(sales_log.la).to eq(nil)
get "/address-search/manual-input/sales_log/#{sales_log.id}"
get address_manual_input_path(log_type: "sales_log", log_id: sales_log.id)
sales_log.reload
expect(sales_log.manual_address_entry_selected).to eq(true)
@ -58,7 +58,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(lettings_log.town_or_city).to eq("London")
expect(lettings_log.la).to eq("E09000033")
get "/address-search/manual-input/lettings_log/#{lettings_log.id}"
get address_manual_input_path(log_type: "lettings_log", log_id: lettings_log.id)
lettings_log.reload
expect(lettings_log.manual_address_entry_selected).to eq(true)
@ -94,7 +94,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(lettings_log.town_or_city).to eq(nil)
expect(lettings_log.la).to eq(nil)
get "/address-search/search-input/lettings_log/#{lettings_log.id}"
get address_search_input_path(log_type: "lettings_log", log_id: lettings_log.id)
lettings_log.reload
expect(lettings_log.manual_address_entry_selected).to eq(false)
@ -128,7 +128,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(sales_log.town_or_city).to eq("Test Town")
expect(sales_log.la).to eq("E09000033")
get "/address-search/search-input/sales_log/#{sales_log.id}"
get address_search_input_path(log_type: "sales_log", log_id: sales_log.id)
sales_log.reload
expect(sales_log.manual_address_entry_selected).to eq(false)
@ -144,5 +144,109 @@ RSpec.describe AddressSearchController, type: :request do
expect(sales_log.la).to eq(nil)
end
end
context "when searching by address and UPRN" do
let(:sales_log) { create(:sales_log, :completed, manual_address_entry_selected: false, assigned_to: user) }
context "and theres no uprn returned" do
before do
body = { results: [{ DPA: { "ADDRESS": "100, Test Street", "UPRN": "100" } }] }.to_json
uprn_body = { results: [{ DPA: nil }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=100")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "returns the address results" do
get address_search_path, params: { query: "100" }
expect(response).to have_http_status(:ok)
expect(response.body).to eq([{ text: "100, Test Street", value: "100" }].to_json)
end
end
context "and theres no address returned" do
before do
body = { results: [{ DPA: nil }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "321, Test Street", UPRN: "321" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=321")
.to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=321")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "returns the address results" do
get address_search_path, params: { query: "321" }
expect(response).to have_http_status(:ok)
expect(response.body).to eq([{ text: "321, Test Street", value: "321" }].to_json)
end
end
end
end
describe "GET #index" do
context "when query is nil" do
it "returns a bad request error" do
get address_search_path, params: { query: nil }
expect(response).to have_http_status(:bad_request)
expect(response.body).to include("Query cannot be blank.")
end
end
context "when query is all numbers and greater than 5 digits" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "2, Test Street", UPRN: "123456" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=123456")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123456")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "assumes it's a UPRN and returns the address" do
get address_search_path, params: { query: "123456" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("2, Test Street")
expect(response.body).not_to include("Path not taken")
end
end
context "when query contains any non-digit characters" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "70, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=70,")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=70,")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "assumes it's an address and returns the address results" do
get address_search_path, params: { query: "70," }
expect(response).to have_http_status(:ok)
expect(response.body).to include("70, Test Street")
expect(response.body).not_to include("Path not taken")
end
end
context "when query is ambiguous" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "111, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "70 Bean Road", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=111")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=111")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "uses both APIs and merges results" do
get address_search_path, params: { query: "111" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("111, Test Street")
expect(response.body).to include("70 Bean Road")
end
end
end
end

2
spec/requests/bulk_upload_lettings_results_controller_spec.rb

@ -138,7 +138,7 @@ RSpec.describe BulkUploadLettingsResultsController, type: :request do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end

4
spec/requests/bulk_upload_sales_results_controller_spec.rb

@ -30,7 +30,7 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end
@ -113,7 +113,7 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end
end

48
spec/services/bulk_upload/sales/year2025/row_parser_spec.rb

@ -60,7 +60,7 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
field_31: "28",
field_32: "1",
field_33: "1",
field_34: "R",
field_34: "3",
field_35: "32",
field_36: "F",
field_37: "17",
@ -1145,6 +1145,52 @@ RSpec.describe BulkUpload::Sales::Year2025::RowParser do
end
end
describe "relationship field mappings" do
[
%w[field_34 relat2 2],
%w[field_42 relat3 3],
%w[field_46 relat4 4],
%w[field_50 relat5 5],
%w[field_54 relat6 6],
].each do |input_field, relationship_attribute, person_num|
describe input_field.to_s do
context "when #{input_field} is 1" do
let(:attributes) { setup_section_params.merge({ input_field.to_sym => "1", field_41: "5" }) }
it "sets relationship to P" do
expect(parser.log.public_send(relationship_attribute)).to eq("P")
end
end
context "when #{input_field} is 2" do
let(:attributes) { setup_section_params.merge({ input_field.to_sym => "2", field_41: "5" }) }
it "sets relationship to X" do
expect(parser.log.public_send(relationship_attribute)).to eq("X")
end
end
context "when #{input_field} is 3" do
let(:attributes) { setup_section_params.merge({ input_field.to_sym => "3", field_41: "5" }) }
it "sets relationship to R" do
expect(parser.log.public_send(relationship_attribute)).to eq("R")
end
end
context "when #{input_field} is 4" do
let(:attributes) { setup_section_params.merge({ input_field.to_sym => "4", field_41: "5" }) }
it "gives a validation error" do
parser.valid?
validation_message = "You must answer person #{person_num} is the partner of buyer 1."
expect(parser.errors[input_field]).to include validation_message
end
end
end
end
end
describe "field_39" do # ecstat2
context "when buyer 2 has no age but has ecstat as child" do
let(:attributes) { valid_attributes.merge({ field_35: nil, field_39: "9" }) }

193
spec/services/csv/lettings_log_csv_service_spec.rb

@ -194,6 +194,199 @@ RSpec.describe Csv::LettingsLogCsvService do
end
describe "the full CSV output" do
context "when the requested log year is 2025" do
let(:year) { 2025 }
let(:organisation) { create(:organisation, provider_type: "LA", name: "MHCLG") }
let(:log) do
create(
:lettings_log,
:ignore_validation_errors,
created_by: user,
assigned_to: user,
created_at: Time.zone.local(2025, 4, 1),
updated_at: Time.zone.local(2025, 4, 1),
owning_organisation: organisation,
managing_organisation: organisation,
needstype: 1,
renewal: 0,
startdate: Time.zone.local(2025, 4, 1),
rent_type: 1,
tenancycode: "HIJKLMN",
propcode: "ABCDEFG",
declaration: 1,
address_line1: "Address line 1",
town_or_city: "London",
postcode_full: "NW9 5LL",
la: "E09000003",
is_la_inferred: false,
address_line1_as_entered: "address line 1 as entered",
address_line2_as_entered: "address line 2 as entered",
town_or_city_as_entered: "town or city as entered",
county_as_entered: "county as entered",
postcode_full_as_entered: "AB1 2CD",
la_as_entered: "la as entered",
first_time_property_let_as_social_housing: 0,
unitletas: 2,
rsnvac: 6,
unittype_gn: 7,
builtype: 1,
wchair: 1,
beds: 3,
voiddate: Time.zone.local(2025, 3, 30),
majorrepairs: 1,
mrcdate: Time.zone.local(2025, 3, 31),
joint: 3,
startertenancy: 1,
tenancy: 4,
tenancylength: 2,
hhmemb: 4,
age1_known: 0,
age1: 35,
sex1: "F",
ethnic_group: 0,
ethnic: 2,
nationality_all: 36,
ecstat1: 0,
details_known_2: 0,
relat2: "P",
age2_known: 0,
age2: 32,
sex2: "M",
ecstat2: 6,
details_known_3: 1,
details_known_4: 0,
relat4: "R",
age4_known: 1,
sex4: "R",
ecstat4: 10,
armedforces: 1,
leftreg: 4,
reservist: 1,
preg_occ: 2,
housingneeds: 1,
housingneeds_type: 0,
housingneeds_a: 1,
housingneeds_b: 0,
housingneeds_c: 0,
housingneeds_f: 0,
housingneeds_g: 0,
housingneeds_h: 0,
housingneeds_other: 0,
illness: 1,
illness_type_1: 0,
illness_type_2: 1,
illness_type_3: 0,
illness_type_4: 0,
illness_type_5: 0,
illness_type_6: 0,
illness_type_7: 0,
illness_type_8: 0,
illness_type_9: 0,
illness_type_10: 0,
layear: 2,
waityear: 7,
reason: 4,
prevten: 6,
homeless: 1,
ppcodenk: 1,
ppostcode_full: "TN23 6LZ",
previous_la_known: 1,
prevloc: "E07000105",
reasonpref: 1,
rp_homeless: 0,
rp_insan_unsat: 1,
rp_medwel: 0,
rp_hardship: 0,
rp_dontknow: 0,
cbl: 0,
chr: 1,
cap: 0,
accessible_register: 0,
referral: 2,
net_income_known: 0,
incref: 0,
incfreq: 1,
earnings: 268,
hb: 6,
has_benefits: 1,
benefits: 1,
period: 2,
brent: 200,
scharge: 50,
pscharge: 40,
supcharg: 35,
tcharge: 325,
hbrentshortfall: 1,
tshortfall_known: 1,
tshortfall: 12,
)
end
context "when exporting with human readable labels" do
let(:export_type) { "labels" }
context "when the current user is a support user" do
let(:user) { create(:user, :support, organisation:, email: "s.port@jeemayle.com") }
it "exports the CSV with 2025 ordering and all values correct" do
expected_content = CSV.read("spec/fixtures/files/lettings_log_csv_export_labels_25.csv")
values_to_delete = %w[id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv).to eq expected_content
end
end
context "when the current user is not a support user" do
let(:user) { create(:user, :data_provider, organisation:, email: "choreographer@owtluk.com") }
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/lettings_log_csv_export_non_support_labels_25.csv")
values_to_delete = %w[id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv).to eq expected_content
end
end
end
context "when exporting values as codes" do
let(:export_type) { "codes" }
context "when the current user is a support user" do
let(:user) { create(:user, :support, organisation:, email: "s.port@jeemayle.com") }
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/lettings_log_csv_export_codes_25.csv")
values_to_delete = %w[id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv).to eq expected_content
end
end
context "when the current user is not a support user" do
let(:user) { create(:user, :data_provider, organisation:, email: "choreographer@owtluk.com") }
it "exports the CSV with all values correct" do
expected_content = CSV.read("spec/fixtures/files/lettings_log_csv_export_non_support_codes_25.csv")
values_to_delete = %w[id]
values_to_delete.each do |attribute|
index = attribute_line.index(attribute)
content_line[index] = nil
end
expect(csv).to eq expected_content
end
end
end
end
context "when the requested log year is 2024" do
let(:year) { 2024 }
let(:organisation) { create(:organisation, provider_type: "LA", name: "MHCLG") }

247
spec/services/exports/export_service_spec.rb

@ -9,12 +9,14 @@ RSpec.describe Exports::ExportService do
let(:user) { FactoryBot.create(:user, email: "test1@example.com") }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: {}) }
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: {}) }
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
allow(storage_service).to receive(:write_file)
allow(Exports::LettingsLogExportService).to receive(:new).and_return(lettings_logs_export_service)
allow(Exports::SalesLogExportService).to receive(:new).and_return(sales_logs_export_service)
allow(Exports::UserExportService).to receive(:new).and_return(users_export_service)
allow(Exports::OrganisationExportService).to receive(:new).and_return(organisations_export_service)
end
@ -23,7 +25,9 @@ RSpec.describe Exports::ExportService do
Timecop.return
end
context "when exporting daily XMLs" do
context "when exporting daily XMLs before 2025" do
let(:start_time) { Time.zone.local(2022, 5, 1) }
context "and no lettings archives get created in lettings logs export" do
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
@ -193,6 +197,64 @@ RSpec.describe Exports::ExportService do
end
end
end
context "and multiple sales archives get created in sales logs export" do
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time, "second_file_base_name" => start_time }) }
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time, "second_sales_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_user_file_base_name,2022-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2022-05-01 00:00:00 +0100,second_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user and organisation archives gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: { "some_organisation_file_base_name" => start_time, "second_organisation_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_user_file_base_name,2022-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2022-05-01 00:00:00 +0100,second_user_file_base_name.zip\nsome_organisation_file_base_name,2022-05-01 00:00:00 +0100,some_organisation_file_base_name.zip\nsecond_organisation_file_base_name,2022-05-01 00:00:00 +0100,second_organisation_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
end
context "when exporting specific lettings log collection" do
@ -204,7 +266,7 @@ RSpec.describe Exports::ExportService do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "2022")
export_service.export_xml(full_update: true, collection: "lettings", year: "2022")
end
it "does not write user data" do
@ -212,7 +274,7 @@ RSpec.describe Exports::ExportService do
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "2022")
export_service.export_xml(full_update: true, collection: "lettings", year: "2022")
expect(actual_content).to eq(expected_content)
end
end
@ -226,7 +288,7 @@ RSpec.describe Exports::ExportService do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "2023")
export_service.export_xml(full_update: true, collection: "lettings", year: "2023")
end
it "does not write user data" do
@ -234,7 +296,7 @@ RSpec.describe Exports::ExportService do
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "2023")
export_service.export_xml(full_update: true, collection: "lettings", year: "2023")
expect(actual_content).to eq(expected_content)
end
end
@ -330,4 +392,179 @@ RSpec.describe Exports::ExportService do
end
end
end
context "with date after 2025-04-01" do
let(:start_time) { Time.zone.local(2025, 5, 1) }
let(:expected_master_manifest_filename) { "Manifest_2025_05_01_0001.csv" }
context "when exporting daily XMLs" do
context "and no sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
context "and no user or organisation archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers but no data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
context "and one sales archive gets created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time }) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and one user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
context "and multiple sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time, "second_sales_file_base_name" => start_time }) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time, "second_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2025-05-01 00:00:00 +0100,second_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user and organisation archives gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: { "some_organisation_file_base_name" => start_time, "second_organisation_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2025-05-01 00:00:00 +0100,second_user_file_base_name.zip\nsome_organisation_file_base_name,2025-05-01 00:00:00 +0100,some_organisation_file_base_name.zip\nsecond_organisation_file_base_name,2025-05-01 00:00:00 +0100,second_organisation_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
end
end
context "when exporting specific sales log collection" do
let(:start_time) { Time.zone.local(2025, 5, 1) }
let(:expected_master_manifest_filename) { "Manifest_2025_05_01_0001.csv" }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
context "and no sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
context "and user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "sales", year: "2022")
end
it "does not write user data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "sales", year: "2022")
expect(actual_content).to eq(expected_content)
end
end
end
context "and sales archive gets created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time }) }
context "and user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "sales", year: "2023")
end
it "does not write user data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "sales", year: "2023")
expect(actual_content).to eq(expected_content)
end
end
end
end
end

32
spec/services/exports/lettings_log_export_service_spec.rb

@ -448,6 +448,38 @@ RSpec.describe Exports::LettingsLogExportService do
end
end
end
context "with 25/26 collection period" do
let(:start_time) { Time.zone.local(2025, 4, 3) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one lettings log is available for export" do
let!(:lettings_log) { FactoryBot.create(:lettings_log, :completed, startdate: Time.zone.local(2025, 4, 3), assigned_to: user, age1: 35, sex1: "F", age2: 32, sex2: "M", ppostcode_full: "A1 1AA", nationality_all_group: 13, propcode: "123", postcode_full: "SE2 6RT", tenancycode: "BZ737", voiddate: Time.zone.local(2021, 11, 3), mrcdate: Time.zone.local(2022, 5, 5, 10, 36, 49), tenancylength: 5, underoccupation_benefitcap: 4, creation_method: 2, bulk_upload_id: 1, address_line1_as_entered: "address line 1 as entered", address_line2_as_entered: "address line 2 as entered", town_or_city_as_entered: "town or city as entered", county_as_entered: "county as entered", postcode_full_as_entered: "AB1 2CD", la_as_entered: "la as entered", manual_address_entry_selected: false, uprn: "1", uprn_known: 1) }
let(:expected_zip_filename) { "core_2025_2026_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_2025_2026_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/general_needs_log_25_26.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(lettings_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_lettings_logs
end
end
end
end
context "when exporting a supported housing lettings logs in XML" do

425
spec/services/exports/sales_log_export_service_spec.rb

@ -0,0 +1,425 @@
require "rails_helper"
RSpec.describe Exports::SalesLogExportService do
subject(:export_service) { described_class.new(storage_service, start_time) }
let(:storage_service) { instance_double(Storage::S3Service) }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log.xml", "r:UTF-8") }
let(:local_manifest_file) { File.open("spec/fixtures/exports/manifest.xml", "r:UTF-8") }
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001_pt001.xml" }
let(:expected_manifest_filename) { "manifest.xml" }
let(:start_time) { Time.zone.local(2026, 3, 1) }
let(:organisation) { create(:organisation, name: "MHCLG", housing_registration_no: 1234) }
let(:user) { FactoryBot.create(:user, email: "test1@example.com", organisation:) }
def replace_entity_ids(sales_log, export_template)
export_template.sub!(/\{owning_org_id\}/, sales_log["owning_organisation_id"].to_s)
export_template.sub!(/\{owning_org_name\}/, sales_log.owning_organisation.name.to_s)
export_template.sub!(/\{managing_org_id\}/, sales_log["managing_organisation_id"].to_s)
export_template.sub!(/\{managing_org_name\}/, sales_log.managing_organisation.name.to_s)
export_template.sub!(/\{assigned_to_id\}/, sales_log["assigned_to_id"].to_s)
export_template.sub!(/\{assigned_to_email\}/, sales_log.assigned_to&.email.to_s)
export_template.sub!(/\{created_by_id\}/, sales_log["created_by_id"].to_s)
export_template.sub!(/\{created_by_email\}/, sales_log.created_by&.email.to_s)
export_template.sub!(/\{id\}/, sales_log["id"].to_s)
end
def replace_record_number(export_template, record_number)
export_template.sub!(/\{recno\}/, record_number.to_s)
end
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
allow(storage_service).to receive(:write_file)
end
after do
Timecop.return
end
context "when exporting daily sales logs in XML" do
context "and no sales logs are available for export" do
it "returns an empty archives list" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
context "when one pending sales log exists" do
before do
FactoryBot.create(
:sales_log,
:export,
status: "pending",
skip_update_status: true,
)
end
it "returns empty archives list for archives manifest" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, assigned_to: user) }
it "generates a ZIP export file with the expected filename" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
export_service.export_xml_sales_logs
end
it "generates an XML export file with the expected filename within the ZIP file" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.name).to eq(expected_data_filename)
end
export_service.export_xml_sales_logs
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 1)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "returns the list with correct archive" do
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "") => start_time })
end
end
context "and multiple sales logs are available for export on different periods" do
let(:previous_zip_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001.zip" }
let(:next_zip_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001.zip" }
before do
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2024, 5, 1))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 5, 1))
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2026, 4, 1))
end
context "when sales logs are across multiple years" do
it "generates multiple ZIP export files with the expected filenames" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
expect(storage_service).not_to receive(:write_file).with(previous_zip_filename, any_args)
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2025")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2025_2026_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2025_2026_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2025_2026_apr_mar_f0001_inc0001.zip")
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs
end
it "generates zip export files only for specified year" do
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs(collection_year: 2026)
end
context "and previous full exports are different for previous years" do
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0007_inc0004.zip" }
let(:next_zip_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001.zip" }
before do
Export.new(started_at: Time.zone.yesterday, base_number: 7, increment_number: 3, collection: "sales", year: 2025).save!
end
it "generates multiple ZIP export files with different base numbers in the filenames" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2025")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2025_2026_apr_mar_f0007_inc0004 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2025_2026_apr_mar_f0007_inc0004_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2025_2026_apr_mar_f0007_inc0004.zip")
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs
end
end
end
end
context "and multiple sales logs are available for export on same quarter" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 4, 1))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 4, 20))
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 2)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "creates a logs export record in a database with correct time" do
expect { export_service.export_xml_sales_logs }
.to change(Export, :count).by(2)
expect(Export.last.started_at).to be_within(2.seconds).of(start_time)
end
context "when this is the first export (full)" do
it "returns a ZIP archive for the master manifest (existing sales logs)" do
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "").gsub(".zip", "") => start_time })
end
end
context "and underlying data changes between getting the logs and writting the manifest" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1))
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2026, 4, 1))
end
def remove_logs(logs)
logs.each(&:destroy)
file = Tempfile.new
doc = Nokogiri::XML("<forms/>")
doc.write_xml_to(file, encoding: "UTF-8")
file.rewind
file
end
def create_fake_maifest
file = Tempfile.new
doc = Nokogiri::XML("<forms/>")
doc.write_xml_to(file, encoding: "UTF-8")
file.rewind
file
end
it "maintains the same record number" do
# rubocop:disable RSpec/SubjectStub
allow(export_service).to receive(:build_export_xml) do |logs|
remove_logs(logs)
end
allow(export_service).to receive(:build_manifest_xml) do
create_fake_maifest
end
expect(export_service).to receive(:build_manifest_xml).with(1)
# rubocop:enable RSpec/SubjectStub
export_service.export_xml_sales_logs
end
end
context "when this is a second export (partial)" do
before do
start_time = Time.zone.local(2026, 6, 1)
Export.new(started_at: start_time, collection: "sales", year: 2025).save!
end
it "does not add any entry for the master manifest (no sales logs)" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
end
context "and a previous export has run the same day having sales logs" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 5, 1))
export_service.export_xml_sales_logs
end
context "and we trigger another full update" do
it "increments the base number" do
export_service.export_xml_sales_logs(full_update: true)
expect(Export.last.base_number).to eq(2)
end
it "resets the increment number" do
export_service.export_xml_sales_logs(full_update: true)
expect(Export.last.increment_number).to eq(1)
end
it "returns a correct archives list for manifest file" do
expect(export_service.export_xml_sales_logs(full_update: true)).to eq({ "core_sales_2025_2026_apr_mar_f0002_inc0001" => start_time })
end
it "generates a ZIP export file with the expected filename" do
expect(storage_service).to receive(:write_file).with("core_sales_2025_2026_apr_mar_f0002_inc0001.zip", any_args)
export_service.export_xml_sales_logs(full_update: true)
end
end
end
context "and a previous export has run having no sales logs" do
before { export_service.export_xml_sales_logs }
it "doesn't increment the manifest number by 1" do
export_service.export_xml_sales_logs
expect(Export.last.increment_number).to eq(1)
end
end
context "and a log has been manually updated since the previous partial export" do
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0002.zip" }
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1), updated_at: Time.zone.local(2026, 2, 27), values_updated_at: Time.zone.local(2026, 2, 29))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1), updated_at: Time.zone.local(2026, 2, 27), values_updated_at: Time.zone.local(2026, 2, 29))
Export.create!(started_at: Time.zone.local(2026, 2, 28), base_number: 1, increment_number: 1, collection: "sales", year: 2025)
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 2)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "") => start_time })
end
end
context "and one sales log with duplicate reference is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, duplicate_set_id: 123) }
def replace_duplicate_set_id(export_file)
export_file.sub!("<DUPLICATESET/>", "<DUPLICATESET>123</DUPLICATESET>")
end
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_duplicate_set_id(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
context "when exporting only 24/25 collection period" do
let(:start_time) { Time.zone.local(2024, 4, 3) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export) }
let(:expected_zip_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log_2024.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs(full_update: true, collection_year: 2024)
end
end
end
context "when exporting various fees, correctly maps the values" do
context "with discounted ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, mscharge: 123) }
def replace_mscharge_value(export_file)
export_file.sub!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE>123.0</MSCHARGE>")
end
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_mscharge_value(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
context "with shared ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 2, type: 30, mscharge: 321, has_management_fee: 1, management_fee: 222) }
def replace_mscharge_and_shared_ownership_values(export_file)
export_file.sub!("<HASSERVICECHARGES/>", "<HASSERVICECHARGES>1</HASSERVICECHARGES>")
export_file.sub!("<SERVICECHARGES/>", "<SERVICECHARGES>321.0</SERVICECHARGES>")
export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
export_file.sub!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE/>")
export_file.sub!("<HASMSCHARGE>1</HASMSCHARGE>", "<HASMSCHARGE/>")
export_file.sub!("<TYPE>8</TYPE>", "<TYPE>30</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>2</STAIRCASE>")
export_file.sub!("<GRANT>10000.0</GRANT>", "<GRANT/>")
export_file.sub!("<PPCODENK>0</PPCODENK>", "<PPCODENK>1</PPCODENK>")
export_file.sub!("<PPOSTC1>SW1A</PPOSTC1>", "<PPOSTC1/>")
export_file.sub!("<PPOSTC2>1AA</PPOSTC2>", "<PPOSTC2/>")
export_file.sub!("<PREVLOC>E09000033</PREVLOC>", "<PREVLOC/>")
export_file.sub!("<EXTRABOR>1</EXTRABOR>", "<EXTRABOR/>")
export_file.sub!("<OWNERSHIP>2</OWNERSHIP>", "<OWNERSHIP>1</OWNERSHIP>")
export_file.sub!("<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>", "<PREVIOUSLAKNOWN>0</PREVIOUSLAKNOWN>")
export_file.sub!("<PREVLOCNAME>Westminster</PREVLOCNAME>", "<PREVLOCNAME/>")
end
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_mscharge_and_shared_ownership_values(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
end
end
end

21
spec/services/exports/user_export_service_spec.rb

@ -202,7 +202,26 @@ RSpec.describe Exports::UserExportService do
before do
create(:user, updated_at: Time.zone.local(2022, 4, 27), organisation:)
create(:user, updated_at: Time.zone.local(2022, 4, 27), organisation:)
Export.create!(started_at: Time.zone.local(2022, 4, 26), base_number: 1, increment_number: 1)
Export.create!(started_at: Time.zone.local(2022, 4, 26), base_number: 1, increment_number: 1, empty_export: true, collection: "users")
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 2)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
expect(export_service.export_xml_users).to eq({ expected_zip_filename.gsub(".zip", "") => start_time })
end
end
context "and a user has been manually updated since the previous partial export" do
before do
create(:user, updated_at: Time.zone.local(2022, 4, 25), values_updated_at: Time.zone.local(2022, 4, 27), organisation:)
create(:user, updated_at: Time.zone.local(2022, 4, 25), values_updated_at: Time.zone.local(2022, 4, 27), organisation:)
Export.create!(started_at: Time.zone.local(2022, 4, 26), base_number: 1, increment_number: 1, empty_export: true, collection: "users")
end
it "generates an XML manifest file with the expected content within the ZIP file" do

2
spec/services/merge/merge_organisations_service_spec.rb

@ -31,10 +31,12 @@ RSpec.describe Merge::MergeOrganisationsService do
expect(Rails.logger).to receive(:info).with("\t#{merging_organisation.data_protection_officers.first.name} (#{merging_organisation.data_protection_officers.first.email})")
expect(Rails.logger).to receive(:info).with("\tfake name (fake@email.com)")
expect(Rails.logger).to receive(:info).with("New schemes from fake org:")
expect(merging_organisation_user.values_updated_at).to be_nil
merge_organisations_service.call
merging_organisation_user.reload
expect(merging_organisation_user.organisation).to eq(absorbing_organisation)
expect(merging_organisation_user.values_updated_at).not_to be_nil
end
it "sets merge date on merged organisation" do

28
spec/services/uprn_client_spec.rb

@ -86,5 +86,33 @@ describe UprnClient do
expect(client.error).to be_nil
end
end
describe "result" do
context "when response is successful" do
before do
stub_api_request(body: valid_response)
client.call
end
it "returns parsed result" do
expect(client.result).to eq({ "postcode" => "12345" })
expect(client.send(:response).code.to_i).to eq(200)
end
end
context "when response is not successful" do
before do
stub_api_request(body: valid_response, status: 500)
client.call
end
it "returns nil" do
expect(client.result).to be_nil
expect(client.error).to eq("UPRN client failed to return a valid result, try again later.")
end
end
end
end
end

77
yarn.lock

@ -18,6 +18,15 @@
"@babel/highlight" "^7.24.7"
picocolors "^1.0.0"
"@babel/code-frame@^7.26.2":
version "7.26.2"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85"
integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==
dependencies:
"@babel/helper-validator-identifier" "^7.25.9"
js-tokens "^4.0.0"
picocolors "^1.0.0"
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2":
version "7.25.2"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.2.tgz#e41928bd33475305c586f6acbbb7e3ade7a6f7f5"
@ -190,11 +199,21 @@
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz#5b3329c9a58803d5df425e5785865881a81ca48d"
integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==
"@babel/helper-string-parser@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c"
integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==
"@babel/helper-validator-identifier@^7.24.7":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db"
integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==
"@babel/helper-validator-identifier@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7"
integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==
"@babel/helper-validator-option@^7.24.8":
version "7.24.8"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz#3725cdeea8b480e86d34df15304806a06975e33d"
@ -210,12 +229,12 @@
"@babel/types" "^7.25.0"
"@babel/helpers@^7.25.0":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.25.0.tgz#e69beb7841cb93a6505531ede34f34e6a073650a"
integrity sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384"
integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==
dependencies:
"@babel/template" "^7.25.0"
"@babel/types" "^7.25.0"
"@babel/template" "^7.26.9"
"@babel/types" "^7.26.10"
"@babel/highlight@^7.24.7":
version "7.24.7"
@ -234,6 +253,13 @@
dependencies:
"@babel/types" "^7.25.2"
"@babel/parser@^7.26.9":
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.10.tgz#e9bdb82f14b97df6569b0b038edd436839c57749"
integrity sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==
dependencies:
"@babel/types" "^7.26.10"
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.3":
version "7.25.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz#dca427b45a6c0f5c095a1c639dfe2476a3daba7f"
@ -913,24 +939,10 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.12.5":
version "7.19.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.16.7":
version "7.24.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e"
integrity sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.8.4":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
"@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.8.4":
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2"
integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==
dependencies:
regenerator-runtime "^0.14.0"
@ -943,6 +955,15 @@
"@babel/parser" "^7.25.0"
"@babel/types" "^7.25.0"
"@babel/template@^7.26.9":
version "7.26.9"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.26.9.tgz#4577ad3ddf43d194528cff4e1fa6b232fa609bb2"
integrity sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==
dependencies:
"@babel/code-frame" "^7.26.2"
"@babel/parser" "^7.26.9"
"@babel/types" "^7.26.9"
"@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.2", "@babel/traverse@^7.25.3":
version "7.25.3"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.3.tgz#f1b901951c83eda2f3e29450ce92743783373490"
@ -965,6 +986,14 @@
"@babel/helper-validator-identifier" "^7.24.7"
to-fast-properties "^2.0.0"
"@babel/types@^7.26.10", "@babel/types@^7.26.9":
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.10.tgz#396382f6335bd4feb65741eacfc808218f859259"
integrity sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==
dependencies:
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@csstools/css-parser-algorithms@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.0.tgz#dd428c4569caea7185716fbba174202a4ba41fda"
@ -3820,7 +3849,7 @@ regenerate@^1.4.2:
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.9:
regenerator-runtime@^0.13.9:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==

Loading…
Cancel
Save