Browse Source

Refactor ImportUtils and add additional tests

CLDC-1222-improve-case-log-import-performance
Mo Seedat 2 years ago
parent
commit
a232f5ec9b
  1. 14
      app/services/imports/import_service.rb
  2. 15
      app/services/imports/import_utils.rb
  3. 678
      app/services/imports/lettings_logs_import_processor.rb
  4. 673
      app/services/imports/lettings_logs_import_service.rb
  5. 33
      spec/jobs/lettings_log_import_job_spec.rb
  6. 32
      spec/services/imports/lettings_logs_field_import_service_spec.rb
  7. 248
      spec/services/imports/lettings_logs_import_processor_spec.rb

14
app/services/imports/import_service.rb

@ -1,18 +1,4 @@
module Imports
module ImportUtils
def field_value(xml_document, namespace, field)
xml_document.at_xpath("//#{namespace}:#{field}")&.text
end
def overridden?(xml_document, namespace, field)
xml_document.at_xpath("//#{namespace}:#{field}").attributes["override-field"].value
end
def to_boolean(input_string)
input_string == "true"
end
end
class ImportService
include Imports::ImportUtils

15
app/services/imports/import_utils.rb

@ -0,0 +1,15 @@
module Imports
module ImportUtils
def field_value(xml_document, namespace, field)
xml_document.at_xpath("//#{namespace}:#{field}")&.text
end
def overridden?(xml_document, namespace, field)
xml_document.at_xpath("//#{namespace}:#{field}").attributes["override-field"].value
end
def to_boolean(input_string)
input_string == "true"
end
end
end

678
app/services/imports/lettings_logs_import_processor.rb

@ -0,0 +1,678 @@
module Imports
class LettingsLogsImportProcessor
include ::Imports::ImportUtils
FORM_NAME_INDEX = {
start_year: 0,
rent_type: 2,
needs_type: 3,
}.freeze
GN_SH = {
general_needs: 1,
supported_housing: 2,
}.freeze
SR_AR_IR = {
social_rent: 1,
affordable_rent: 2,
intermediate_rent: 3,
}.freeze
# For providertype, values are reversed!!!
PRP_LA = {
private_registered_provider: 1,
local_authority: 2,
}.freeze
IRPRODUCT = {
rent_to_buy: 1,
london_living_rent: 2,
other_intermediate_rent_product: 3,
}.freeze
# TODO: RENT_TYPE enum values are referenced in lettings_log.rb
# but are described here. Similar situation with many other fields.
# Propose moving to LettingLog
#
# These must match our form
RENT_TYPE = {
social_rent: 0,
affordable_rent: 1,
london_affordable_rent: 2,
rent_to_buy: 3,
london_living_rent: 4,
other_intermediate_rent_product: 5,
}.freeze
IMPORT_MAPPING_SEX = {
"Male" => "M",
"Female" => "F",
"Other" => "X",
"Non-binary" => "X",
"Refused" => "R",
}.freeze
IMPORT_MAPPING_RELATION = {
"Child" => "C",
"Partner" => "P",
"Other" => "X",
"Non-binary" => "X",
"Refused" => "R",
}.freeze
FIELDS_NOT_PRESENT_IN_SOFTWIRE_DATA = %w[
housingneeds_other
housingneeds_type
illness_type_0
major_repairs_date_value_check
majorrepairs
net_income_value_check
pregnancy_value_check
rent_value_check
retirement_value_check
tshortfall_known
void_date_value_check
].freeze
attr_reader :xml_doc, :logs_overridden, :discrepancy, :old_id, :logger
def initialize(xml_document_as_string, logger = Rails.logger)
@xml_doc = Nokogiri::XML(xml_document_as_string)
@discrepancy = false
@old_id = ""
@logs_overridden = false
@logger = logger
create_log
end
def create_log
attributes = {}
previous_status = field_value(xml_doc, "meta", "status")
# Required fields for status complete or logic to work
# Note: order matters when we derive from previous values (attributes parameter)
attributes["startdate"] = compose_date(xml_doc, "DAY", "MONTH", "YEAR")
attributes["owning_organisation_id"] = find_organisation_id(xml_doc, "OWNINGORGID")
attributes["managing_organisation_id"] = find_organisation_id(xml_doc, "MANINGORGID")
attributes["joint"] = unsafe_string_as_integer(xml_doc, "joint")
attributes["startertenancy"] = unsafe_string_as_integer(xml_doc, "_2a")
attributes["tenancy"] = unsafe_string_as_integer(xml_doc, "Q2b")
attributes["tenancycode"] = string_or_nil(xml_doc, "_2bTenCode")
attributes["tenancyother"] = string_or_nil(xml_doc, "Q2ba")
attributes["tenancylength"] = safe_string_as_integer(xml_doc, "_2cYears")
attributes["needstype"] = needs_type(xml_doc)
attributes["lar"] = london_affordable_rent(xml_doc)
attributes["irproduct"] = unsafe_string_as_integer(xml_doc, "IRProduct")
attributes["irproduct_other"] = string_or_nil(xml_doc, "IRProductOther")
attributes["rent_type"] = rent_type(xml_doc, attributes["lar"], attributes["irproduct"])
attributes["hhmemb"] = household_members(xml_doc, previous_status)
(1..8).each do |index|
attributes["age#{index}"] = safe_string_as_integer(xml_doc, "P#{index}Age")
attributes["age#{index}_known"] = age_known(xml_doc, index, attributes["hhmemb"])
attributes["sex#{index}"] = sex(xml_doc, index)
attributes["ecstat#{index}"] = unsafe_string_as_integer(xml_doc, "P#{index}Eco")
end
(2..8).each do |index|
attributes["relat#{index}"] = relat(xml_doc, index)
attributes["details_known_#{index}"] = details_known(index, attributes)
# Trips validation
if attributes["age#{index}"].present? && attributes["age#{index}"] < 16 && attributes["relat#{index}"].present? && attributes["relat#{index}"] != "C" && attributes["relat#{index}"] != "R"
attributes["age#{index}"] = nil
attributes["relat#{index}"] = nil
end
end
attributes["ethnic"] = unsafe_string_as_integer(xml_doc, "P1Eth")
attributes["ethnic_group"] = ethnic_group(attributes["ethnic"])
attributes["national"] = unsafe_string_as_integer(xml_doc, "P1Nat")
attributes["preg_occ"] = unsafe_string_as_integer(xml_doc, "Preg")
attributes["armedforces"] = unsafe_string_as_integer(xml_doc, "ArmedF")
attributes["leftreg"] = unsafe_string_as_integer(xml_doc, "LeftAF")
attributes["reservist"] = unsafe_string_as_integer(xml_doc, "Inj")
attributes["hb"] = unsafe_string_as_integer(xml_doc, "Q6Ben")
attributes["benefits"] = unsafe_string_as_integer(xml_doc, "Q7Ben")
attributes["earnings"] = safe_string_as_decimal(xml_doc, "Q8Money")
attributes["net_income_known"] = net_income_known(xml_doc, attributes["earnings"])
attributes["incfreq"] = unsafe_string_as_integer(xml_doc, "Q8a")
attributes["reason"] = unsafe_string_as_integer(xml_doc, "Q9a")
attributes["reasonother"] = string_or_nil(xml_doc, "Q9aa")
attributes["underoccupation_benefitcap"] = unsafe_string_as_integer(xml_doc, "_9b")
%w[a b c f g h].each do |letter|
attributes["housingneeds_#{letter}"] = housing_needs(xml_doc, letter)
end
attributes["housingneeds"] = 1 if [attributes["housingneeds_a"], attributes["housingneeds_b"], attributes["housingneeds_c"], attributes["housingneeds_f"]].any? { |housingneed| housingneed == 1 }
attributes["housingneeds"] = 2 if attributes["housingneeds_g"] == 1
attributes["housingneeds"] = 3 if attributes["housingneeds_h"] == 1
attributes["housingneeds_type"] = 0 if attributes["housingneeds_a"] == 1
attributes["housingneeds_type"] = 1 if attributes["housingneeds_b"] == 1
attributes["housingneeds_type"] = 2 if attributes["housingneeds_c"] == 1
attributes["housingneeds_type"] = 3 if attributes["housingneeds_f"] == 1 && [attributes["housingneeds_a"], attributes["housingneeds_b"], attributes["housingneeds_c"]].all? { |housingneed| housingneed != 1 }
attributes["housingneeds_other"] = attributes["housingneeds_f"] == 1 ? 1 : 0
attributes["illness"] = unsafe_string_as_integer(xml_doc, "Q10ia")
(1..10).each do |index|
attributes["illness_type_#{index}"] = illness_type(xml_doc, index, attributes["illness"])
end
attributes["illness_type_0"] = 1 if (1..10).all? { |idx| attributes["illness_type_#{idx}"].nil? || attributes["illness_type_#{idx}"].zero? }
attributes["prevten"] = unsafe_string_as_integer(xml_doc, "Q11")
attributes["prevloc"] = string_or_nil(xml_doc, "Q12aONS")
attributes["ppostcode_full"] = compose_postcode(xml_doc, "PPOSTC1", "PPOSTC2")
attributes["ppcodenk"] = previous_postcode_known(xml_doc, attributes["ppostcode_full"], attributes["prevloc"])
attributes["layear"] = unsafe_string_as_integer(xml_doc, "Q12c")
attributes["waityear"] = unsafe_string_as_integer(xml_doc, "Q12d")
attributes["homeless"] = unsafe_string_as_integer(xml_doc, "Q13")
attributes["reasonpref"] = unsafe_string_as_integer(xml_doc, "Q14a")
attributes["rp_homeless"] = unsafe_string_as_integer(xml_doc, "Q14b1").present? ? 1 : nil
attributes["rp_insan_unsat"] = unsafe_string_as_integer(xml_doc, "Q14b2").present? ? 1 : nil
attributes["rp_medwel"] = unsafe_string_as_integer(xml_doc, "Q14b3").present? ? 1 : nil
attributes["rp_hardship"] = unsafe_string_as_integer(xml_doc, "Q14b4").present? ? 1 : nil
attributes["rp_dontknow"] = unsafe_string_as_integer(xml_doc, "Q14b5").present? ? 1 : nil
# Trips validation
if attributes["homeless"] == 1 && attributes["rp_homeless"] == 1
attributes["homeless"] = nil
attributes["rp_homeless"] = nil
end
attributes["cbl"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CBL"))
attributes["chr"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CHR"))
attributes["cap"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CAP"))
attributes["letting_allocation_unknown"] = allocation_system_unknown(attributes["cbl"], attributes["chr"], attributes["cap"])
attributes["referral"] = unsafe_string_as_integer(xml_doc, "Q16")
attributes["period"] = unsafe_string_as_integer(xml_doc, "Q17")
attributes["brent"] = safe_string_as_decimal(xml_doc, "Q18ai")
attributes["scharge"] = safe_string_as_decimal(xml_doc, "Q18aii")
attributes["pscharge"] = safe_string_as_decimal(xml_doc, "Q18aiii")
attributes["supcharg"] = safe_string_as_decimal(xml_doc, "Q18aiv")
attributes["tcharge"] = safe_string_as_decimal(xml_doc, "Q18av")
attributes["hbrentshortfall"] = unsafe_string_as_integer(xml_doc, "Q18d")
attributes["tshortfall"] = safe_string_as_decimal(xml_doc, "Q18dyes")
attributes["tshortfall_known"] = tshortfall_known?(xml_doc, attributes)
attributes["voiddate"] = compose_date(xml_doc, "VDAY", "VMONTH", "VYEAR")
attributes["mrcdate"] = compose_date(xml_doc, "MRCDAY", "MRCMONTH", "MRCYEAR")
attributes["majorrepairs"] = if attributes["mrcdate"].present? && previous_status.include?("submitted")
1
elsif previous_status.include?("submitted")
0
end
attributes["offered"] = safe_string_as_integer(xml_doc, "Q20")
attributes["propcode"] = string_or_nil(xml_doc, "Q21a")
attributes["beds"] = safe_string_as_integer(xml_doc, "Q22")
attributes["unittype_gn"] = unsafe_string_as_integer(xml_doc, "Q23")
attributes["builtype"] = unsafe_string_as_integer(xml_doc, "Q24")
attributes["wchair"] = unsafe_string_as_integer(xml_doc, "Q25")
attributes["unitletas"] = unsafe_string_as_integer(xml_doc, "Q26")
attributes["rsnvac"] = unsafe_string_as_integer(xml_doc, "Q27")
attributes["renewal"] = renewal(attributes["rsnvac"])
attributes["la"] = string_or_nil(xml_doc, "Q28ONS")
attributes["postcode_full"] = compose_postcode(xml_doc, "POSTCODE", "POSTCOD2")
attributes["postcode_known"] = postcode_known(attributes)
# Not specific to our form but required for consistency (present in import)
attributes["old_form_id"] = safe_string_as_integer(xml_doc, "FORM")
attributes["created_at"] = Time.zone.parse(field_value(xml_doc, "meta", "created-date"))
attributes["updated_at"] = Time.zone.parse(field_value(xml_doc, "meta", "modified-date"))
attributes["old_id"] = field_value(xml_doc, "meta", "document-id")
@old_id = attributes["old_id"]
# Required for our form invalidated questions (not present in import)
attributes["previous_la_known"] = attributes["prevloc"].nil? ? 0 : 1
attributes["is_la_inferred"] = attributes["postcode_full"].present?
attributes["first_time_property_let_as_social_housing"] = first_time_let(attributes["rsnvac"])
attributes["declaration"] = declaration(xml_doc)
set_partial_charges_to_zero(attributes)
# Supported Housing fields
if attributes["needstype"] == GN_SH[:supported_housing]
location_old_visible_id = safe_string_as_integer(xml_doc, "_1cschemecode")
scheme_old_visible_id = safe_string_as_integer(xml_doc, "_1cmangroupcode")
schemes = Scheme.where(old_visible_id: scheme_old_visible_id, owning_organisation_id: attributes["owning_organisation_id"])
location = Location.find_by(old_visible_id: location_old_visible_id, scheme: schemes)
raise "No matching location for scheme #{scheme_old_visible_id} and location #{location_old_visible_id} (visible IDs)" if location.nil?
# Set the scheme via location, because the scheme old visible ID can be duplicated at import
attributes["location_id"] = location.id
attributes["scheme_id"] = location.scheme.id
attributes["sheltered"] = unsafe_string_as_integer(xml_doc, "Q1e")
attributes["chcharge"] = safe_string_as_decimal(xml_doc, "Q18b")
attributes["household_charge"] = household_charge(xml_doc)
attributes["is_carehome"] = is_carehome(location.scheme)
end
# Handles confidential schemes
if attributes["postcode_full"] == "******"
attributes["postcode_known"] = 0
attributes["postcode_full"] = nil
end
# Soft validations can become required answers, set them to yes by default
attributes["pregnancy_value_check"] = 0
attributes["major_repairs_date_value_check"] = 0
attributes["void_date_value_check"] = 0
attributes["retirement_value_check"] = 0
attributes["rent_value_check"] = 0
attributes["net_income_value_check"] = 0
# Sets the log creator
owner_id = field_value(xml_doc, "meta", "owner-user-id").strip
if owner_id.present?
user = LegacyUser.find_by(old_user_id: owner_id)&.user
@logger.warn "Missing user! We expected to find a legacy user with old_user_id #{owner_id}" unless user
attributes["created_by"] = user
end
apply_date_consistency!(attributes)
apply_household_consistency!(attributes)
lettings_log = save_lettings_log(attributes)
compute_differences(lettings_log, attributes)
check_status_completed(lettings_log, previous_status)
end
def save_lettings_log(attributes)
lettings_log = LettingsLog.new(attributes)
begin
lettings_log.save!
lettings_log
rescue ActiveRecord::RecordNotUnique
legacy_id = attributes["old_id"]
record = LettingsLog.find_by(old_id: legacy_id)
@logger.info "Updating lettings log #{record.id} with legacy ID #{legacy_id}"
record.update!(attributes)
record
rescue ActiveRecord::RecordInvalid => e
rescue_validation_or_raise(lettings_log, attributes, e)
end
end
def rescue_validation_or_raise(lettings_log, attributes, exception)
if lettings_log.errors.of_kind?(:referral, :internal_transfer_non_social_housing)
@logger.warn("Log #{lettings_log.old_id}: Removing internal transfer referral since previous tenancy is a non social housing")
@logs_overridden = true
attributes.delete("referral")
save_lettings_log(attributes)
elsif lettings_log.errors.of_kind?(:referral, :internal_transfer_fixed_or_lifetime)
@logger.warn("Log #{lettings_log.old_id}: Removing internal transfer referral since previous tenancy is fixed terms or lifetime")
@logs_overridden = true
attributes.delete("referral")
save_lettings_log(attributes)
else
@logger.error("[rescue_validation_or_raise] No actionable error for exception: #{exception.message}")
raise exception
end
end
def compute_differences(lettings_log, attributes)
differences = attributes.map do |key, value|
lettings_log_value = lettings_log.send(key.to_sym)
next if FIELDS_NOT_PRESENT_IN_SOFTWIRE_DATA.include?(key)
next if value == lettings_log_value
"#{key} #{value.inspect} #{lettings_log_value.inspect}"
end.compact
if differences.any?
@logger.warn "Differences found when saving log #{lettings_log.old_id}: #{differences}"
end
differences
end
# @logs_overridden can only include?(lettings_log.old_id) if there was a
# validation error raised therefore no need to do @logs_overridden.include? but rather
# enough to set a flag for logs_overriden
def check_status_completed(lettings_log, previous_status)
return if @logs_overridden
if previous_status.include?("submitted") && lettings_log.status != "completed"
@logger.warn "DISCREPENCY lettings log #{lettings_log.id} is not completed"
@logger.warn "DISCREPENCY lettings log with old id:#{lettings_log.old_id} is incomplete but status should be complete"
@discrepancy = true
end
end
# Safe: A string that represents only an integer (or empty/nil)
def safe_string_as_integer(xml_doc, attribute)
str = field_value(xml_doc, "xmlns", attribute)
Integer(str, exception: false)
end
# Safe: A string that represents only a decimal (or empty/nil)
def safe_string_as_decimal(xml_doc, attribute)
str = string_or_nil(xml_doc, attribute)
return if str.nil?
BigDecimal(str, exception: false)
end
# Unsafe: A string that has more than just the integer value
def unsafe_string_as_integer(xml_doc, attribute)
str = string_or_nil(xml_doc, attribute)
return if str.nil?
str.to_i
end
def compose_date(xml_doc, day_str, month_str, year_str)
day = Integer(field_value(xml_doc, "xmlns", day_str), exception: false)
month = Integer(field_value(xml_doc, "xmlns", month_str), exception: false)
year = Integer(field_value(xml_doc, "xmlns", year_str), exception: false)
return if [day, month, year].any?(&:nil?)
Time.zone.local(year, month, day)
end
def get_form_name_component(xml_doc, index)
form_name = field_value(xml_doc, "meta", "form-name")
form_type_components = form_name.split("-")
form_type_components[index]
end
def needs_type(xml_doc)
gn_sh = get_form_name_component(xml_doc, FORM_NAME_INDEX[:needs_type])
case gn_sh
when "GN"
GN_SH[:general_needs]
when "SH"
GN_SH[:supported_housing]
else
raise "Unknown needstype value: #{gn_sh}"
end
end
# This does not match renttype (CDS) which is derived by lettings log logic
def rent_type(xml_doc, lar, irproduct)
sr_ar_ir = get_form_name_component(xml_doc, FORM_NAME_INDEX[:rent_type])
case sr_ar_ir
when "SR"
RENT_TYPE[:social_rent]
when "AR"
if lar == 1
RENT_TYPE[:london_affordable_rent]
else
RENT_TYPE[:affordable_rent]
end
when "IR"
if irproduct == IRPRODUCT[:rent_to_buy]
RENT_TYPE[:rent_to_buy]
elsif irproduct == IRPRODUCT[:london_living_rent]
RENT_TYPE[:london_living_rent]
elsif irproduct == IRPRODUCT[:other_intermediate_rent_product]
RENT_TYPE[:other_intermediate_rent_product]
end
else
raise "Could not infer rent type with '#{sr_ar_ir}'"
end
end
def find_organisation_id(xml_doc, id_field)
old_visible_id = unsafe_string_as_integer(xml_doc, id_field)
organisation = Organisation.find_by(old_visible_id:)
raise "Organisation not found with legacy ID #{old_visible_id}" if organisation.nil?
organisation.id
end
def sex(xml_doc, index)
unmapped_sex = string_or_nil(xml_doc, "P#{index}Sex")
IMPORT_MAPPING_SEX[unmapped_sex]
end
def relat(xml_doc, index)
unmapped_relation = string_or_nil(xml_doc, "P#{index}Rel")
IMPORT_MAPPING_RELATION[unmapped_relation]
end
def age_known(xml_doc, index, hhmemb)
return nil if hhmemb.present? && index > hhmemb
age_refused = string_or_nil(xml_doc, "P#{index}AR")
return 0 if age_refused.blank?
age_refused.casecmp?("AGE_REFUSED") || age_refused.casecmp?("No") ? 1 : 0
end
def details_known(index, attributes)
return nil if attributes["hhmemb"].nil? || index > attributes["hhmemb"]
if attributes["age#{index}_known"] == 1 &&
attributes["sex#{index}"] == "R" &&
attributes["relat#{index}"] == "R" &&
attributes["ecstat#{index}"] == 10
1 # No
else
0 # Yes
end
end
def previous_postcode_known(xml_doc, previous_postcode, prevloc)
previous_postcode_known = string_or_nil(xml_doc, "Q12bnot")
if previous_postcode_known == "Temporary_or_Unknown" || (previous_postcode.nil? && prevloc.present?)
0
elsif previous_postcode.nil?
nil
else
1
end
end
def compose_postcode(xml_doc, outcode, incode)
outcode_value = string_or_nil(xml_doc, outcode)
incode_value = string_or_nil(xml_doc, incode)
if outcode_value.nil? || incode_value.nil? || !"#{outcode_value} #{incode_value}".match(POSTCODE_REGEXP)
nil
else
"#{outcode_value} #{incode_value}"
end
end
# Default to No (2) for any other values (nil, not known)
def london_affordable_rent(xml_doc)
unsafe_string_as_integer(xml_doc, "LAR") == 1 ? 1 : 2
end
# Relet – renewal of fixed-term tenancy
def renewal(rsnvac)
rsnvac == 14 ? 1 : 0
end
def string_or_nil(xml_doc, attribute)
str = field_value(xml_doc, "xmlns", attribute)
str.presence
end
def ethnic_group(ethnic)
case ethnic
when 1, 2, 3, 18
# White
0
when 4, 5, 6, 7
# Mixed
1
when 8, 9, 10, 11, 15
# Asian
2
when 12, 13, 14
# Black
3
when 16, 19
# Others
4
when 17
# Refused
17
end
end
# Letters should be lowercase to match case
def housing_needs(xml_doc, letter)
string_or_nil(xml_doc, "Q10-#{letter}") == "Yes" ? 1 : 0
end
def net_income_known(xml_doc, earnings)
incref = string_or_nil(xml_doc, "Q8Refused")
if incref == "Refused"
# Tenant prefers not to say
2
elsif earnings.nil?
# No
1
else
# Yes
0
end
end
def illness_type(xml_doc, index, illness)
illness_type = string_or_nil(xml_doc, "Q10ib-#{index}")
if illness_type == "Yes" && illness == 1
1
elsif illness == 1
0
end
end
def first_time_let(rsnvac)
if [15, 16, 17].include?(rsnvac)
1
else
0
end
end
def declaration(xml_doc)
declaration = string_or_nil(xml_doc, "Qdp")
if declaration == "Yes"
1
end
end
def postcode_known(attributes)
if attributes["postcode_full"].nil? && attributes["la"].nil?
nil
elsif attributes["postcode_full"].nil?
0 # Assumes we selected No in the form since the LA is present
else
1
end
end
def household_members(xml_doc, previous_status)
hhmemb = safe_string_as_integer(xml_doc, "HHMEMB")
if previous_status.include?("submitted") && hhmemb.nil?
hhmemb = people_with_details(xml_doc).count
return nil if hhmemb.zero?
end
hhmemb
end
def people_with_details(xml_doc)
((2..8).map { |x| string_or_nil(xml_doc, "P#{x}Rel") } + [string_or_nil(xml_doc, "P1Sex")]).compact
end
def tshortfall_known?(xml_doc, attributes)
if attributes["tshortfall"].blank? && attributes["hbrentshortfall"] == 1 && overridden?(xml_doc, "xmlns", "Q18dyes")
1
else
0
end
end
def allocation_system(value)
value == 1 ? 1 : 0
end
def allocation_system_unknown(cbl, chr, cap)
allocation_values = [cbl, chr, cap]
if allocation_values.all?(&:nil?)
nil
elsif allocation_values.all? { |att| att&.zero? }
1
else
0
end
end
def apply_date_consistency!(attributes)
return if attributes["voiddate"].nil? || attributes["startdate"].nil?
if attributes["voiddate"] > attributes["startdate"]
attributes.delete("voiddate")
end
end
def apply_household_consistency!(attributes)
(2..8).each do |index|
next if attributes["age#{index}"].nil?
if attributes["age#{index}"] < 16 && attributes["relat#{index}"] == "R"
attributes["relat#{index}"] = "C"
end
end
end
def household_charge(xml_doc)
value = string_or_nil(xml_doc, "Q18c")
start_year = Integer(get_form_name_component(xml_doc, FORM_NAME_INDEX[:start_year]))
if start_year <= 2021
# Yes means that there are no charges (2021 or earlier)
value && value.include?("Yes") ? 1 : 0
else
# Yes means that there are charges (2022 onwards)
value && value.include?("Yes") ? 0 : 1
end
end
def set_partial_charges_to_zero(attributes)
unless attributes["brent"].nil? &&
attributes["scharge"].nil? &&
attributes["pscharge"].nil? &&
attributes["supcharg"].nil?
attributes["brent"] ||= BigDecimal("0.0")
attributes["scharge"] ||= BigDecimal("0.0")
attributes["pscharge"] ||= BigDecimal("0.0")
attributes["supcharg"] ||= BigDecimal("0.0")
end
end
def is_carehome(scheme)
return nil unless scheme
if [2, 3, 4].include?(scheme.registered_under_care_act_before_type_cast)
1
else
0
end
end
def discrepancy?
!!@discrepancy
end
end
end

673
app/services/imports/lettings_logs_import_service.rb

@ -5,7 +5,7 @@ module Imports
end
def create_logs(folder)
@run_id = "LLRun-#{Time.zone.now}"
@run_id = "LLRun-#{Time.zone.now.strftime('%d%m%Y%H%M')}"
@logger.info("START: Importing Lettings Logs @ #{Time.zone.now.strftime('%d-%m-%Y %H:%M')}. RunId: #{@run_id}")
import_from(folder, :enqueue_job)
@ -17,675 +17,4 @@ module Imports
LettingsLogImportJob.perform_later(@run_id, xml_document.to_s)
end
end
class LettingsLogsImportProcessor
include Imports::ImportUtils
FORM_NAME_INDEX = {
start_year: 0,
rent_type: 2,
needs_type: 3,
}.freeze
GN_SH = {
general_needs: 1,
supported_housing: 2,
}.freeze
SR_AR_IR = {
social_rent: 1,
affordable_rent: 2,
intermediate_rent: 3,
}.freeze
# For providertype, values are reversed!!!
PRP_LA = {
private_registered_provider: 1,
local_authority: 2,
}.freeze
IRPRODUCT = {
rent_to_buy: 1,
london_living_rent: 2,
other_intermediate_rent_product: 3,
}.freeze
# TODO: RENT_TYPE enum values are referenced in lettings_log.rb
# but are described here. Similar situation with many other fields.
# Propose moving to LettingLog
#
# These must match our form
RENT_TYPE = {
social_rent: 0,
affordable_rent: 1,
london_affordable_rent: 2,
rent_to_buy: 3,
london_living_rent: 4,
other_intermediate_rent_product: 5,
}.freeze
IMPORT_MAPPING_SEX = {
"Male" => "M",
"Female" => "F",
"Other" => "X",
"Non-binary" => "X",
"Refused" => "R",
}.freeze
IMPORT_MAPPING_RELATION = {
"Child" => "C",
"Partner" => "P",
"Other" => "X",
"Non-binary" => "X",
"Refused" => "R",
}.freeze
FIELDS_NOT_PRESENT_IN_SOFTWIRE_DATA = %w[
housingneeds_other
housingneeds_type
illness_type_0
major_repairs_date_value_check
majorrepairs
net_income_value_check
pregnancy_value_check
rent_value_check
retirement_value_check
tshortfall_known
void_date_value_check
].freeze
attr_reader :xml_doc, :logs_overridden, :discrepancy, :old_id, :logger
def initialize(xml_document_as_string, logger = Rails.logger)
@xml_doc = Nokogiri::XML(xml_document_as_string)
@discrepancy = false
@old_id = ""
@logs_overridden = false
@logger = logger
create_log
end
def create_log
attributes = {}
previous_status = field_value(xml_doc, "meta", "status")
# Required fields for status complete or logic to work
# Note: order matters when we derive from previous values (attributes parameter)
attributes["startdate"] = compose_date(xml_doc, "DAY", "MONTH", "YEAR")
attributes["owning_organisation_id"] = find_organisation_id(xml_doc, "OWNINGORGID")
attributes["managing_organisation_id"] = find_organisation_id(xml_doc, "MANINGORGID")
attributes["joint"] = unsafe_string_as_integer(xml_doc, "joint")
attributes["startertenancy"] = unsafe_string_as_integer(xml_doc, "_2a")
attributes["tenancy"] = unsafe_string_as_integer(xml_doc, "Q2b")
attributes["tenancycode"] = string_or_nil(xml_doc, "_2bTenCode")
attributes["tenancyother"] = string_or_nil(xml_doc, "Q2ba")
attributes["tenancylength"] = safe_string_as_integer(xml_doc, "_2cYears")
attributes["needstype"] = needs_type(xml_doc)
attributes["lar"] = london_affordable_rent(xml_doc)
attributes["irproduct"] = unsafe_string_as_integer(xml_doc, "IRProduct")
attributes["irproduct_other"] = string_or_nil(xml_doc, "IRProductOther")
attributes["rent_type"] = rent_type(xml_doc, attributes["lar"], attributes["irproduct"])
attributes["hhmemb"] = household_members(xml_doc, previous_status)
(1..8).each do |index|
attributes["age#{index}"] = safe_string_as_integer(xml_doc, "P#{index}Age")
attributes["age#{index}_known"] = age_known(xml_doc, index, attributes["hhmemb"])
attributes["sex#{index}"] = sex(xml_doc, index)
attributes["ecstat#{index}"] = unsafe_string_as_integer(xml_doc, "P#{index}Eco")
end
(2..8).each do |index|
attributes["relat#{index}"] = relat(xml_doc, index)
attributes["details_known_#{index}"] = details_known(index, attributes)
# Trips validation
if attributes["age#{index}"].present? && attributes["age#{index}"] < 16 && attributes["relat#{index}"].present? && attributes["relat#{index}"] != "C" && attributes["relat#{index}"] != "R"
attributes["age#{index}"] = nil
attributes["relat#{index}"] = nil
end
end
attributes["ethnic"] = unsafe_string_as_integer(xml_doc, "P1Eth")
attributes["ethnic_group"] = ethnic_group(attributes["ethnic"])
attributes["national"] = unsafe_string_as_integer(xml_doc, "P1Nat")
attributes["preg_occ"] = unsafe_string_as_integer(xml_doc, "Preg")
attributes["armedforces"] = unsafe_string_as_integer(xml_doc, "ArmedF")
attributes["leftreg"] = unsafe_string_as_integer(xml_doc, "LeftAF")
attributes["reservist"] = unsafe_string_as_integer(xml_doc, "Inj")
attributes["hb"] = unsafe_string_as_integer(xml_doc, "Q6Ben")
attributes["benefits"] = unsafe_string_as_integer(xml_doc, "Q7Ben")
attributes["earnings"] = safe_string_as_decimal(xml_doc, "Q8Money")
attributes["net_income_known"] = net_income_known(xml_doc, attributes["earnings"])
attributes["incfreq"] = unsafe_string_as_integer(xml_doc, "Q8a")
attributes["reason"] = unsafe_string_as_integer(xml_doc, "Q9a")
attributes["reasonother"] = string_or_nil(xml_doc, "Q9aa")
attributes["underoccupation_benefitcap"] = unsafe_string_as_integer(xml_doc, "_9b")
%w[a b c f g h].each do |letter|
attributes["housingneeds_#{letter}"] = housing_needs(xml_doc, letter)
end
attributes["housingneeds"] = 1 if [attributes["housingneeds_a"], attributes["housingneeds_b"], attributes["housingneeds_c"], attributes["housingneeds_f"]].any? { |housingneed| housingneed == 1 }
attributes["housingneeds"] = 2 if attributes["housingneeds_g"] == 1
attributes["housingneeds"] = 3 if attributes["housingneeds_h"] == 1
attributes["housingneeds_type"] = 0 if attributes["housingneeds_a"] == 1
attributes["housingneeds_type"] = 1 if attributes["housingneeds_b"] == 1
attributes["housingneeds_type"] = 2 if attributes["housingneeds_c"] == 1
attributes["housingneeds_type"] = 3 if attributes["housingneeds_f"] == 1 && [attributes["housingneeds_a"], attributes["housingneeds_b"], attributes["housingneeds_c"]].all? { |housingneed| housingneed != 1 }
attributes["housingneeds_other"] = attributes["housingneeds_f"] == 1 ? 1 : 0
attributes["illness"] = unsafe_string_as_integer(xml_doc, "Q10ia")
(1..10).each do |index|
attributes["illness_type_#{index}"] = illness_type(xml_doc, index, attributes["illness"])
end
attributes["illness_type_0"] = 1 if (1..10).all? { |idx| attributes["illness_type_#{idx}"].nil? || attributes["illness_type_#{idx}"].zero? }
attributes["prevten"] = unsafe_string_as_integer(xml_doc, "Q11")
attributes["prevloc"] = string_or_nil(xml_doc, "Q12aONS")
attributes["ppostcode_full"] = compose_postcode(xml_doc, "PPOSTC1", "PPOSTC2")
attributes["ppcodenk"] = previous_postcode_known(xml_doc, attributes["ppostcode_full"], attributes["prevloc"])
attributes["layear"] = unsafe_string_as_integer(xml_doc, "Q12c")
attributes["waityear"] = unsafe_string_as_integer(xml_doc, "Q12d")
attributes["homeless"] = unsafe_string_as_integer(xml_doc, "Q13")
attributes["reasonpref"] = unsafe_string_as_integer(xml_doc, "Q14a")
attributes["rp_homeless"] = unsafe_string_as_integer(xml_doc, "Q14b1").present? ? 1 : nil
attributes["rp_insan_unsat"] = unsafe_string_as_integer(xml_doc, "Q14b2").present? ? 1 : nil
attributes["rp_medwel"] = unsafe_string_as_integer(xml_doc, "Q14b3").present? ? 1 : nil
attributes["rp_hardship"] = unsafe_string_as_integer(xml_doc, "Q14b4").present? ? 1 : nil
attributes["rp_dontknow"] = unsafe_string_as_integer(xml_doc, "Q14b5").present? ? 1 : nil
# Trips validation
if attributes["homeless"] == 1 && attributes["rp_homeless"] == 1
attributes["homeless"] = nil
attributes["rp_homeless"] = nil
end
attributes["cbl"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CBL"))
attributes["chr"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CHR"))
attributes["cap"] = allocation_system(unsafe_string_as_integer(xml_doc, "Q15CAP"))
attributes["letting_allocation_unknown"] = allocation_system_unknown(attributes["cbl"], attributes["chr"], attributes["cap"])
attributes["referral"] = unsafe_string_as_integer(xml_doc, "Q16")
attributes["period"] = unsafe_string_as_integer(xml_doc, "Q17")
attributes["brent"] = safe_string_as_decimal(xml_doc, "Q18ai")
attributes["scharge"] = safe_string_as_decimal(xml_doc, "Q18aii")
attributes["pscharge"] = safe_string_as_decimal(xml_doc, "Q18aiii")
attributes["supcharg"] = safe_string_as_decimal(xml_doc, "Q18aiv")
attributes["tcharge"] = safe_string_as_decimal(xml_doc, "Q18av")
attributes["hbrentshortfall"] = unsafe_string_as_integer(xml_doc, "Q18d")
attributes["tshortfall"] = safe_string_as_decimal(xml_doc, "Q18dyes")
attributes["tshortfall_known"] = tshortfall_known?(xml_doc, attributes)
attributes["voiddate"] = compose_date(xml_doc, "VDAY", "VMONTH", "VYEAR")
attributes["mrcdate"] = compose_date(xml_doc, "MRCDAY", "MRCMONTH", "MRCYEAR")
attributes["majorrepairs"] = if attributes["mrcdate"].present? && previous_status.include?("submitted")
1
elsif previous_status.include?("submitted")
0
end
attributes["offered"] = safe_string_as_integer(xml_doc, "Q20")
attributes["propcode"] = string_or_nil(xml_doc, "Q21a")
attributes["beds"] = safe_string_as_integer(xml_doc, "Q22")
attributes["unittype_gn"] = unsafe_string_as_integer(xml_doc, "Q23")
attributes["builtype"] = unsafe_string_as_integer(xml_doc, "Q24")
attributes["wchair"] = unsafe_string_as_integer(xml_doc, "Q25")
attributes["unitletas"] = unsafe_string_as_integer(xml_doc, "Q26")
attributes["rsnvac"] = unsafe_string_as_integer(xml_doc, "Q27")
attributes["renewal"] = renewal(attributes["rsnvac"])
attributes["la"] = string_or_nil(xml_doc, "Q28ONS")
attributes["postcode_full"] = compose_postcode(xml_doc, "POSTCODE", "POSTCOD2")
attributes["postcode_known"] = postcode_known(attributes)
# Not specific to our form but required for consistency (present in import)
attributes["old_form_id"] = safe_string_as_integer(xml_doc, "FORM")
attributes["created_at"] = Time.zone.parse(field_value(xml_doc, "meta", "created-date"))
attributes["updated_at"] = Time.zone.parse(field_value(xml_doc, "meta", "modified-date"))
attributes["old_id"] = field_value(xml_doc, "meta", "document-id")
@old_id = attributes["old_id"]
# Required for our form invalidated questions (not present in import)
attributes["previous_la_known"] = attributes["prevloc"].nil? ? 0 : 1
attributes["is_la_inferred"] = attributes["postcode_full"].present?
attributes["first_time_property_let_as_social_housing"] = first_time_let(attributes["rsnvac"])
attributes["declaration"] = declaration(xml_doc)
set_partial_charges_to_zero(attributes)
# Supported Housing fields
if attributes["needstype"] == GN_SH[:supported_housing]
location_old_visible_id = safe_string_as_integer(xml_doc, "_1cschemecode")
scheme_old_visible_id = safe_string_as_integer(xml_doc, "_1cmangroupcode")
schemes = Scheme.where(old_visible_id: scheme_old_visible_id, owning_organisation_id: attributes["owning_organisation_id"])
location = Location.find_by(old_visible_id: location_old_visible_id, scheme: schemes)
raise "No matching location for scheme #{scheme_old_visible_id} and location #{location_old_visible_id} (visible IDs)" if location.nil?
# Set the scheme via location, because the scheme old visible ID can be duplicated at import
attributes["location_id"] = location.id
attributes["scheme_id"] = location.scheme.id
attributes["sheltered"] = unsafe_string_as_integer(xml_doc, "Q1e")
attributes["chcharge"] = safe_string_as_decimal(xml_doc, "Q18b")
attributes["household_charge"] = household_charge(xml_doc)
attributes["is_carehome"] = is_carehome(location.scheme)
end
# Handles confidential schemes
if attributes["postcode_full"] == "******"
attributes["postcode_known"] = 0
attributes["postcode_full"] = nil
end
# Soft validations can become required answers, set them to yes by default
attributes["pregnancy_value_check"] = 0
attributes["major_repairs_date_value_check"] = 0
attributes["void_date_value_check"] = 0
attributes["retirement_value_check"] = 0
attributes["rent_value_check"] = 0
attributes["net_income_value_check"] = 0
# Sets the log creator
owner_id = field_value(xml_doc, "meta", "owner-user-id").strip
if owner_id.present?
user = LegacyUser.find_by(old_user_id: owner_id)&.user
@logger.warn "Missing user! We expected to find a legacy user with old_user_id #{owner_id}" unless user
attributes["created_by"] = user
end
apply_date_consistency!(attributes)
apply_household_consistency!(attributes)
lettings_log = save_lettings_log(attributes)
compute_differences(lettings_log, attributes)
check_status_completed(lettings_log, previous_status)
end
def save_lettings_log(attributes)
lettings_log = LettingsLog.new(attributes)
begin
lettings_log.save!
lettings_log
rescue ActiveRecord::RecordNotUnique
legacy_id = attributes["old_id"]
record = LettingsLog.find_by(old_id: legacy_id)
@logger.info "Updating lettings log #{record.id} with legacy ID #{legacy_id}"
record.update!(attributes)
record
rescue ActiveRecord::RecordInvalid => e
rescue_validation_or_raise(lettings_log, attributes, e)
end
end
def rescue_validation_or_raise(lettings_log, attributes, exception)
if lettings_log.errors.of_kind?(:referral, :internal_transfer_non_social_housing)
@logger.warn("Log #{lettings_log.old_id}: Removing internal transfer referral since previous tenancy is a non social housing")
@logs_overridden = true
attributes.delete("referral")
save_lettings_log(attributes)
elsif lettings_log.errors.of_kind?(:referral, :internal_transfer_fixed_or_lifetime)
@logger.warn("Log #{lettings_log.old_id}: Removing internal transfer referral since previous tenancy is fixed terms or lifetime")
@logs_overridden = true
attributes.delete("referral")
save_lettings_log(attributes)
else
@logger.error("[rescue_validation_or_raise] No actionable error for exception: #{exception.message}")
raise exception
end
end
def compute_differences(lettings_log, attributes)
differences = []
puts "DIFFERENCES: Attributes => #{attributes}"
attributes.each do |key, value|
lettings_log_value = lettings_log.send(key.to_sym)
next if FIELDS_NOT_PRESENT_IN_SOFTWIRE_DATA.include?(key)
if value != lettings_log_value
differences.push("#{key} #{value.inspect} #{lettings_log_value.inspect}")
end
end
byebug
@logger.warn "Differences found when saving log #{lettings_log.old_id}: #{differences}" unless differences.empty?
end
# @logs_overridden can only include?(lettings_log.old_id) if there was a
# validation error raised therefore no need to do @logs_overridden.include? but rather
# enough to set a flag for logs_overriden
def check_status_completed(lettings_log, previous_status)
return if @logs_overridden
if previous_status.include?("submitted") && lettings_log.status != "completed"
@logger.warn "DISCREPENCY lettings log #{lettings_log.id} is not completed"
@logger.warn "DISCREPENCY lettings log with old id:#{lettings_log.old_id} is incomplete but status should be complete"
@discrepancy = true
end
end
# Safe: A string that represents only an integer (or empty/nil)
def safe_string_as_integer(xml_doc, attribute)
str = field_value(xml_doc, "xmlns", attribute)
Integer(str, exception: false)
end
# Safe: A string that represents only a decimal (or empty/nil)
def safe_string_as_decimal(xml_doc, attribute)
str = string_or_nil(xml_doc, attribute)
return if str.nil?
BigDecimal(str, exception: false)
end
# Unsafe: A string that has more than just the integer value
def unsafe_string_as_integer(xml_doc, attribute)
str = string_or_nil(xml_doc, attribute)
return if str.nil?
str.to_i
end
def compose_date(xml_doc, day_str, month_str, year_str)
day = Integer(field_value(xml_doc, "xmlns", day_str), exception: false)
month = Integer(field_value(xml_doc, "xmlns", month_str), exception: false)
year = Integer(field_value(xml_doc, "xmlns", year_str), exception: false)
return if [day, month, year].any?(&:nil?)
Time.zone.local(year, month, day)
end
def get_form_name_component(xml_doc, index)
form_name = field_value(xml_doc, "meta", "form-name")
form_type_components = form_name.split("-")
form_type_components[index]
end
def needs_type(xml_doc)
gn_sh = get_form_name_component(xml_doc, FORM_NAME_INDEX[:needs_type])
case gn_sh
when "GN"
GN_SH[:general_needs]
when "SH"
GN_SH[:supported_housing]
else
raise "Unknown needstype value: #{gn_sh}"
end
end
# This does not match renttype (CDS) which is derived by lettings log logic
def rent_type(xml_doc, lar, irproduct)
sr_ar_ir = get_form_name_component(xml_doc, FORM_NAME_INDEX[:rent_type])
case sr_ar_ir
when "SR"
RENT_TYPE[:social_rent]
when "AR"
if lar == 1
RENT_TYPE[:london_affordable_rent]
else
RENT_TYPE[:affordable_rent]
end
when "IR"
if irproduct == IRPRODUCT[:rent_to_buy]
RENT_TYPE[:rent_to_buy]
elsif irproduct == IRPRODUCT[:london_living_rent]
RENT_TYPE[:london_living_rent]
elsif irproduct == IRPRODUCT[:other_intermediate_rent_product]
RENT_TYPE[:other_intermediate_rent_product]
end
else
raise "Could not infer rent type with '#{sr_ar_ir}'"
end
end
def find_organisation_id(xml_doc, id_field)
old_visible_id = unsafe_string_as_integer(xml_doc, id_field)
organisation = Organisation.find_by(old_visible_id:)
raise "Organisation not found with legacy ID #{old_visible_id}" if organisation.nil?
organisation.id
end
def sex(xml_doc, index)
unmapped_sex = string_or_nil(xml_doc, "P#{index}Sex")
IMPORT_MAPPING_SEX[unmapped_sex]
end
def relat(xml_doc, index)
unmapped_relation = string_or_nil(xml_doc, "P#{index}Rel")
IMPORT_MAPPING_RELATION[unmapped_relation]
end
def age_known(xml_doc, index, hhmemb)
return nil if hhmemb.present? && index > hhmemb
age_refused = string_or_nil(xml_doc, "P#{index}AR")
return 0 if age_refused.blank?
age_refused.casecmp?("AGE_REFUSED") || age_refused.casecmp?("No") ? 1 : 0
end
def details_known(index, attributes)
return nil if attributes["hhmemb"].nil? || index > attributes["hhmemb"]
if attributes["age#{index}_known"] == 1 &&
attributes["sex#{index}"] == "R" &&
attributes["relat#{index}"] == "R" &&
attributes["ecstat#{index}"] == 10
1 # No
else
0 # Yes
end
end
def previous_postcode_known(xml_doc, previous_postcode, prevloc)
previous_postcode_known = string_or_nil(xml_doc, "Q12bnot")
if previous_postcode_known == "Temporary_or_Unknown" || (previous_postcode.nil? && prevloc.present?)
0
elsif previous_postcode.nil?
nil
else
1
end
end
def compose_postcode(xml_doc, outcode, incode)
outcode_value = string_or_nil(xml_doc, outcode)
incode_value = string_or_nil(xml_doc, incode)
if outcode_value.nil? || incode_value.nil? || !"#{outcode_value} #{incode_value}".match(POSTCODE_REGEXP)
nil
else
"#{outcode_value} #{incode_value}"
end
end
# Default to No (2) for any other values (nil, not known)
def london_affordable_rent(xml_doc)
unsafe_string_as_integer(xml_doc, "LAR") == 1 ? 1 : 2
end
# Relet – renewal of fixed-term tenancy
def renewal(rsnvac)
rsnvac == 14 ? 1 : 0
end
def string_or_nil(xml_doc, attribute)
str = field_value(xml_doc, "xmlns", attribute)
str.presence
end
def ethnic_group(ethnic)
case ethnic
when 1, 2, 3, 18
# White
0
when 4, 5, 6, 7
# Mixed
1
when 8, 9, 10, 11, 15
# Asian
2
when 12, 13, 14
# Black
3
when 16, 19
# Others
4
when 17
# Refused
17
end
end
# Letters should be lowercase to match case
def housing_needs(xml_doc, letter)
string_or_nil(xml_doc, "Q10-#{letter}") == "Yes" ? 1 : 0
end
def net_income_known(xml_doc, earnings)
incref = string_or_nil(xml_doc, "Q8Refused")
if incref == "Refused"
# Tenant prefers not to say
2
elsif earnings.nil?
# No
1
else
# Yes
0
end
end
def illness_type(xml_doc, index, illness)
string_or_nil(xml_doc, "Q10ib-#{index}") == "Yes" && illness == 1 ? 1 : 0
end
def first_time_let(rsnvac)
if [15, 16, 17].include?(rsnvac)
1
else
0
end
end
def declaration(xml_doc)
declaration = string_or_nil(xml_doc, "Qdp")
if declaration == "Yes"
1
end
end
def postcode_known(attributes)
if attributes["postcode_full"].nil? && attributes["la"].nil?
nil
elsif attributes["postcode_full"].nil?
0 # Assumes we selected No in the form since the LA is present
else
1
end
end
def household_members(xml_doc, previous_status)
hhmemb = safe_string_as_integer(xml_doc, "HHMEMB")
if previous_status.include?("submitted") && hhmemb.nil?
hhmemb = people_with_details(xml_doc).count
return nil if hhmemb.zero?
end
hhmemb
end
def people_with_details(xml_doc)
((2..8).map { |x| string_or_nil(xml_doc, "P#{x}Rel") } + [string_or_nil(xml_doc, "P1Sex")]).compact
end
def tshortfall_known?(xml_doc, attributes)
if attributes["tshortfall"].blank? && attributes["hbrentshortfall"] == 1 && overridden?(xml_doc, "xmlns", "Q18dyes")
1
else
0
end
end
def allocation_system(value)
value == 1 ? 1 : 0
end
def allocation_system_unknown(cbl, chr, cap)
allocation_values = [cbl, chr, cap]
if allocation_values.all?(&:nil?)
nil
elsif allocation_values.all? { |att| att&.zero? }
1
else
0
end
end
def apply_date_consistency!(attributes)
return if attributes["voiddate"].nil? || attributes["startdate"].nil?
if attributes["voiddate"] > attributes["startdate"]
attributes.delete("voiddate")
end
end
def apply_household_consistency!(attributes)
(2..8).each do |index|
next if attributes["age#{index}"].nil?
if attributes["age#{index}"] < 16 && attributes["relat#{index}"] == "R"
attributes["relat#{index}"] = "C"
end
end
end
def household_charge(xml_doc)
value = string_or_nil(xml_doc, "Q18c")
start_year = Integer(get_form_name_component(xml_doc, FORM_NAME_INDEX[:start_year]))
if start_year <= 2021
# Yes means that there are no charges (2021 or earlier)
value && value.include?("Yes") ? 1 : 0
else
# Yes means that there are charges (2022 onwards)
value && value.include?("Yes") ? 0 : 1
end
end
def set_partial_charges_to_zero(attributes)
unless attributes["brent"].nil? &&
attributes["scharge"].nil? &&
attributes["pscharge"].nil? &&
attributes["supcharg"].nil?
attributes["brent"] ||= BigDecimal("0.0")
attributes["scharge"] ||= BigDecimal("0.0")
attributes["pscharge"] ||= BigDecimal("0.0")
attributes["supcharg"] ||= BigDecimal("0.0")
end
end
def is_carehome(scheme)
return nil unless scheme
if [2, 3, 4].include?(scheme.registered_under_care_act_before_type_cast)
1
else
0
end
end
def discrepancy?
!!@discrepancy
end
end
end

33
spec/jobs/lettings_log_import_job_spec.rb

@ -0,0 +1,33 @@
require "rails_helper"
RSpec.describe LettingsLogImportJob do
include Helpers
let(:job) { described_class.new }
describe "#perform" do
context "with valid params" do
before do
end
it "executes LettingsLogsImportProcessor" do
expect(Imports::LettingsLogsImportProcessor).to receive(:new)
# Very basic example. See fixtures/imports/logs for
# thorough examples
xml_document_as_string = <<~XML
<Group>
<Group>
<Q17>7 Weekly for 48 weeks</Q17>
<Q18aiii override-field=""/>
<Q19repair/>
</Group>
</Group>
XML
job.perform("LLRun-202210040105", xml_document_as_string)
end
end
end
end

32
spec/services/imports/lettings_logs_field_import_service_spec.rb

@ -38,6 +38,9 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
allow(storage_service).to receive(:get_file_io)
.with("#{remote_folder}/#{lettings_log_id}.xml")
.and_return(lettings_log_file)
allow(logger).to receive(:info).with(/START: Importing Lettings Logs @/)
allow(logger).to receive(:info).with(/FINISH: Importing Lettings Logs @/)
end
context "when updating tenant code" do
@ -47,12 +50,16 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
let(:lettings_log) { LettingsLog.find_by(old_id: lettings_log_id) }
before do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
perform_enqueued_jobs do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
end
lettings_log_file.rewind
end
it "logs that the tenancycode already has a value and does not update the lettings_log" do
expect(logger).to receive(:info).with(/lettings log \d+ has a value for tenancycode, skipping update/)
expect(logger).to receive(:info).with(/lettings log \d+ has a value for tenancycode, skipping update/).at_least(:once)
expect { import_service.send(:update_field, field, remote_folder) }
.not_to(change { lettings_log.reload.tenancycode })
end
@ -62,7 +69,10 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
let(:lettings_log) { LettingsLog.find_by(old_id: lettings_log_id) }
before do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
perform_enqueued_jobs do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
end
lettings_log_file.rewind
lettings_log.update!(tenancycode: nil)
end
@ -80,7 +90,11 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
before do
allow(logger).to receive(:warn)
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
perform_enqueued_jobs do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
end
lettings_log_file.rewind
end
@ -200,7 +214,10 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
let(:lettings_log) { LettingsLog.find_by(old_id: lettings_log_id) }
before do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
perform_enqueued_jobs do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
end
lettings_log_file.rewind
lettings_log.update!(majorrepairs: 0, mrcdate: Time.zone.local(2021, 10, 30, 10, 10, 10))
end
@ -222,7 +239,10 @@ RSpec.describe Imports::LettingsLogsFieldImportService do
let(:lettings_log) { LettingsLog.find_by(old_id: lettings_log_id) }
before do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
perform_enqueued_jobs do
Imports::LettingsLogsImportService.new(storage_service, logger).create_logs(fixture_directory)
end
lettings_log_file.rewind
lettings_log.update!(mrcdate: nil, majorrepairs: nil)
end

248
spec/services/imports/lettings_logs_import_processor_spec.rb

@ -1,5 +1,4 @@
require "rails_helper"
require_relative "../../../app/services/imports/lettings_logs_import_service"
RSpec.describe Imports::LettingsLogsImportProcessor do
let(:storage_service) { instance_double(Storage::S3Service) }
@ -42,87 +41,75 @@ RSpec.describe Imports::LettingsLogsImportProcessor do
let(:lettings_log_file) { open_file(fixture_directory, lettings_log_id) }
let(:lettings_log_xml) { Nokogiri::XML(lettings_log_file) }
context "and the void date is after the start date" do
before { lettings_log_xml.at_xpath("//xmlns:VYEAR").content = 2023 }
describe '#initialize' do
context "with valid params" do
it "sets document-id as old_id" do
import = Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
it "does not import the voiddate" do
expect(logger).to receive(:warn).with(/is not completed/).once
expect(logger).to receive(:warn).with(/lettings log with old id:#{lettings_log_id} is incomplete but status should be complete/).once
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.where(old_id: lettings_log_id).first
expect(lettings_log&.voiddate).to be_nil
end
end
context "and the organisation legacy ID does not exist" do
before { lettings_log_xml.at_xpath("//xmlns:OWNINGORGID").content = 99_999 }
it "raises an exception" do
expect { Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger) }
.to raise_error(RuntimeError, "Organisation not found with legacy ID 99999")
expect(import.old_id).to eq "0ead17cb-1668-442d-898c-0d52879ff592"
end
end
end
context "and a person is under 16" do
before { lettings_log_xml.at_xpath("//xmlns:P2Age").content = 14 }
context "when the void date is after the start date" do
before { lettings_log_xml.at_xpath("//xmlns:VYEAR").content = 2023 }
context "when the economic status is set to refuse" do
before { lettings_log_xml.at_xpath("//xmlns:P2Eco").content = "10) Refused" }
it "does not import the voiddate" do
expect(logger).to receive(:warn).with(/is not completed/).once
expect(logger).to receive(:warn).with(/lettings log with old id:#{lettings_log_id} is incomplete but status should be complete/).once
it "sets the economic status to child under 16" do
# The update is done when calculating derived variables
expect(logger).to receive(:warn).with(/Differences found when saving log/)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
import = Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.where(old_id: lettings_log_id).first
expect(lettings_log&.ecstat2).to be(9)
expect(lettings_log&.voiddate).to be_nil
expect(import.discrepancy).to be true
end
end
context "when the relationship to lead tenant is set to refuse" do
before { lettings_log_xml.at_xpath("//xmlns:P2Rel").content = "Refused" }
it "sets the relationship to lead tenant to child" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
context "when the organisation legacy ID does not exist" do
before { lettings_log_xml.at_xpath("//xmlns:OWNINGORGID").content = 99_999 }
lettings_log = LettingsLog.where(old_id: lettings_log_id).first
expect(lettings_log&.relat2).to eq("C")
it "raises an exception" do
expect { Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger) }
.to raise_error(RuntimeError, "Organisation not found with legacy ID 99999")
end
end
end
context "and this is an internal transfer from a non social housing" do
before do
lettings_log_xml.at_xpath("//xmlns:Q11").content = "9 Residential care home"
lettings_log_xml.at_xpath("//xmlns:Q16").content = "1 Internal Transfer"
end
context "when a person is under 16" do
before { lettings_log_xml.at_xpath("//xmlns:P2Age").content = 14 }
it "intercepts the relevant validation error" do
expect(logger).to receive(:warn).with(/Removing internal transfer referral since previous tenancy is a non social housing/)
expect { Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger) }
.not_to raise_error
end
context "when the economic status is set to refuse" do
before { lettings_log_xml.at_xpath("//xmlns:P2Eco").content = "10) Refused" }
it "clears out the referral answer" do
allow(logger).to receive(:warn)
it "sets the economic status to child under 16" do
# The update is done when calculating derived variables
expect(logger).to receive(:warn).with(/Differences found when saving log/)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.where(old_id: lettings_log_id).first
expect(lettings_log&.ecstat2).to be(9)
end
end
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
context "when the relationship to lead tenant is set to refuse" do
before { lettings_log_xml.at_xpath("//xmlns:P2Rel").content = "Refused" }
expect(lettings_log).not_to be_nil
expect(lettings_log.referral).to be_nil
it "sets the relationship to lead tenant to child" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.where(old_id: lettings_log_id).first
expect(lettings_log&.relat2).to eq("C")
end
end
end
context "and this is an internal transfer from a previous fixed term tenancy" do
context "when this is an internal transfer from a non social housing" do
before do
lettings_log_xml.at_xpath("//xmlns:Q11").content = "30 Fixed term Local Authority General Needs tenancy"
lettings_log_xml.at_xpath("//xmlns:Q11").content = "9 Residential care home"
lettings_log_xml.at_xpath("//xmlns:Q16").content = "1 Internal Transfer"
end
it "intercepts the relevant validation error" do
expect(logger).to receive(:warn).with(/Removing internal transfer referral since previous tenancy is fixed terms or lifetime/)
expect(logger).to receive(:warn).with(/Removing internal transfer referral since previous tenancy is a non social housing/)
expect { Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger) }
.not_to raise_error
end
@ -136,86 +123,109 @@ RSpec.describe Imports::LettingsLogsImportProcessor do
expect(lettings_log).not_to be_nil
expect(lettings_log.referral).to be_nil
end
end
end
context "and the net income soft validation is triggered (net_income_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:Q8a").content = "1 Weekly"
lettings_log_xml.at_xpath("//xmlns:Q8Money").content = 890.00
end
context "and this is an internal transfer from a previous fixed term tenancy" do
before do
lettings_log_xml.at_xpath("//xmlns:Q11").content = "30 Fixed term Local Authority General Needs tenancy"
lettings_log_xml.at_xpath("//xmlns:Q16").content = "1 Internal Transfer"
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
end
end
it "intercepts the relevant validation error" do
expect(logger).to receive(:warn).with(/Removing internal transfer referral since previous tenancy is fixed terms or lifetime/)
expect { Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger) }
.not_to raise_error
end
context "and the rent soft validation is triggered (rent_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:Q18ai").content = 200.00
lettings_log_xml.at_xpath("//xmlns:Q18av").content = 232.02
lettings_log_xml.at_xpath("//xmlns:Q17").content = "1 Weekly for 52 weeks"
LaRentRange.create!(
start_year: 2021,
la: "E08000035",
beds: 2,
lettype: 1,
soft_max: 900,
hard_max: 1500,
soft_min: 500,
hard_min: 100,
)
it "clears out the referral answer" do
allow(logger).to receive(:warn)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log).not_to be_nil
expect(lettings_log.referral).to be_nil
end
end
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
context "when the net income soft validation is triggered (net_income_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:Q8a").content = "1 Weekly"
lettings_log_xml.at_xpath("//xmlns:Q8Money").content = 890.00
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
end
end
end
context "and the retirement soft validation is triggered (retirement_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:P1Age").content = 68
lettings_log_xml.at_xpath("//xmlns:P1Eco").content = "6) Not Seeking Work"
context "when the rent soft validation is triggered (rent_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:Q18ai").content = 200.00
lettings_log_xml.at_xpath("//xmlns:Q18av").content = 232.02
lettings_log_xml.at_xpath("//xmlns:Q17").content = "1 Weekly for 52 weeks"
LaRentRange.create!(
start_year: 2021,
la: "E08000035",
beds: 2,
lettype: 1,
soft_max: 900,
hard_max: 1500,
soft_min: 500,
hard_min: 100,
)
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
end
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
context "when the retirement soft validation is triggered (retirement_value_check)" do
before do
lettings_log_xml.at_xpath("//xmlns:P1Age").content = 68
lettings_log_xml.at_xpath("//xmlns:P1Eco").content = "6) Not Seeking Work"
end
it "completes the log" do
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.status).to eq("completed")
end
end
end
context "and this is a supported housing log with multiple locations under a scheme" do
let(:lettings_log_id) { "0b4a68df-30cc-474a-93c0-a56ce8fdad3b" }
context "when this is a supported housing log with multiple locations under a scheme" do
let(:lettings_log_id) { "0b4a68df-30cc-474a-93c0-a56ce8fdad3b" }
it "sets the scheme and location values" do
expect(logger).not_to receive(:warn)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
it "sets the scheme and location values" do
expect(logger).not_to receive(:warn)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.scheme_id).not_to be_nil
expect(lettings_log.location_id).not_to be_nil
expect(lettings_log.status).to eq("completed")
expect(lettings_log.scheme_id).not_to be_nil
expect(lettings_log.location_id).not_to be_nil
expect(lettings_log.status).to eq("completed")
end
end
end
context "and this is a supported housing log with a single location under a scheme" do
let(:lettings_log_id) { "0b4a68df-30cc-474a-93c0-a56ce8fdad3b" }
context "when this is a supported housing log with a single location under a scheme" do
let(:lettings_log_id) { "0b4a68df-30cc-474a-93c0-a56ce8fdad3b" }
before { lettings_log_xml.at_xpath("//xmlns:_1cmangroupcode").content = scheme2.old_visible_id }
before { lettings_log_xml.at_xpath("//xmlns:_1cmangroupcode").content = scheme2.old_visible_id }
it "sets the scheme and location values" do
expect(logger).not_to receive(:warn)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
it "sets the scheme and location values" do
expect(logger).not_to receive(:warn)
Imports::LettingsLogsImportProcessor.new(lettings_log_xml.to_s, logger)
lettings_log = LettingsLog.find_by(old_id: lettings_log_id)
expect(lettings_log.scheme_id).not_to be_nil
expect(lettings_log.location_id).not_to be_nil
expect(lettings_log.status).to eq("completed")
expect(lettings_log.scheme_id).not_to be_nil
expect(lettings_log.location_id).not_to be_nil
expect(lettings_log.status).to eq("completed")
end
end
end
end

Loading…
Cancel
Save