Skip to content

Commit

Permalink
[CPDNPQ-2210] Create statements in admin console
Browse files Browse the repository at this point in the history
  • Loading branch information
rwrrll committed Jan 13, 2025
1 parent 7db9144 commit e87c050
Show file tree
Hide file tree
Showing 16 changed files with 815 additions and 0 deletions.
75 changes: 75 additions & 0 deletions app/controllers/npq_separation/admin/statements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
class NpqSeparation::Admin::StatementsController < NpqSeparation::AdminController
before_action :ensure_super_admin
before_action :set_cohort
before_action :store_uploads, only: :create

def new; end

def create
@service = Statements::BulkCreator.new(
cohort: @cohort,
statements_csv_id: bulk_creator_params[:statements_csv_id],
contracts_csv_id: bulk_creator_params[:contracts_csv_id],
)

@statements = @service.call(dry_run:)

if @service.errors.any?
render :new
elsif dry_run
set_preview
render
else
flash[:success] = "#{@statements.count} statements created successfully"
redirect_to npq_separation_admin_cohort_path(@cohort)
end
end

private

def set_cohort
@cohort = Cohort.find(params[:cohort_id])
end

def set_preview
@preview = {
statements: @statements.uniq { [_1.year, _1.month] },
contracts: @statements.group_by { _1.lead_provider.name }.transform_values { _1.first.contracts },
lead_providers_count: @statements.uniq(&:lead_provider).count,
}
end

def dry_run
bulk_creator_params[:confirm] != "1"
end

def bulk_creator_params
return {} if params[:statements_bulk_creator].blank?

params.require(:statements_bulk_creator).permit(:statements_csv_id, :contracts_csv_id, :confirm)
end

def store_uploads
return unless (p = params[:statements_bulk_creator])

{
statements_csv_file: :statements_csv_id,
contracts_csv_file: :contracts_csv_id,
}.each do |file_key, id_key|
next unless (file = p.delete(file_key))

p[id_key] = store_file(file)
end
end

def store_file(file)
ActiveStorage::Blob.create_and_upload!(io: file, filename: file.original_filename).signed_id
end

def ensure_super_admin
unless current_admin.super_admin?
flash[:error] = "You must be a super admin to create statements"
redirect_to npq_separation_admin_cohort_path(params[:cohort_id])
end
end
end
156 changes: 156 additions & 0 deletions app/services/statements/bulk_creator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
module Statements
class BulkCreator
include ActiveModel::Validations

attr_reader :cohort, :statements_csv_id, :contracts_csv_id

validates :statements_csv_id, presence: { message: "must be provided" }
validates :contracts_csv_id, presence: { message: "must be provided" }
validate :validate_statements_csv
validate :validate_contracts_csv
validate :validate_statement_uniqueness

def initialize(cohort:, statements_csv_id:, contracts_csv_id:)
@cohort = cohort
@statements_csv_id = statements_csv_id
@contracts_csv_id = contracts_csv_id
end

def call(dry_run: true)
return unless valid?

ApplicationRecord.transaction do
@result = create_statements
raise ActiveRecord::Rollback if dry_run
end

@result
end

private

def validate_statements_csv
return if statements_csv_id.blank?

statement_parser.errors.each { errors.add :statements_csv, _1 }
end

def validate_contracts_csv
return if contracts_csv_id.blank?

contract_parser.errors.each { errors.add :contracts_csv, _1 }
end

def validate_statement_uniqueness
return if statements_csv_id.blank? || contracts_csv_id.blank?

lead_providers = contract_parser.valid
.uniq(&:lead_provider_name)
.map { lead_provider_for _1 }

statement_parser.valid.each.with_index(2) do |statement, line_number|
lead_providers.each do |lead_provider|
next unless Statement.exists?(cohort:, lead_provider:, year: statement.year, month: statement.month)

errors.add(:statements_csv, "Statement already exists on line #{line_number}")
break
end
end
end

def create_statements
cache = {}

statement_parser.each do |statement_row|
contract_parser.each do |contract_row|
statement_attributes = statement_attributes_for(statement_row, contract_row)
contract_attributes = contract_attributes_for(contract_row)

cache[statement_attributes] ||= Statement.create!(statement_attributes)
cache[statement_attributes].contracts.create!(contract_attributes)
end
end

cache.values
end

def statement_attributes_for(statement_row, contract_row)
lead_provider = lead_provider_for(contract_row)

statement_row.attributes.merge(cohort:, lead_provider:)
end

def contract_attributes_for(contract_row)
course = course_for(contract_row)
contract_template = contract_template_for(contract_row, course.course_group)

{ contract_template:, course: }
end

def lead_provider_for(contract_row)
name = contract_row.lead_provider_name

@lead_provider_cache ||= {}
@lead_provider_cache[name] ||= LeadProvider.find_by!(name:)
end

def course_for(contract_row)
@course_cache ||= {}

identifier = contract_row.course_identifier
@course_cache[identifier] ||= Course.find_by!(identifier:)
end

def contract_template_for(contract_row, course_group)
attributes = contract_row.contract_template_attributes
.merge(contract_template_attributes_for(course_group))

@contract_template_cache ||= {}
@contract_template_cache[attributes] ||= ContractTemplate.find_or_create_by!(attributes)
end

def contract_template_attributes_for(course_group)
case course_group.name
when "leadership"
{
number_of_payment_periods: 4,
service_fee_percentage: 40,
output_payment_percentage: 60,
}
when "specialist"
{
number_of_payment_periods: 3,
service_fee_percentage: 40,
output_payment_percentage: 60,
}
when "support"
{
number_of_payment_periods: 4,
service_fee_percentage: 0,
output_payment_percentage: 100,
}
when "ehco"
{
number_of_payment_periods: 4,
service_fee_percentage: 0,
output_payment_percentage: 100,
}
else
raise ArgumentError, "Invalid course group name"
end
end

def statement_parser
@statement_parser ||= parse_blob(statements_csv_id, StatementRow)
end

def contract_parser
@contract_parser ||= parse_blob(contracts_csv_id, ContractRow)
end

def parse_blob(signed_id, row_class)
blob = ActiveStorage::Blob.find_signed!(signed_id)
blob.open { |file| Parser.read(file, row_class) }
end
end
end
44 changes: 44 additions & 0 deletions app/services/statements/bulk_creator/contract_row.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module Statements
class BulkCreator
class ContractRow
include ActiveModel::Model
include ActiveModel::Attributes

CONTRACT_TEMPLATE_ATTRIBUTES = %w[
recruitment_target
per_participant
special_course
monthly_service_fee
service_fee_installments
].freeze

attribute :lead_provider_name, :string
attribute :course_identifier, :string
attribute :recruitment_target, :strict_integer
attribute :per_participant, :strict_decimal, default: nil
attribute :special_course, :boolean, default: false
attribute :monthly_service_fee, :strict_decimal, default: nil
attribute :service_fee_installments, :strict_integer, default: nil

validates :lead_provider_name, inclusion: { in: -> { LeadProvider.pluck(:name) }, message: "is not recognised" }
validates :course_identifier, inclusion: { in: -> { Course.pluck(:identifier) }, message: "is not recognised" }
validates :recruitment_target, numericality: { greater_than: 0 }
validates :per_participant, numericality: { greater_than: 0 }
validates :monthly_service_fee, numericality: { greater_than_or_equal_to: 0 }
validates :service_fee_installments, numericality: { greater_than_or_equal_to: 0 }

def self.example_csv
<<~CSV.strip
lead_provider_name,course_identifier,recruitment_target,per_participant,service_fee_installments,special_course,monthly_service_fee
"#{LeadProvider.first.name}",#{Course.first.identifier},30,1000,12,false,100
"#{LeadProvider.first.name}",#{Course.last.identifier},50,400,6,true,200
"#{LeadProvider.last.name}",#{Course.first.identifier},20,750,9,false,0
CSV
end

def contract_template_attributes
attributes.slice(*CONTRACT_TEMPLATE_ATTRIBUTES)
end
end
end
end
35 changes: 35 additions & 0 deletions app/services/statements/bulk_creator/parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module Statements
class BulkCreator
class Parser < Array
attr_accessor :error

def self.read(csv_file, row_class)
lines = CSV.read(csv_file, headers: true, encoding: "bom|utf-8") # encoding needed for some Excel CSVs

if lines.none?
new.tap { _1.error = "No rows found" }
elsif (missing_headers = row_class.attribute_names - lines.first.headers).any?
new.tap { _1.error = "Missing headers: #{missing_headers.join(", ")}" }
else
new lines.map { row_class.new(_1.to_h.slice(*row_class.attribute_names)) }
end
end

def valid?
error.nil? && present? && valid.count == count
end

def valid
select(&:valid?)
end

def errors
return [] if valid?

[error].compact + flat_map.with_index(2) do |object, line_number|
object.errors.map { _1.full_message + " on line #{line_number}" }
end
end
end
end
end
28 changes: 28 additions & 0 deletions app/services/statements/bulk_creator/statement_row.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Statements
class BulkCreator
class StatementRow
include ActiveModel::Model
include ActiveModel::Attributes

attribute :year, :integer
attribute :month, :integer
attribute :deadline_date, :date
attribute :payment_date, :date
attribute :output_fee, :boolean, default: false

validates :year, inclusion: { in: 2020..2040, message: "must be between 2020 and 2040" }
validates :month, inclusion: { in: 1..12, message: "must be between 1 and 12" }
validates :deadline_date, presence: { message: "must be a date (e.g. YYYY-MM-DD)" }
validates :payment_date, presence: { message: "must be a date (e.g. YYYY-MM-DD)" }

def self.example_csv
<<~CSV.strip
year,month,deadline_date,payment_date,output_fee
2025,2,2024-12-25,2025-01-26,true
2025,3,2025-01-26,2025-02-27,false
2025,4,2025-02-24,2025-03-25,false
CSV
end
end
end
end
3 changes: 3 additions & 0 deletions app/views/npq_separation/admin/cohorts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<%= govuk_button_link_to('Edit cohort details', edit_npq_separation_admin_cohort_path(@cohort)) %>
<%= govuk_button_link_to('Delete cohort', npq_separation_admin_cohort_path(@cohort), method: :delete, warning: true) %>
<% end %>
<% if current_admin.super_admin? %>
<%= govuk_button_link_to('Create statements', new_npq_separation_admin_cohort_statement_path(@cohort), secondary: true) %>
<% end %>

<hr class="govuk-section-break govuk-section-break--l">

Expand Down
16 changes: 16 additions & 0 deletions app/views/npq_separation/admin/statements/_errors.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="govuk-error-summary" data-module="govuk-error-summary">
<div role="alert">
<h2 class="govuk-error-summary__title">There is a problem</h2>
<div class="govuk-error-summary__body">
<ul class="govuk-list govuk-error-summary__list">
<% errors.full_messages.first(20).each do |message| %>
<li><%= message.gsub(' csv ', ' CSV: ') %></li>
<% end %>
<% if errors.count > 20 %>
<li>...and <%= errors.count - 20 %> more errors</li>
<% end %>
</ul>
<p class="govuk-body">Please check the provided files and try again.</p>
</div>
</div>
</div>
Loading

0 comments on commit e87c050

Please sign in to comment.