use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HrPayrollThresholds {
pub min_calculation_accuracy: f64,
pub tolerance: f64,
}
impl Default for HrPayrollThresholds {
fn default() -> Self {
Self {
min_calculation_accuracy: 0.999,
tolerance: 0.01,
}
}
}
#[derive(Debug, Clone)]
pub struct PayrollLineItemData {
pub employee_id: String,
pub gross_pay: f64,
pub base_pay: f64,
pub overtime_pay: f64,
pub bonus_pay: f64,
pub net_pay: f64,
pub total_deductions: f64,
pub tax_deduction: f64,
pub social_security: f64,
pub health_insurance: f64,
pub retirement: f64,
pub other_deductions: f64,
}
#[derive(Debug, Clone)]
pub struct PayrollRunData {
pub run_id: String,
pub total_net_pay: f64,
pub line_items: Vec<PayrollLineItemData>,
}
#[derive(Debug, Clone)]
pub struct TimeEntryData {
pub employee_id: String,
pub total_hours: f64,
}
#[derive(Debug, Clone)]
pub struct PayrollHoursData {
pub employee_id: String,
pub payroll_hours: f64,
}
#[derive(Debug, Clone)]
pub struct ExpenseReportData {
pub report_id: String,
pub total_amount: f64,
pub line_items_sum: f64,
pub is_approved: bool,
pub has_approver: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HrPayrollEvaluation {
pub gross_to_net_accuracy: f64,
pub component_sum_accuracy: f64,
pub deduction_sum_accuracy: f64,
pub run_sum_accuracy: f64,
pub time_to_payroll_mapping_rate: f64,
pub expense_line_item_sum_accuracy: f64,
pub expense_approval_consistency: f64,
pub total_line_items: usize,
pub total_runs: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct HrPayrollEvaluator {
thresholds: HrPayrollThresholds,
}
impl HrPayrollEvaluator {
pub fn new() -> Self {
Self {
thresholds: HrPayrollThresholds::default(),
}
}
pub fn with_thresholds(thresholds: HrPayrollThresholds) -> Self {
Self { thresholds }
}
pub fn evaluate(
&self,
runs: &[PayrollRunData],
time_entries: &[TimeEntryData],
payroll_hours: &[PayrollHoursData],
expense_reports: &[ExpenseReportData],
) -> EvalResult<HrPayrollEvaluation> {
let mut issues = Vec::new();
let tol = self.thresholds.tolerance;
let all_items: Vec<&PayrollLineItemData> =
runs.iter().flat_map(|r| r.line_items.iter()).collect();
let total_line_items = all_items.len();
let gross_to_net_ok = all_items
.iter()
.filter(|li| (li.net_pay - (li.gross_pay - li.total_deductions)).abs() <= tol)
.count();
let gross_to_net_accuracy = if total_line_items > 0 {
gross_to_net_ok as f64 / total_line_items as f64
} else {
1.0
};
let component_ok = all_items
.iter()
.filter(|li| {
(li.gross_pay - (li.base_pay + li.overtime_pay + li.bonus_pay)).abs() <= tol
})
.count();
let component_sum_accuracy = if total_line_items > 0 {
component_ok as f64 / total_line_items as f64
} else {
1.0
};
let deduction_ok = all_items
.iter()
.filter(|li| {
let computed = li.tax_deduction
+ li.social_security
+ li.health_insurance
+ li.retirement
+ li.other_deductions;
(li.total_deductions - computed).abs() <= tol
})
.count();
let deduction_sum_accuracy = if total_line_items > 0 {
deduction_ok as f64 / total_line_items as f64
} else {
1.0
};
let total_runs = runs.len();
let run_ok = runs
.iter()
.filter(|run| {
let computed_total: f64 = run.line_items.iter().map(|li| li.net_pay).sum();
(run.total_net_pay - computed_total).abs() <= tol
})
.count();
let run_sum_accuracy = if total_runs > 0 {
run_ok as f64 / total_runs as f64
} else {
1.0
};
let time_map: std::collections::HashMap<&str, f64> = time_entries
.iter()
.map(|te| (te.employee_id.as_str(), te.total_hours))
.collect();
let mapped_count = payroll_hours
.iter()
.filter(|ph| {
time_map
.get(ph.employee_id.as_str())
.map(|&hours| (hours - ph.payroll_hours).abs() <= 1.0)
.unwrap_or(false)
})
.count();
let time_to_payroll_mapping_rate = if payroll_hours.is_empty() {
1.0
} else {
mapped_count as f64 / payroll_hours.len() as f64
};
let expense_sum_ok = expense_reports
.iter()
.filter(|er| (er.total_amount - er.line_items_sum).abs() <= tol)
.count();
let expense_line_item_sum_accuracy = if expense_reports.is_empty() {
1.0
} else {
expense_sum_ok as f64 / expense_reports.len() as f64
};
let approved_reports: Vec<&ExpenseReportData> =
expense_reports.iter().filter(|er| er.is_approved).collect();
let approval_consistent = approved_reports.iter().filter(|er| er.has_approver).count();
let expense_approval_consistency = if approved_reports.is_empty() {
1.0
} else {
approval_consistent as f64 / approved_reports.len() as f64
};
let min_acc = self.thresholds.min_calculation_accuracy;
if gross_to_net_accuracy < min_acc {
issues.push(format!(
"Gross-to-net accuracy {gross_to_net_accuracy:.4} < {min_acc:.4}"
));
}
if component_sum_accuracy < min_acc {
issues.push(format!(
"Component sum accuracy {component_sum_accuracy:.4} < {min_acc:.4}"
));
}
if deduction_sum_accuracy < min_acc {
issues.push(format!(
"Deduction sum accuracy {deduction_sum_accuracy:.4} < {min_acc:.4}"
));
}
if run_sum_accuracy < min_acc {
issues.push(format!(
"Run sum accuracy {run_sum_accuracy:.4} < {min_acc:.4}"
));
}
let passes = issues.is_empty();
Ok(HrPayrollEvaluation {
gross_to_net_accuracy,
component_sum_accuracy,
deduction_sum_accuracy,
run_sum_accuracy,
time_to_payroll_mapping_rate,
expense_line_item_sum_accuracy,
expense_approval_consistency,
total_line_items,
total_runs,
passes,
issues,
})
}
}
impl Default for HrPayrollEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn valid_line_item() -> PayrollLineItemData {
PayrollLineItemData {
employee_id: "EMP001".to_string(),
gross_pay: 5000.0,
base_pay: 4000.0,
overtime_pay: 500.0,
bonus_pay: 500.0,
net_pay: 3500.0,
total_deductions: 1500.0,
tax_deduction: 800.0,
social_security: 300.0,
health_insurance: 200.0,
retirement: 150.0,
other_deductions: 50.0,
}
}
#[test]
fn test_valid_payroll() {
let evaluator = HrPayrollEvaluator::new();
let runs = vec![PayrollRunData {
run_id: "PR001".to_string(),
total_net_pay: 3500.0,
line_items: vec![valid_line_item()],
}];
let result = evaluator.evaluate(&runs, &[], &[], &[]).unwrap();
assert!(result.passes);
assert_eq!(result.gross_to_net_accuracy, 1.0);
assert_eq!(result.component_sum_accuracy, 1.0);
assert_eq!(result.run_sum_accuracy, 1.0);
}
#[test]
fn test_broken_gross_to_net() {
let evaluator = HrPayrollEvaluator::new();
let mut item = valid_line_item();
item.net_pay = 4000.0;
let runs = vec![PayrollRunData {
run_id: "PR001".to_string(),
total_net_pay: 4000.0,
line_items: vec![item],
}];
let result = evaluator.evaluate(&runs, &[], &[], &[]).unwrap();
assert!(!result.passes);
assert!(result.gross_to_net_accuracy < 1.0);
}
#[test]
fn test_empty_data() {
let evaluator = HrPayrollEvaluator::new();
let result = evaluator.evaluate(&[], &[], &[], &[]).unwrap();
assert!(result.passes);
}
#[test]
fn test_expense_report_consistency() {
let evaluator = HrPayrollEvaluator::new();
let expenses = vec![
ExpenseReportData {
report_id: "ER001".to_string(),
total_amount: 500.0,
line_items_sum: 500.0,
is_approved: true,
has_approver: true,
},
ExpenseReportData {
report_id: "ER002".to_string(),
total_amount: 300.0,
line_items_sum: 300.0,
is_approved: true,
has_approver: false, },
];
let result = evaluator.evaluate(&[], &[], &[], &expenses).unwrap();
assert_eq!(result.expense_line_item_sum_accuracy, 1.0);
assert_eq!(result.expense_approval_consistency, 0.5);
}
}