Browse Source
* Copy 23 BU files to 24 * Update field mapping * Add duplicate log error to charges * Check the sum of charges for duplicates * update stub * Only add charges to duplciate hash if they exist * Rebase change * Update columns * Add tcharge back to error mapping * Add prepare your file * Add csv parser 24/25 cases * Fix date check * Rebase fixpull/2202/head
kosiakkatrina
11 months ago
committed by
GitHub
11 changed files with 4440 additions and 3 deletions
@ -0,0 +1,113 @@ |
|||||||
|
require "csv" |
||||||
|
|
||||||
|
class BulkUpload::Lettings::Year2024::CsvParser |
||||||
|
include CollectionTimeHelper |
||||||
|
|
||||||
|
FIELDS = 130 |
||||||
|
MAX_COLUMNS = 131 |
||||||
|
FORM_YEAR = 2024 |
||||||
|
|
||||||
|
attr_reader :path |
||||||
|
|
||||||
|
def initialize(path:) |
||||||
|
@path = path |
||||||
|
end |
||||||
|
|
||||||
|
def row_offset |
||||||
|
if with_headers? |
||||||
|
rows.find_index { |row| row[0].match(/field number/i) } + 1 |
||||||
|
else |
||||||
|
0 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def col_offset |
||||||
|
with_headers? ? 1 : 0 |
||||||
|
end |
||||||
|
|
||||||
|
def cols |
||||||
|
@cols ||= ("A".."EA").to_a |
||||||
|
end |
||||||
|
|
||||||
|
def row_parsers |
||||||
|
@row_parsers ||= body_rows.map do |row| |
||||||
|
stripped_row = row[col_offset..] |
||||||
|
hash = Hash[field_numbers.zip(stripped_row)] |
||||||
|
|
||||||
|
BulkUpload::Lettings::Year2024::RowParser.new(hash) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def body_rows |
||||||
|
rows[row_offset..] |
||||||
|
end |
||||||
|
|
||||||
|
def rows |
||||||
|
@rows ||= CSV.parse(normalised_string, row_sep:) |
||||||
|
end |
||||||
|
|
||||||
|
def column_for_field(field) |
||||||
|
cols[field_numbers.find_index(field) + col_offset] |
||||||
|
end |
||||||
|
|
||||||
|
def correct_field_count? |
||||||
|
valid_field_numbers_count = field_numbers.count { |f| f != "field_blank" } |
||||||
|
|
||||||
|
valid_field_numbers_count == FIELDS |
||||||
|
end |
||||||
|
|
||||||
|
def too_many_columns? |
||||||
|
return if with_headers? |
||||||
|
|
||||||
|
max_columns_count = body_rows.map(&:size).max - col_offset |
||||||
|
|
||||||
|
max_columns_count > MAX_COLUMNS |
||||||
|
end |
||||||
|
|
||||||
|
def wrong_template_for_year? |
||||||
|
collection_start_year_for_date(first_record_start_date) != FORM_YEAR |
||||||
|
rescue Date::Error |
||||||
|
false |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def default_field_numbers |
||||||
|
(1..FIELDS).map { |h| h.present? && h.to_s.match?(/^[0-9]+$/) ? "field_#{h}" : "field_blank" } |
||||||
|
end |
||||||
|
|
||||||
|
def field_numbers |
||||||
|
@field_numbers ||= if with_headers? |
||||||
|
rows[row_offset - 1][col_offset..].map { |h| h.present? && h.match?(/^[0-9]+$/) ? "field_#{h}" : "field_blank" } |
||||||
|
else |
||||||
|
default_field_numbers |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def with_headers? |
||||||
|
rows.map { |r| r[0] }.any? { |cell| cell&.match?(/field number/i) } |
||||||
|
end |
||||||
|
|
||||||
|
def row_sep |
||||||
|
"\n" |
||||||
|
end |
||||||
|
|
||||||
|
def normalised_string |
||||||
|
return @normalised_string if @normalised_string |
||||||
|
|
||||||
|
@normalised_string = File.read(path, encoding: "bom|utf-8") |
||||||
|
@normalised_string.gsub!("\r\n", "\n") |
||||||
|
@normalised_string.scrub!("") |
||||||
|
@normalised_string.tr!("\r", "\n") |
||||||
|
|
||||||
|
@normalised_string |
||||||
|
end |
||||||
|
|
||||||
|
def first_record_start_date |
||||||
|
if with_headers? |
||||||
|
Date.new(row_parsers.first.field_10.to_i + 2000, row_parsers.first.field_9.to_i, row_parsers.first.field_8.to_i) |
||||||
|
else |
||||||
|
Date.new(rows.first[9].to_i + 2000, rows.first[8].to_i, rows.first[7].to_i) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@ |
|||||||
|
<% content_for :before_content do %> |
||||||
|
<%= govuk_back_link href: @form.back_path %> |
||||||
|
<% end %> |
||||||
|
|
||||||
|
<div class="govuk-grid-row"> |
||||||
|
<div class="govuk-grid-column-two-thirds"> |
||||||
|
<%= form_with model: @form, scope: :form, url: bulk_upload_lettings_log_path(id: "prepare-your-file"), method: :patch do |f| %> |
||||||
|
<%= f.hidden_field :year %> |
||||||
|
|
||||||
|
<span class="govuk-caption-l">Upload lettings logs in bulk (<%= @form.year_combo %>)</span> |
||||||
|
<h1 class="govuk-heading-l">Prepare your file</h1> |
||||||
|
<p class="govuk-body govuk-!-margin-bottom-2"><%= govuk_link_to "Read the full guidance", bulk_upload_lettings_log_path(id: "guidance", form: { year: @form.year }) %> before you start if you have not used bulk upload before.</p> |
||||||
|
|
||||||
|
<h2 class="govuk-heading-s">Download template</h2> |
||||||
|
|
||||||
|
<p class="govuk-body govuk-!-margin-bottom-2">Use one of these templates to upload logs for 2024/25:</p> |
||||||
|
<ul class="govuk-list govuk-list--bullet"> |
||||||
|
<li> |
||||||
|
<%= govuk_link_to "Download the new template", @form.template_path %>: In this template, the questions are in the same order as the 2024/25 paper form and web form. |
||||||
|
</li> |
||||||
|
|
||||||
|
<li> |
||||||
|
<%= govuk_link_to "Download the legacy template", @form.legacy_template_path %>: In this template, the questions are in the same order as the 2022/23 template, with new questions added on to the end. |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
<p class="govuk-body govuk-!-margin-bottom-2">There are 7 or 8 rows of content in the templates. These rows are called the ‘headers’. They contain the CORE form questions and guidance about which questions are required and how to format your answers.</p> |
||||||
|
|
||||||
|
<h2 class="govuk-heading-s">Create your file</h2> |
||||||
|
|
||||||
|
<ul class="govuk-list govuk-list--bullet"> |
||||||
|
<li>Fill in the template with data from your housing management system. Your data should go below the headers, with one row per log. Leave column A blank - the bulk upload fields start in column B.</li> |
||||||
|
<li>Make sure each column of your data aligns with the matching headers above. You may need to reorder your data.</li> |
||||||
|
<li>Use the <%= govuk_link_to "Lettings #{@form.year_combo} Bulk Upload Specification", @form.specification_path %> to check your data is in the correct format.</li> |
||||||
|
<li><strong>Username field:</strong> To assign a log to someone else, enter the email address they use to log into CORE.</li> |
||||||
|
<li>If you are using the new template, keep the headers. If you are using the legacy template, you can either keep or remove the headers. If you remove the headers, you should also remove the blank column A.</li> |
||||||
|
</ul> |
||||||
|
|
||||||
|
<%= govuk_inset_text(text: "You can upload both general needs and supported housing logs in the same file for 2023/24 data.") %> |
||||||
|
|
||||||
|
<h2 class="govuk-heading-s">Save your file</h2> |
||||||
|
|
||||||
|
<ul class="govuk-list govuk-list--bullet"> |
||||||
|
<li>Save your file as a CSV.</li> |
||||||
|
<li>Your file should now be ready to upload.</li> |
||||||
|
</ul> |
||||||
|
|
||||||
|
<%= f.govuk_submit class: "govuk-!-margin-top-7" %> |
||||||
|
<% end %> |
||||||
|
</div> |
||||||
|
</div> |
@ -0,0 +1,226 @@ |
|||||||
|
require "rails_helper" |
||||||
|
|
||||||
|
RSpec.describe BulkUpload::Lettings::Year2024::CsvParser do |
||||||
|
subject(:service) { described_class.new(path:) } |
||||||
|
|
||||||
|
let(:file) { Tempfile.new } |
||||||
|
let(:path) { file.path } |
||||||
|
let(:log) { build(:lettings_log, :completed) } |
||||||
|
|
||||||
|
context "when parsing csv with headers" do |
||||||
|
before do |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct offsets" do |
||||||
|
expect(service.row_offset).to eq(7) |
||||||
|
expect(service.col_offset).to eq(1) |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing csv with headers with extra rows" do |
||||||
|
before do |
||||||
|
file.write("Section\n") |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct offsets" do |
||||||
|
expect(service.row_offset).to eq(8) |
||||||
|
expect(service.col_offset).to eq(1) |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing csv with headers in arbitrary order" do |
||||||
|
let(:seed) { rand } |
||||||
|
|
||||||
|
before do |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row(seed:)) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row(seed:)) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct offsets" do |
||||||
|
expect(service.row_offset).to eq(7) |
||||||
|
expect(service.col_offset).to eq(1) |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing csv with extra invalid headers" do |
||||||
|
let(:seed) { rand } |
||||||
|
let(:log_to_csv) { BulkUpload::LettingsLogToCsv.new(log:) } |
||||||
|
let(:field_numbers) { log_to_csv.default_2024_field_numbers + %w[invalid_field_number] } |
||||||
|
let(:field_values) { log_to_csv.to_2024_row + %w[value_for_invalid_field_number] } |
||||||
|
|
||||||
|
before do |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(log_to_csv.custom_field_numbers_row(seed:, field_numbers:)) |
||||||
|
file.write(log_to_csv.to_custom_csv_row(seed:, field_values:)) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
|
||||||
|
it "counts the number of valid field numbers correctly" do |
||||||
|
expect(service).to be_correct_field_count |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing csv without headers" do |
||||||
|
before do |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct offsets" do |
||||||
|
expect(service.row_offset).to eq(0) |
||||||
|
expect(service.col_offset).to eq(0) |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing with BOM aka byte order mark" do |
||||||
|
let(:bom) { "\uFEFF" } |
||||||
|
|
||||||
|
before do |
||||||
|
file.write(bom) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when an invalid byte sequence" do |
||||||
|
let(:invalid_sequence) { "\x81" } |
||||||
|
|
||||||
|
before do |
||||||
|
file.write(invalid_sequence) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when parsing csv with carriage returns" do |
||||||
|
before do |
||||||
|
file.write("Question\r\n") |
||||||
|
file.write("Additional info\r") |
||||||
|
file.write("Values\r\n") |
||||||
|
file.write("Can be empty?\r") |
||||||
|
file.write("Type of letting the question applies to\r\n") |
||||||
|
file.write("Duplicate check field?\r") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "parses csv correctly" do |
||||||
|
expect(service.row_parsers[0].field_13).to eql(log.tenancycode) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "#column_for_field", aggregate_failures: true do |
||||||
|
context "when with headers using default ordering" do |
||||||
|
before do |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct column" do |
||||||
|
expect(service.column_for_field("field_5")).to eql("F") |
||||||
|
expect(service.column_for_field("field_22")).to eql("W") |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when without headers using default ordering" do |
||||||
|
before do |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:, col_offset: 0).to_2024_csv_row) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct column" do |
||||||
|
expect(service.column_for_field("field_5")).to eql("E") |
||||||
|
expect(service.column_for_field("field_22")).to eql("V") |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context "when with headers using custom ordering" do |
||||||
|
let(:seed) { 123 } |
||||||
|
|
||||||
|
before do |
||||||
|
file.write("Question\n") |
||||||
|
file.write("Additional info\n") |
||||||
|
file.write("Values\n") |
||||||
|
file.write("Can be empty?\n") |
||||||
|
file.write("Type of letting the question applies to\n") |
||||||
|
file.write("Duplicate check field?\n") |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).default_2024_field_numbers_row(seed:)) |
||||||
|
file.write(BulkUpload::LettingsLogToCsv.new(log:).to_2024_csv_row(seed:)) |
||||||
|
file.rewind |
||||||
|
end |
||||||
|
|
||||||
|
it "returns correct column" do |
||||||
|
expect(service.column_for_field("field_5")).to eql("B") |
||||||
|
expect(service.column_for_field("field_22")).to eql("AS") |
||||||
|
expect(service.column_for_field("field_26")).to eql("DH") |
||||||
|
expect(service.column_for_field("field_25")).to eql("I") |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue