Add 'libdrcr/' from commit '0d680275df56300ff9059928f5611b9da2313b74'
git-subtree-dir: libdrcr git-subtree-mainline: a40e2f81ba7f5d01ea3fbce037d47c1949d1b64d git-subtree-split: 0d680275df56300ff9059928f5611b9da2313b74
This commit is contained in:
commit
62b7981224
1
libdrcr/.gitignore
vendored
Normal file
1
libdrcr/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
1987
libdrcr/Cargo.lock
generated
Normal file
1987
libdrcr/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
libdrcr/Cargo.toml
Normal file
17
libdrcr/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "libdrcr"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1.88"
|
||||||
|
chrono = "0.4.41"
|
||||||
|
downcast-rs = "2.0.1"
|
||||||
|
dyn-clone = "1.0.19"
|
||||||
|
dyn-eq = "0.1.3"
|
||||||
|
dyn-hash = "0.2.2"
|
||||||
|
indexmap = "2.9.0"
|
||||||
|
serde = "1.0.219"
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] }
|
||||||
|
tokio = { version = "1.45.0", features = ["full"] }
|
1
libdrcr/rustfmt.toml
Normal file
1
libdrcr/rustfmt.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
hard_tabs = true
|
43
libdrcr/src/account_config.rs
Normal file
43
libdrcr/src/account_config.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct AccountConfiguration {
|
||||||
|
pub id: Option<u64>,
|
||||||
|
pub account: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert [`Vec<AccountConfiguration>`] into a [HashMap] mapping account names to account kinds
|
||||||
|
pub fn kinds_for_account(
|
||||||
|
account_configurations: Vec<AccountConfiguration>,
|
||||||
|
) -> HashMap<String, Vec<String>> {
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
|
||||||
|
for account_configuration in account_configurations {
|
||||||
|
// Record the account kind
|
||||||
|
result
|
||||||
|
.entry(account_configuration.account)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(account_configuration.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
266
libdrcr/src/db.rs
Normal file
266
libdrcr/src/db.rs
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
|
use sqlx::sqlite::SqliteRow;
|
||||||
|
use sqlx::{Connection, Row, SqliteConnection};
|
||||||
|
|
||||||
|
use crate::account_config::AccountConfiguration;
|
||||||
|
use crate::model::assertions::BalanceAssertion;
|
||||||
|
use crate::model::statements::StatementLine;
|
||||||
|
use crate::model::transaction::{Posting, Transaction, TransactionWithPostings};
|
||||||
|
use crate::{util::format_date, QuantityInt};
|
||||||
|
|
||||||
|
pub struct DbConnection {
|
||||||
|
url: String,
|
||||||
|
metadata: DbMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbConnection {
|
||||||
|
pub async fn new(url: &str) -> Self {
|
||||||
|
let mut connection = SqliteConnection::connect(url).await.expect("SQL error");
|
||||||
|
let metadata = DbMetadata::from_database(&mut connection).await;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
url: url.to_string(),
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata(&self) -> &DbMetadata {
|
||||||
|
&self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self) -> SqliteConnection {
|
||||||
|
SqliteConnection::connect(&self.url)
|
||||||
|
.await
|
||||||
|
.expect("SQL error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get account configurations from the database
|
||||||
|
pub async fn get_account_configurations(&self) -> Vec<AccountConfiguration> {
|
||||||
|
let mut connection = self.connect().await;
|
||||||
|
|
||||||
|
let mut account_configurations =
|
||||||
|
sqlx::query("SELECT id, account, kind, data FROM account_configurations")
|
||||||
|
.map(|r: SqliteRow| AccountConfiguration {
|
||||||
|
id: r.get("id"),
|
||||||
|
account: r.get("account"),
|
||||||
|
kind: r.get("kind"),
|
||||||
|
data: r.get("data"),
|
||||||
|
})
|
||||||
|
.fetch_all(&mut connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
// System accounts
|
||||||
|
account_configurations.push(AccountConfiguration {
|
||||||
|
id: None,
|
||||||
|
account: crate::CURRENT_YEAR_EARNINGS.to_string(),
|
||||||
|
kind: "drcr.equity".to_string(),
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
account_configurations.push(AccountConfiguration {
|
||||||
|
id: None,
|
||||||
|
account: crate::RETAINED_EARNINGS.to_string(),
|
||||||
|
kind: "drcr.equity".to_string(),
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
account_configurations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get balance assertions from the database
|
||||||
|
pub async fn get_balance_assertions(&self) -> Vec<BalanceAssertion> {
|
||||||
|
let mut connection = self.connect().await;
|
||||||
|
|
||||||
|
let balance_assertions = sqlx::query(
|
||||||
|
"SELECT id, dt, description, account, quantity, commodity
|
||||||
|
FROM balance_assertions
|
||||||
|
ORDER BY dt DESC, id DESC",
|
||||||
|
)
|
||||||
|
.map(|r: SqliteRow| BalanceAssertion {
|
||||||
|
id: r.get("id"),
|
||||||
|
dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d %H:%M:%S.%6f")
|
||||||
|
.expect("Invalid balance_assertions.dt"),
|
||||||
|
description: r.get("description"),
|
||||||
|
account: r.get("account"),
|
||||||
|
quantity: r.get("quantity"),
|
||||||
|
commodity: r.get("commodity"),
|
||||||
|
})
|
||||||
|
.fetch_all(&mut connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
balance_assertions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get account balances from the database
|
||||||
|
pub async fn get_balances(&self, date: NaiveDate) -> HashMap<String, QuantityInt> {
|
||||||
|
let mut connection = self.connect().await;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"-- Get last transaction for each account
|
||||||
|
WITH max_dt_by_account AS (
|
||||||
|
SELECT account, max(dt) AS max_dt
|
||||||
|
FROM joined_transactions
|
||||||
|
WHERE DATE(dt) <= DATE($1)
|
||||||
|
GROUP BY account
|
||||||
|
),
|
||||||
|
max_tid_by_account AS (
|
||||||
|
SELECT max_dt_by_account.account, max(transaction_id) AS max_tid
|
||||||
|
FROM max_dt_by_account
|
||||||
|
JOIN joined_transactions ON max_dt_by_account.account = joined_transactions.account AND max_dt_by_account.max_dt = joined_transactions.dt
|
||||||
|
GROUP BY max_dt_by_account.account
|
||||||
|
)
|
||||||
|
-- Get running balance at last transaction for each account
|
||||||
|
SELECT max_tid_by_account.account, running_balance AS quantity
|
||||||
|
FROM max_tid_by_account
|
||||||
|
JOIN transactions_with_running_balances ON max_tid = transactions_with_running_balances.transaction_id AND max_tid_by_account.account = transactions_with_running_balances.account"
|
||||||
|
).bind(format_date(date)).fetch_all(&mut connection).await.expect("SQL error");
|
||||||
|
|
||||||
|
let mut balances = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
balances.insert(row.get("account"), row.get("quantity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
balances
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transactions from the database
|
||||||
|
pub async fn get_transactions(&self) -> Vec<TransactionWithPostings> {
|
||||||
|
let mut connection = self.connect().await;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost
|
||||||
|
FROM transactions_with_quantity_ascost
|
||||||
|
ORDER BY dt, transaction_id, id"
|
||||||
|
).fetch_all(&mut connection).await.expect("SQL error");
|
||||||
|
|
||||||
|
// Un-flatten transaction list
|
||||||
|
let mut transactions: Vec<TransactionWithPostings> = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
if transactions.is_empty()
|
||||||
|
|| transactions.last().unwrap().transaction.id != row.get("transaction_id")
|
||||||
|
{
|
||||||
|
// New transaction
|
||||||
|
transactions.push(TransactionWithPostings {
|
||||||
|
transaction: Transaction {
|
||||||
|
id: row.get("transaction_id"),
|
||||||
|
dt: NaiveDateTime::parse_from_str(row.get("dt"), "%Y-%m-%d %H:%M:%S.%6f")
|
||||||
|
.expect("Invalid transactions.dt"),
|
||||||
|
description: row.get("transaction_description"),
|
||||||
|
},
|
||||||
|
postings: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions.last_mut().unwrap().postings.push(Posting {
|
||||||
|
id: row.get("id"),
|
||||||
|
transaction_id: row.get("transaction_id"),
|
||||||
|
description: row.get("description"),
|
||||||
|
account: row.get("account"),
|
||||||
|
quantity: row.get("quantity"),
|
||||||
|
commodity: row.get("commodity"),
|
||||||
|
quantity_ascost: row.get("quantity_ascost"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get unreconciled statement lines from the database
|
||||||
|
pub async fn get_unreconciled_statement_lines(&self) -> Vec<StatementLine> {
|
||||||
|
let mut connection = self.connect().await;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
// On testing, JOIN is much faster than WHERE NOT EXISTS
|
||||||
|
"SELECT statement_lines.* FROM statement_lines
|
||||||
|
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
||||||
|
WHERE statement_line_reconciliations.id IS NULL"
|
||||||
|
).map(|r: SqliteRow| StatementLine {
|
||||||
|
id: Some(r.get("id")),
|
||||||
|
source_account: r.get("source_account"),
|
||||||
|
dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d").expect("Invalid statement_lines.dt"),
|
||||||
|
description: r.get("description"),
|
||||||
|
quantity: r.get("quantity"),
|
||||||
|
balance: r.get("balance"),
|
||||||
|
commodity: r.get("commodity"),
|
||||||
|
}).fetch_all(&mut connection).await.expect("SQL error");
|
||||||
|
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for cached database-related metadata
|
||||||
|
pub struct DbMetadata {
|
||||||
|
pub version: u32,
|
||||||
|
pub eofy_date: NaiveDate,
|
||||||
|
pub reporting_commodity: String,
|
||||||
|
pub dps: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbMetadata {
|
||||||
|
/// Initialise [DbMetadata] with values from the metadata database table
|
||||||
|
async fn from_database(connection: &mut SqliteConnection) -> Self {
|
||||||
|
let version = sqlx::query("SELECT value FROM metadata WHERE key = 'version'")
|
||||||
|
.map(|r: SqliteRow| {
|
||||||
|
r.get::<String, _>(0)
|
||||||
|
.parse()
|
||||||
|
.expect("Invalid metadata.version")
|
||||||
|
})
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
let eofy_date = sqlx::query("SELECT value FROM metadata WHERE key ='eofy_date'")
|
||||||
|
.map(|r: SqliteRow| {
|
||||||
|
NaiveDate::parse_from_str(r.get(0), "%Y-%m-%d").expect("Invalid metadata.eofy_date")
|
||||||
|
})
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
let reporting_commodity =
|
||||||
|
sqlx::query("SELECT value FROM metadata WHERE key = 'reporting_commodity'")
|
||||||
|
.map(|r: SqliteRow| r.get(0))
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
let dps = sqlx::query("SELECT value FROM metadata WHERE key = 'amount_dps'")
|
||||||
|
.map(|r: SqliteRow| {
|
||||||
|
r.get::<String, _>(0)
|
||||||
|
.parse()
|
||||||
|
.expect("Invalid metadata.amount_dps")
|
||||||
|
})
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await
|
||||||
|
.expect("SQL error");
|
||||||
|
|
||||||
|
DbMetadata {
|
||||||
|
version,
|
||||||
|
eofy_date,
|
||||||
|
reporting_commodity,
|
||||||
|
dps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
libdrcr/src/lib.rs
Normal file
14
libdrcr/src/lib.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
pub mod account_config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod model;
|
||||||
|
pub mod reporting;
|
||||||
|
pub mod serde;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
/// Data type used to represent transaction and account quantities
|
||||||
|
pub type QuantityInt = i64;
|
||||||
|
|
||||||
|
// Magic strings
|
||||||
|
// TODO: Make this configurable
|
||||||
|
pub const CURRENT_YEAR_EARNINGS: &'static str = "Current Year Earnings";
|
||||||
|
pub const RETAINED_EARNINGS: &'static str = "Retained Earnings";
|
237
libdrcr/src/main.rs
Normal file
237
libdrcr/src/main.rs
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use libdrcr::db::DbConnection;
|
||||||
|
use libdrcr::reporting::builders::register_dynamic_builders;
|
||||||
|
use libdrcr::reporting::calculator::{steps_as_graphviz, steps_for_targets};
|
||||||
|
use libdrcr::reporting::dynamic_report::DynamicReport;
|
||||||
|
use libdrcr::reporting::generate_report;
|
||||||
|
use libdrcr::reporting::steps::register_lookup_fns;
|
||||||
|
use libdrcr::reporting::types::{
|
||||||
|
DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
|
||||||
|
ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
const YEAR: i32 = 2025;
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
let db_connection = DbConnection::new("sqlite:drcr_testing.db").await;
|
||||||
|
|
||||||
|
// Initialise ReportingContext
|
||||||
|
let mut context = ReportingContext::new(
|
||||||
|
db_connection,
|
||||||
|
NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
|
||||||
|
"$".to_string(),
|
||||||
|
);
|
||||||
|
register_lookup_fns(&mut context);
|
||||||
|
register_dynamic_builders(&mut context);
|
||||||
|
|
||||||
|
let context = Arc::new(context);
|
||||||
|
|
||||||
|
// Print Graphviz
|
||||||
|
|
||||||
|
let targets = vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: "CalculateIncomeTax",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
// ReportingProductId {
|
||||||
|
// name: "AllTransactionsExceptEarningsToEquity",
|
||||||
|
// kind: ReportingProductKind::Transactions,
|
||||||
|
// args: Box::new(DateArgs {
|
||||||
|
// date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
// }),
|
||||||
|
// },
|
||||||
|
ReportingProductId {
|
||||||
|
name: "BalanceSheet",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(MultipleDateArgs {
|
||||||
|
dates: vec![DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: "IncomeStatement",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(MultipleDateStartDateEndArgs {
|
||||||
|
dates: vec![DateStartDateEndArgs {
|
||||||
|
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
|
||||||
|
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (sorted_steps, dependencies) = steps_for_targets(targets, &context).unwrap();
|
||||||
|
|
||||||
|
println!("Graphviz:");
|
||||||
|
println!("{}", steps_as_graphviz(&sorted_steps, &dependencies));
|
||||||
|
|
||||||
|
// Get income statement
|
||||||
|
|
||||||
|
let targets = vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: "CalculateIncomeTax",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: "AllTransactionsExceptEarningsToEquity",
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(DateStartDateEndArgs {
|
||||||
|
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
|
||||||
|
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let products = generate_report(targets, Arc::clone(&context))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: "AllTransactionsExceptEarningsToEquity",
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(DateStartDateEndArgs {
|
||||||
|
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
|
||||||
|
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Income statement:");
|
||||||
|
println!("{:?}", result);
|
||||||
|
|
||||||
|
// Get balance sheet
|
||||||
|
|
||||||
|
let targets = vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: "CalculateIncomeTax",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: "BalanceSheet",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(MultipleDateArgs {
|
||||||
|
dates: vec![DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let products = generate_report(targets, Arc::clone(&context))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: "BalanceSheet",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(MultipleDateArgs {
|
||||||
|
dates: vec![DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Balance sheet:");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
result.downcast_ref::<DynamicReport>().unwrap().to_json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get trial balance
|
||||||
|
|
||||||
|
let targets = vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: "CalculateIncomeTax",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: "TrialBalance",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let products = generate_report(targets, Arc::clone(&context))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: "TrialBalance",
|
||||||
|
kind: ReportingProductKind::Generic,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Trial balance:");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
result.downcast_ref::<DynamicReport>().unwrap().to_json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all transactions
|
||||||
|
|
||||||
|
/*let targets = vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: "CalculateIncomeTax",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: "AllTransactionsExceptEarningsToEquity",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let products = generate_report(targets, Arc::clone(&context))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: "AllTransactionsExceptEarningsToEquity",
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("All transactions:");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
result.downcast_ref::<Transactions>().unwrap().to_json()
|
||||||
|
);*/
|
||||||
|
}
|
33
libdrcr/src/model/assertions.rs
Normal file
33
libdrcr/src/model/assertions.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::QuantityInt;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct BalanceAssertion {
|
||||||
|
pub id: Option<u64>,
|
||||||
|
#[serde(with = "crate::serde::naivedatetime_to_js")]
|
||||||
|
pub dt: NaiveDateTime,
|
||||||
|
pub description: String,
|
||||||
|
pub account: String,
|
||||||
|
pub quantity: QuantityInt,
|
||||||
|
pub commodity: String,
|
||||||
|
}
|
21
libdrcr/src/model/mod.rs
Normal file
21
libdrcr/src/model/mod.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
pub mod assertions;
|
||||||
|
pub mod statements;
|
||||||
|
pub mod transaction;
|
31
libdrcr/src/model/statements.rs
Normal file
31
libdrcr/src/model/statements.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::QuantityInt;
|
||||||
|
|
||||||
|
pub struct StatementLine {
|
||||||
|
pub id: Option<u64>,
|
||||||
|
pub source_account: String,
|
||||||
|
pub dt: NaiveDateTime,
|
||||||
|
pub description: String,
|
||||||
|
pub quantity: QuantityInt,
|
||||||
|
pub balance: QuantityInt,
|
||||||
|
pub commodity: String,
|
||||||
|
}
|
67
libdrcr/src/model/transaction.rs
Normal file
67
libdrcr/src/model/transaction.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::QuantityInt;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Transaction {
|
||||||
|
pub id: Option<u64>,
|
||||||
|
#[serde(with = "crate::serde::naivedatetime_to_js")]
|
||||||
|
pub dt: NaiveDateTime,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct TransactionWithPostings {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub transaction: Transaction,
|
||||||
|
pub postings: Vec<Posting>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Posting {
|
||||||
|
pub id: Option<u64>,
|
||||||
|
pub transaction_id: Option<u64>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub account: String,
|
||||||
|
pub quantity: QuantityInt,
|
||||||
|
pub commodity: String,
|
||||||
|
pub quantity_ascost: Option<QuantityInt>,
|
||||||
|
//pub running_balance: Option<QuantityInt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_balances_from_transactions<
|
||||||
|
'a,
|
||||||
|
I: Iterator<Item = &'a TransactionWithPostings>,
|
||||||
|
>(
|
||||||
|
balances: &mut HashMap<String, QuantityInt>,
|
||||||
|
transactions: I,
|
||||||
|
) {
|
||||||
|
for transaction in transactions {
|
||||||
|
for posting in transaction.postings.iter() {
|
||||||
|
// FIXME: Do currency conversion
|
||||||
|
let running_balance = balances.get(&posting.account).unwrap_or(&0) + posting.quantity;
|
||||||
|
balances.insert(posting.account.clone(), running_balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
855
libdrcr/src/reporting/builders.rs
Normal file
855
libdrcr/src/reporting/builders.rs
Normal file
@ -0,0 +1,855 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! This module contains implementations of dynamic step builders
|
||||||
|
//!
|
||||||
|
//! See [ReportingContext::register_dynamic_builder][super::types::ReportingContext::register_dynamic_builder].
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::model::transaction::update_balances_from_transactions;
|
||||||
|
|
||||||
|
use super::calculator::{has_step_or_can_build, HasStepOrCanBuild, ReportingGraphDependencies};
|
||||||
|
use super::executor::ReportingExecutionError;
|
||||||
|
use super::types::{
|
||||||
|
BalancesAt, BalancesBetween, DateArgs, DateStartDateEndArgs, ReportingContext,
|
||||||
|
ReportingProductId, ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs,
|
||||||
|
ReportingStepDynamicBuilder, ReportingStepId, Transactions, VoidArgs,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Call [ReportingContext::register_dynamic_builder] for all dynamic builders provided by this module
|
||||||
|
pub fn register_dynamic_builders(context: &mut ReportingContext) {
|
||||||
|
GenerateBalances::register_dynamic_builder(context);
|
||||||
|
UpdateBalancesBetween::register_dynamic_builder(context);
|
||||||
|
UpdateBalancesAt::register_dynamic_builder(context);
|
||||||
|
|
||||||
|
// This is the least efficient way of generating BalancesBetween so put at the end
|
||||||
|
BalancesAtToBalancesBetween::register_dynamic_builder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This dynamic builder automatically generates a [BalancesBetween] by subtracting [BalancesAt] between two dates
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BalancesAtToBalancesBetween {
|
||||||
|
step_name: &'static str,
|
||||||
|
args: DateStartDateEndArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BalancesAtToBalancesBetween {
|
||||||
|
// Implements BalancesAt, BalancesAt -> BalancesBetween
|
||||||
|
|
||||||
|
fn register_dynamic_builder(context: &mut ReportingContext) {
|
||||||
|
context.register_dynamic_builder(ReportingStepDynamicBuilder {
|
||||||
|
name: "BalancesAtToBalancesBetween",
|
||||||
|
can_build: Self::can_build,
|
||||||
|
build: Self::build,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_build(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
args: &Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> bool {
|
||||||
|
// Check for BalancesAt, BalancesAt -> BalancesBetween
|
||||||
|
if kind == ReportingProductKind::BalancesBetween {
|
||||||
|
if !args.is::<DateStartDateEndArgs>() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let args = args.downcast_ref::<DateStartDateEndArgs>().unwrap();
|
||||||
|
|
||||||
|
match has_step_or_can_build(
|
||||||
|
&ReportingProductId {
|
||||||
|
name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: args.date_start.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
HasStepOrCanBuild::HasStep(_)
|
||||||
|
| HasStepOrCanBuild::CanLookup(_)
|
||||||
|
| HasStepOrCanBuild::CanBuild(_) => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
name: &'static str,
|
||||||
|
_kind: ReportingProductKind,
|
||||||
|
args: Box<dyn ReportingStepArgs>,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
_dependencies: &ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) -> Box<dyn ReportingStep> {
|
||||||
|
Box::new(BalancesAtToBalancesBetween {
|
||||||
|
step_name: name,
|
||||||
|
args: *args.downcast().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for BalancesAtToBalancesBetween {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(
|
||||||
|
"{} {{BalancesAtToBalancesBetween}}",
|
||||||
|
self.id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReportingStep for BalancesAtToBalancesBetween {
|
||||||
|
fn id(&self) -> ReportingStepId {
|
||||||
|
ReportingStepId {
|
||||||
|
name: self.step_name,
|
||||||
|
product_kinds: &[ReportingProductKind::BalancesBetween],
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires(&self, _context: &ReportingContext) -> Vec<ReportingProductId> {
|
||||||
|
// BalancesAtToBalancesBetween depends on BalancesAt at both time points
|
||||||
|
vec![
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date_end,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
_dependencies: &ReportingGraphDependencies,
|
||||||
|
products: &RwLock<ReportingProducts>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
let products = products.read().await;
|
||||||
|
|
||||||
|
// Get balances at dates
|
||||||
|
let balances_start = &products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day
|
||||||
|
}),
|
||||||
|
})?
|
||||||
|
.downcast_ref::<BalancesAt>()
|
||||||
|
.unwrap()
|
||||||
|
.balances;
|
||||||
|
|
||||||
|
let balances_end = &products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date_end,
|
||||||
|
}),
|
||||||
|
})?
|
||||||
|
.downcast_ref::<BalancesAt>()
|
||||||
|
.unwrap()
|
||||||
|
.balances;
|
||||||
|
|
||||||
|
// Compute balances_end - balances_start
|
||||||
|
let mut balances = BalancesBetween {
|
||||||
|
balances: balances_end.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (account, balance) in balances_start.iter() {
|
||||||
|
let running_balance = balances.balances.get(account).unwrap_or(&0) - balance;
|
||||||
|
balances.balances.insert(account.clone(), running_balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
let mut result = ReportingProducts::new();
|
||||||
|
result.insert(
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.id().name,
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
Box::new(balances),
|
||||||
|
);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This dynamic builder automatically generates a [BalancesAt] from a step which has no dependencies and generates [Transactions] (e.g. [PostUnreconciledStatementLines][super::steps::PostUnreconciledStatementLines])
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GenerateBalances {
|
||||||
|
step_name: &'static str,
|
||||||
|
args: DateArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenerateBalances {
|
||||||
|
fn register_dynamic_builder(context: &mut ReportingContext) {
|
||||||
|
context.register_dynamic_builder(ReportingStepDynamicBuilder {
|
||||||
|
name: "GenerateBalances",
|
||||||
|
can_build: Self::can_build,
|
||||||
|
build: Self::build,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_build(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
args: &Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> bool {
|
||||||
|
// Check for Transactions -> BalancesAt
|
||||||
|
if kind == ReportingProductKind::BalancesAt {
|
||||||
|
// Try DateArgs
|
||||||
|
match has_step_or_can_build(
|
||||||
|
&ReportingProductId {
|
||||||
|
name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: args.clone(),
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
HasStepOrCanBuild::HasStep(step) => {
|
||||||
|
// Check for () -> Transactions
|
||||||
|
if dependencies.dependencies_for_step(&step.id()).len() == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanLookup(lookup_fn) => {
|
||||||
|
// Check for () -> Transactions
|
||||||
|
let step = lookup_fn(args.clone());
|
||||||
|
if step.requires(context).len() == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try VoidArgs
|
||||||
|
match has_step_or_can_build(
|
||||||
|
&ReportingProductId {
|
||||||
|
name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
HasStepOrCanBuild::HasStep(step) => {
|
||||||
|
// Check for () -> Transactions
|
||||||
|
if dependencies.dependencies_for_step(&step.id()).len() == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanLookup(lookup_fn) => {
|
||||||
|
// Check for () -> Transactions
|
||||||
|
let step = lookup_fn(args.clone());
|
||||||
|
if step.requires(context).len() == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
name: &'static str,
|
||||||
|
_kind: ReportingProductKind,
|
||||||
|
args: Box<dyn ReportingStepArgs>,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
_dependencies: &ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) -> Box<dyn ReportingStep> {
|
||||||
|
Box::new(GenerateBalances {
|
||||||
|
step_name: name,
|
||||||
|
args: *args.downcast().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for GenerateBalances {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{} {{GenerateBalances}}", self.id()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReportingStep for GenerateBalances {
|
||||||
|
fn id(&self) -> ReportingStepId {
|
||||||
|
ReportingStepId {
|
||||||
|
name: self.step_name,
|
||||||
|
product_kinds: &[ReportingProductKind::BalancesAt],
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_graph(
|
||||||
|
&self,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &mut ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) {
|
||||||
|
// Add a dependency on the Transactions result
|
||||||
|
// Look up that step, so we can extract the appropriate args
|
||||||
|
|
||||||
|
// Try DateArgs
|
||||||
|
match has_step_or_can_build(
|
||||||
|
&ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
HasStepOrCanBuild::HasStep(_)
|
||||||
|
| HasStepOrCanBuild::CanLookup(_)
|
||||||
|
| HasStepOrCanBuild::CanBuild(_) => {
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::None => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be VoidArgs (as checked in can_build)
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: Box::new(VoidArgs {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
products: &RwLock<ReportingProducts>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
let products = products.read().await;
|
||||||
|
|
||||||
|
// Get the transactions
|
||||||
|
let transactions_product = &dependencies.dependencies_for_step(&self.id())[0].product;
|
||||||
|
let transactions = &products
|
||||||
|
.get_or_err(transactions_product)?
|
||||||
|
.downcast_ref::<Transactions>()
|
||||||
|
.unwrap()
|
||||||
|
.transactions;
|
||||||
|
|
||||||
|
// Sum balances
|
||||||
|
let mut balances = BalancesAt {
|
||||||
|
balances: HashMap::new(),
|
||||||
|
};
|
||||||
|
update_balances_from_transactions(&mut balances.balances, transactions.iter());
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
let mut result = ReportingProducts::new();
|
||||||
|
result.insert(
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
Box::new(balances),
|
||||||
|
);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This dynamic builder automatically generates a [BalancesAt] from:
|
||||||
|
/// - a step which generates [Transactions] from [BalancesAt], or
|
||||||
|
/// - a step which generates [Transactions] from [BalancesBetween], and for which a [BalancesAt] is also available
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UpdateBalancesAt {
|
||||||
|
step_name: &'static str,
|
||||||
|
args: DateArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateBalancesAt {
|
||||||
|
// Implements (BalancesAt -> Transactions) -> BalancesAt
|
||||||
|
|
||||||
|
fn register_dynamic_builder(context: &mut ReportingContext) {
|
||||||
|
context.register_dynamic_builder(ReportingStepDynamicBuilder {
|
||||||
|
name: "UpdateBalancesAt",
|
||||||
|
can_build: Self::can_build,
|
||||||
|
build: Self::build,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_build(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
args: &Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> bool {
|
||||||
|
if !args.is::<DateArgs>() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Transactions -> BalancesAt
|
||||||
|
if kind == ReportingProductKind::BalancesAt {
|
||||||
|
// Initially no need to check args
|
||||||
|
if let Some(step) = steps.iter().find(|s| {
|
||||||
|
s.id().name == name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
}) {
|
||||||
|
// Check for BalancesAt -> Transactions
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&step.id());
|
||||||
|
if dependencies_for_step.len() == 1
|
||||||
|
&& dependencies_for_step[0].product.kind == ReportingProductKind::BalancesAt
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if BalancesBetween -> Transactions and BalancesAt is available
|
||||||
|
if dependencies_for_step.len() == 1
|
||||||
|
&& dependencies_for_step[0].product.kind
|
||||||
|
== ReportingProductKind::BalancesBetween
|
||||||
|
{
|
||||||
|
match has_step_or_can_build(
|
||||||
|
&ReportingProductId {
|
||||||
|
name: dependencies_for_step[0].product.name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: args.downcast_ref::<DateArgs>().unwrap().date,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
HasStepOrCanBuild::HasStep(_)
|
||||||
|
| HasStepOrCanBuild::CanLookup(_)
|
||||||
|
| HasStepOrCanBuild::CanBuild(_) => {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
name: &'static str,
|
||||||
|
_kind: ReportingProductKind,
|
||||||
|
args: Box<dyn ReportingStepArgs>,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
_dependencies: &ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) -> Box<dyn ReportingStep> {
|
||||||
|
Box::new(UpdateBalancesAt {
|
||||||
|
step_name: name,
|
||||||
|
args: *args.downcast().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for UpdateBalancesAt {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{} {{UpdateBalancesAt}}", self.id()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReportingStep for UpdateBalancesAt {
|
||||||
|
fn id(&self) -> ReportingStepId {
|
||||||
|
ReportingStepId {
|
||||||
|
name: self.step_name,
|
||||||
|
product_kinds: &[ReportingProductKind::BalancesAt],
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_graph(
|
||||||
|
&self,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &mut ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) {
|
||||||
|
// Add a dependency on the Transactions result
|
||||||
|
// Look up that step, so we can extract the appropriate args
|
||||||
|
let parent_step = steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| {
|
||||||
|
s.id().name == self.step_name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
})
|
||||||
|
.unwrap(); // Existence is checked in can_build
|
||||||
|
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: parent_step.id().args.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up the BalancesAt step
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
|
||||||
|
let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
|
||||||
|
|
||||||
|
if dependency.kind == ReportingProductKind::BalancesAt {
|
||||||
|
// Directly depends on BalancesAt -> Transaction
|
||||||
|
// Do not need to add extra dependencies
|
||||||
|
} else {
|
||||||
|
// As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: dependency.name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
products: &RwLock<ReportingProducts>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
let products = products.read().await;
|
||||||
|
|
||||||
|
// Look up the parent step, so we can extract the appropriate args
|
||||||
|
let parent_step = steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| {
|
||||||
|
s.id().name == self.step_name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
})
|
||||||
|
.unwrap(); // Existence is checked in can_build
|
||||||
|
|
||||||
|
// Get transactions
|
||||||
|
let transactions = &products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: parent_step.id().args,
|
||||||
|
})?
|
||||||
|
.downcast_ref::<Transactions>()
|
||||||
|
.unwrap()
|
||||||
|
.transactions;
|
||||||
|
|
||||||
|
// Look up the BalancesAt step
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
|
||||||
|
let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
|
||||||
|
|
||||||
|
let opening_balances_at;
|
||||||
|
|
||||||
|
if dependency.kind == ReportingProductKind::BalancesAt {
|
||||||
|
// Directly depends on BalancesAt -> Transaction
|
||||||
|
opening_balances_at = products
|
||||||
|
.get_or_err(&dependency)?
|
||||||
|
.downcast_ref::<BalancesAt>()
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
// As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available
|
||||||
|
opening_balances_at = products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: dependency.name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(DateArgs {
|
||||||
|
date: self.args.date,
|
||||||
|
}),
|
||||||
|
})?
|
||||||
|
.downcast_ref()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum balances
|
||||||
|
let mut balances = BalancesAt {
|
||||||
|
balances: opening_balances_at.balances.clone(),
|
||||||
|
};
|
||||||
|
update_balances_from_transactions(
|
||||||
|
&mut balances.balances,
|
||||||
|
transactions
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.transaction.dt.date() <= self.args.date),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
let mut result = ReportingProducts::new();
|
||||||
|
result.insert(
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesAt,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
Box::new(balances),
|
||||||
|
);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This dynamic builder automatically generates a [BalancesBetween] from a step which generates [Transactions] from [BalancesBetween]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UpdateBalancesBetween {
|
||||||
|
step_name: &'static str,
|
||||||
|
args: DateStartDateEndArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateBalancesBetween {
|
||||||
|
fn register_dynamic_builder(context: &mut ReportingContext) {
|
||||||
|
context.register_dynamic_builder(ReportingStepDynamicBuilder {
|
||||||
|
name: "UpdateBalancesBetween",
|
||||||
|
can_build: Self::can_build,
|
||||||
|
build: Self::build,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_build(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
_args: &Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) -> bool {
|
||||||
|
// Check for Transactions -> BalancesBetween
|
||||||
|
if kind == ReportingProductKind::BalancesBetween {
|
||||||
|
// Initially no need to check args
|
||||||
|
if let Some(step) = steps.iter().find(|s| {
|
||||||
|
s.id().name == name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
}) {
|
||||||
|
// Check for BalancesBetween -> Transactions
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&step.id());
|
||||||
|
if dependencies_for_step.len() == 1
|
||||||
|
&& dependencies_for_step[0].product.kind
|
||||||
|
== ReportingProductKind::BalancesBetween
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
name: &'static str,
|
||||||
|
_kind: ReportingProductKind,
|
||||||
|
args: Box<dyn ReportingStepArgs>,
|
||||||
|
_steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
_dependencies: &ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) -> Box<dyn ReportingStep> {
|
||||||
|
Box::new(UpdateBalancesBetween {
|
||||||
|
step_name: name,
|
||||||
|
args: *args.downcast().unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for UpdateBalancesBetween {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{} {{UpdateBalancesBetween}}", self.id()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReportingStep for UpdateBalancesBetween {
|
||||||
|
fn id(&self) -> ReportingStepId {
|
||||||
|
ReportingStepId {
|
||||||
|
name: self.step_name,
|
||||||
|
product_kinds: &[ReportingProductKind::BalancesBetween],
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_graph(
|
||||||
|
&self,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &mut ReportingGraphDependencies,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
) {
|
||||||
|
// Add a dependency on the Transactions result
|
||||||
|
// Look up that step, so we can extract the appropriate args
|
||||||
|
let parent_step = steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| {
|
||||||
|
s.id().name == self.step_name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
})
|
||||||
|
.unwrap(); // Existence is checked in can_build
|
||||||
|
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: parent_step.id().args,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up the BalancesBetween step
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
|
||||||
|
let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
|
||||||
|
|
||||||
|
if *balances_between_product
|
||||||
|
.args
|
||||||
|
.downcast_ref::<DateStartDateEndArgs>()
|
||||||
|
.unwrap() == self.args
|
||||||
|
{
|
||||||
|
// Directly depends on BalanceBetween -> Transaction with appropriate date
|
||||||
|
// Do not need to add extra dependencies
|
||||||
|
} else {
|
||||||
|
// Depends on BalanceBetween with appropriate date
|
||||||
|
dependencies.add_dependency(
|
||||||
|
self.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: balances_between_product.name,
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_context: &ReportingContext,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
products: &RwLock<ReportingProducts>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
let products = products.read().await;
|
||||||
|
|
||||||
|
// Look up the parent step, so we can extract the appropriate args
|
||||||
|
let parent_step = steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| {
|
||||||
|
s.id().name == self.step_name
|
||||||
|
&& s.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&ReportingProductKind::Transactions)
|
||||||
|
})
|
||||||
|
.unwrap(); // Existence is checked in can_build
|
||||||
|
|
||||||
|
// Get transactions
|
||||||
|
let transactions = &products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::Transactions,
|
||||||
|
args: parent_step.id().args,
|
||||||
|
})?
|
||||||
|
.downcast_ref::<Transactions>()
|
||||||
|
.unwrap()
|
||||||
|
.transactions;
|
||||||
|
|
||||||
|
// Look up the BalancesBetween step
|
||||||
|
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
|
||||||
|
let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness is checked in can_build
|
||||||
|
|
||||||
|
// Get opening balances
|
||||||
|
let opening_balances = &products
|
||||||
|
.get_or_err(&ReportingProductId {
|
||||||
|
name: balances_between_product.name,
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
})?
|
||||||
|
.downcast_ref::<BalancesBetween>()
|
||||||
|
.unwrap()
|
||||||
|
.balances;
|
||||||
|
|
||||||
|
// Sum balances
|
||||||
|
let mut balances = BalancesBetween {
|
||||||
|
balances: opening_balances.clone(),
|
||||||
|
};
|
||||||
|
update_balances_from_transactions(
|
||||||
|
&mut balances.balances,
|
||||||
|
transactions.iter().filter(|t| {
|
||||||
|
t.transaction.dt.date() >= self.args.date_start
|
||||||
|
&& t.transaction.dt.date() <= self.args.date_end
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
let mut result = ReportingProducts::new();
|
||||||
|
result.insert(
|
||||||
|
ReportingProductId {
|
||||||
|
name: self.step_name,
|
||||||
|
kind: ReportingProductKind::BalancesBetween,
|
||||||
|
args: Box::new(self.args.clone()),
|
||||||
|
},
|
||||||
|
Box::new(balances),
|
||||||
|
);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
429
libdrcr/src/reporting/calculator.rs
Normal file
429
libdrcr/src/reporting/calculator.rs
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! This module implements the dependency resolution for [ReportingStep]s
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
ReportingContext, ReportingProductId, ReportingStep, ReportingStepDynamicBuilder,
|
||||||
|
ReportingStepFromArgsFn, ReportingStepId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// List of dependencies between [ReportingStep]s and [ReportingProduct][super::types::ReportingProduct]s
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReportingGraphDependencies {
|
||||||
|
vec: Vec<Dependency>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingGraphDependencies {
|
||||||
|
/// Get the list of [Dependency]s
|
||||||
|
pub fn vec(&self) -> &Vec<Dependency> {
|
||||||
|
&self.vec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that the [ReportingStep] depends on the [ReportingProduct][super::types::ReportingProduct]
|
||||||
|
pub fn add_dependency(&mut self, step: ReportingStepId, product: ReportingProductId) {
|
||||||
|
if !self
|
||||||
|
.vec
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.step == step && d.product == product)
|
||||||
|
{
|
||||||
|
self.vec.push(Dependency { step, product });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the [Dependency]s for the given [ReportingStep]
|
||||||
|
pub fn dependencies_for_step(&self, step: &ReportingStepId) -> Vec<&Dependency> {
|
||||||
|
return self.vec.iter().filter(|d| d.step == *step).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents that a [ReportingStep] depends on a [ReportingProduct][super::types::ReportingProduct]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Dependency {
|
||||||
|
pub step: ReportingStepId,
|
||||||
|
pub product: ReportingProductId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates an error during dependency resolution in [steps_for_targets]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ReportingCalculationError {
|
||||||
|
UnknownStep { message: String },
|
||||||
|
NoStepForProduct { message: String },
|
||||||
|
CircularDependencies,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum HasStepOrCanBuild<'a, 'b> {
|
||||||
|
HasStep(&'a Box<dyn ReportingStep>),
|
||||||
|
CanLookup(ReportingStepFromArgsFn),
|
||||||
|
CanBuild(&'b ReportingStepDynamicBuilder),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines whether the [ReportingProduct][super::types::ReportingProduct] is generated by a known step, or can be generated by a lookup function or dynamic builder
|
||||||
|
pub fn has_step_or_can_build<'a, 'b>(
|
||||||
|
product: &ReportingProductId,
|
||||||
|
steps: &'a Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &'b ReportingContext,
|
||||||
|
) -> HasStepOrCanBuild<'a, 'b> {
|
||||||
|
if let Some(step) = steps.iter().find(|s| {
|
||||||
|
s.id().name == product.name
|
||||||
|
&& s.id().args == product.args
|
||||||
|
&& s.id().product_kinds.contains(&product.kind)
|
||||||
|
}) {
|
||||||
|
return HasStepOrCanBuild::HasStep(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try lookup function
|
||||||
|
if let Some(lookup_key) = context
|
||||||
|
.step_lookup_fn
|
||||||
|
.keys()
|
||||||
|
.find(|(name, kinds)| *name == product.name && kinds.contains(&product.kind))
|
||||||
|
{
|
||||||
|
let (takes_args_fn, from_args_fn) = context.step_lookup_fn.get(lookup_key).unwrap();
|
||||||
|
if takes_args_fn(&product.args) {
|
||||||
|
return HasStepOrCanBuild::CanLookup(*from_args_fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit step for product - try builders
|
||||||
|
for builder in context.step_dynamic_builders.iter() {
|
||||||
|
if (builder.can_build)(
|
||||||
|
product.name,
|
||||||
|
product.kind,
|
||||||
|
&product.args,
|
||||||
|
steps,
|
||||||
|
dependencies,
|
||||||
|
context,
|
||||||
|
) {
|
||||||
|
return HasStepOrCanBuild::CanBuild(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HasStepOrCanBuild::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a new step which generates the requested [ReportingProduct][super::types::ReportingProduct], using a lookup function or dynamic builder
|
||||||
|
///
|
||||||
|
/// Panics if a known step already generates the requested [ReportingProduct][super::types::ReportingProduct].
|
||||||
|
fn build_step_for_product(
|
||||||
|
product: &ReportingProductId,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> Option<Box<dyn ReportingStep>> {
|
||||||
|
let new_step;
|
||||||
|
match has_step_or_can_build(product, steps, dependencies, context) {
|
||||||
|
HasStepOrCanBuild::HasStep(_) => {
|
||||||
|
panic!("Attempted to call build_step_for_product for already existing step")
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanLookup(from_args_fn) => {
|
||||||
|
new_step = from_args_fn(product.args.clone());
|
||||||
|
|
||||||
|
// Check new step meets the dependency
|
||||||
|
if new_step.id().name != product.name {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from lookup function (expected name {}, got {})",
|
||||||
|
product.name,
|
||||||
|
new_step.id().name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if new_step.id().args != product.args {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from lookup function {} (expected args {:?}, got {:?})",
|
||||||
|
product.name,
|
||||||
|
product.args,
|
||||||
|
new_step.id().args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !new_step.id().product_kinds.contains(&product.kind) {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})",
|
||||||
|
product.name,
|
||||||
|
product.kind,
|
||||||
|
new_step.id().product_kinds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::CanBuild(builder) => {
|
||||||
|
new_step = (builder.build)(
|
||||||
|
product.name,
|
||||||
|
product.kind,
|
||||||
|
product.args.clone(),
|
||||||
|
&steps,
|
||||||
|
&dependencies,
|
||||||
|
&context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check new step meets the dependency
|
||||||
|
if new_step.id().name != product.name {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from builder {} (expected name {}, got {})",
|
||||||
|
builder.name,
|
||||||
|
product.name,
|
||||||
|
new_step.id().name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if new_step.id().args != product.args {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from builder {} for {} (expected args {:?}, got {:?})",
|
||||||
|
builder.name,
|
||||||
|
product.name,
|
||||||
|
product.args,
|
||||||
|
new_step.id().args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !new_step.id().product_kinds.contains(&product.kind) {
|
||||||
|
panic!(
|
||||||
|
"Unexpected step returned from builder {} for {} (expected kind {:?}, got {:?})",
|
||||||
|
builder.name,
|
||||||
|
product.name,
|
||||||
|
product.kind,
|
||||||
|
new_step.id().product_kinds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HasStepOrCanBuild::None => {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(new_step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the [ReportingStep] would be ready to execute, if the given previous steps have already completed
|
||||||
|
pub(crate) fn would_be_ready_to_execute(
|
||||||
|
step: &Box<dyn ReportingStep>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
previous_steps: &Vec<usize>,
|
||||||
|
) -> bool {
|
||||||
|
'check_each_dependency: for dependency in dependencies.vec.iter() {
|
||||||
|
if dependency.step == step.id() {
|
||||||
|
// Check if the dependency has been produced by a previous step
|
||||||
|
for previous_step in previous_steps {
|
||||||
|
if steps[*previous_step].id().name == dependency.product.name
|
||||||
|
&& steps[*previous_step].id().args == dependency.product.args
|
||||||
|
&& steps[*previous_step]
|
||||||
|
.id()
|
||||||
|
.product_kinds
|
||||||
|
.contains(&dependency.product.kind)
|
||||||
|
{
|
||||||
|
continue 'check_each_dependency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency is not met
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively resolve the dependencies of the target [ReportingProductId]s and return a sorted [Vec] of [ReportingStep]s
|
||||||
|
pub fn steps_for_targets(
|
||||||
|
targets: Vec<ReportingProductId>,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> Result<(Vec<Box<dyn ReportingStep>>, ReportingGraphDependencies), ReportingCalculationError> {
|
||||||
|
let mut steps: Vec<Box<dyn ReportingStep>> = Vec::new();
|
||||||
|
let mut dependencies = ReportingGraphDependencies { vec: Vec::new() };
|
||||||
|
|
||||||
|
// Process initial targets
|
||||||
|
for target in targets.iter() {
|
||||||
|
if !steps.iter().any(|s| {
|
||||||
|
s.id().name == target.name
|
||||||
|
&& s.id().args == target.args
|
||||||
|
&& s.id().product_kinds.contains(&target.kind)
|
||||||
|
}) {
|
||||||
|
// No current step generates the product - try to lookup or build
|
||||||
|
if let Some(new_step) = build_step_for_product(&target, &steps, &dependencies, context)
|
||||||
|
{
|
||||||
|
steps.push(new_step);
|
||||||
|
let new_step = steps.last().unwrap();
|
||||||
|
for dependency in new_step.requires(&context) {
|
||||||
|
dependencies.add_dependency(new_step.id(), dependency);
|
||||||
|
}
|
||||||
|
new_step.init_graph(&steps, &mut dependencies, &context);
|
||||||
|
} else {
|
||||||
|
return Err(ReportingCalculationError::NoStepForProduct {
|
||||||
|
message: format!("No step builds target product {}", target),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after_init_graph
|
||||||
|
for step in steps.iter() {
|
||||||
|
step.as_ref()
|
||||||
|
.after_init_graph(&steps, &mut dependencies, &context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process dependencies
|
||||||
|
loop {
|
||||||
|
let mut new_steps = Vec::new();
|
||||||
|
|
||||||
|
for dependency in dependencies.vec.iter() {
|
||||||
|
if !steps.iter().any(|s| s.id() == dependency.step) {
|
||||||
|
// Unknown step for which a dependency has been declared
|
||||||
|
// FIXME: Call the lookup function
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
if !steps.iter().any(|s| {
|
||||||
|
s.id().name == dependency.product.name
|
||||||
|
&& s.id().args == dependency.product.args
|
||||||
|
&& s.id().product_kinds.contains(&dependency.product.kind)
|
||||||
|
}) {
|
||||||
|
// No current step generates the product - try to lookup or build
|
||||||
|
if let Some(new_step) =
|
||||||
|
build_step_for_product(&dependency.product, &steps, &dependencies, context)
|
||||||
|
{
|
||||||
|
new_steps.push(new_step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_steps.len() == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise new steps
|
||||||
|
let mut new_step_indexes = Vec::new();
|
||||||
|
for new_step in new_steps {
|
||||||
|
new_step_indexes.push(steps.len());
|
||||||
|
steps.push(new_step);
|
||||||
|
let new_step = steps.last().unwrap();
|
||||||
|
for dependency in new_step.requires(&context) {
|
||||||
|
dependencies.add_dependency(new_step.id(), dependency);
|
||||||
|
}
|
||||||
|
new_step
|
||||||
|
.as_ref()
|
||||||
|
.init_graph(&steps, &mut dependencies, &context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after_init_graph on all steps
|
||||||
|
for step in steps.iter() {
|
||||||
|
step.as_ref()
|
||||||
|
.after_init_graph(&steps, &mut dependencies, &context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all dependencies satisfied
|
||||||
|
for dependency in dependencies.vec.iter() {
|
||||||
|
if !steps.iter().any(|s| s.id() == dependency.step) {
|
||||||
|
return Err(ReportingCalculationError::UnknownStep {
|
||||||
|
message: format!(
|
||||||
|
"No implementation for step {} which {} is a dependency of",
|
||||||
|
dependency.step, dependency.product
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !steps.iter().any(|s| {
|
||||||
|
s.id().name == dependency.product.name
|
||||||
|
&& s.id().args == dependency.product.args
|
||||||
|
&& s.id().product_kinds.contains(&dependency.product.kind)
|
||||||
|
}) {
|
||||||
|
return Err(ReportingCalculationError::NoStepForProduct {
|
||||||
|
message: format!(
|
||||||
|
"No step builds product {} wanted by {}",
|
||||||
|
dependency.product, dependency.step
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
let mut sorted_step_indexes = Vec::new();
|
||||||
|
let mut steps_remaining = steps.iter().enumerate().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
'loop_until_all_sorted: while !steps_remaining.is_empty() {
|
||||||
|
for (cur_index, (orig_index, step)) in steps_remaining.iter().enumerate() {
|
||||||
|
if would_be_ready_to_execute(step, &steps, &dependencies, &sorted_step_indexes) {
|
||||||
|
sorted_step_indexes.push(*orig_index);
|
||||||
|
steps_remaining.remove(cur_index);
|
||||||
|
continue 'loop_until_all_sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No steps to execute - must be circular dependency
|
||||||
|
return Err(ReportingCalculationError::CircularDependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sort_mapping = vec![0_usize; sorted_step_indexes.len()];
|
||||||
|
for i in 0..sorted_step_indexes.len() {
|
||||||
|
sort_mapping[sorted_step_indexes[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This can be done in place
|
||||||
|
let mut sorted_steps = steps.into_iter().zip(sort_mapping).collect::<Vec<_>>();
|
||||||
|
sorted_steps.sort_unstable_by_key(|(_s, order)| *order);
|
||||||
|
let sorted_steps = sorted_steps
|
||||||
|
.into_iter()
|
||||||
|
.map(|(s, _idx)| s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok((sorted_steps, dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate graphviz code representing the dependency tree
|
||||||
|
///
|
||||||
|
/// Useful for debugging or visualisation. Can be compiled using e.g. `dot -Tpdf -O output.gv`.
|
||||||
|
pub fn steps_as_graphviz(
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
) -> String {
|
||||||
|
let mut result = String::from("strict digraph drcr {\n");
|
||||||
|
|
||||||
|
// Output all steps
|
||||||
|
for step in steps.iter() {
|
||||||
|
let step_display_name = step.to_string();
|
||||||
|
if step_display_name.contains("{") {
|
||||||
|
// Bodge: Detect dynamic step builders
|
||||||
|
result.push_str(&format!(
|
||||||
|
"\"{}\" [shape=box, style=dashed, label=\"{}\"];\n",
|
||||||
|
step.id(),
|
||||||
|
step_display_name
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
result.push_str(&format!("\"{}\" [shape=box];\n", step.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output the products of the step
|
||||||
|
for product_kind in step.id().product_kinds.iter() {
|
||||||
|
result.push_str(&format!(
|
||||||
|
"\"{}\" -> \"{}\";\n",
|
||||||
|
step.id(),
|
||||||
|
ReportingProductId {
|
||||||
|
name: step.id().name,
|
||||||
|
kind: *product_kind,
|
||||||
|
args: step.id().args
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output all dependencies
|
||||||
|
for dependency in dependencies.vec().iter() {
|
||||||
|
result.push_str(&format!(
|
||||||
|
"\"{}\" -> \"{}\";\n",
|
||||||
|
dependency.product, dependency.step
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str("}");
|
||||||
|
result
|
||||||
|
}
|
562
libdrcr/src/reporting/dynamic_report.rs
Normal file
562
libdrcr/src/reporting/dynamic_report.rs
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// FIXME: Tidy up this file
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::QuantityInt;
|
||||||
|
|
||||||
|
use super::types::ReportingProduct;
|
||||||
|
|
||||||
|
/// Represents a dynamically generated report composed of [CalculatableDynamicReportEntry]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CalculatableDynamicReport {
|
||||||
|
pub title: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
// This must use RefCell as, during calculation, we iterate while mutating the report
|
||||||
|
pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalculatableDynamicReport {
|
||||||
|
pub fn new(
|
||||||
|
title: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
entries: Vec<CalculatableDynamicReportEntry>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
title,
|
||||||
|
columns,
|
||||||
|
entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively calculate all [CalculatedRow] entries
|
||||||
|
pub fn calculate(self) -> DynamicReport {
|
||||||
|
let mut calculated_entries = Vec::new();
|
||||||
|
|
||||||
|
for (entry_idx, entry) in self.entries.iter().enumerate() {
|
||||||
|
let entry_ref = entry.borrow();
|
||||||
|
|
||||||
|
match &*entry_ref {
|
||||||
|
CalculatableDynamicReportEntry::CalculatableSection(section) => {
|
||||||
|
// Clone first, in case calculation needs to take reference to the section
|
||||||
|
let updated_section = section.clone().calculate(&self);
|
||||||
|
|
||||||
|
drop(entry_ref); // Drop entry_ref so we can borrow mutably
|
||||||
|
let mut entry_mut = self.entries[entry_idx].borrow_mut();
|
||||||
|
*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
|
||||||
|
|
||||||
|
calculated_entries.push(DynamicReportEntry::Section(updated_section));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Section(section) => {
|
||||||
|
calculated_entries.push(DynamicReportEntry::Section(section.clone()));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row) => {
|
||||||
|
calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::CalculatedRow(row) => {
|
||||||
|
let updated_row = row.calculate(&self);
|
||||||
|
|
||||||
|
drop(entry_ref); // Drop entry_ref so we can borrow mutably
|
||||||
|
let mut entry_mut = self.entries[entry_idx].borrow_mut();
|
||||||
|
*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
|
||||||
|
|
||||||
|
calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Spacer => {
|
||||||
|
calculated_entries.push(DynamicReportEntry::Spacer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicReport {
|
||||||
|
title: self.title,
|
||||||
|
columns: self.columns,
|
||||||
|
entries: calculated_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up [CalculatableDynamicReportEntry] by id
|
||||||
|
///
|
||||||
|
/// Returns a cloned copy of the [CalculatableDynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow.
|
||||||
|
pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
|
||||||
|
// Manually iterate over self.entries rather than self.entries()
|
||||||
|
// To catch the situation where entry is already mutably borrowed
|
||||||
|
for entry in self.entries.iter() {
|
||||||
|
match entry.try_borrow() {
|
||||||
|
Ok(entry) => match &*entry {
|
||||||
|
CalculatableDynamicReportEntry::CalculatableSection(section) => {
|
||||||
|
if let Some(i) = §ion.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = section.by_id(id) {
|
||||||
|
return Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Section(section) => {
|
||||||
|
if let Some(i) = §ion.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = section.by_id(id) {
|
||||||
|
return Some(match e {
|
||||||
|
DynamicReportEntry::Section(section) => {
|
||||||
|
CalculatableDynamicReportEntry::Section(section.clone())
|
||||||
|
}
|
||||||
|
DynamicReportEntry::LiteralRow(row) => {
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row.clone())
|
||||||
|
}
|
||||||
|
DynamicReportEntry::Spacer => {
|
||||||
|
CalculatableDynamicReportEntry::Spacer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row) => {
|
||||||
|
if let Some(i) = &row.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
|
||||||
|
CalculatableDynamicReportEntry::Spacer => (),
|
||||||
|
},
|
||||||
|
Err(err) => panic!(
|
||||||
|
"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
|
||||||
|
err
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the subtotals for the [Section] with the given id
|
||||||
|
pub fn subtotal_for_id(&self, id: &str) -> Vec<QuantityInt> {
|
||||||
|
let entry = self.by_id(id).expect("Invalid id");
|
||||||
|
if let CalculatableDynamicReportEntry::CalculatableSection(section) = entry {
|
||||||
|
section.subtotal(&self)
|
||||||
|
} else {
|
||||||
|
panic!("Called subtotal_for_id on non-Section");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the quantities for the [LiteralRow] with the given id
|
||||||
|
pub fn quantity_for_id(&self, id: &str) -> Vec<QuantityInt> {
|
||||||
|
let entry = self.by_id(id).expect("Invalid id");
|
||||||
|
if let CalculatableDynamicReportEntry::LiteralRow(row) = entry {
|
||||||
|
row.quantity
|
||||||
|
} else {
|
||||||
|
panic!("Called quantity_for_id on non-LiteralRow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a dynamically generated report composed of [DynamicReportEntry], with no [CalculatedRow]s
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DynamicReport {
|
||||||
|
pub title: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub entries: Vec<DynamicReportEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DynamicReport {
|
||||||
|
pub fn new(title: String, columns: Vec<String>, entries: Vec<DynamicReportEntry>) -> Self {
|
||||||
|
Self {
|
||||||
|
title,
|
||||||
|
columns,
|
||||||
|
entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all entries from the report where auto_hide is enabled and quantity is zero
|
||||||
|
pub fn auto_hide(&mut self) {
|
||||||
|
self.entries.retain_mut(|e| match e {
|
||||||
|
DynamicReportEntry::Section(section) => {
|
||||||
|
section.auto_hide_children();
|
||||||
|
if section.can_auto_hide_self() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::LiteralRow(row) => {
|
||||||
|
if row.can_auto_hide() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::Spacer => true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise the report (as JSON) using serde
|
||||||
|
pub fn to_json(&self) -> String {
|
||||||
|
serde_json::to_string(self).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingProduct for DynamicReport {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum CalculatableDynamicReportEntry {
|
||||||
|
CalculatableSection(CalculatableSection),
|
||||||
|
Section(Section),
|
||||||
|
LiteralRow(LiteralRow),
|
||||||
|
CalculatedRow(CalculatedRow),
|
||||||
|
Spacer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub enum DynamicReportEntry {
|
||||||
|
Section(Section),
|
||||||
|
LiteralRow(LiteralRow),
|
||||||
|
Spacer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CalculatableSection {
|
||||||
|
pub text: String,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub visible: bool,
|
||||||
|
pub auto_hide: bool,
|
||||||
|
pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalculatableSection {
|
||||||
|
pub fn new(
|
||||||
|
text: String,
|
||||||
|
id: Option<String>,
|
||||||
|
visible: bool,
|
||||||
|
auto_hide: bool,
|
||||||
|
entries: Vec<CalculatableDynamicReportEntry>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
id,
|
||||||
|
visible,
|
||||||
|
auto_hide,
|
||||||
|
entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively calculate all [CalculatedRow] entries
|
||||||
|
pub fn calculate(&mut self, report: &CalculatableDynamicReport) -> Section {
|
||||||
|
let mut calculated_entries = Vec::new();
|
||||||
|
|
||||||
|
for (entry_idx, entry) in self.entries.iter().enumerate() {
|
||||||
|
let entry_ref = entry.borrow();
|
||||||
|
|
||||||
|
match &*entry_ref {
|
||||||
|
CalculatableDynamicReportEntry::CalculatableSection(section) => {
|
||||||
|
let updated_section = section.clone().calculate(&report);
|
||||||
|
|
||||||
|
drop(entry_ref); // Drop entry_ref so we can borrow mutably
|
||||||
|
let mut entry_mut = self.entries[entry_idx].borrow_mut();
|
||||||
|
*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
|
||||||
|
|
||||||
|
calculated_entries.push(DynamicReportEntry::Section(updated_section));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Section(section) => {
|
||||||
|
calculated_entries.push(DynamicReportEntry::Section(section.clone()));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row) => {
|
||||||
|
calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::CalculatedRow(row) => {
|
||||||
|
let updated_row = row.calculate(&report);
|
||||||
|
|
||||||
|
drop(entry_ref); // Drop entry_ref so we can borrow mutably
|
||||||
|
let mut entry_mut = self.entries[entry_idx].borrow_mut();
|
||||||
|
*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
|
||||||
|
|
||||||
|
calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Spacer => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
text: self.text.clone(),
|
||||||
|
id: self.id.clone(),
|
||||||
|
visible: self.visible,
|
||||||
|
auto_hide: self.auto_hide,
|
||||||
|
entries: calculated_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up [CalculatableDynamicReportEntry] by id
|
||||||
|
///
|
||||||
|
/// Returns a cloned copy of the [CalculatableDynamicReportEntry].
|
||||||
|
pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
|
||||||
|
// Manually iterate over self.entries rather than self.entries()
|
||||||
|
// To catch the situation where entry is already mutably borrowed
|
||||||
|
for entry in self.entries.iter() {
|
||||||
|
match entry.try_borrow() {
|
||||||
|
Ok(entry) => match &*entry {
|
||||||
|
CalculatableDynamicReportEntry::CalculatableSection(section) => {
|
||||||
|
if let Some(i) = §ion.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = section.by_id(id) {
|
||||||
|
return Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Section(_) => todo!(),
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row) => {
|
||||||
|
if let Some(i) = &row.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
|
||||||
|
CalculatableDynamicReportEntry::Spacer => (),
|
||||||
|
},
|
||||||
|
Err(err) => panic!(
|
||||||
|
"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
|
||||||
|
err
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the subtotals for this [CalculatableSection]
|
||||||
|
pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec<QuantityInt> {
|
||||||
|
let mut subtotals = vec![0; report.columns.len()];
|
||||||
|
for entry in self.entries.iter() {
|
||||||
|
match &*entry.borrow() {
|
||||||
|
CalculatableDynamicReportEntry::CalculatableSection(section) => {
|
||||||
|
for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
|
||||||
|
subtotals[col_idx] += subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::Section(section) => {
|
||||||
|
for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
|
||||||
|
subtotals[col_idx] += subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::LiteralRow(row) => {
|
||||||
|
for (col_idx, subtotal) in row.quantity.iter().enumerate() {
|
||||||
|
subtotals[col_idx] += subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
|
||||||
|
CalculatableDynamicReportEntry::Spacer => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subtotals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Section {
|
||||||
|
pub text: String,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub visible: bool,
|
||||||
|
pub auto_hide: bool,
|
||||||
|
pub entries: Vec<DynamicReportEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
fn auto_hide_children(&mut self) {
|
||||||
|
self.entries.retain_mut(|e| match e {
|
||||||
|
DynamicReportEntry::Section(section) => {
|
||||||
|
section.auto_hide_children();
|
||||||
|
if section.can_auto_hide_self() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::LiteralRow(row) => {
|
||||||
|
if row.can_auto_hide() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::Spacer => true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_auto_hide_self(&self) -> bool {
|
||||||
|
self.auto_hide
|
||||||
|
&& self.entries.iter().all(|e| match e {
|
||||||
|
DynamicReportEntry::Section(section) => section.can_auto_hide_self(),
|
||||||
|
DynamicReportEntry::LiteralRow(row) => row.can_auto_hide(),
|
||||||
|
DynamicReportEntry::Spacer => true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up [DynamicReportEntry] by id
|
||||||
|
///
|
||||||
|
/// Returns a cloned copy of the [DynamicReportEntry].
|
||||||
|
pub fn by_id(&self, id: &str) -> Option<DynamicReportEntry> {
|
||||||
|
// Manually iterate over self.entries rather than self.entries()
|
||||||
|
// To catch the situation where entry is already mutably borrowed
|
||||||
|
for entry in self.entries.iter() {
|
||||||
|
match entry {
|
||||||
|
DynamicReportEntry::Section(section) => {
|
||||||
|
if let Some(i) = §ion.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = section.by_id(id) {
|
||||||
|
return Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::LiteralRow(row) => {
|
||||||
|
if let Some(i) = &row.id {
|
||||||
|
if i == id {
|
||||||
|
return Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::Spacer => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the subtotals for this [Section]
|
||||||
|
pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec<QuantityInt> {
|
||||||
|
let mut subtotals = vec![0; report.columns.len()];
|
||||||
|
for entry in self.entries.iter() {
|
||||||
|
match entry {
|
||||||
|
DynamicReportEntry::Section(section) => {
|
||||||
|
for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
|
||||||
|
subtotals[col_idx] += subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::LiteralRow(row) => {
|
||||||
|
for (col_idx, subtotal) in row.quantity.iter().enumerate() {
|
||||||
|
subtotals[col_idx] += subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicReportEntry::Spacer => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subtotals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct LiteralRow {
|
||||||
|
pub text: String,
|
||||||
|
pub quantity: Vec<QuantityInt>,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub visible: bool,
|
||||||
|
pub auto_hide: bool,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub heading: bool,
|
||||||
|
pub bordered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiteralRow {
|
||||||
|
/// Returns whether the row has auto_hide enabled and all quantities are zero
|
||||||
|
fn can_auto_hide(&self) -> bool {
|
||||||
|
self.auto_hide && self.quantity.iter().all(|q| *q == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CalculatedRow {
|
||||||
|
//pub text: String,
|
||||||
|
pub calculate_fn: fn(report: &CalculatableDynamicReport) -> LiteralRow,
|
||||||
|
//pub id: Option<String>,
|
||||||
|
//pub visible: bool,
|
||||||
|
//pub auto_hide: bool,
|
||||||
|
//pub link: Option<String>,
|
||||||
|
//pub heading: bool,
|
||||||
|
//pub bordered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalculatedRow {
|
||||||
|
fn calculate(&self, report: &CalculatableDynamicReport) -> LiteralRow {
|
||||||
|
(self.calculate_fn)(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_for_kind(
|
||||||
|
kind: &str,
|
||||||
|
invert: bool,
|
||||||
|
balances: &Vec<&HashMap<String, QuantityInt>>,
|
||||||
|
kinds_for_account: &HashMap<String, Vec<String>>,
|
||||||
|
) -> Vec<CalculatableDynamicReportEntry> {
|
||||||
|
// Get accounts of specified kind
|
||||||
|
let mut accounts = kinds_for_account
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(a, k)| {
|
||||||
|
if k.iter().any(|k| k == kind) {
|
||||||
|
Some(a)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
accounts.sort();
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for account in accounts {
|
||||||
|
let quantities = balances
|
||||||
|
.iter()
|
||||||
|
.map(|b| b.get(account).unwrap_or(&0) * if invert { -1 } else { 1 })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Some exceptions for the link
|
||||||
|
let link;
|
||||||
|
if account == crate::CURRENT_YEAR_EARNINGS {
|
||||||
|
link = Some("/income-statement".to_string());
|
||||||
|
} else if account == crate::RETAINED_EARNINGS {
|
||||||
|
link = None
|
||||||
|
} else {
|
||||||
|
link = Some(format!("/transactions/{}", account));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = LiteralRow {
|
||||||
|
text: account.to_string(),
|
||||||
|
quantity: quantities,
|
||||||
|
id: None,
|
||||||
|
visible: true,
|
||||||
|
auto_hide: true,
|
||||||
|
link,
|
||||||
|
heading: false,
|
||||||
|
bordered: false,
|
||||||
|
};
|
||||||
|
entries.push(CalculatableDynamicReportEntry::LiteralRow(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
120
libdrcr/src/reporting/executor.rs
Normal file
120
libdrcr/src/reporting/executor.rs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::{sync::RwLock, task::JoinSet};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
calculator::{would_be_ready_to_execute, ReportingGraphDependencies},
|
||||||
|
types::{ReportingContext, ReportingProducts, ReportingStep},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ReportingExecutionError {
|
||||||
|
DependencyNotAvailable { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_step(
|
||||||
|
step_idx: usize,
|
||||||
|
steps: Arc<Vec<Box<dyn ReportingStep>>>,
|
||||||
|
dependencies: Arc<ReportingGraphDependencies>,
|
||||||
|
context: Arc<ReportingContext>,
|
||||||
|
products: Arc<RwLock<ReportingProducts>>,
|
||||||
|
) -> (usize, Result<ReportingProducts, ReportingExecutionError>) {
|
||||||
|
let step = &steps[step_idx];
|
||||||
|
let result = step
|
||||||
|
.execute(&*context, &*steps, &*dependencies, &*products)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
(step_idx, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_steps(
|
||||||
|
steps: Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: ReportingGraphDependencies,
|
||||||
|
context: Arc<ReportingContext>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
let products = Arc::new(RwLock::new(ReportingProducts::new()));
|
||||||
|
|
||||||
|
// Prepare for async
|
||||||
|
let steps = Arc::new(steps);
|
||||||
|
let dependencies = Arc::new(dependencies);
|
||||||
|
|
||||||
|
// Execute steps asynchronously
|
||||||
|
let mut handles = JoinSet::new();
|
||||||
|
let mut steps_done = Vec::new();
|
||||||
|
let mut steps_remaining = (0..steps.len()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
while steps_done.len() != steps.len() {
|
||||||
|
// Execute each step which is ready to run
|
||||||
|
for step_idx in steps_remaining.iter().copied().collect::<Vec<_>>() {
|
||||||
|
// Check if ready to run
|
||||||
|
if would_be_ready_to_execute(&steps[step_idx], &steps, &dependencies, &steps_done) {
|
||||||
|
// Spawn new task
|
||||||
|
// Unfortunately the compiler cannot guarantee lifetimes are correct, so we must pass Arc across thread boundaries
|
||||||
|
handles.spawn(execute_step(
|
||||||
|
step_idx,
|
||||||
|
Arc::clone(&steps),
|
||||||
|
Arc::clone(&dependencies),
|
||||||
|
Arc::clone(&context),
|
||||||
|
Arc::clone(&products),
|
||||||
|
));
|
||||||
|
steps_remaining
|
||||||
|
.remove(steps_remaining.iter().position(|i| *i == step_idx).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join next result
|
||||||
|
let (step_idx, result) = handles.join_next().await.unwrap().unwrap();
|
||||||
|
let step = &steps[step_idx];
|
||||||
|
steps_done.push(step_idx);
|
||||||
|
|
||||||
|
let mut new_products = result?;
|
||||||
|
|
||||||
|
// Sanity check the new products
|
||||||
|
for (product_id, _product) in new_products.map().iter() {
|
||||||
|
if product_id.name != step.id().name {
|
||||||
|
panic!(
|
||||||
|
"Unexpected product name {} from step {}",
|
||||||
|
product_id,
|
||||||
|
step.id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !step.id().product_kinds.contains(&product_id.kind) {
|
||||||
|
panic!(
|
||||||
|
"Unexpected product kind {} from step {}",
|
||||||
|
product_id,
|
||||||
|
step.id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if product_id.args != step.id().args {
|
||||||
|
panic!(
|
||||||
|
"Unexpected product args {} from step {}",
|
||||||
|
product_id,
|
||||||
|
step.id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new products
|
||||||
|
products.write().await.append(&mut new_products);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Arc::into_inner(products).unwrap().into_inner())
|
||||||
|
}
|
64
libdrcr/src/reporting/mod.rs
Normal file
64
libdrcr/src/reporting/mod.rs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use calculator::{steps_for_targets, ReportingCalculationError};
|
||||||
|
use executor::{execute_steps, ReportingExecutionError};
|
||||||
|
use types::{ReportingContext, ReportingProductId, ReportingProducts};
|
||||||
|
|
||||||
|
pub mod builders;
|
||||||
|
pub mod calculator;
|
||||||
|
pub mod dynamic_report;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod steps;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ReportingError {
|
||||||
|
ReportingCalculationError(ReportingCalculationError),
|
||||||
|
ReportingExecutionError(ReportingExecutionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReportingCalculationError> for ReportingError {
|
||||||
|
fn from(err: ReportingCalculationError) -> Self {
|
||||||
|
ReportingError::ReportingCalculationError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReportingExecutionError> for ReportingError {
|
||||||
|
fn from(err: ReportingExecutionError) -> Self {
|
||||||
|
ReportingError::ReportingExecutionError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the steps required to generate the requested [ReportingProductId]s and then execute them
|
||||||
|
///
|
||||||
|
/// Helper function to call [steps_for_targets] followed by [execute_steps].
|
||||||
|
pub async fn generate_report(
|
||||||
|
targets: Vec<ReportingProductId>,
|
||||||
|
context: Arc<ReportingContext>,
|
||||||
|
) -> Result<ReportingProducts, ReportingError> {
|
||||||
|
// Solve dependencies
|
||||||
|
let (sorted_steps, dependencies) = steps_for_targets(targets, &*context)?;
|
||||||
|
|
||||||
|
// Execute steps
|
||||||
|
let products = execute_steps(sorted_steps, dependencies, context).await?;
|
||||||
|
|
||||||
|
Ok(products)
|
||||||
|
}
|
1729
libdrcr/src/reporting/steps.rs
Normal file
1729
libdrcr/src/reporting/steps.rs
Normal file
File diff suppressed because it is too large
Load Diff
431
libdrcr/src/reporting/types.rs
Normal file
431
libdrcr/src/reporting/types.rs
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::{Debug, Display};
|
||||||
|
use std::hash::Hash;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use downcast_rs::Downcast;
|
||||||
|
use dyn_clone::DynClone;
|
||||||
|
use dyn_eq::DynEq;
|
||||||
|
use dyn_hash::DynHash;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::db::DbConnection;
|
||||||
|
use crate::model::transaction::TransactionWithPostings;
|
||||||
|
use crate::QuantityInt;
|
||||||
|
|
||||||
|
use super::calculator::ReportingGraphDependencies;
|
||||||
|
use super::executor::ReportingExecutionError;
|
||||||
|
|
||||||
|
// -----------------
|
||||||
|
// REPORTING CONTEXT
|
||||||
|
|
||||||
|
/// Records the context for a single reporting job
|
||||||
|
pub struct ReportingContext {
|
||||||
|
// Configuration
|
||||||
|
pub db_connection: DbConnection,
|
||||||
|
pub eofy_date: NaiveDate,
|
||||||
|
pub reporting_commodity: String,
|
||||||
|
|
||||||
|
// State
|
||||||
|
pub(crate) step_lookup_fn: HashMap<
|
||||||
|
(&'static str, &'static [ReportingProductKind]),
|
||||||
|
(ReportingStepTakesArgsFn, ReportingStepFromArgsFn),
|
||||||
|
>,
|
||||||
|
pub(crate) step_dynamic_builders: Vec<ReportingStepDynamicBuilder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingContext {
|
||||||
|
/// Initialise a new [ReportingContext]
|
||||||
|
pub fn new(
|
||||||
|
db_connection: DbConnection,
|
||||||
|
eofy_date: NaiveDate,
|
||||||
|
reporting_commodity: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
db_connection,
|
||||||
|
eofy_date,
|
||||||
|
reporting_commodity,
|
||||||
|
step_lookup_fn: HashMap::new(),
|
||||||
|
step_dynamic_builders: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a lookup function
|
||||||
|
///
|
||||||
|
/// A lookup function generates concrete [ReportingStep]s from a [ReportingStepId].
|
||||||
|
pub fn register_lookup_fn(
|
||||||
|
&mut self,
|
||||||
|
name: &'static str,
|
||||||
|
product_kinds: &'static [ReportingProductKind],
|
||||||
|
takes_args_fn: ReportingStepTakesArgsFn,
|
||||||
|
from_args_fn: ReportingStepFromArgsFn,
|
||||||
|
) {
|
||||||
|
self.step_lookup_fn
|
||||||
|
.insert((name, product_kinds), (takes_args_fn, from_args_fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a dynamic builder
|
||||||
|
///
|
||||||
|
/// Dynamic builders are called when no concrete [ReportingStep] is implemented, and can dynamically generate a [ReportingStep]. Dynamic builders are implemented in [super::builders].
|
||||||
|
pub fn register_dynamic_builder(&mut self, builder: ReportingStepDynamicBuilder) {
|
||||||
|
if !self
|
||||||
|
.step_dynamic_builders
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.name == builder.name)
|
||||||
|
{
|
||||||
|
self.step_dynamic_builders.push(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Function which determines whether the [ReportingStepArgs] are valid arguments for a given [ReportingStep]
|
||||||
|
///
|
||||||
|
/// See [ReportingContext::register_lookup_fn].
|
||||||
|
pub type ReportingStepTakesArgsFn = fn(args: &Box<dyn ReportingStepArgs>) -> bool;
|
||||||
|
|
||||||
|
/// Function which builds a concrete [ReportingStep] from the given [ReportingStepArgs]
|
||||||
|
///
|
||||||
|
/// See [ReportingContext::register_lookup_fn].
|
||||||
|
pub type ReportingStepFromArgsFn = fn(args: Box<dyn ReportingStepArgs>) -> Box<dyn ReportingStep>;
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// REPORTING STEP DYNAMIC BUILDERS
|
||||||
|
|
||||||
|
/// Represents a reporting step dynamic builder
|
||||||
|
///
|
||||||
|
/// See [ReportingContext::register_dynamic_builder].
|
||||||
|
pub struct ReportingStepDynamicBuilder {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub can_build: fn(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
args: &Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> bool,
|
||||||
|
pub build: fn(
|
||||||
|
name: &'static str,
|
||||||
|
kind: ReportingProductKind,
|
||||||
|
args: Box<dyn ReportingStepArgs>,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) -> Box<dyn ReportingStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------
|
||||||
|
// REPORTING PRODUCTS
|
||||||
|
|
||||||
|
/// Identifies a [ReportingProduct]
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct ReportingProductId {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub kind: ReportingProductKind,
|
||||||
|
pub args: Box<dyn ReportingStepArgs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ReportingProductId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{}.{:?}({})", self.name, self.kind, self.args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies a type of [ReportingProduct]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum ReportingProductKind {
|
||||||
|
Transactions,
|
||||||
|
BalancesAt,
|
||||||
|
BalancesBetween,
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the result of a [ReportingStep]
|
||||||
|
pub trait ReportingProduct: Debug + Downcast + DynClone + Send + Sync {}
|
||||||
|
|
||||||
|
downcast_rs::impl_downcast!(ReportingProduct);
|
||||||
|
dyn_clone::clone_trait_object!(ReportingProduct);
|
||||||
|
|
||||||
|
/// Records a list of transactions generated by a [ReportingStep]
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Transactions {
|
||||||
|
pub transactions: Vec<TransactionWithPostings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingProduct for Transactions {}
|
||||||
|
|
||||||
|
/// Records cumulative account balances at a particular point in time
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BalancesAt {
|
||||||
|
pub balances: HashMap<String, QuantityInt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingProduct for BalancesAt {}
|
||||||
|
|
||||||
|
/// Records the total value of transactions in each account between two points in time
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BalancesBetween {
|
||||||
|
pub balances: HashMap<String, QuantityInt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingProduct for BalancesBetween {}
|
||||||
|
|
||||||
|
/// Map from [ReportingProductId] to [ReportingProduct]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ReportingProducts {
|
||||||
|
// This needs to be an IndexMap not HashMap, because sometimes we query which product is more up to date
|
||||||
|
map: IndexMap<ReportingProductId, Box<dyn ReportingProduct>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingProducts {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
map: IndexMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the underlying [IndexMap]
|
||||||
|
pub fn map(&self) -> &IndexMap<ReportingProductId, Box<dyn ReportingProduct>> {
|
||||||
|
&self.map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a key-value pair in the map
|
||||||
|
///
|
||||||
|
/// See [IndexMap::insert].
|
||||||
|
pub fn insert(&mut self, key: ReportingProductId, value: Box<dyn ReportingProduct>) {
|
||||||
|
self.map.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves all key-value pairs from `other` into `self`, leaving `other` empty
|
||||||
|
///
|
||||||
|
/// See [IndexMap::append].
|
||||||
|
pub fn append(&mut self, other: &mut ReportingProducts) {
|
||||||
|
self.map.append(&mut other.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_or_err(
|
||||||
|
&self,
|
||||||
|
key: &ReportingProductId,
|
||||||
|
) -> Result<&Box<dyn ReportingProduct>, ReportingExecutionError> {
|
||||||
|
match self.map.get(key) {
|
||||||
|
Some(value) => Ok(value),
|
||||||
|
None => Err(ReportingExecutionError::DependencyNotAvailable {
|
||||||
|
message: format!("Product {} not available when expected", key),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_owned_or_err(
|
||||||
|
mut self,
|
||||||
|
key: &ReportingProductId,
|
||||||
|
) -> Result<Box<dyn ReportingProduct>, ReportingExecutionError> {
|
||||||
|
match self.map.swap_remove(key) {
|
||||||
|
Some(value) => Ok(value),
|
||||||
|
None => Err(ReportingExecutionError::DependencyNotAvailable {
|
||||||
|
message: format!("Product {} not available when expected", key),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ReportingProducts {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(
|
||||||
|
"ReportingProducts {{\n{}\n}}",
|
||||||
|
self.map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!(" {}: {:?}", k, v))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------
|
||||||
|
// REPORTING STEPS
|
||||||
|
|
||||||
|
/// Identifies a [ReportingStep]
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ReportingStepId {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub product_kinds: &'static [ReportingProductKind],
|
||||||
|
pub args: Box<dyn ReportingStepArgs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ReportingStepId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(
|
||||||
|
"{}{:?}({})",
|
||||||
|
self.name, self.product_kinds, self.args
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a step in a reporting job
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ReportingStep: Debug + Display + Downcast + Send + Sync {
|
||||||
|
/// Get the [ReportingStepId] for this [ReportingStep]
|
||||||
|
fn id(&self) -> ReportingStepId;
|
||||||
|
|
||||||
|
/// Return a list of statically defined dependencies for this [ReportingStep]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn requires(&self, context: &ReportingContext) -> Vec<ReportingProductId> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the [ReportingStep] is initialised in [super::calculator::steps_for_targets]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn init_graph(
|
||||||
|
&self,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &mut ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when new [ReportingStep]s are initialised in [super::calculator::steps_for_targets]
|
||||||
|
///
|
||||||
|
/// This callback can be used to dynamically declare dependencies between [ReportingStep]s that are not known at initialisation.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn after_init_graph(
|
||||||
|
&self,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &mut ReportingGraphDependencies,
|
||||||
|
context: &ReportingContext,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called to generate the [ReportingProduct] for this [ReportingStep]
|
||||||
|
///
|
||||||
|
/// Returns a [ReportingProducts] containing (only) the new [ReportingProduct]s.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
context: &ReportingContext,
|
||||||
|
steps: &Vec<Box<dyn ReportingStep>>,
|
||||||
|
dependencies: &ReportingGraphDependencies,
|
||||||
|
products: &RwLock<ReportingProducts>,
|
||||||
|
) -> Result<ReportingProducts, ReportingExecutionError> {
|
||||||
|
todo!("{}", self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downcast_rs::impl_downcast!(ReportingStep);
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// REPORTING STEP ARGUMENTS
|
||||||
|
|
||||||
|
/// Represents arguments to a [ReportingStep]
|
||||||
|
pub trait ReportingStepArgs:
|
||||||
|
Debug + Display + Downcast + DynClone + DynEq + DynHash + Send + Sync
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
downcast_rs::impl_downcast!(ReportingStepArgs);
|
||||||
|
dyn_clone::clone_trait_object!(ReportingStepArgs);
|
||||||
|
dyn_eq::eq_trait_object!(ReportingStepArgs);
|
||||||
|
dyn_hash::hash_trait_object!(ReportingStepArgs);
|
||||||
|
|
||||||
|
/// [ReportingStepArgs] implementation which takes no arguments
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct VoidArgs {}
|
||||||
|
|
||||||
|
impl ReportingStepArgs for VoidArgs {}
|
||||||
|
|
||||||
|
impl Display for VoidArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ReportingStepArgs] implementation which takes a single date
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct DateArgs {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingStepArgs for DateArgs {}
|
||||||
|
|
||||||
|
impl Display for DateArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{}", self.date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ReportingStepArgs] implementation which takes a date range
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct DateStartDateEndArgs {
|
||||||
|
pub date_start: NaiveDate,
|
||||||
|
pub date_end: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingStepArgs for DateStartDateEndArgs {}
|
||||||
|
|
||||||
|
impl Display for DateStartDateEndArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!("{}, {}", self.date_start, self.date_end))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ReportingStepArgs] implementation which takes multiple [DateArgs]
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct MultipleDateArgs {
|
||||||
|
pub dates: Vec<DateArgs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingStepArgs for MultipleDateArgs {}
|
||||||
|
|
||||||
|
impl Display for MultipleDateArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(
|
||||||
|
"{}",
|
||||||
|
self.dates
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ReportingStepArgs] implementation which takes multiple [DateStartDateEndArgs]
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct MultipleDateStartDateEndArgs {
|
||||||
|
pub dates: Vec<DateStartDateEndArgs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportingStepArgs for MultipleDateStartDateEndArgs {}
|
||||||
|
|
||||||
|
impl Display for MultipleDateStartDateEndArgs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_fmt(format_args!(
|
||||||
|
"{}",
|
||||||
|
self.dates
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!("({})", a))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
62
libdrcr/src/serde.rs
Normal file
62
libdrcr/src/serde.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Serialises [chrono::NaiveDateTime] in database format
|
||||||
|
///
|
||||||
|
/// Use as `#[serde(with = "crate::serde::naivedatetime_to_js")]`, etc.
|
||||||
|
pub mod naivedatetime_to_js {
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{
|
||||||
|
de::{self, Unexpected, Visitor},
|
||||||
|
Deserializer, Serializer,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn serialize<S: Serializer>(
|
||||||
|
dt: &NaiveDateTime,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DateVisitor;
|
||||||
|
impl<'de> Visitor<'de> for DateVisitor {
|
||||||
|
type Value = NaiveDateTime;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "a date string")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
match NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.6f") {
|
||||||
|
Ok(dt) => Ok(dt),
|
||||||
|
Err(_) => Err(de::Error::invalid_value(Unexpected::Str(s), &self)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<NaiveDateTime, D::Error> {
|
||||||
|
deserializer.deserialize_str(DateVisitor)
|
||||||
|
}
|
||||||
|
}
|
43
libdrcr/src/util.rs
Normal file
43
libdrcr/src/util.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
|
||||||
|
/// Return the end date of the current financial year for the given date
|
||||||
|
pub fn get_eofy(date: &NaiveDate, eofy_date: &NaiveDate) -> NaiveDate {
|
||||||
|
let date_eofy = eofy_date.with_year(date.year()).unwrap();
|
||||||
|
if date_eofy >= *date {
|
||||||
|
date_eofy
|
||||||
|
} else {
|
||||||
|
date_eofy.with_year(date_eofy.year() + 1).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the start date of the financial year, given the end date of the financial year
|
||||||
|
pub fn sofy_from_eofy(eofy_date: NaiveDate) -> NaiveDate {
|
||||||
|
eofy_date
|
||||||
|
.with_year(eofy_date.year() - 1)
|
||||||
|
.unwrap()
|
||||||
|
.succ_opt()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the [NaiveDate] as a string
|
||||||
|
pub fn format_date(date: NaiveDate) -> String {
|
||||||
|
date.format("%Y-%m-%d 00:00:00.000000").to_string()
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user