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/sales_log_export_constants.rb b/app/services/exports/sales_log_export_constants.rb
new file mode 100644
index 000000000..4d5400758
--- /dev/null
+++ b/app/services/exports/sales_log_export_constants.rb
@@ -0,0 +1,194 @@
+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",
+ "saledate",
+ "created_at",
+ "updated_at",
+ "owning_organisation_id",
+ "assigned_to_id",
+ "purchid",
+ "type",
+ "ownershipsch",
+ "othtype",
+ "jointmore",
+ "jointpur",
+ "beds",
+ "companybuy",
+ "ethnic",
+ "ethnic_group",
+ "buy1livein",
+ "buylivein",
+ "builtype",
+ "proptype",
+ "noint",
+ "buy2livein",
+ "privacynotice",
+ "wheel",
+ "hholdcount",
+ "la",
+ "la_known",
+ "income1",
+ "income1nk",
+ "details_known_2",
+ "details_known_3",
+ "details_known_4",
+ "inc1mort",
+ "income2",
+ "income2nk",
+ "savingsnk",
+ "savings",
+ "prevown",
+ "updated_by_id",
+ "income1_value_check",
+ "mortgage",
+ "inc2mort",
+ "mortgage_value_check",
+ "hb",
+ "savings_value_check",
+ "deposit_value_check",
+ "frombeds",
+ "staircase",
+ "stairbought",
+ "stairowned",
+ "mrent",
+ "exdate",
+ "exday",
+ "exmonth",
+ "exyear",
+ "resale",
+ "deposit",
+ "cashdis",
+ "disabled",
+ "lanomagr",
+ "wheel_value_check",
+ "soctenant",
+ "value",
+ "equity",
+ "discount",
+ "grant",
+ "pregyrha",
+ "pregla",
+ "pregghb",
+ "pregother",
+ "ppostcode_full",
+ "is_previous_la_inferred",
+ "ppcodenk",
+ "ppostc1",
+ "ppostc2",
+ "prevloc",
+ "previous_la_known",
+ "hhregres",
+ "hhregresstill",
+ "proplen",
+ "has_mscharge",
+ "mscharge",
+ "prevten",
+ "mortgageused",
+ "wchair",
+ "income2_value_check",
+ "armedforcesspouse",
+ "hodate",
+ "hoday",
+ "homonth",
+ "hoyear",
+ "fromprop",
+ "socprevten",
+ "mortgagelender",
+ "mortgagelenderother",
+ "mortlen",
+ "extrabor",
+ "hhmemb",
+ "totadult",
+ "totchild",
+ "hhtype",
+ "pcode1",
+ "pcode2",
+ "pcodenk",
+ "postcode_full",
+ "is_la_inferred",
+ "bulk_upload_id",
+ "retirement_value_check",
+ "hodate_check",
+ "extrabor_value_check",
+ "deposit_and_mortgage_value_check",
+ "shared_ownership_deposit_value_check",
+ "grant_value_check",
+ "value_value_check",
+ "old_persons_shared_ownership_value_check",
+ "staircase_bought_value_check",
+ "monthly_charges_value_check",
+ "details_known_5",
+ "details_known_6",
+ "saledate_check",
+ "prevshared",
+ "staircasesale",
+ "ethnic_group2",
+ "ethnicbuy2",
+ "proplen_asked",
+ "old_id",
+ "buy2living",
+ "prevtenbuy2",
+ "pregblank",
+ "uprn",
+ "uprn_known",
+ "uprn_confirmed",
+ "address_line1",
+ "address_line2",
+ "town_or_city",
+ "county",
+ "nationalbuy2",
+ "discounted_sale_value_check",
+ "student_not_child_value_check",
+ "percentage_discount_value_check",
+ "combined_income_value_check",
+ "buyer_livein_value_check",
+ "discarded_at",
+ "stairowned_value_check",
+ "creation_method",
+ "old_form_id",
+ "managing_organisation_id",
+ "duplicate_set_id",
+ "nationality_all",
+ "nationality_all_group",
+ "nationality_all_buyer2",
+ "nationality_all_buyer2_group",
+ "address_line1_input",
+ "postcode_full_input",
+ "address_search_value_check",
+ "uprn_selection",
+ "address_line1_as_entered",
+ "address_line2_as_entered",
+ "town_or_city_as_entered",
+ "county_as_entered",
+ "postcode_full_as_entered",
+ "la_as_entered",
+ "partner_under_16_value_check",
+ "multiple_partners_value_check",
+ "created_by_id",
+ "has_management_fee",
+ "management_fee",
+ "firststair",
+ "numstair",
+ "mrentprestaircasing",
+ "lasttransaction",
+ "initialpurchase"]
+
+ (1..6).each do |index|
+ EXPORT_FIELDS << "age#{index}"
+ EXPORT_FIELDS << "age#{index}_known"
+ 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
index ad57c9c58..14ef118de 100644
--- a/app/services/exports/sales_log_export_service.rb
+++ b/app/services/exports/sales_log_export_service.rb
@@ -1,5 +1,77 @@
module Exports
class SalesLogExportService < Exports::XmlExportService
- def export_xml_sales_logs(full_update: false, collection_year: nil); end
+ 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)
+ sales_log.attributes_before_type_cast
+ # attribute_hash["formid"] = attribute_hash["old_form_id"] || (attribute_hash["id"] + LOG_ID_OFFSET)
+ 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/spec/factories/sales_log.rb b/spec/factories/sales_log.rb
index 687b042a4..9a13fbf4e 100644
--- a/spec/factories/sales_log.rb
+++ b/spec/factories/sales_log.rb
@@ -200,5 +200,109 @@ 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 }
+ proplen { 10 }
+ pregyrha { 1 }
+ pregla { 1 }
+ pregother { 1 }
+ pregghb { 1 }
+ 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..02ec1adc5
--- /dev/null
+++ b/spec/fixtures/exports/sales_log.xml
@@ -0,0 +1,207 @@
+
+
+
+
diff --git a/spec/fixtures/exports/sales_log_2024.xml b/spec/fixtures/exports/sales_log_2024.xml
new file mode 100644
index 000000000..3b4ce2221
--- /dev/null
+++ b/spec/fixtures/exports/sales_log_2024.xml
@@ -0,0 +1,207 @@
+
+
+
+
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..df525bd70
--- /dev/null
+++ b/spec/services/exports/sales_log_export_service_spec.rb
@@ -0,0 +1,363 @@
+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!(/\{id\}/, (sales_log["id"] + Exports::SalesLogExportService::LOG_ID_OFFSET).to_s)
+ export_template.sub!(/\{owning_org_id\}/, sales_log["owning_organisation_id"].to_s)
+ export_template.sub!(/\{managing_org_id\}/, sales_log["managing_organisation_id"].to_s)
+ export_template.sub!(/\{assigned_to_id\}/, sales_log["assigned_to_id"].to_s)
+ export_template.sub!(/\{created_by_id\}/, sales_log["created_by_id"].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
+ end
+end