1
use chrono::{DateTime, Utc};
2
use sqlx::types::Uuid;
3
use supp_macro::command;
4

            
5
use crate::{config::ConfigError, user::User};
6

            
7
use super::super::{
8
    ActivityData, ActivityGroup, ActivityGroupResult, ActivityPeriod, CmdError, CmdResult,
9
    FilterEntity, PeriodGrouping, ReportFilter, ReportMeta,
10
};
11
use super::fetch::{
12
    fetch_accounts, fetch_date_range_splits_filtered_no_conversion,
13
    fetch_date_range_splits_filtered_with_conversion, fetch_target_symbol,
14
};
15
use super::period::boundaries;
16

            
17
/// Queries a single activity report may issue.
18
///
19
/// The report runs one query per period per group; 4096 is already far past a
20
/// readable report and keeps one request off a cluster shared with other
21
/// tenants.
22
pub(super) const MAX_QUERIES: usize = 4096;
23
use super::tree::build_tree;
24

            
25
// TODO(script-classifier): swap this helper for one that evaluates a
26
// user-supplied nomiscript expression returning a Vec<ActivityGroup>. See
27
// doc/reporting.org "Future work" for the intent.
28
42
fn default_activity_groups() -> Vec<ActivityGroup> {
29
42
    vec![
30
42
        ActivityGroup {
31
42
            label: "Income".to_owned(),
32
42
            filter: ReportFilter::Tag {
33
42
                entity: FilterEntity::Account,
34
42
                name: "type".to_owned(),
35
42
                value: "income".to_owned(),
36
42
            },
37
42
            flip_sign: true,
38
42
        },
39
42
        ActivityGroup {
40
42
            label: "Expense".to_owned(),
41
42
            filter: ReportFilter::Tag {
42
42
                entity: FilterEntity::Account,
43
42
                name: "type".to_owned(),
44
42
                value: "expense".to_owned(),
45
42
            },
46
42
            flip_sign: false,
47
42
        },
48
    ]
49
42
}
50

            
51
/// Leaves in the largest query this report will run.
52
///
53
/// The MAX, not the sum: each query is `And(group.filter, report_filter)` for
54
/// ONE group (see [`compose_group_filter`]), so summing across groups measures
55
/// a cost no query ever incurs. How MANY groups there are is bounded separately,
56
/// by the query budget.
57
48
pub(super) fn widest_query_leaves(
58
48
    report_filter: Option<&ReportFilter>,
59
48
    groups: &[ActivityGroup],
60
48
) -> usize {
61
48
    report_filter.map_or(0, ReportFilter::leaves)
62
291
        + groups.iter().map(|g| g.filter.leaves()).max().unwrap_or(0)
63
48
}
64

            
65
91
fn compose_group_filter(group: &ActivityGroup, user_filter: Option<&ReportFilter>) -> ReportFilter {
66
91
    match user_filter {
67
2
        Some(f) => ReportFilter::And(vec![group.filter.clone(), f.clone()]),
68
89
        None => group.filter.clone(),
69
    }
70
91
}
71

            
72
91
async fn fetch_range(
73
91
    conn: &mut sqlx::PgConnection,
74
91
    target_commodity_id: Option<Uuid>,
75
91
    target_symbol: Option<&str>,
76
91
    from: DateTime<Utc>,
77
91
    to: DateTime<Utc>,
78
91
    filter: &ReportFilter,
79
91
) -> Result<super::tree::AccountAmounts, CmdError> {
80
65
    match (target_commodity_id, target_symbol) {
81
        (Some(tid), Some(sym)) => {
82
            fetch_date_range_splits_filtered_with_conversion(conn, tid, sym, from, to, filter).await
83
        }
84
65
        _ => fetch_date_range_splits_filtered_no_conversion(conn, from, to, filter).await,
85
    }
86
65
}
87

            
88
struct RunCtx<'a> {
89
    accounts: &'a [super::tree::AccountRow],
90
    user_filter: Option<&'a ReportFilter>,
91
    target_commodity_id: Option<Uuid>,
92
    target_symbol: Option<&'a str>,
93
}
94

            
95
91
async fn run_group_for_period(
96
91
    conn: &mut sqlx::PgConnection,
97
91
    ctx: &RunCtx<'_>,
98
91
    group: &ActivityGroup,
99
91
    from: DateTime<Utc>,
100
91
    to: DateTime<Utc>,
101
91
) -> Result<ActivityGroupResult, CmdError> {
102
65
    let filter = compose_group_filter(group, ctx.user_filter);
103
65
    let amounts = fetch_range(
104
65
        conn,
105
65
        ctx.target_commodity_id,
106
65
        ctx.target_symbol,
107
65
        from,
108
65
        to,
109
65
        &filter,
110
65
    )
111
65
    .await?;
112
65
    let roots = build_tree(ctx.accounts, &amounts);
113
65
    Ok(ActivityGroupResult {
114
65
        label: group.label.clone(),
115
65
        flip_sign: group.flip_sign,
116
65
        roots,
117
65
    })
118
65
}
119

            
120
command! {
121
    ActivityReport {
122
        #[required]
123
        user_id: Uuid,
124
        #[required]
125
        date_from: DateTime<Utc>,
126
        #[required]
127
        date_to: DateTime<Utc>,
128
        #[optional]
129
        target_commodity_id: Uuid,
130
        #[optional]
131
        period_grouping: PeriodGrouping,
132
        #[optional]
133
        groups: Vec<ActivityGroup>,
134
        #[optional]
135
        report_filter: ReportFilter,
136
    } => {
137
        let user = User { id: user_id };
138
        let mut conn = user.get_connection().await.map_err(|err| {
139
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
140
            ConfigError::DB
141
        })?;
142

            
143
        let accounts = fetch_accounts(&mut conn).await?;
144
        let groups = groups.unwrap_or_else(default_activity_groups);
145

            
146
        let target_symbol = match target_commodity_id {
147
            Some(tid) => Some(fetch_target_symbol(&mut conn, tid).await?),
148
            None => None,
149
        };
150

            
151
        super::period::check_range(date_from, date_to)?;
152
        let period_boundaries = period_grouping
153
1
            .map(|g| boundaries(g, date_from, date_to))
154
            .transpose()?
155
            .unwrap_or_default();
156

            
157
        // The loop below runs one query per period PER GROUP, and both factors
158
        // come from the caller: `groups` is JSON on the query string with no
159
        // length of its own. Bounding only the periods leaves the product
160
        // unbounded, which is the whole defect over again.
161
        super::period::query_budget(period_boundaries.len(), groups.len(), MAX_QUERIES)?;
162

            
163
        super::filter::check_filter_size(widest_query_leaves(report_filter.as_ref(), &groups))?;
164

            
165
        let ctx = RunCtx {
166
            accounts: &accounts,
167
            user_filter: report_filter.as_ref(),
168
            target_commodity_id,
169
            target_symbol: target_symbol.as_deref(),
170
        };
171

            
172
        let periods = if period_boundaries.is_empty() {
173
            let mut group_results = Vec::with_capacity(groups.len());
174
            for group in &groups {
175
                group_results.push(
176
                    run_group_for_period(&mut conn, &ctx, group, date_from, date_to).await?,
177
                );
178
            }
179
            vec![ActivityPeriod {
180
                label: None,
181
                groups: group_results,
182
            }]
183
        } else {
184
            let mut out = Vec::with_capacity(period_boundaries.len());
185
            for (label, pfrom, pto) in &period_boundaries {
186
                let mut group_results = Vec::with_capacity(groups.len());
187
                for group in &groups {
188
                    group_results
189
                        .push(run_group_for_period(&mut conn, &ctx, group, *pfrom, *pto).await?);
190
                }
191
                out.push(ActivityPeriod {
192
                    label: Some(label.clone()),
193
                    groups: group_results,
194
                });
195
            }
196
            out
197
        };
198

            
199
        Ok(Some(CmdResult::Activity(ActivityData {
200
            meta: ReportMeta {
201
                date_from: Some(date_from),
202
                date_to: Some(date_to),
203
                target_commodity_id,
204
            },
205
            periods,
206
        })))
207
    }
208
211
}