Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CPDNPQ-2210] Statement + contract setup #2116

Merged
merged 9 commits into from
Jan 21, 2025
Merged
76 changes: 76 additions & 0 deletions app/controllers/npq_separation/admin/statements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
class NpqSeparation::Admin::StatementsController < NpqSeparation::AdminController
rwrrll marked this conversation as resolved.
Show resolved Hide resolved
before_action :require_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
else
flash[:success] = "#{@statements.count} statements created successfully"
redirect_to npq_separation_admin_cohort_path(@cohort)
end
end

def show
row_class = {
"statements" => Statements::BulkCreator::StatementRow,
"contracts" => Statements::BulkCreator::ContractRow,
}.fetch(params[:id])

send_data row_class.example_csv.lines.first, filename: "#{params[:id]}.csv", type: :csv
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
end
9 changes: 8 additions & 1 deletion app/controllers/npq_separation/admin_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ class NpqSeparation::AdminController < ApplicationController

def require_admin
unless current_admin
flash[:negative] = { title: "Unauthorized", text: "Sign in with your admininstrator account" }
flash[:negative] = { title: "Unauthorized", text: "Sign in with your administrator account" }
redirect_to sign_in_path
end
end

def require_super_admin
unless current_admin.super_admin?
flash[:negative] = { title: "Unauthorized", text: "Sign in with your administrator account" }
redirect_to sign_in_path
end
end
Expand Down
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_rows
.uniq(&:lead_provider_name)
.map { lead_provider_for _1 }

statement_parser.valid_rows.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:, reconcile_amount: 0)
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
jebw marked this conversation as resolved.
Show resolved Hide resolved
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
41 changes: 41 additions & 0 deletions app/services/statements/bulk_creator/parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL bom|utf-8


if lines.none?
new_with_error "No rows found"
elsif (missing_headers = row_class.attribute_names - lines.first.headers).any?
new_with_error "Missing headers: #{missing_headers.join(", ")}"
else
new lines.map { row_class.new(_1.to_h.slice(*row_class.attribute_names)) }
end
rescue CSV::InvalidEncodingError
new_with_error "must be CSV format"
end

def self.new_with_error(error)
new.tap { _1.error = error }
end

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

def valid_rows
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
Loading