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, #[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, #[serde(rename = "Type")] account_type: String, #[serde(rename = "CostOutput")] cost_output: Option, #[serde(rename = "PercentFixed")] percent_fixed: f64, } type CsvCostCentre = HashMap; type CsvArea = HashMap; // 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, } // TODO: Also need a way to dictate the order of the departments? pub trait ReciprocalAllocationSolver { fn solve(&self, costs: &DMatrix) -> DMatrix; } impl ReciprocalAllocationSolver for LU { fn solve(&self, costs: &DMatrix) -> DMatrix { self.solve(costs).unwrap() } } impl ReciprocalAllocationSolver for DMatrix { fn solve(&self, costs: &DMatrix) -> DMatrix { self * costs } } pub fn reciprocal_allocation( lines: csv::Reader, accounts: csv::Reader, allocation_statistics: csv::Reader, areas: csv::Reader, cost_centres: csv::Reader, output: csv::Writer, 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::, csv::Error>>()?; let all_accounts_sorted: Vec = if use_numeric_accounts { lines .iter() .map(|line| line.account.clone().parse::().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::, 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 = 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::>() }) .collect(); // TODO: If ignore negative is used, then set values < 0 to 0 let mut rollups: HashMap>> = 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, ) -> Vec { // 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::>(); 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, account_costs: Vec, // TODO: Throw an appropriate error ) -> anyhow::Result> { 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 = 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, department_type: DepartmentType, ) -> HashMap { 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( solver: T, account_costs: Vec, overhead_department_mappings: HashMap, allocations: Vec, ) -> anyhow::Result> { 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 = DMatrix::from_vec( operating_department_mappings.len(), overhead_department_mappings.len(), operating_overhead_mappings, ); let mut final_account_costs: Vec = 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 = 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 = 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 = 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); } }