use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::ProductId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumptionRecord {
pub product_id: Option<ProductId>,
pub sku: String,
pub quantity: Decimal,
pub consumed_at: NaiveDate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumptionRow {
pub sku: String,
pub total_quantity: Decimal,
pub event_count: u64,
pub avg_daily: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumptionReport {
pub from: NaiveDate,
pub to: NaiveDate,
pub days: i64,
pub rows: Vec<ConsumptionRow>,
pub total_quantity: Decimal,
}
#[must_use]
pub fn window_days(from: NaiveDate, to: NaiveDate) -> i64 {
((to - from).num_days() + 1).max(1)
}
#[must_use]
pub fn compute_consumption(
records: &[ConsumptionRecord],
from: NaiveDate,
to: NaiveDate,
) -> ConsumptionReport {
use std::collections::BTreeMap;
let days = window_days(from, to);
let days_dec = Decimal::from(days);
let mut by_sku: BTreeMap<String, (Decimal, u64)> = BTreeMap::new();
let mut total_quantity = Decimal::ZERO;
for rec in records {
if rec.consumed_at < from || rec.consumed_at > to {
continue;
}
total_quantity += rec.quantity;
let entry = by_sku.entry(rec.sku.clone()).or_insert((Decimal::ZERO, 0));
entry.0 += rec.quantity;
entry.1 += 1;
}
let mut rows: Vec<ConsumptionRow> = by_sku
.into_iter()
.map(|(sku, (total, count))| ConsumptionRow {
sku,
total_quantity: total,
event_count: count,
avg_daily: (total / days_dec).round_dp(4),
})
.collect();
rows.sort_by(|a, b| b.total_quantity.cmp(&a.total_quantity).then(a.sku.cmp(&b.sku)));
ConsumptionReport { from, to, days, rows, total_quantity }
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use rust_decimal_macros::dec;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn rec(sku: &str, qty: Decimal, date: NaiveDate) -> ConsumptionRecord {
ConsumptionRecord { product_id: None, sku: sku.into(), quantity: qty, consumed_at: date }
}
#[test]
fn window_days_inclusive() {
assert_eq!(window_days(day(2026, 6, 1), day(2026, 6, 30)), 30);
assert_eq!(window_days(day(2026, 6, 1), day(2026, 6, 1)), 1);
}
#[test]
fn aggregates_and_rates() {
let from = day(2026, 6, 1);
let to = day(2026, 6, 10); let records = vec![
rec("a", dec!(20), day(2026, 6, 2)),
rec("a", dec!(30), day(2026, 6, 5)),
rec("b", dec!(5), day(2026, 6, 3)),
];
let report = compute_consumption(&records, from, to);
assert_eq!(report.days, 10);
assert_eq!(report.total_quantity, dec!(55));
assert_eq!(report.rows[0].sku, "a");
assert_eq!(report.rows[0].total_quantity, dec!(50));
assert_eq!(report.rows[0].event_count, 2);
assert_eq!(report.rows[0].avg_daily, dec!(5.0000));
assert_eq!(report.rows[1].avg_daily, dec!(0.5000));
}
#[test]
fn date_range_filters() {
let from = day(2026, 6, 1);
let to = day(2026, 6, 30);
let records = vec![
rec("a", dec!(10), day(2026, 5, 31)), rec("a", dec!(10), day(2026, 6, 15)), rec("a", dec!(10), day(2026, 7, 1)), ];
let report = compute_consumption(&records, from, to);
assert_eq!(report.total_quantity, dec!(10));
}
#[test]
fn empty_report() {
let report = compute_consumption(&[], day(2026, 6, 1), day(2026, 6, 30));
assert_eq!(report.total_quantity, dec!(0));
assert!(report.rows.is_empty());
assert_eq!(report.days, 30);
}
}