902 lines
34 KiB
Rust
902 lines
34 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
io::{Read, Write},
|
|
};
|
|
|
|
use csv::Writer;
|
|
use itertools::Itertools;
|
|
use nalgebra::{zero, DMatrix, Dynamic, LU};
|
|
use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{CsvAccount, CsvCost};
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum DepartmentType {
|
|
Operating,
|
|
Overhead,
|
|
}
|
|
|
|
impl DepartmentType {
|
|
pub fn from(s: &str) -> DepartmentType {
|
|
if s == "P" {
|
|
DepartmentType::Operating
|
|
} else {
|
|
DepartmentType::Overhead
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CsvAllocationStatistic {
|
|
#[serde(rename = "Name")]
|
|
name: String,
|
|
#[serde(rename = "AccountType")]
|
|
account_type: String,
|
|
#[serde(rename = "AccountRanges")]
|
|
account_ranges: String,
|
|
}
|
|
|
|
pub struct AllocationStatisticAccountRange {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
// 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>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct MovedAmount {
|
|
account: String,
|
|
cost_centre: String,
|
|
value: f64,
|
|
from_cost_centre: String,
|
|
}
|
|
|
|
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: &mut csv::Reader<Lines>,
|
|
accounts: &mut csv::Reader<Account>,
|
|
allocation_statistics: &mut csv::Reader<AllocationStatistic>,
|
|
areas: &mut csv::Reader<Area>,
|
|
cost_centres: &mut csv::Reader<CostCentre>,
|
|
// TODO: Receiver method rather than this writer that can accept
|
|
// the raw float results, so we can write in an alternate format
|
|
// that more accurately represents the values on disk
|
|
output: &mut csv::Writer<Output>,
|
|
use_numeric_accounts: bool,
|
|
exclude_negative_allocation_statistics: bool,
|
|
any_limit_criteria: bool,
|
|
account_type: String,
|
|
show_from: bool,
|
|
zero_threshold: f64,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
Lines: Read,
|
|
Account: Read,
|
|
AllocationStatistic: Read,
|
|
Area: Read,
|
|
CostCentre: Read,
|
|
Output: std::io::Write,
|
|
{
|
|
let lines = lines
|
|
.deserialize()
|
|
.collect::<Result<Vec<CsvCost>, csv::Error>>()?;
|
|
|
|
let all_accounts_sorted: Vec<String> = if use_numeric_accounts {
|
|
accounts
|
|
.deserialize::<CsvAccount>()
|
|
.filter(|account| {
|
|
account.is_ok() && account.as_ref().unwrap().account_type == account_type
|
|
})
|
|
.map(|line| line.unwrap().code.clone().parse::<i32>().unwrap())
|
|
.unique()
|
|
.sorted()
|
|
.map(|account| account.to_string())
|
|
.collect()
|
|
} else {
|
|
accounts
|
|
.deserialize::<CsvAccount>()
|
|
.filter(|account| {
|
|
account.is_ok() && account.as_ref().unwrap().account_type == account_type
|
|
})
|
|
.map(|line| line.unwrap().code.clone())
|
|
.unique()
|
|
.sorted()
|
|
.collect()
|
|
};
|
|
|
|
let allocation_statistics = allocation_statistics
|
|
.deserialize::<CsvAllocationStatistic>()
|
|
.filter(|allocation_statistic| {
|
|
allocation_statistic.as_ref().unwrap().account_type == account_type
|
|
})
|
|
.collect::<Result<Vec<CsvAllocationStatistic>, csv::Error>>()?;
|
|
|
|
let split_allocation_ranges: Vec<(String, Vec<AllocationStatisticAccountRange>)> =
|
|
allocation_statistics
|
|
.iter()
|
|
.map(|allocation_statistic| {
|
|
(
|
|
allocation_statistic.name.clone(),
|
|
split_allocation_statistic_range(allocation_statistic, &all_accounts_sorted),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
// For each allocation statistic, sum the cost centres across accounts in the allocaiton statistic range
|
|
// value is (cc, allocation_statistic, total)
|
|
let mut totals: HashMap<(String, String), f64> = HashMap::new();
|
|
for line in lines.iter() {
|
|
let line_index = all_accounts_sorted
|
|
.iter()
|
|
.position(|account| account == &line.account);
|
|
// Skip account as it doesn't exist (likely due to wrong account type)
|
|
if line_index.is_none() {
|
|
continue;
|
|
}
|
|
let line_index = line_index.unwrap();
|
|
// Find the allocation statistics this line is in
|
|
for (allocation_statistic, range) in split_allocation_ranges.iter() {
|
|
if range
|
|
.iter()
|
|
.find(|range| line_index >= range.start && line_index <= range.end)
|
|
.is_some()
|
|
{
|
|
*totals
|
|
.entry((line.department.clone(), allocation_statistic.clone()))
|
|
.or_insert(0.) += line.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If ignore negative is used, then set values < 0 to 0
|
|
if exclude_negative_allocation_statistics {
|
|
for ((_, _), total) in totals.iter_mut() {
|
|
if *total < 0. {
|
|
*total = 0.;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Group ccs by area
|
|
let mut area_ccs: HashMap<String, Vec<String>> = HashMap::new();
|
|
let headers = cost_centres.headers()?;
|
|
// Group ccs by rollup, and group rollups into their slot
|
|
let mut rollups: HashMap<String, HashMap<String, Vec<String>>> = headers
|
|
.iter()
|
|
.filter(|name| name.to_lowercase().starts_with("rollupslot:"))
|
|
.map(|rollupslot| (rollupslot.to_owned(), HashMap::new()))
|
|
.collect();
|
|
for cost_centre in cost_centres.deserialize() {
|
|
let cost_centre: HashMap<String, String> = cost_centre?;
|
|
let name = cost_centre.get("Code").unwrap();
|
|
let area = cost_centre.get("Area").unwrap();
|
|
if area.is_empty() {
|
|
continue;
|
|
}
|
|
area_ccs
|
|
.entry(area.clone())
|
|
.or_insert(Vec::new())
|
|
.push(name.clone());
|
|
|
|
for rollupslot in rollups.iter_mut() {
|
|
let rollup_name = cost_centre.get(rollupslot.0).unwrap();
|
|
rollupslot
|
|
.1
|
|
.entry(rollup_name.clone())
|
|
.or_insert(Vec::new())
|
|
.push(name.clone());
|
|
}
|
|
}
|
|
|
|
let headers = areas.headers()?;
|
|
let limit_tos: Vec<String> = headers
|
|
.iter()
|
|
.filter(|header| header.to_lowercase().starts_with("limitto:"))
|
|
.map(|header| header["limitto:".len()..].to_owned())
|
|
.collect();
|
|
let mut overhead_other_total: Vec<(String, String, f64)> = Vec::new();
|
|
|
|
// Save overhead ccs, so we later know whether a to cc is overhead or operating
|
|
let mut overhead_ccs: HashSet<String> = HashSet::new();
|
|
// For each overhead area, get the cost centres in the area (overhead cost centres), 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.deserialize() {
|
|
let area: HashMap<String, String> = area?;
|
|
// Check for limitTos, should probably somehow build out the list of allocation rules from this point.
|
|
let area_name = area.get("Name").unwrap();
|
|
let allocation_statistic = area.get("AllocationStatistic").unwrap();
|
|
let department_type: DepartmentType = DepartmentType::from(area.get("Type").unwrap());
|
|
|
|
if department_type == DepartmentType::Overhead {
|
|
let current_area_ccs = area_ccs.get(area_name);
|
|
|
|
if current_area_ccs.is_none() {
|
|
continue;
|
|
}
|
|
let current_area_ccs = current_area_ccs.unwrap().clone();
|
|
|
|
for cc in current_area_ccs {
|
|
overhead_ccs.insert(cc);
|
|
}
|
|
let overhead_ccs = area_ccs.get(area_name).unwrap();
|
|
// TODO: This depends on the area limit criteria. For now just doing any limit criteria
|
|
let mut limited_ccs: Vec<String> = Vec::new();
|
|
for limit_to in limit_tos.iter() {
|
|
// TODO: It is technically possible to have more than one limit to (I think?) for a slot, so consider eventually splitting this and doing a foreach
|
|
let limit_value = area.get(&("LimitTo:".to_owned() + limit_to)).unwrap();
|
|
if limit_value.is_empty() {
|
|
continue;
|
|
}
|
|
if limit_to.eq_ignore_ascii_case("costcentre") {
|
|
limited_ccs.push(limit_value.clone());
|
|
} else {
|
|
let mut found_ccs = rollups
|
|
.get(&("RollupSlot:".to_owned() + limit_to))
|
|
.map(|rollups| rollups.get(limit_value))
|
|
.flatten()
|
|
.unwrap()
|
|
.clone();
|
|
limited_ccs.append(&mut found_ccs);
|
|
}
|
|
}
|
|
if limited_ccs.is_empty() {
|
|
let mut other_ccs: Vec<String> = area_ccs
|
|
.values()
|
|
.flat_map(|ccs| ccs.iter().map(|cc| cc.clone()))
|
|
.collect();
|
|
// No limit criteria, use all ccs
|
|
limited_ccs.append(&mut other_ccs);
|
|
}
|
|
let mut totals: Vec<(String, String, f64)> = overhead_ccs
|
|
.par_iter()
|
|
.flat_map(|overhead_cc| {
|
|
let limited = limited_ccs
|
|
.iter()
|
|
.filter(|other_cc| {
|
|
totals.contains_key(&(
|
|
// TODO: This looks terrible
|
|
other_cc.clone().clone(),
|
|
allocation_statistic.clone(),
|
|
))
|
|
})
|
|
.map(|other_cc| {
|
|
(
|
|
overhead_cc.clone(),
|
|
other_cc.clone(),
|
|
totals
|
|
.get(&(other_cc.clone(), allocation_statistic.clone()))
|
|
.map(|f| *f)
|
|
.unwrap(),
|
|
)
|
|
})
|
|
.filter(|(_, _, value)| *value != 0.)
|
|
.filter(|(from_cc, to_cc, _)| from_cc != to_cc)
|
|
.collect_vec();
|
|
// TODO: Put me back if rayon proves problematic
|
|
// Insert is safe, since an overhead cc can only be a part of one area
|
|
// overhead_cc_totals.insert(
|
|
// overhead_cc.clone(),
|
|
// limited.iter().map(|(_, _, value)| value).sum(),
|
|
// );
|
|
limited
|
|
})
|
|
.collect();
|
|
overhead_other_total.append(&mut totals);
|
|
}
|
|
}
|
|
// TODO: This seems to do nothing even in a complex setting where I'd expect it to do something, can probably be removed
|
|
let error_amounts: Vec<usize> = overhead_other_total
|
|
.iter()
|
|
.filter(|(_, _, value)| *value < zero_threshold && *value > -1. * zero_threshold)
|
|
.enumerate()
|
|
.map(|(index, _)| index)
|
|
.collect();
|
|
for index in error_amounts.iter() {
|
|
let (overhead_cc, _, value) = overhead_other_total.remove(*index);
|
|
let non_error_match = overhead_other_total
|
|
.iter_mut()
|
|
.filter(|next| {
|
|
next.0 == *overhead_cc && (next.2 > zero_threshold || next.2 < -1. * zero_threshold)
|
|
})
|
|
.next();
|
|
if let Some((_, _, match_value)) = non_error_match {
|
|
*match_value = *match_value + value;
|
|
}
|
|
}
|
|
|
|
// overhead department -> total (summed limit to costs)
|
|
let mut overhead_cc_totals: HashMap<String, f64> = HashMap::new();
|
|
// Using rayon and doing another pass later proves to be
|
|
for (overhead_cc, _, value) in overhead_other_total.iter() {
|
|
*overhead_cc_totals.entry(overhead_cc.clone()).or_insert(0.) += value;
|
|
}
|
|
|
|
// Export initial totals for operating departments
|
|
if show_from {
|
|
for line in lines.iter() {
|
|
if !overhead_ccs.contains(&line.department) {
|
|
output.serialize(MovedAmount {
|
|
account: line.account.clone(),
|
|
cost_centre: line.department.clone(),
|
|
value: line.value,
|
|
from_cost_centre: line.department.clone(),
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 (thus getting the relative cost)
|
|
|
|
// At this point we convert to our format that's actually used, need to somehow recover the to_cc_type... could build that out from the areas
|
|
|
|
let allocation_rules: Vec<OverheadAllocationRule> = overhead_other_total
|
|
.iter()
|
|
.map(
|
|
|(from_overhead_department, to_department, percent)| OverheadAllocationRule {
|
|
from_overhead_department: from_overhead_department.clone(),
|
|
to_department: to_department.clone(),
|
|
percent: percent / overhead_cc_totals.get(from_overhead_department).unwrap(),
|
|
to_department_type: if overhead_ccs.contains(to_department) {
|
|
DepartmentType::Overhead
|
|
} else {
|
|
DepartmentType::Operating
|
|
},
|
|
},
|
|
)
|
|
.collect();
|
|
|
|
let mut initial_account_costs: HashMap<String, Vec<TotalDepartmentCost>> = HashMap::new();
|
|
for line in lines {
|
|
// Only include accounts we've already filtered on (i.e. by account type)
|
|
if all_accounts_sorted
|
|
.iter()
|
|
.find(|account| **account == line.account)
|
|
.is_some()
|
|
{
|
|
initial_account_costs
|
|
.entry(line.account)
|
|
.or_insert(Vec::new())
|
|
.push(TotalDepartmentCost {
|
|
department: line.department,
|
|
value: line.value,
|
|
});
|
|
}
|
|
}
|
|
|
|
// TODO: (Consider) We could actually cheat here and not use this matrix implementation at all (and thus be more
|
|
// memory efficient, but maybe slower)
|
|
// Since we know each operating department in an account will get the proportion of the total overhead amount relative
|
|
// according to its operating amount from the total amount of the overhead departments, we can just directly calculate
|
|
// these totals and do some simple multiplications (it does get trickier with multiple accounts, as the cost drivers
|
|
// are consistent across all accounts, but depend on the allocation statistic to determine which lines to pick from).
|
|
let results = reciprocal_allocation_impl(
|
|
allocation_rules,
|
|
initial_account_costs
|
|
.into_iter()
|
|
.map(|(account, total_cost)| AccountCost {
|
|
account: account,
|
|
summed_department_costs: total_cost,
|
|
})
|
|
.collect(),
|
|
if show_from { Some(output) } else { None },
|
|
zero_threshold,
|
|
)?;
|
|
|
|
if !show_from {
|
|
for cost in results {
|
|
for department in cost.summed_department_costs {
|
|
// Any consumers should assume missing cc/account value was 0 (we already ignore overhead, as they all 0 out)
|
|
if department.value > 0.00001 || department.value < -0.00001 {
|
|
output.serialize(CsvCost {
|
|
account: cost.account.clone(),
|
|
department: department.department,
|
|
value: department.value,
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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, so use a regex
|
|
let split = allocation_statistic.account_ranges.split(";");
|
|
split
|
|
.map(|split| {
|
|
let range_split = split.split('-').collect::<Vec<_>>();
|
|
let start = remove_quote_and_padding(range_split[0]);
|
|
let start_index = accounts_sorted
|
|
.iter()
|
|
.position(|account| *account == start)
|
|
.unwrap();
|
|
if range_split.len() == 1 {
|
|
AllocationStatisticAccountRange {
|
|
start: start_index,
|
|
end: start_index,
|
|
}
|
|
} else {
|
|
let end = remove_quote_and_padding(range_split[1]);
|
|
let end_index = accounts_sorted
|
|
.iter()
|
|
.position(|account| *account == end)
|
|
.unwrap();
|
|
AllocationStatisticAccountRange {
|
|
start: start_index,
|
|
end: end_index,
|
|
}
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// Removes quotes and padding from accounts int he allocation statistic account range.
|
|
// e.g. "'100' " becomes "100"
|
|
fn remove_quote_and_padding(s: &str) -> String {
|
|
if s.contains('\'') {
|
|
s.trim()[1..s.trim().len() - 1].to_owned()
|
|
} else {
|
|
s.trim().to_owned()
|
|
}
|
|
}
|
|
|
|
// 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<W: Write>(
|
|
allocations: Vec<OverheadAllocationRule>,
|
|
account_costs: Vec<AccountCost>,
|
|
movement_writer: Option<&mut csv::Writer<W>>,
|
|
zero_threshold: f64,
|
|
) -> 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();
|
|
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,
|
|
movement_writer,
|
|
zero_threshold,
|
|
)
|
|
} else {
|
|
do_solve_reciprocal(
|
|
mat.lu(),
|
|
account_costs,
|
|
overhead_department_mappings,
|
|
allocations,
|
|
movement_writer,
|
|
zero_threshold,
|
|
)
|
|
}
|
|
}
|
|
|
|
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 + Sync + Send>(
|
|
solver: T,
|
|
account_costs: Vec<AccountCost>,
|
|
overhead_department_mappings: HashMap<String, usize>,
|
|
allocations: Vec<OverheadAllocationRule>,
|
|
temp_writer: Option<&mut Writer<impl Write>>,
|
|
zero_threshold: f64,
|
|
) -> 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 * operating_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 temp_writer = temp_writer;
|
|
if let Some(temp_writer) = temp_writer.as_mut() {
|
|
solve_reciprocal_with_from(
|
|
solver,
|
|
account_costs,
|
|
overhead_department_mappings,
|
|
operating_department_mappings,
|
|
operating_overhead_mappings_mat,
|
|
temp_writer,
|
|
zero_threshold,
|
|
)?;
|
|
Ok(vec![])
|
|
} else {
|
|
Ok(solve_reciprocal_no_from(
|
|
solver,
|
|
account_costs,
|
|
overhead_department_mappings,
|
|
operating_department_mappings,
|
|
operating_overhead_mappings_mat,
|
|
))
|
|
}
|
|
}
|
|
|
|
fn solve_reciprocal_no_from(
|
|
solver: impl ReciprocalAllocationSolver + Sync + Send,
|
|
account_costs: Vec<AccountCost>,
|
|
overhead_department_mappings: HashMap<String, usize>,
|
|
operating_department_mappings: HashMap<String, usize>,
|
|
operating_overhead_mappings_mat: DMatrix<f64>,
|
|
) -> Vec<AccountCost> {
|
|
account_costs
|
|
.par_iter()
|
|
// .filter(|cost| cost.account == "A480200")
|
|
.map(|total_costs| {
|
|
// To get the from/to ccs like ppm does, we ignore the initial totals. Then for each overhead cc,
|
|
// we zero out all the calculated overheads except for this cc and do
|
|
// operating_overhead_mappings * calculated_overheads (basically the first part of the normal calculation)
|
|
|
|
// 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();
|
|
// Redistribute floating point errors (only for ccs we actually allocated from/to)
|
|
// Considered removing this since redistribution should be done in cost driver calculations, however since that usually
|
|
// does nothing, we may as well keep this just in case.
|
|
|
|
// TODO: Not sure we actually need this, would probably be better to have a better storage format than
|
|
// csv/string conversions
|
|
// let initial_cost: f64 = total_costs
|
|
// .summed_department_costs
|
|
// .iter()
|
|
// .filter(|cost| {
|
|
// operating_department_mappings.contains_key(&cost.department)
|
|
// || overhead_department_mappings.contains_key(&cost.department)
|
|
// })
|
|
// .map(|cost| cost.value)
|
|
// .sum();
|
|
// let new_cost: f64 = converted_result.iter().map(|cost| cost.value).sum();
|
|
// let diff = initial_cost - new_cost;
|
|
|
|
AccountCost {
|
|
account: total_costs.account.clone(),
|
|
summed_department_costs: converted_result
|
|
.into_iter()
|
|
.map(|cost| TotalDepartmentCost {
|
|
department: cost.department,
|
|
value: cost.value, // + if new_cost == 0_f64 || diff == 0_f64 {
|
|
// 0_f64
|
|
// } else {
|
|
// cost.value / new_cost * diff
|
|
// },
|
|
})
|
|
.collect(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn solve_reciprocal_with_from<T: ReciprocalAllocationSolver + Sync + Send>(
|
|
solver: T,
|
|
total_costs: Vec<AccountCost>,
|
|
overhead_department_mappings: HashMap<String, usize>,
|
|
operating_department_mappings: HashMap<String, usize>,
|
|
operating_overhead_mappings: DMatrix<f64>,
|
|
temp_writer: &mut Writer<impl Write>,
|
|
zero_threshold: f64,
|
|
) -> anyhow::Result<()> {
|
|
for total_costs in total_costs {
|
|
let moved_amounts: Vec<MovedAmount> = total_costs
|
|
.summed_department_costs
|
|
.par_iter()
|
|
.filter(|overhead_department_cost| {
|
|
overhead_department_mappings.contains_key(&overhead_department_cost.department)
|
|
&& overhead_department_cost.value != 0_f64
|
|
})
|
|
.flat_map(|overhead_department_cost| {
|
|
let mut overhead_slice_costs = vec![0.; overhead_department_mappings.len()];
|
|
overhead_slice_costs[*overhead_department_mappings
|
|
.get(&overhead_department_cost.department)
|
|
.unwrap()] = overhead_department_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 calculated = &operating_overhead_mappings * calculated_overheads;
|
|
operating_department_mappings
|
|
.iter()
|
|
.map(|(department, index)| (department, *calculated.get(*index).unwrap()))
|
|
.map(|(department, value)| MovedAmount {
|
|
account: total_costs.account.clone(),
|
|
cost_centre: department.clone(),
|
|
value: value,
|
|
from_cost_centre: department.clone(),
|
|
})
|
|
.collect::<Vec<MovedAmount>>()
|
|
})
|
|
.collect();
|
|
|
|
for moved_amount in moved_amounts {
|
|
temp_writer.serialize(moved_amount)?;
|
|
}
|
|
}
|
|
temp_writer.flush().unwrap();
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs::File;
|
|
|
|
use crate::reciprocal_allocation;
|
|
use crate::AccountCost;
|
|
use crate::DepartmentType;
|
|
use crate::OverheadAllocationRule;
|
|
use crate::TotalDepartmentCost;
|
|
|
|
use super::reciprocal_allocation_impl;
|
|
use super::MovedAmount;
|
|
|
|
#[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 mut movement_writer = csv::Writer::from_path("test_output.csv").unwrap();
|
|
let result = reciprocal_allocation_impl::<File>(
|
|
allocation_rules,
|
|
initial_totals,
|
|
Some(&mut movement_writer),
|
|
0.00001,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(expected_final_allocations, result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_real() {
|
|
let result = reciprocal_allocation(
|
|
&mut csv::Reader::from_path("test_line.csv").unwrap(),
|
|
&mut csv::Reader::from_path("test_account.csv").unwrap(),
|
|
&mut csv::Reader::from_path("test_alloc_stat.csv").unwrap(),
|
|
&mut csv::Reader::from_path("test_area.csv").unwrap(),
|
|
&mut csv::Reader::from_path("test_costcentre.csv").unwrap(),
|
|
&mut csv::Writer::from_path("test_output_alloc_stat.csv").unwrap(),
|
|
true,
|
|
false,
|
|
true,
|
|
"E".to_owned(),
|
|
false,
|
|
0.1,
|
|
);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_real() {
|
|
let result = reciprocal_allocation(
|
|
&mut csv::Reader::from_path("output.csv").unwrap(),
|
|
&mut csv::Reader::from_path("account.csv").unwrap(),
|
|
&mut csv::Reader::from_path("allocstat.csv").unwrap(),
|
|
&mut csv::Reader::from_path("area.csv").unwrap(),
|
|
&mut csv::Reader::from_path("costcentre.csv").unwrap(),
|
|
&mut csv::Writer::from_path("output_alloc_stat.csv").unwrap(),
|
|
false,
|
|
false,
|
|
true,
|
|
"E".to_owned(),
|
|
true,
|
|
0.001,
|
|
);
|
|
assert!(result.is_ok())
|
|
}
|
|
}
|