diff --git a/app/models/export.rb b/app/models/export.rb index 27e574c72..d820b2f98 100644 --- a/app/models/export.rb +++ b/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 diff --git a/app/services/exports/export_service.rb b/app/services/exports/export_service.rb index 40c10055b..d828fdc49 100644 --- a/app/services/exports/export_service.rb +++ b/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 diff --git a/app/services/exports/sales_log_export_constants.rb b/app/services/exports/sales_log_export_constants.rb new file mode 100644 index 000000000..09a0d5d68 --- /dev/null +++ b/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 diff --git a/app/services/exports/sales_log_export_service.rb b/app/services/exports/sales_log_export_service.rb new file mode 100644 index 000000000..fa806f6ab --- /dev/null +++ b/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("") + + 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 diff --git a/app/services/feature_toggle.rb b/app/services/feature_toggle.rb index 93a92dcd4..4b2c440fc 100644 --- a/app/services/feature_toggle.rb +++ b/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 diff --git a/spec/factories/sales_log.rb b/spec/factories/sales_log.rb index 4c83fd04d..856a6d7e1 100644 --- a/spec/factories/sales_log.rb +++ b/spec/factories/sales_log.rb @@ -235,5 +235,104 @@ FactoryBot.define do instance.save!(validate: false) 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 diff --git a/spec/fixtures/exports/sales_log.xml b/spec/fixtures/exports/sales_log.xml new file mode 100644 index 000000000..37dc6dda5 --- /dev/null +++ b/spec/fixtures/exports/sales_log.xml @@ -0,0 +1,154 @@ + + +
+ {id} + 1 + 123 + 8 + 1 + 2 + 27 + F + 17 + 1 + 1 + 33 + P + X + 2 + 1 + 1 + 1 + 1 + 4 + 14 + E09000033 + 10000 + 18 + 40 + 40 + 1 + 10000 + 1 + + 1 + F + 20000.0 + 1 + 9 + 3 + 2 + 1 + X + X + R + R + 4 + X + M + X + + + + + + + 80000.0 + + 1 + 110000.0 + + + 10000.0 + 0 + SW1A + 1AA + E09000033 + 7 + + + 100.0 + 1 + 1 + 1 + 5 + + + + + + 1 + 6 + + 2 + 3 + + + + + + + 1 + 3 + 2026 + 2026-03-01T00:00:00+00:00 + {created_by_email} + {created_by_id} + {assigned_to_email} + {assigned_to_id} + 2026-03-01T00:00:00+00:00 + + + {owning_org_id} + {owning_org_name} + {managing_org_id} + {managing_org_name} + 1 + + 2025 + 2 + 1 + 17 + 17 + 1 + 1 + + + 0 + 0 + SW1A 1AA + true + 10 + + + Address line 1 + + City + Westminster + Address line 1 + SW1A 1AA + + + + + + + + 826 + 826 + Westminster + 1 + 1 + + + + + + + + + + + + +
diff --git a/spec/fixtures/exports/sales_log_2024.xml b/spec/fixtures/exports/sales_log_2024.xml new file mode 100644 index 000000000..e894ac973 --- /dev/null +++ b/spec/fixtures/exports/sales_log_2024.xml @@ -0,0 +1,154 @@ + + +
+ {id} + 2 + 123 + 8 + 1 + 2 + 27 + F + 17 + 1 + 1 + 33 + P + X + 2 + 1 + 1 + 1 + 1 + 4 + 14 + E09000033 + 10000 + 18 + 40 + 40 + 1 + 10000 + 1 + + 1 + F + 20000.0 + 1 + 9 + 3 + 2 + 1 + X + X + R + R + 4 + X + M + X + + + + + + + 80000.0 + + 1 + 110000.0 + + + 10000.0 + 0 + SW1A + 1AA + E09000033 + 7 + + + 100.0 + 1 + 1 + 1 + 5 + + + + + + 1 + 6 + + 2 + 3 + + + + + + + 3 + 4 + 2024 + 2024-04-03T00:00:00+01:00 + {created_by_email} + {created_by_id} + {assigned_to_email} + {assigned_to_id} + 2024-04-03T00:00:00+01:00 + + + {owning_org_id} + {owning_org_name} + {managing_org_id} + {managing_org_name} + 1 + + 2024 + 2 + 1 + 17 + 17 + 1 + 1 + + + 0 + 0 + SW1A 1AA + true + 10 + + + Address line 1 + + City + Westminster + Address line 1 + SW1A 1AA + + + + + + + + 826 + 826 + Westminster + 1 + 1 + + + + + + + + + + + + +
diff --git a/spec/services/exports/export_service_spec.rb b/spec/services/exports/export_service_spec.rb index fb52c5274..051a0f38d 100644 --- a/spec/services/exports/export_service_spec.rb +++ b/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 diff --git a/spec/services/exports/sales_log_export_service_spec.rb b/spec/services/exports/sales_log_export_service_spec.rb new file mode 100644 index 000000000..c11c13712 --- /dev/null +++ b/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("") + doc.write_xml_to(file, encoding: "UTF-8") + file.rewind + file + end + + def create_fake_maifest + file = Tempfile.new + doc = Nokogiri::XML("") + 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!("", "123") + 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!("100.0", "123.0") + 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!("", "1") + export_file.sub!("", "321.0") + export_file.sub!("", "1") + export_file.sub!("", "222.0") + export_file.sub!("100.0", "") + export_file.sub!("1", "") + + export_file.sub!("8", "30") + export_file.sub!("", "2") + export_file.sub!("10000.0", "") + export_file.sub!("0", "1") + export_file.sub!("SW1A", "") + export_file.sub!("1AA", "") + export_file.sub!("E09000033", "") + export_file.sub!("1", "") + export_file.sub!("2", "1") + export_file.sub!("1", "0") + export_file.sub!("Westminster", "") + 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