From d1eb0b6e355e878359b893054eaa664dabcb9625 Mon Sep 17 00:00:00 2001 From: Piv <18462828+Piv200@users.noreply.github.com> Date: Thu, 9 Feb 2023 22:17:24 +1030 Subject: [PATCH] Add progress on overhead allocation conversion from ppm format --- src/move_money.rs | 1 - src/overhead_allocation.rs | 232 +++++++++++++++++++++++++++++++------ 2 files changed, 198 insertions(+), 35 deletions(-) diff --git a/src/move_money.rs b/src/move_money.rs index 943a1a1..35df9e3 100644 --- a/src/move_money.rs +++ b/src/move_money.rs @@ -211,7 +211,6 @@ where // Then run move_money let moved = move_money_2(lines, &rules); - let mut output = output; // Ouput the list moved moneys for money in moved { diff --git a/src/overhead_allocation.rs b/src/overhead_allocation.rs index 0690a48..4de97be 100644 --- a/src/overhead_allocation.rs +++ b/src/overhead_allocation.rs @@ -12,6 +12,16 @@ pub enum DepartmentType { 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")] @@ -92,7 +102,7 @@ pub fn reciprocal_allocation, areas: csv::Reader, cost_centres: csv::Reader, - output: csv::Writer, + output: &mut csv::Writer, use_numeric_accounts: bool, ) -> anyhow::Result<()> where @@ -108,18 +118,21 @@ where .deserialize() .collect::, csv::Error>>()?; + let mut accounts = accounts; + + // TODO: Accounts need to come from actual account fiile let all_accounts_sorted: Vec = if use_numeric_accounts { - lines - .iter() - .map(|line| line.account.clone().parse::().unwrap()) + accounts + .deserialize::() + .map(|line| line.unwrap().code.clone().parse::().unwrap()) .unique() .sorted() .map(|account| account.to_string()) .collect() } else { - lines - .iter() - .map(|line| line.account.clone()) + accounts + .deserialize::() + .map(|line| line.unwrap().code.clone()) .unique() .sorted() .collect() @@ -131,7 +144,9 @@ where .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 + // value is (cc, allocation_statistic, total) + // TODO: This is super slow + let flat_department_costs: HashMap<(String, String), f64> = allocation_statistics .iter() .map(|allocation_statistic| { ( @@ -163,52 +178,181 @@ where .iter() .map(|entry| { ( - entry.0.clone(), - allocation_statistic.0.name.clone(), + (entry.0.clone(), allocation_statistic.0.name.clone()), *entry.1, ) }) - .collect::>() + .collect::>() }) .collect(); // TODO: If ignore negative is used, then set values < 0 to 0 let mut rollups: HashMap>> = HashMap::new(); + let mut area_ccs: 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 headers = cost_centres.headers()?; + headers + .iter() + .filter(|name| name.to_lowercase().starts_with("rollupslot:")) + .for_each(|rollupslot| { + rollups.insert(rollupslot.to_owned(), HashMap::new()); + }); + for cost_centre in cost_centres.deserialize() { + let cost_centre: HashMap = cost_centre?; + let name = cost_centre.get("Code").unwrap(); + let area = cost_centre.get("Area").unwrap(); + 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 mut areas = areas; - let area_name_index = areas - .headers()? + let headers = areas.headers()?; + let limit_tos: Vec = headers .iter() - .position(|header| header == "Name") - .unwrap(); - let allocation_statistic_index = areas - .headers()? - .iter() - .position(|header| header == "AllocationStatistic") - .unwrap(); + .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(); + let mut overhead_ccs: Vec = Vec::new(); // 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?; + for area in areas.deserialize() { + let area: HashMap = 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(); + let area_name = area.get("Name").unwrap(); + let allocation_statistic = area.get("AllocationStatistic").unwrap(); + let department_type: DepartmentType = DepartmentType::from(area.get("Type").unwrap()); + let current_area_ccs = area_ccs.get(area_name); + + if current_area_ccs.is_none() { + continue; + } + + let mut current_area_ccs = current_area_ccs.unwrap().clone(); + + if department_type == DepartmentType::Overhead { + overhead_ccs.append(&mut current_area_ccs); + let overhead_ccs = area_ccs.get(area_name).unwrap(); + // TODO: This depends on the area limit criteria. For now just assuming any limit criteria + let mut limited_ccs: Vec = Vec::new(); + for limit_to in limit_tos.iter() { + // TODO: It is technically possible to have more than one limit to 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 = 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 + .iter() + .flat_map(|overhead_cc| { + limited_ccs + .iter() + .map(|other_cc| { + ( + overhead_cc.clone(), + other_cc.clone(), + flat_department_costs + .get(&(other_cc.clone(), allocation_statistic.clone())) + .map(|f| *f) + .unwrap_or(0.), + ) + }) + .filter(|(_, _, value)| *value != 0.) + }) + .collect(); + overhead_other_total.append(&mut totals); + } } // 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 + // divide the other cc by this summed amount (thus getting the relative cost) - // do reciprocal allocation (only for variable portion of accounts), for each account + // 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 - // Copy across fixed stuff (if necessary, not sure it is)... don't think it's necessary, initial totals handle this + let allocation_rules: Vec = 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_other_total + .iter() + .filter(|cc| cc.1 == *from_overhead_department) + .map(|cc| cc.2) + .sum::(), + to_department_type: if overhead_ccs.contains(&to_department) { + DepartmentType::Overhead + } else { + DepartmentType::Operating + }, + }, + ) + .collect(); + + let mut initial_account_costs: HashMap> = HashMap::new(); + for line in lines { + initial_account_costs + .entry(line.account) + .or_insert(Vec::new()) + .push(TotalDepartmentCost { + department: line.department, + value: line.value, + }); + } + + 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(), + )?; + + for cost in results { + for department in cost.summed_department_costs { + output.serialize(CsvCost { + account: cost.account.clone(), + department: department.department, + value: department.value, + })?; + } + } Ok(()) } @@ -221,9 +365,10 @@ fn split_allocation_statistic_range( split .map(|split| { let range_split = split.split('-').collect::>(); + let start = remove_quote_and_padding(range_split[0]); let start_index = accounts_sorted .iter() - .position(|account| *account == range_split[0].to_owned()) + .position(|account| *account == start) .unwrap(); if range_split.len() == 1 { AllocationStatisticAccountRange { @@ -231,9 +376,10 @@ fn split_allocation_statistic_range( end: start_index, } } else { + let end = remove_quote_and_padding(range_split[1]); let end_index = accounts_sorted .iter() - .position(|account| *account == range_split[1].to_owned()) + .position(|account| *account == end) .unwrap(); AllocationStatisticAccountRange { start: start_index, @@ -244,6 +390,10 @@ fn split_allocation_statistic_range( .collect() } +fn remove_quote_and_padding(s: &str) -> String { + s.trim()[1..s.trim().len() - 1].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 @@ -267,7 +417,6 @@ fn reciprocal_allocation_impl( 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.; } @@ -401,6 +550,7 @@ fn do_solve_reciprocal( #[cfg(test)] mod tests { + use crate::reciprocal_allocation; use crate::AccountCost; use crate::DepartmentType; use crate::OverheadAllocationRule; @@ -486,4 +636,18 @@ mod tests { let result = reciprocal_allocation_impl(allocation_rules, initial_totals).unwrap(); assert_eq!(expected_final_allocations, result); } + + #[test] + fn test_real() { + let result = reciprocal_allocation( + csv::Reader::from_path("output.csv").unwrap(), + csv::Reader::from_path("account.csv").unwrap(), + csv::Reader::from_path("allocstat.csv").unwrap(), + csv::Reader::from_path("area.csv").unwrap(), + csv::Reader::from_path("costcentre.csv").unwrap(), + &mut csv::Writer::from_path("output_alloc_stat.csv").unwrap(), + false, + ); + assert!(result.is_ok()) + } }