490 lines
18 KiB
Rust
490 lines
18 KiB
Rust
use std::{collections::HashMap, io::Read, path::Path};
|
|
|
|
use itertools::Itertools;
|
|
use nalgebra::{DMatrix, Dynamic, LU};
|
|
use serde::Deserialize;
|
|
|
|
use crate::CsvCost;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum DepartmentType {
|
|
Operating,
|
|
Overhead,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CsvAllocationStatistic {
|
|
#[serde(rename = "Name")]
|
|
name: String,
|
|
#[serde(rename = "Description")]
|
|
description: Option<String>,
|
|
#[serde(rename = "AccountType")]
|
|
account_type: String,
|
|
#[serde(rename = "AccountRanges")]
|
|
account_ranges: String,
|
|
}
|
|
|
|
pub struct AllocationStatisticAccountRange {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CsvAccount {
|
|
#[serde(rename = "Code")]
|
|
code: String,
|
|
#[serde(rename = "Description")]
|
|
description: Option<String>,
|
|
#[serde(rename = "Type")]
|
|
account_type: String,
|
|
#[serde(rename = "CostOutput")]
|
|
cost_output: Option<String>,
|
|
#[serde(rename = "PercentFixed")]
|
|
percent_fixed: f64,
|
|
}
|
|
|
|
type CsvCostCentre = HashMap<String, String>;
|
|
|
|
type CsvArea = HashMap<String, String>;
|
|
|
|
// Note: remember these are overhead departments only when calculating the lu decomposition or pseudoinverse, and for each department,
|
|
// you either need -1 or rest negative for a row to subtract the initial amounts so we end up effectively 0 (simultaneous equations end
|
|
// up with negative there so yes this is expected)
|
|
pub struct OverheadAllocationRule {
|
|
from_overhead_department: String,
|
|
to_department: String,
|
|
percent: f64,
|
|
to_department_type: DepartmentType,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct TotalDepartmentCost {
|
|
department: String,
|
|
value: f64,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct AccountCost {
|
|
account: String,
|
|
summed_department_costs: Vec<TotalDepartmentCost>,
|
|
}
|
|
|
|
// TODO: Also need a way to dictate the order of the departments?
|
|
pub trait ReciprocalAllocationSolver {
|
|
fn solve(&self, costs: &DMatrix<f64>) -> DMatrix<f64>;
|
|
}
|
|
|
|
impl ReciprocalAllocationSolver for LU<f64, Dynamic, Dynamic> {
|
|
fn solve(&self, costs: &DMatrix<f64>) -> DMatrix<f64> {
|
|
self.solve(costs).unwrap()
|
|
}
|
|
}
|
|
|
|
impl ReciprocalAllocationSolver for DMatrix<f64> {
|
|
fn solve(&self, costs: &DMatrix<f64>) -> DMatrix<f64> {
|
|
self * costs
|
|
}
|
|
}
|
|
|
|
pub fn reciprocal_allocation<Lines, Account, AllocationStatistic, Area, CostCentre, Output>(
|
|
lines: csv::Reader<Lines>,
|
|
accounts: csv::Reader<Account>,
|
|
allocation_statistics: csv::Reader<AllocationStatistic>,
|
|
areas: csv::Reader<Area>,
|
|
cost_centres: csv::Reader<CostCentre>,
|
|
output: csv::Writer<Output>,
|
|
use_numeric_accounts: bool,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
Lines: Read,
|
|
Account: Read,
|
|
AllocationStatistic: Read,
|
|
Area: Read,
|
|
CostCentre: Read,
|
|
Output: std::io::Write,
|
|
{
|
|
let mut lines_reader = lines;
|
|
let lines = lines_reader
|
|
.deserialize()
|
|
.collect::<Result<Vec<CsvCost>, csv::Error>>()?;
|
|
|
|
let all_accounts_sorted: Vec<String> = if use_numeric_accounts {
|
|
lines
|
|
.iter()
|
|
.map(|line| line.account.clone().parse::<i32>().unwrap())
|
|
.unique()
|
|
.sorted()
|
|
.map(|account| account.to_string())
|
|
.collect()
|
|
} else {
|
|
lines
|
|
.iter()
|
|
.map(|line| line.account.clone())
|
|
.unique()
|
|
.sorted()
|
|
.collect()
|
|
};
|
|
|
|
let mut allocation_statistics_reader = allocation_statistics;
|
|
let allocation_statistics = allocation_statistics_reader
|
|
.deserialize()
|
|
.collect::<Result<Vec<CsvAllocationStatistic>, csv::Error>>()?;
|
|
|
|
// For each allocation statistic, sum the cost centres across accounts in the allocaiton statistic range
|
|
let flat_department_costs: Vec<(String, String, f64)> = allocation_statistics
|
|
.iter()
|
|
.map(|allocation_statistic| {
|
|
(
|
|
allocation_statistic,
|
|
split_allocation_statistic_range(allocation_statistic, &all_accounts_sorted),
|
|
)
|
|
})
|
|
.flat_map(|allocation_statistic| {
|
|
let mut total_department_costs: HashMap<String, f64> = HashMap::new();
|
|
lines
|
|
.iter()
|
|
.filter(|line| {
|
|
let line_index = all_accounts_sorted
|
|
.iter()
|
|
.position(|account| account == &line.account)
|
|
.unwrap();
|
|
allocation_statistic
|
|
.1
|
|
.iter()
|
|
.find(|range| line_index >= range.start && line_index <= range.end)
|
|
.is_some()
|
|
})
|
|
.for_each(|line| {
|
|
*total_department_costs
|
|
.entry(line.department.clone())
|
|
.or_insert(0.) += line.value;
|
|
});
|
|
total_department_costs
|
|
.iter()
|
|
.map(|entry| {
|
|
(
|
|
entry.0.clone(),
|
|
allocation_statistic.0.name.clone(),
|
|
*entry.1,
|
|
)
|
|
})
|
|
.collect::<Vec<(String, String, f64)>>()
|
|
})
|
|
.collect();
|
|
// TODO: If ignore negative is used, then set values < 0 to 0
|
|
|
|
let mut rollups: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
|
let mut cost_centres = cost_centres;
|
|
for cost_centre in cost_centres.records() {
|
|
let cost_centre = cost_centre?;
|
|
// Extract rollups, used later with the areas... I could do a map of rollups -> cc's, or just a list of rollups on each cc struct
|
|
// I think map of rollup -> cc would be better, although this would need to be for each rollup slot... so a map of maps?
|
|
}
|
|
|
|
let mut areas = areas;
|
|
let area_name_index = areas
|
|
.headers()?
|
|
.iter()
|
|
.position(|header| header == "Name")
|
|
.unwrap();
|
|
let allocation_statistic_index = areas
|
|
.headers()?
|
|
.iter()
|
|
.position(|header| header == "AllocationStatistic")
|
|
.unwrap();
|
|
// For each overhead area, get the cost centres in the area, and get all cost centres
|
|
// that fit the limit to criteria for the area (skip any cases of overhead cc = other cc).
|
|
// Then get the totals for the other ccs, by looking in the flat_department_costs, where the
|
|
// allocation statistic matches the allocation statistic for this area
|
|
for area in areas.records() {
|
|
let area = area?;
|
|
// Check for limitTos, should probably somehow build out the list of allocation rules from this point.
|
|
let area_name = area.get(area_name_index).unwrap();
|
|
let allocation_statistic = area.get(allocation_statistic_index).unwrap();
|
|
}
|
|
|
|
// Finally, for each cc match total produced previously, sum the overhead cc where overhead cc appears in other cc, then
|
|
// divide the other cc by this summed amount
|
|
|
|
// do reciprocal allocation (only for variable portion of accounts), for each account
|
|
|
|
// Copy across fixed stuff (if necessary, not sure it is)... don't think it's necessary, initial totals handle this
|
|
Ok(())
|
|
}
|
|
|
|
fn split_allocation_statistic_range(
|
|
allocation_statistic: &CsvAllocationStatistic,
|
|
accounts_sorted: &Vec<String>,
|
|
) -> Vec<AllocationStatisticAccountRange> {
|
|
// TODO: This split needs to be more comprehensive so that we don't split between quotes
|
|
let split = allocation_statistic.account_ranges.split(";");
|
|
split
|
|
.map(|split| {
|
|
let range_split = split.split('-').collect::<Vec<_>>();
|
|
let start_index = accounts_sorted
|
|
.iter()
|
|
.position(|account| *account == range_split[0].to_owned())
|
|
.unwrap();
|
|
if range_split.len() == 1 {
|
|
AllocationStatisticAccountRange {
|
|
start: start_index,
|
|
end: start_index,
|
|
}
|
|
} else {
|
|
let end_index = accounts_sorted
|
|
.iter()
|
|
.position(|account| *account == range_split[1].to_owned())
|
|
.unwrap();
|
|
AllocationStatisticAccountRange {
|
|
start: start_index,
|
|
end: end_index,
|
|
}
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// Perform the reciprocal allocation (matrix) method to allocate servicing departments (indirect) costs
|
|
// to functional departments. Basically just a matrix solve, uses regression (moore-penrose pseudoinverse) when
|
|
// matrix is singular
|
|
fn reciprocal_allocation_impl(
|
|
allocations: Vec<OverheadAllocationRule>,
|
|
account_costs: Vec<AccountCost>,
|
|
// TODO: Throw an appropriate error
|
|
) -> anyhow::Result<Vec<AccountCost>> {
|
|
let overhead_department_mappings = get_rules_indexes(&allocations, DepartmentType::Overhead);
|
|
|
|
let mut slice_allocations =
|
|
vec![0.; overhead_department_mappings.len() * overhead_department_mappings.len()];
|
|
|
|
for allocation in allocations
|
|
.iter()
|
|
.filter(|allocation| allocation.to_department_type == DepartmentType::Overhead)
|
|
{
|
|
let from_index = overhead_department_mappings
|
|
.get(&allocation.from_overhead_department)
|
|
.unwrap();
|
|
let to_index = overhead_department_mappings
|
|
.get(&allocation.to_department)
|
|
.unwrap();
|
|
// TODO: Check if dmatrix is row or column major order, would need to flip if column major
|
|
slice_allocations[from_index * overhead_department_mappings.len() + to_index] =
|
|
allocation.percent * -1.;
|
|
}
|
|
|
|
let mut mat: DMatrix<f64> = DMatrix::from_vec(
|
|
overhead_department_mappings.len(),
|
|
overhead_department_mappings.len(),
|
|
slice_allocations,
|
|
);
|
|
mat.fill_diagonal(1.);
|
|
|
|
if mat.determinant() == 0. {
|
|
let pseudo_inverse = mat.svd(true, true).pseudo_inverse(0.000001);
|
|
do_solve_reciprocal(
|
|
pseudo_inverse.unwrap(),
|
|
account_costs,
|
|
overhead_department_mappings,
|
|
allocations,
|
|
)
|
|
} else {
|
|
do_solve_reciprocal(
|
|
mat.lu(),
|
|
account_costs,
|
|
overhead_department_mappings,
|
|
allocations,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn get_rules_indexes(
|
|
allocations: &Vec<OverheadAllocationRule>,
|
|
department_type: DepartmentType,
|
|
) -> HashMap<String, usize> {
|
|
allocations
|
|
.iter()
|
|
.filter(|allocation| allocation.to_department_type == department_type)
|
|
.flat_map(|department| {
|
|
if department.to_department_type == DepartmentType::Operating {
|
|
vec![department.to_department.clone()]
|
|
} else {
|
|
vec![
|
|
department.from_overhead_department.clone(),
|
|
department.to_department.clone(),
|
|
]
|
|
}
|
|
})
|
|
.unique()
|
|
.enumerate()
|
|
.map(|(index, department)| (department, index))
|
|
.collect()
|
|
}
|
|
|
|
fn do_solve_reciprocal<T: ReciprocalAllocationSolver>(
|
|
solver: T,
|
|
account_costs: Vec<AccountCost>,
|
|
overhead_department_mappings: HashMap<String, usize>,
|
|
allocations: Vec<OverheadAllocationRule>,
|
|
) -> anyhow::Result<Vec<AccountCost>> {
|
|
let operating_department_mappings = get_rules_indexes(&allocations, DepartmentType::Operating);
|
|
let mut operating_overhead_mappings =
|
|
vec![0.; overhead_department_mappings.len() * operating_department_mappings.len()];
|
|
for rule in allocations {
|
|
if rule.to_department_type == DepartmentType::Operating {
|
|
let from_index = *overhead_department_mappings
|
|
.get(&rule.from_overhead_department)
|
|
.unwrap();
|
|
let to_index = *operating_department_mappings
|
|
.get(&rule.to_department)
|
|
.unwrap();
|
|
operating_overhead_mappings
|
|
[from_index * overhead_department_mappings.len() + to_index] = rule.percent;
|
|
}
|
|
}
|
|
let operating_overhead_mappings_mat: DMatrix<f64> = DMatrix::from_vec(
|
|
operating_department_mappings.len(),
|
|
overhead_department_mappings.len(),
|
|
operating_overhead_mappings,
|
|
);
|
|
let mut final_account_costs: Vec<AccountCost> = Vec::with_capacity(account_costs.len());
|
|
for total_costs in account_costs {
|
|
// TODO: There has to be a cleaner way to do this, perhaps by presorting things?
|
|
let mut overhead_slice_costs = vec![0.; overhead_department_mappings.len()];
|
|
for cost in total_costs.summed_department_costs.iter() {
|
|
if overhead_department_mappings.contains_key(&cost.department) {
|
|
overhead_slice_costs[*overhead_department_mappings.get(&cost.department).unwrap()] =
|
|
cost.value
|
|
}
|
|
}
|
|
let overhead_costs_vec: DMatrix<f64> =
|
|
DMatrix::from_row_slice(overhead_department_mappings.len(), 1, &overhead_slice_costs);
|
|
let calculated_overheads = solver.solve(&overhead_costs_vec);
|
|
|
|
let mut operating_slice_costs = vec![0.; operating_department_mappings.len()];
|
|
for cost in total_costs.summed_department_costs {
|
|
if operating_department_mappings.contains_key(&cost.department) {
|
|
let elem = &mut operating_slice_costs
|
|
[*operating_department_mappings.get(&cost.department).unwrap()];
|
|
*elem = cost.value;
|
|
}
|
|
}
|
|
let operating_costs_vec: DMatrix<f64> = DMatrix::from_row_slice(
|
|
operating_department_mappings.len(),
|
|
1,
|
|
&operating_slice_costs,
|
|
);
|
|
|
|
// Borrow so we don't move between loops
|
|
let operating_overhead_mappings = &operating_overhead_mappings_mat;
|
|
let calculated_overheads = &calculated_overheads;
|
|
|
|
// Calculation: operating_overhead_usage . calculated_overheads + initial_totals
|
|
// Where operating_overhead_usage is the direct mapping from overhead -> operating department, calculated overheads is the
|
|
// solved overheads usages after taking into account usage between departments, and initial_totals is the initial values
|
|
// for the operating departments.
|
|
let calculated = operating_overhead_mappings * calculated_overheads + operating_costs_vec;
|
|
|
|
let converted_result: Vec<TotalDepartmentCost> = operating_department_mappings
|
|
.iter()
|
|
.map(|(department, index)| TotalDepartmentCost {
|
|
department: department.clone(),
|
|
value: *calculated.get(*index).unwrap(),
|
|
})
|
|
.collect();
|
|
final_account_costs.push(AccountCost {
|
|
account: total_costs.account,
|
|
summed_department_costs: converted_result,
|
|
});
|
|
}
|
|
Ok(final_account_costs)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::AccountCost;
|
|
use crate::DepartmentType;
|
|
use crate::OverheadAllocationRule;
|
|
use crate::TotalDepartmentCost;
|
|
|
|
use super::reciprocal_allocation_impl;
|
|
|
|
#[test]
|
|
fn test_basic() {
|
|
let allocation_rules = vec![
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Y".to_owned(),
|
|
to_department: "Z".to_owned(),
|
|
percent: 0.2,
|
|
to_department_type: DepartmentType::Overhead,
|
|
},
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Z".to_owned(),
|
|
to_department: "Y".to_owned(),
|
|
percent: 0.3,
|
|
to_department_type: DepartmentType::Overhead,
|
|
},
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Y".to_owned(),
|
|
to_department: "A".to_owned(),
|
|
percent: 0.4,
|
|
to_department_type: DepartmentType::Operating,
|
|
},
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Y".to_owned(),
|
|
to_department: "B".to_owned(),
|
|
percent: 0.4,
|
|
to_department_type: DepartmentType::Operating,
|
|
},
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Z".to_owned(),
|
|
to_department: "A".to_owned(),
|
|
percent: 0.2,
|
|
to_department_type: DepartmentType::Operating,
|
|
},
|
|
OverheadAllocationRule {
|
|
from_overhead_department: "Z".to_owned(),
|
|
to_department: "B".to_owned(),
|
|
percent: 0.5,
|
|
to_department_type: DepartmentType::Operating,
|
|
},
|
|
];
|
|
let initial_totals = vec![AccountCost {
|
|
account: "Default".to_owned(),
|
|
summed_department_costs: vec![
|
|
TotalDepartmentCost {
|
|
department: "Y".to_owned(),
|
|
value: 7260.,
|
|
},
|
|
TotalDepartmentCost {
|
|
department: "Z".to_owned(),
|
|
value: 4000.,
|
|
},
|
|
TotalDepartmentCost {
|
|
department: "A".to_owned(),
|
|
value: 12000.,
|
|
},
|
|
TotalDepartmentCost {
|
|
department: "B".to_owned(),
|
|
value: 16000.,
|
|
},
|
|
],
|
|
}];
|
|
let expected_final_allocations = vec![AccountCost {
|
|
account: "Default".to_owned(),
|
|
summed_department_costs: vec![
|
|
TotalDepartmentCost {
|
|
department: "A".to_owned(),
|
|
value: 16760.,
|
|
},
|
|
TotalDepartmentCost {
|
|
department: "B".to_owned(),
|
|
value: 22500.,
|
|
},
|
|
],
|
|
}];
|
|
|
|
let result = reciprocal_allocation_impl(allocation_rules, initial_totals).unwrap();
|
|
assert_eq!(expected_final_allocations, result);
|
|
}
|
|
}
|