From 0d95025ab6861c9ba007f945dcd843b8fa353206 Mon Sep 17 00:00:00 2001
From: kosiakkatrina <54268893+kosiakkatrina@users.noreply.github.com>
Date: Tue, 18 Mar 2025 09:48:05 +0000
Subject: [PATCH] CLDC-3788 Export sales logs (#2943)
* Add sales export to the export service
* Update sales export
* Remove comments and redundant or statement
* Update sales export fields
* Add mrentprestaircasing, update factory and tests
* Enable sales export on test environments
* Update fees mappings for the export
* Upcase fields and rebase changes
* Update some more fields
---
app/models/export.rb | 1 +
app/services/exports/export_service.rb | 13 +-
.../exports/sales_log_export_constants.rb | 146 ++++++
.../exports/sales_log_export_service.rb | 156 +++++++
app/services/feature_toggle.rb | 4 +
spec/factories/sales_log.rb | 99 ++++
spec/fixtures/exports/sales_log.xml | 154 +++++++
spec/fixtures/exports/sales_log_2024.xml | 154 +++++++
spec/services/exports/export_service_spec.rb | 247 +++++++++-
.../exports/sales_log_export_service_spec.rb | 425 ++++++++++++++++++
10 files changed, 1392 insertions(+), 7 deletions(-)
create mode 100644 app/services/exports/sales_log_export_constants.rb
create mode 100644 app/services/exports/sales_log_export_service.rb
create mode 100644 spec/fixtures/exports/sales_log.xml
create mode 100644 spec/fixtures/exports/sales_log_2024.xml
create mode 100644 spec/services/exports/sales_log_export_service_spec.rb
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 @@
+
+
+
+
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 @@
+
+
+
+
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