1use chrono::{DateTime, Utc};
2use num_rational::Rational64;
3use sqlx::types::Uuid;
4use std::collections::HashMap;
5use supp_macro::command;
6
7use crate::{config::ConfigError, user::User};
8
9use super::super::{
10 BreakdownData, BreakdownPeriod, BreakdownRow, BreakdownSort, CmdError, CmdResult,
11 CommodityAmount, FilterEntity, PeriodGrouping, ReportFilter, ReportMeta, UNCATEGORIZED_KEY,
12};
13use super::fetch::{
14 BreakdownSplit, fetch_date_range_breakdown_filtered_no_conversion,
15 fetch_date_range_breakdown_filtered_with_conversion, fetch_target_symbol,
16};
17use super::period::boundaries;
18
19const DEFAULT_TAG_NAME: &str = "category";
20
21fn default_scope_filter() -> ReportFilter {
29 ReportFilter::TagIn {
30 entity: FilterEntity::Account,
31 name: "type".to_owned(),
32 values: vec!["income".to_owned(), "expense".to_owned()],
33 }
34}
35
36fn compose_scope(user_filter: Option<&ReportFilter>) -> ReportFilter {
37 match user_filter {
38 Some(f) => ReportFilter::And(vec![default_scope_filter(), f.clone()]),
39 None => default_scope_filter(),
40 }
41}
42
43async fn fetch_splits(
44 conn: &mut sqlx::PgConnection,
45 target_commodity_id: Option<Uuid>,
46 target_symbol: Option<&str>,
47 from: DateTime<Utc>,
48 to: DateTime<Utc>,
49 tag_name: &str,
50 filter: &ReportFilter,
51) -> Result<Vec<BreakdownSplit>, CmdError> {
52 match (target_commodity_id, target_symbol) {
53 (Some(tid), Some(sym)) => {
54 fetch_date_range_breakdown_filtered_with_conversion(
55 conn, tid, sym, from, to, tag_name, filter,
56 )
57 .await
58 }
59 _ => {
60 fetch_date_range_breakdown_filtered_no_conversion(conn, from, to, tag_name, filter)
61 .await
62 }
63 }
64}
65
66type AmountByCommodity = HashMap<Uuid, (Rational64, String)>;
67
68fn aggregate(splits: Vec<BreakdownSplit>, include_uncategorized: bool) -> Vec<BreakdownRow> {
69 let mut by_tag: HashMap<String, AmountByCommodity> = HashMap::new();
70
71 for s in splits {
72 let key = s.category.unwrap_or_else(|| UNCATEGORIZED_KEY.to_owned());
73 by_tag
74 .entry(key)
75 .or_default()
76 .entry(s.commodity_id)
77 .and_modify(|(sum, _)| *sum += s.value)
78 .or_insert_with(|| (s.value, s.commodity_symbol));
79 }
80
81 by_tag
82 .into_iter()
83 .filter(|(k, _)| include_uncategorized || k != UNCATEGORIZED_KEY)
84 .map(|(tag_value, amounts)| {
85 let is_uncategorized = tag_value == UNCATEGORIZED_KEY;
86 let mut amounts: Vec<CommodityAmount> = amounts
87 .into_iter()
88 .map(
89 |(commodity_id, (amount, commodity_symbol))| CommodityAmount {
90 commodity_id,
91 commodity_symbol,
92 amount,
93 },
94 )
95 .collect();
96 amounts.sort_by_key(|a| a.commodity_id);
97 BreakdownRow {
98 tag_value,
99 is_uncategorized,
100 amounts,
101 }
102 })
103 .collect()
104}
105
106fn row_sort_key(row: &BreakdownRow, target_commodity_id: Option<Uuid>) -> Rational64 {
107 match target_commodity_id {
108 Some(tid) => row
109 .amounts
110 .iter()
111 .find(|a| a.commodity_id == tid)
112 .map_or(Rational64::new(0, 1), |a| a.amount),
113 None => row
114 .amounts
115 .iter()
116 .map(|a| {
117 if a.amount < Rational64::new(0, 1) {
118 -a.amount
119 } else {
120 a.amount
121 }
122 })
123 .fold(Rational64::new(0, 1), |acc, v| acc + v),
124 }
125}
126
127fn sort_rows(rows: &mut [BreakdownRow], order: BreakdownSort, target_commodity_id: Option<Uuid>) {
128 match order {
129 BreakdownSort::AmountDesc => rows.sort_by(|a, b| {
130 row_sort_key(b, target_commodity_id).cmp(&row_sort_key(a, target_commodity_id))
131 }),
132 BreakdownSort::AmountAsc => rows.sort_by(|a, b| {
133 row_sort_key(a, target_commodity_id).cmp(&row_sort_key(b, target_commodity_id))
134 }),
135 BreakdownSort::NameAsc => rows.sort_by(|a, b| a.tag_value.cmp(&b.tag_value)),
136 BreakdownSort::NameDesc => rows.sort_by(|a, b| b.tag_value.cmp(&a.tag_value)),
137 }
138}
139
140command! {
141 CategoryBreakdown {
142 #[required]
143 user_id: Uuid,
144 #[required]
145 date_from: DateTime<Utc>,
146 #[required]
147 date_to: DateTime<Utc>,
148 #[optional]
149 tag_name: String,
150 #[optional]
151 target_commodity_id: Uuid,
152 #[optional]
153 period_grouping: PeriodGrouping,
154 #[optional]
155 report_filter: ReportFilter,
156 #[optional]
157 sort_order: BreakdownSort,
158 #[optional]
159 include_uncategorized: bool,
160 #[optional]
164 result_script: String,
165 } => {
166 let user = User { id: user_id };
167 let mut conn = user.get_connection().await.map_err(|err| {
168 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
169 ConfigError::DB
170 })?;
171
172 let tag_name = tag_name.unwrap_or_else(|| DEFAULT_TAG_NAME.to_owned());
173 let include_uncategorized = include_uncategorized.unwrap_or(true);
174 let sort_order = sort_order.unwrap_or_default();
175 let _ = result_script;
176 let scoped_filter = compose_scope(report_filter.as_ref());
179 super::filter::check_filter_size(scoped_filter.leaves())?;
180
181 let target_symbol = match target_commodity_id {
182 Some(tid) => Some(fetch_target_symbol(&mut conn, tid).await?),
183 None => None,
184 };
185
186 super::period::check_range(date_from, date_to)?;
187 let period_boundaries = period_grouping
188 .map(|g| boundaries(g, date_from, date_to))
189 .transpose()?
190 .unwrap_or_default();
191
192 let mut periods = if period_boundaries.is_empty() {
193 let splits = fetch_splits(
194 &mut conn,
195 target_commodity_id,
196 target_symbol.as_deref(),
197 date_from,
198 date_to,
199 &tag_name,
200 &scoped_filter,
201 )
202 .await?;
203 let rows = aggregate(splits, include_uncategorized);
204 vec![BreakdownPeriod { label: None, rows }]
205 } else {
206 let mut out = Vec::with_capacity(period_boundaries.len());
207 for (label, pfrom, pto) in &period_boundaries {
208 let splits = fetch_splits(
209 &mut conn,
210 target_commodity_id,
211 target_symbol.as_deref(),
212 *pfrom,
213 *pto,
214 &tag_name,
215 &scoped_filter,
216 )
217 .await?;
218 let rows = aggregate(splits, include_uncategorized);
219 out.push(BreakdownPeriod {
220 label: Some(label.clone()),
221 rows,
222 });
223 }
224 out
225 };
226
227 for period in &mut periods {
228 sort_rows(&mut period.rows, sort_order, target_commodity_id);
229 }
230
231 Ok(Some(CmdResult::Breakdown(BreakdownData {
232 meta: ReportMeta {
233 date_from: Some(date_from),
234 date_to: Some(date_to),
235 target_commodity_id,
236 },
237 tag_name,
238 periods,
239 })))
240 }
241}