1
use chrono::{DateTime, Utc};
2
use num_rational::Rational64;
3
use sqlx::types::Uuid;
4
use std::collections::HashMap;
5
use supp_macro::command;
6

            
7
use crate::{config::ConfigError, user::User};
8

            
9
use super::super::{
10
    BreakdownData, BreakdownPeriod, BreakdownRow, BreakdownSort, CmdError, CmdResult,
11
    CommodityAmount, FilterEntity, PeriodGrouping, ReportFilter, ReportMeta, UNCATEGORIZED_KEY,
12
};
13
use super::fetch::{
14
    BreakdownSplit, fetch_date_range_breakdown_filtered_no_conversion,
15
    fetch_date_range_breakdown_filtered_with_conversion, fetch_target_symbol,
16
};
17
use super::period::boundaries;
18

            
19
const DEFAULT_TAG_NAME: &str = "category";
20

            
21
/// Default scope filter: only count splits that live on income or expense
22
/// accounts. Without this, the balancing asset/liability leg of every
23
/// transaction lands in `Uncategorized` and every balanced transaction
24
/// sums to zero across the breakdown.
25
///
26
// TODO(script-classifier): mirror ActivityReport's future script hook —
27
// swap this fixed TagIn for a user-supplied classifier expression.
28
51
fn default_scope_filter() -> ReportFilter {
29
51
    ReportFilter::TagIn {
30
51
        entity: FilterEntity::Account,
31
51
        name: "type".to_owned(),
32
51
        values: vec!["income".to_owned(), "expense".to_owned()],
33
51
    }
34
51
}
35

            
36
51
fn compose_scope(user_filter: Option<&ReportFilter>) -> ReportFilter {
37
51
    match user_filter {
38
1
        Some(f) => ReportFilter::And(vec![default_scope_filter(), f.clone()]),
39
50
        None => default_scope_filter(),
40
    }
41
51
}
42

            
43
52
async fn fetch_splits(
44
52
    conn: &mut sqlx::PgConnection,
45
52
    target_commodity_id: Option<Uuid>,
46
52
    target_symbol: Option<&str>,
47
52
    from: DateTime<Utc>,
48
52
    to: DateTime<Utc>,
49
52
    tag_name: &str,
50
52
    filter: &ReportFilter,
51
52
) -> Result<Vec<BreakdownSplit>, CmdError> {
52
39
    match (target_commodity_id, target_symbol) {
53
2
        (Some(tid), Some(sym)) => {
54
2
            fetch_date_range_breakdown_filtered_with_conversion(
55
2
                conn, tid, sym, from, to, tag_name, filter,
56
2
            )
57
2
            .await
58
        }
59
        _ => {
60
37
            fetch_date_range_breakdown_filtered_no_conversion(conn, from, to, tag_name, filter)
61
37
                .await
62
        }
63
    }
64
39
}
65

            
66
type AmountByCommodity = HashMap<Uuid, (Rational64, String)>;
67

            
68
52
fn aggregate(splits: Vec<BreakdownSplit>, include_uncategorized: bool) -> Vec<BreakdownRow> {
69
52
    let mut by_tag: HashMap<String, AmountByCommodity> = HashMap::new();
70

            
71
62
    for s in splits {
72
23
        let key = s.category.unwrap_or_else(|| UNCATEGORIZED_KEY.to_owned());
73
23
        by_tag
74
23
            .entry(key)
75
23
            .or_default()
76
23
            .entry(s.commodity_id)
77
23
            .and_modify(|(sum, _)| *sum += s.value)
78
23
            .or_insert_with(|| (s.value, s.commodity_symbol));
79
    }
80

            
81
52
    by_tag
82
52
        .into_iter()
83
61
        .filter(|(k, _)| include_uncategorized || k != UNCATEGORIZED_KEY)
84
60
        .map(|(tag_value, amounts)| {
85
21
            let is_uncategorized = tag_value == UNCATEGORIZED_KEY;
86
21
            let mut amounts: Vec<CommodityAmount> = amounts
87
21
                .into_iter()
88
21
                .map(
89
                    |(commodity_id, (amount, commodity_symbol))| CommodityAmount {
90
21
                        commodity_id,
91
21
                        commodity_symbol,
92
21
                        amount,
93
21
                    },
94
                )
95
21
                .collect();
96
21
            amounts.sort_by_key(|a| a.commodity_id);
97
21
            BreakdownRow {
98
21
                tag_value,
99
21
                is_uncategorized,
100
21
                amounts,
101
21
            }
102
21
        })
103
52
        .collect()
104
52
}
105

            
106
14
fn row_sort_key(row: &BreakdownRow, target_commodity_id: Option<Uuid>) -> Rational64 {
107
14
    match target_commodity_id {
108
10
        Some(tid) => row
109
10
            .amounts
110
10
            .iter()
111
10
            .find(|a| a.commodity_id == tid)
112
10
            .map_or(Rational64::new(0, 1), |a| a.amount),
113
4
        None => row
114
4
            .amounts
115
4
            .iter()
116
4
            .map(|a| {
117
4
                if a.amount < Rational64::new(0, 1) {
118
                    -a.amount
119
                } else {
120
4
                    a.amount
121
                }
122
4
            })
123
4
            .fold(Rational64::new(0, 1), |acc, v| acc + v),
124
    }
125
14
}
126

            
127
52
fn sort_rows(rows: &mut [BreakdownRow], order: BreakdownSort, target_commodity_id: Option<Uuid>) {
128
52
    match order {
129
50
        BreakdownSort::AmountDesc => rows.sort_by(|a, b| {
130
4
            row_sort_key(b, target_commodity_id).cmp(&row_sort_key(a, target_commodity_id))
131
4
        }),
132
3
        BreakdownSort::AmountAsc => rows.sort_by(|a, b| {
133
3
            row_sort_key(a, target_commodity_id).cmp(&row_sort_key(b, target_commodity_id))
134
3
        }),
135
2
        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
52
}
139

            
140
command! {
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
        // TODO(result-script): accept a nomiscript expression to filter/reshape
161
        // BreakdownRow entries before returning. The format is not yet
162
        // designed; see doc/reporting.org "Future work" for the intent.
163
        #[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
37
        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
        // Measured after scoping, because that is the filter the query runs:
177
        // checking the user's half alone let a 64-leaf filter execute as 66.
178
        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
1
            .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
260
}