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