Add 'libdrcr/' from commit '0d680275df56300ff9059928f5611b9da2313b74'

git-subtree-dir: libdrcr
git-subtree-mainline: a40e2f81ba7f5d01ea3fbce037d47c1949d1b64d
git-subtree-split: 0d680275df56300ff9059928f5611b9da2313b74
This commit is contained in:
RunasSudo 2025-05-28 23:00:56 +10:00
commit 62b7981224
21 changed files with 7013 additions and 0 deletions

1
libdrcr/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1987
libdrcr/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
libdrcr/Cargo.toml Normal file
View 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
View File

@ -0,0 +1 @@
hard_tabs = true

View 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
View 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
View 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
View 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()
);*/
}

View 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
View 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;

View 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,
}

View 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);
}
}
}

View 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)
}
}

View 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
}

View 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) = &section.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) = &section.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) = &section.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) = &section.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
}

View 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())
}

View 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)
}

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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()
}