Compare commits
58 Commits
2dd967f5a4
...
0b3770fb95
Author | SHA1 | Date | |
---|---|---|---|
0b3770fb95 | |||
364c44d60a | |||
4364af9b9a | |||
20773c4640 | |||
ffef2d16dc | |||
bbcb3cee6f | |||
ad3276bbd5 | |||
233c6d6aa9 | |||
51a40e5ed9 | |||
c9c3bc0d2c | |||
b938176b5f | |||
dfdd3b0924 | |||
930213c461 | |||
c3a407b048 | |||
1b67df61be | |||
53497e7593 | |||
835af70bc7 | |||
4ff0ea46db | |||
706d26e54f | |||
af47021e4f | |||
42ba33c45c | |||
df8ec39e1e | |||
807316a090 | |||
a967c87dab | |||
5430c6713f | |||
d44c2a1200 | |||
42eaa015bd | |||
9bb9eaabaf | |||
25697b501c | |||
faa53c625c | |||
1dcb31df57 | |||
f3ad696168 | |||
f76d2a5736 | |||
fed7def6f3 | |||
34fd8233cf | |||
8f1903e532 | |||
407974e440 | |||
35d397f5c9 | |||
38014b7c91 | |||
4ba1317fce | |||
9fe7bf22a6 | |||
412b79ee45 | |||
4e94557370 | |||
798c7d3c07 | |||
bfb41d8d15 | |||
7f188db677 | |||
0f8e3e5d4a | |||
ae26b64d5e | |||
37e9e19c5e | |||
58758b0cb3 | |||
349ecf3d76 | |||
1e33074b4d | |||
161acabb7d | |||
5a1b54f782 | |||
61ed6f82d7 | |||
39617a54ac | |||
de890aeade | |||
0ee500af3e |
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"] }
|
BIN
libdrcr/drcr_testing.db
Normal file
BIN
libdrcr/drcr_testing.db
Normal file
Binary file not shown.
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,
|
||||
}
|
@ -1,25 +1,21 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { DrcrReport } from './base.ts';
|
||||
|
||||
export default class TrialBalanceReport implements DrcrReport {
|
||||
constructor(
|
||||
public balances: Map<string, number>
|
||||
) {}
|
||||
}
|
||||
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()
|
||||
}
|
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
hard_tabs = true
|
106
src-tauri/Cargo.lock
generated
106
src-tauri/Cargo.lock
generated
@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
@ -211,9 +211,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.83"
|
||||
version = "0.1.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
|
||||
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -514,15 +514,17 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.38"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-targets 0.52.6",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -914,6 +916,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.1"
|
||||
@ -927,7 +935,9 @@ dependencies = [
|
||||
name = "drcr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"indexmap 2.6.0",
|
||||
"chrono",
|
||||
"indexmap 2.9.0",
|
||||
"libdrcr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@ -963,9 +973,21 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.17"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
|
||||
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-eq"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-hash"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
@ -1950,9 +1972,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.6.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
|
||||
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.1",
|
||||
@ -2149,9 +2171,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.162"
|
||||
version = "0.2.172"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libdrcr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"downcast-rs 2.0.1",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@ -3022,7 +3061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"quick-xml 0.32.0",
|
||||
"serde",
|
||||
"time",
|
||||
@ -3492,9 +3531,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.215"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@ -3512,9 +3551,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.215"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -3534,9 +3573,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.132"
|
||||
version = "1.0.140"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
dependencies = [
|
||||
"itoa 1.0.11",
|
||||
"memchr",
|
||||
@ -3586,7 +3625,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@ -3846,7 +3885,7 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"hashlink",
|
||||
"hex",
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
@ -4390,7 +4429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7022ccbcd1799dcf7acb97805f1a5fc7f046b4cf67518296a2e8921bab613a"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -4638,14 +4677,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.41.1"
|
||||
version = "1.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
|
||||
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
@ -4656,9 +4696,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
||||
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -4728,7 +4768,7 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
@ -4741,7 +4781,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap 2.6.0",
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
@ -5130,7 +5170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"downcast-rs 1.2.1",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
@ -5382,6 +5422,12 @@ dependencies = [
|
||||
"syn 2.0.87",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.2.0"
|
||||
|
@ -18,7 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.41"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
libdrcr = { path = "../libdrcr" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["json", "time"] }
|
||||
|
@ -1,21 +1,22 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
mod libdrcr_bridge;
|
||||
mod sql;
|
||||
|
||||
use tauri::{AppHandle, Builder, Manager, State};
|
||||
@ -32,20 +33,26 @@ struct AppState {
|
||||
// Filename state
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> {
|
||||
async fn get_open_filename(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> Result<Option<String>, tauri_plugin_sql::Error> {
|
||||
let state = state.lock().await;
|
||||
Ok(state.db_filename.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> {
|
||||
async fn set_open_filename(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
app: AppHandle,
|
||||
filename: Option<String>,
|
||||
) -> Result<(), tauri_plugin_sql::Error> {
|
||||
let mut state = state.lock().await;
|
||||
state.db_filename = filename.clone();
|
||||
|
||||
|
||||
// Persist in store
|
||||
let store = app.store("store.json").expect("Error opening store");
|
||||
store.set("db_filename", filename);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -65,15 +72,15 @@ pub fn run() {
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => panic!("Unexpected db_filename in store")
|
||||
}
|
||||
_ => panic!("Unexpected db_filename in store"),
|
||||
};
|
||||
|
||||
|
||||
app.manage(Mutex::new(AppState {
|
||||
db_filename: db_filename,
|
||||
sql_transactions: Vec::new(),
|
||||
}));
|
||||
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@ -81,8 +88,19 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_sql::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_open_filename, set_open_filename,
|
||||
sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit
|
||||
get_open_filename,
|
||||
set_open_filename,
|
||||
libdrcr_bridge::get_all_transactions_except_earnings_to_equity,
|
||||
libdrcr_bridge::get_all_transactions_except_earnings_to_equity_for_account,
|
||||
libdrcr_bridge::get_balance_sheet,
|
||||
libdrcr_bridge::get_income_statement,
|
||||
libdrcr_bridge::get_trial_balance,
|
||||
libdrcr_bridge::get_validated_balance_assertions,
|
||||
sql::sql_transaction_begin,
|
||||
sql::sql_transaction_execute,
|
||||
sql::sql_transaction_select,
|
||||
sql::sql_transaction_rollback,
|
||||
sql::sql_transaction_commit
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Error while running tauri application");
|
||||
|
284
src-tauri/src/libdrcr_bridge.rs
Normal file
284
src-tauri/src/libdrcr_bridge.rs
Normal file
@ -0,0 +1,284 @@
|
||||
/*
|
||||
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::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use libdrcr::db::DbConnection;
|
||||
use libdrcr::model::assertions::BalanceAssertion;
|
||||
use libdrcr::reporting::builders::register_dynamic_builders;
|
||||
use libdrcr::reporting::dynamic_report::DynamicReport;
|
||||
use libdrcr::reporting::generate_report;
|
||||
use libdrcr::reporting::steps::register_lookup_fns;
|
||||
use libdrcr::reporting::types::{
|
||||
BalancesAt, DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
|
||||
ReportingContext, ReportingProduct, ReportingProductId, ReportingProductKind, Transactions,
|
||||
VoidArgs,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
async fn get_report(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
target: &ReportingProductId,
|
||||
) -> Box<dyn ReportingProduct> {
|
||||
let state = state.lock().await;
|
||||
let db_filename = state.db_filename.clone().unwrap();
|
||||
|
||||
// Connect to database
|
||||
let db_connection =
|
||||
DbConnection::new(format!("sqlite:{}", db_filename.as_str()).as_str()).await;
|
||||
|
||||
// Initialise ReportingContext
|
||||
let eofy_date = db_connection.metadata().eofy_date;
|
||||
let mut context = ReportingContext::new(db_connection, eofy_date, "$".to_string());
|
||||
register_lookup_fns(&mut context);
|
||||
register_dynamic_builders(&mut context);
|
||||
|
||||
// Get dynamic report
|
||||
let targets = vec![
|
||||
ReportingProductId {
|
||||
name: "CalculateIncomeTax",
|
||||
kind: ReportingProductKind::Transactions,
|
||||
args: Box::new(VoidArgs {}),
|
||||
},
|
||||
target.clone(),
|
||||
];
|
||||
let products = generate_report(targets, Arc::new(context)).await.unwrap();
|
||||
let result = products.get_owned_or_err(&target).unwrap();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_all_transactions_except_earnings_to_equity(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> Result<String, ()> {
|
||||
let transactions = get_report(
|
||||
state,
|
||||
&ReportingProductId {
|
||||
name: "AllTransactionsExceptEarningsToEquity",
|
||||
kind: ReportingProductKind::Transactions,
|
||||
args: Box::new(DateArgs {
|
||||
date: NaiveDate::from_ymd_opt(9999, 12, 31).unwrap(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.downcast::<Transactions>()
|
||||
.unwrap()
|
||||
.transactions;
|
||||
|
||||
Ok(serde_json::to_string(&transactions).unwrap())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_all_transactions_except_earnings_to_equity_for_account(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
account: String,
|
||||
) -> Result<String, ()> {
|
||||
let transactions = get_report(
|
||||
state,
|
||||
&ReportingProductId {
|
||||
name: "AllTransactionsExceptEarningsToEquity",
|
||||
kind: ReportingProductKind::Transactions,
|
||||
args: Box::new(DateArgs {
|
||||
date: NaiveDate::from_ymd_opt(9999, 12, 31).unwrap(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.downcast::<Transactions>()
|
||||
.unwrap()
|
||||
.transactions;
|
||||
|
||||
// Filter only transactions affecting this account
|
||||
let filtered_transactions = transactions
|
||||
.into_iter()
|
||||
.filter(|t| t.postings.iter().any(|p| p.account == account))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(serde_json::to_string(&filtered_transactions).unwrap())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_balance_sheet(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
dates: Vec<String>,
|
||||
) -> Result<String, ()> {
|
||||
let mut date_args = Vec::new();
|
||||
for date in dates.iter() {
|
||||
date_args.push(DateArgs {
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").expect("Invalid date"),
|
||||
})
|
||||
}
|
||||
|
||||
Ok(get_report(
|
||||
state,
|
||||
&ReportingProductId {
|
||||
name: "BalanceSheet",
|
||||
kind: ReportingProductKind::Generic,
|
||||
args: Box::new(MultipleDateArgs {
|
||||
dates: date_args.clone(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.downcast_ref::<DynamicReport>()
|
||||
.unwrap()
|
||||
.to_json())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_income_statement(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
dates: Vec<(String, String)>,
|
||||
) -> Result<String, ()> {
|
||||
let mut date_args = Vec::new();
|
||||
for (date_start, date_end) in dates.iter() {
|
||||
date_args.push(DateStartDateEndArgs {
|
||||
date_start: NaiveDate::parse_from_str(date_start, "%Y-%m-%d").expect("Invalid date"),
|
||||
date_end: NaiveDate::parse_from_str(date_end, "%Y-%m-%d").expect("Invalid date"),
|
||||
})
|
||||
}
|
||||
|
||||
Ok(get_report(
|
||||
state,
|
||||
&ReportingProductId {
|
||||
name: "IncomeStatement",
|
||||
kind: ReportingProductKind::Generic,
|
||||
args: Box::new(MultipleDateStartDateEndArgs {
|
||||
dates: date_args.clone(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.downcast_ref::<DynamicReport>()
|
||||
.unwrap()
|
||||
.to_json())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_trial_balance(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
date: String,
|
||||
) -> Result<String, ()> {
|
||||
let date = NaiveDate::parse_from_str(&date, "%Y-%m-%d").expect("Invalid date");
|
||||
|
||||
Ok(get_report(
|
||||
state,
|
||||
&ReportingProductId {
|
||||
name: "TrialBalance",
|
||||
kind: ReportingProductKind::Generic,
|
||||
args: Box::new(DateArgs { date }),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.downcast_ref::<DynamicReport>()
|
||||
.unwrap()
|
||||
.to_json())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ValidatedBalanceAssertion {
|
||||
#[serde(flatten)]
|
||||
assertion: BalanceAssertion,
|
||||
is_valid: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_validated_balance_assertions(
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> Result<String, ()> {
|
||||
let state = state.lock().await;
|
||||
let db_filename = state.db_filename.clone().unwrap();
|
||||
|
||||
// Connect to database
|
||||
let db_connection =
|
||||
DbConnection::new(format!("sqlite:{}", db_filename.as_str()).as_str()).await;
|
||||
|
||||
let reporting_commodity = db_connection.metadata().reporting_commodity.clone(); // Needed later
|
||||
|
||||
// First get balance assertions from database
|
||||
let balance_assertions = db_connection.get_balance_assertions().await;
|
||||
|
||||
// Get dates of balance assertions
|
||||
let dates = balance_assertions
|
||||
.iter()
|
||||
.map(|b| b.dt)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// Initialise ReportingContext
|
||||
let eofy_date = db_connection.metadata().eofy_date;
|
||||
let mut context = ReportingContext::new(db_connection, eofy_date, "$".to_string());
|
||||
register_lookup_fns(&mut context);
|
||||
register_dynamic_builders(&mut context);
|
||||
|
||||
// Get report targets
|
||||
let mut targets = vec![ReportingProductId {
|
||||
name: "CalculateIncomeTax",
|
||||
kind: ReportingProductKind::Transactions,
|
||||
args: Box::new(VoidArgs {}),
|
||||
}];
|
||||
for dt in dates {
|
||||
// Request ordinary transaction balances at each balance assertion date
|
||||
targets.push(ReportingProductId {
|
||||
name: "CombineOrdinaryTransactions",
|
||||
kind: ReportingProductKind::BalancesAt,
|
||||
args: Box::new(DateArgs { date: dt.date() }),
|
||||
});
|
||||
}
|
||||
|
||||
// Run report
|
||||
let products = generate_report(targets, Arc::new(context)).await.unwrap();
|
||||
|
||||
// Validate each balance assertion
|
||||
let mut validated_assertions = Vec::new();
|
||||
for balance_assertion in balance_assertions {
|
||||
let balances_at_date = products
|
||||
.get_or_err(&ReportingProductId {
|
||||
name: "CombineOrdinaryTransactions",
|
||||
kind: ReportingProductKind::BalancesAt,
|
||||
args: Box::new(DateArgs {
|
||||
date: balance_assertion.dt.date(),
|
||||
}),
|
||||
})
|
||||
.unwrap()
|
||||
.downcast_ref::<BalancesAt>()
|
||||
.unwrap();
|
||||
|
||||
let account_balance = *balances_at_date
|
||||
.balances
|
||||
.get(&balance_assertion.account)
|
||||
.unwrap_or(&0);
|
||||
|
||||
let is_valid = balance_assertion.quantity == account_balance
|
||||
&& balance_assertion.commodity == reporting_commodity;
|
||||
|
||||
validated_assertions.push(ValidatedBalanceAssertion {
|
||||
assertion: balance_assertion,
|
||||
is_valid,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string(&validated_assertions).unwrap())
|
||||
}
|
@ -55,8 +55,6 @@
|
||||
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
|
||||
import ComboBoxAccounts from './ComboBoxAccounts.vue';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronUpDownIcon } from '@heroicons/vue/24/outline';
|
||||
import { defineModel, defineProps, useTemplateRef } from 'vue';
|
||||
import { useTemplateRef } from 'vue';
|
||||
|
||||
const { values, inputClass } = defineProps<{ values: string[], inputClass?: string }>();
|
||||
const inputField = useTemplateRef('inputField');
|
||||
|
@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import ComboBox from './ComboBox.vue';
|
||||
|
@ -1,63 +0,0 @@
|
||||
<!--
|
||||
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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="reports.length > 0">
|
||||
<h1 class="page-heading">
|
||||
{{ reports[0].title }}
|
||||
</h1>
|
||||
|
||||
<slot />
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th v-for="label of labels" class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ label }} </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ComparativeDynamicReportEntry :row="[row[0], row]" v-for="row of joinedEntries" />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue';
|
||||
|
||||
import { DynamicReport } from '../reports/base.ts';
|
||||
import ComparativeDynamicReportEntry from './ComparativeDynamicReportEntry.vue';
|
||||
|
||||
const { reports, labels } = defineProps<{ reports: DynamicReport[], labels: string[] }>();
|
||||
|
||||
const joinedEntries = computed(() => {
|
||||
// FIXME: Validate reports are of the same type, etc.
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < reports[0].entries.length; i++) {
|
||||
const thisRow = [];
|
||||
for (let report of reports) {
|
||||
thisRow.push(report.entries[i]);
|
||||
}
|
||||
result.push(thisRow);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
@ -1,106 +0,0 @@
|
||||
<!--
|
||||
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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="row[0] instanceof Entry">
|
||||
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
|
||||
<tr :class="row[0].bordered ? 'border-y border-gray-300' : null">
|
||||
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': row[0].heading }">
|
||||
<a :href="row[0].link" class="hover:text-blue-700 hover:underline" v-if="row[0].link !== null">{{ row[0].text }}</a>
|
||||
<template v-if="row[0].link === null">{{ row[0].text }}</template>
|
||||
</component>
|
||||
<template v-for="entry of row[1]">
|
||||
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': row[0].heading }" v-html="entry ? ppBracketed((entry as Entry).quantity, (entry as Entry).link ?? undefined) : ''" />
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-if="row[0] instanceof Section">
|
||||
<tr v-if="row[0].title !== null">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ row[0].title }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<ComparativeDynamicReportEntry :row="childRow" v-for="childRow of joinedChildren" />
|
||||
</template>
|
||||
<template v-if="row[0] instanceof Spacer">
|
||||
<tr><td :colspan="row[1].length + 1" class="py-0.5"> </td></tr>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue';
|
||||
|
||||
import { ppBracketed } from '../display.ts';
|
||||
import { DynamicReportNode, Entry, Section, Spacer, Subtotal } from '../reports/base.ts';
|
||||
|
||||
const { row } = defineProps<{ row: [DynamicReportNode, (DynamicReportNode | null)[]] }>();
|
||||
|
||||
const joinedChildren = computed(() => {
|
||||
// First get all children's names
|
||||
const joinedNames: string[] = [];
|
||||
for (let cell of row[1]) {
|
||||
for (let entry of (cell as any).entries) {
|
||||
if (entry instanceof Subtotal) { // Handle Subtotal separately
|
||||
continue;
|
||||
}
|
||||
if (!joinedNames.includes((entry as any).text)) {
|
||||
joinedNames.push((entry as any).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
joinedNames.sort();
|
||||
|
||||
// Then return joined children in order of sorted names
|
||||
const result: [DynamicReportNode, (DynamicReportNode | null)[]][] = [];
|
||||
for (let name of joinedNames) {
|
||||
const thisRow: DynamicReportNode[] = [];
|
||||
let thisRowExample = null;
|
||||
for (let cell of row[1]) {
|
||||
let thisCell = null;
|
||||
for (let entry of (cell as any).entries) {
|
||||
if ((entry as any).text === name) {
|
||||
thisCell = entry;
|
||||
thisRowExample = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
thisRow.push(thisCell);
|
||||
}
|
||||
result.push([thisRowExample, thisRow]);
|
||||
}
|
||||
|
||||
// Add Subtotal
|
||||
const subtotalRow = [];
|
||||
let subtotalExample = null;
|
||||
for (let cell of row[1]) {
|
||||
let thisCell = null;
|
||||
for (let entry of (cell as any).entries) {
|
||||
if (entry instanceof Subtotal) {
|
||||
thisCell = entry;
|
||||
subtotalExample = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
subtotalRow.push(thisCell);
|
||||
}
|
||||
if (subtotalExample) {
|
||||
result.push([subtotalExample, subtotalRow]);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -43,7 +43,7 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { defineModel, defineProps, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const { values } = defineProps<{ values: [string | null, [string, string][]][] }>(); // Array of [category name, [internal identifier, pretty name]]
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -28,22 +28,19 @@
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ db.metadata.reporting_commodity }} </th>
|
||||
<th v-for="column of report.columns" class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ column }} </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<DynamicReportEntry :entry="entry" v-for="entry of report.entries" />
|
||||
<DynamicReportEntryComponent :entry="entry" v-for="entry of report.entries" />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { DynamicReport } from '../reports/base.ts';
|
||||
import DynamicReportEntry from './DynamicReportEntry.vue';
|
||||
import DynamicReportEntryComponent from './DynamicReportEntryComponent.vue';
|
||||
|
||||
const { report } = defineProps<{ report: DynamicReport | null }>();
|
||||
</script>
|
||||
|
@ -1,49 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="entry instanceof Entry">
|
||||
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
|
||||
<tr :class="entry.bordered ? 'border-y border-gray-300' : null">
|
||||
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': entry.heading }">
|
||||
<a :href="entry.link" class="hover:text-blue-700 hover:underline" v-if="entry.link !== null">{{ entry.text }}</a>
|
||||
<template v-if="entry.link === null">{{ entry.text }}</template>
|
||||
</component>
|
||||
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': entry.heading }" v-html="ppBracketed(entry.quantity, entry.link ?? undefined)" />
|
||||
</tr>
|
||||
</template>
|
||||
<template v-if="entry instanceof Section">
|
||||
<tr v-if="entry.title !== null">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ entry.title }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<DynamicReportEntry :entry="child" v-for="child of entry.entries" />
|
||||
</template>
|
||||
<template v-if="entry instanceof Spacer">
|
||||
<tr><td colspan="2" class="py-0.5"> </td></tr>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { ppBracketed } from '../display.ts';
|
||||
import { DynamicReportNode, Entry, Section, Spacer } from '../reports/base.ts';
|
||||
|
||||
const { entry } = defineProps<{ entry: DynamicReportNode }>();
|
||||
</script>
|
57
src/components/DynamicReportEntryComponent.vue
Normal file
57
src/components/DynamicReportEntryComponent.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<!--
|
||||
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.
|
||||
import { db } from '../db.ts';
|
||||
|
||||
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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="literalRow">
|
||||
<tr :class="literalRow.bordered ? 'border-y border-gray-300' : null">
|
||||
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': literalRow.heading }">
|
||||
<a :href="literalRow.link as string" class="hover:text-blue-700 hover:underline" v-if="literalRow.link !== null">{{ literalRow.text }}</a>
|
||||
<template v-if="literalRow.link === null">{{ literalRow.text }}</template>
|
||||
</component>
|
||||
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': literalRow.heading }" v-html="(cell !== 0 || literalRow.heading) ? ppBracketed(cell, literalRow.link ?? undefined) : ''" v-for="cell of literalRow.quantity">
|
||||
</component>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-if="section">
|
||||
<tr v-if="section.text !== null">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ section.text }}</th>
|
||||
<th></th><!-- FIXME: Have correct colspan -->
|
||||
</tr>
|
||||
<DynamicReportEntryComponent :entry="child" v-for="child of section.entries" />
|
||||
</template>
|
||||
<template v-if="entry == 'Spacer'">
|
||||
<tr><td colspan="2" class="py-0.5"> </td></tr><!-- FIXME: Have correct colspan -->
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ppBracketed } from '../display.ts';
|
||||
import { DynamicReportEntry, LiteralRow, Section } from '../reports/base.ts';
|
||||
|
||||
const { entry } = defineProps<{ entry: DynamicReportEntry }>();
|
||||
|
||||
const literalRow = computed(function() {
|
||||
return (entry as { LiteralRow: LiteralRow }).LiteralRow;
|
||||
});
|
||||
const section = computed(function() {
|
||||
return (entry as { Section: Section }).Section;
|
||||
});
|
||||
</script>
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -43,7 +43,7 @@ async function initApp() {
|
||||
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
|
||||
{ path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') },
|
||||
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./reports/TrialBalanceReport.vue') },
|
||||
];
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -48,8 +48,8 @@
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ pp(Math.abs(assertion.quantity)) }}</td>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
<CheckIcon class="w-4 h-4" v-if="assertion.isValid" />
|
||||
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.isValid" />
|
||||
<CheckIcon class="w-4 h-4" v-if="assertion.is_valid" />
|
||||
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.is_valid" />
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
<a :href="'/balance-assertions/edit/' + assertion.id" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">
|
||||
@ -63,14 +63,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { pp } from '../display.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { pp } from '../display.ts';
|
||||
|
||||
const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
|
||||
|
||||
@ -81,42 +79,11 @@
|
||||
account: string,
|
||||
quantity: number,
|
||||
commodity: string,
|
||||
isValid: boolean,
|
||||
is_valid: boolean,
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const rawBalanceAssertions: any[] = await session.select(
|
||||
`SELECT *
|
||||
FROM balance_assertions
|
||||
ORDER BY dt DESC, id DESC`
|
||||
);
|
||||
|
||||
// Get transactions
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session);
|
||||
const transactions = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
|
||||
|
||||
for (const balanceAssertion of rawBalanceAssertions) {
|
||||
// Check assertion status
|
||||
const balanceAssertionDt = dayjs(balanceAssertion.dt);
|
||||
|
||||
let accountBalance = 0;
|
||||
for (const transaction of transactions) {
|
||||
if (dayjs(transaction.dt) <= balanceAssertionDt) {
|
||||
for (const posting of transaction.postings) {
|
||||
if (posting.account === balanceAssertion.account) {
|
||||
accountBalance += posting.quantity_ascost!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
balanceAssertion.isValid = balanceAssertion.quantity === accountBalance && balanceAssertion.commodity === db.metadata.reporting_commodity;
|
||||
}
|
||||
|
||||
balanceAssertions.value = rawBalanceAssertions as ValidatedBalanceAssertion[];
|
||||
balanceAssertions.value = JSON.parse(await invoke('get_validated_balance_assertions'));
|
||||
}
|
||||
|
||||
load();
|
||||
|
@ -60,16 +60,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Clusterize from 'clusterize.js';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Transaction, db } from '../db.ts';
|
||||
import { Transaction } from '../db.ts';
|
||||
import { pp, ppWithCommodity } from '../display.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
|
||||
const commodityDetail = ref(false);
|
||||
@ -78,14 +75,9 @@
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session);
|
||||
transactions.value = JSON.parse(await invoke('get_all_transactions_except_earnings_to_equity'));
|
||||
|
||||
transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE);
|
||||
|
||||
// Display transactions in reverse chronological order
|
||||
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
|
||||
// Display transactions in reverse chronological order - they are returned in arbitrary order
|
||||
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0)));
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -38,15 +38,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { UnlistenFn, listen } from '@tauri-apps/api/event';
|
||||
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Transaction, db } from '../db.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import { Transaction } from '../db.ts';
|
||||
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
|
||||
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
|
||||
|
||||
@ -56,24 +54,20 @@
|
||||
const transactions = ref([] as Transaction[]);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session); // This also ensures running balances are up to date
|
||||
|
||||
const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE);
|
||||
|
||||
// Filter only transactions affecting this account
|
||||
const filteredTransactions = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account));
|
||||
const transactionsRaw = JSON.parse(await invoke(
|
||||
'get_all_transactions_except_earnings_to_equity_for_account',
|
||||
{ account: route.params.account }
|
||||
)) as Transaction[];
|
||||
|
||||
// In order to correctly sort API transactions, we need to remember their indexes
|
||||
const filteredTxnsWithIndexes = filteredTransactions.map((t, index) => [t, index] as [Transaction, number]);
|
||||
const transactionsRawWithIndexes = transactionsRaw.map((t, index) => [t, index] as [Transaction, number]);
|
||||
|
||||
// Sort transactions in reverse chronological order
|
||||
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
|
||||
// Use Number.MAX_SAFE_INTEGER as ID for API transactions
|
||||
filteredTxnsWithIndexes.sort(([t1, i1], [t2, i2]) => (t2.dt.localeCompare(t1.dt)) || ((t2.id ?? Number.MAX_SAFE_INTEGER) - (t1.id ?? Number.MAX_SAFE_INTEGER) || (i2 - i1)));
|
||||
transactionsRawWithIndexes.sort(([t1, i1], [t2, i2]) => (t2.dt.localeCompare(t1.dt)) || ((t2.id ?? Number.MAX_SAFE_INTEGER) - (t1.id ?? Number.MAX_SAFE_INTEGER) || (i2 - i1)));
|
||||
|
||||
transactions.value = filteredTxnsWithIndexes.map(([t, _idx]) => t);
|
||||
transactions.value = transactionsRawWithIndexes.map(([t, _idx]) => t);
|
||||
}
|
||||
load();
|
||||
|
||||
|
@ -1,79 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
Trial balance
|
||||
</h1>
|
||||
|
||||
<table class="min-w-full" v-if="report">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="[account, quantity] in report.balances.entries()">
|
||||
<td class="py-0.5 pr-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: account } }" class="hover:text-blue-700 hover:underline">{{ account }}</RouterLink></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">
|
||||
<template v-if="quantity >= 0">{{ pp(quantity) }}</template>
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
<template v-if="quantity < 0">{{ pp(-quantity) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Total</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 text-end">{{ pp(total_dr!) }}</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 text-end">{{ pp(-total_cr!) }}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { pp } from '../display.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import TrialBalanceReport from '../reports/TrialBalanceReport.ts';
|
||||
|
||||
const report = ref(null as TrialBalanceReport | null);
|
||||
|
||||
// WebKit: Iterator.reduce not supported - https://bugs.webkit.org/show_bug.cgi?id=248650
|
||||
const total_dr = computed(() => report.value ?
|
||||
[...report.value.balances.values()].reduce((acc, x) => x > 0 ? acc + x : acc, 0)
|
||||
: 0
|
||||
);
|
||||
const total_cr = computed(() => report.value ?
|
||||
[...report.value.balances.values()].reduce((acc, x) => x < 0 ? acc + x : acc, 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session);
|
||||
|
||||
report.value = reportingWorkflow.getReportAtStage(ReportingStage.FINAL_STAGE, TrialBalanceReport) as TrialBalanceReport;
|
||||
}
|
||||
load();
|
||||
</script>
|
294
src/reporting.ts
294
src/reporting.ts
@ -1,294 +0,0 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { asCost } from './amounts.ts';
|
||||
import { DT_FORMAT, JoinedTransactionPosting, StatementLine, Transaction, db, getAccountsForKind, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts';
|
||||
import { ExtendedDatabase } from './dbutil.ts';
|
||||
|
||||
import { BalanceSheetReport } from './reports/BalanceSheetReport.vue';
|
||||
import { DrcrReport } from './reports/base.ts';
|
||||
import TrialBalanceReport from './reports/TrialBalanceReport.ts';
|
||||
import { IncomeStatementReport } from './reports/IncomeStatementReport.vue';
|
||||
|
||||
export enum ReportingStage {
|
||||
// Load transactions from database
|
||||
TransactionsFromDatabase = 100,
|
||||
|
||||
// Load unreconciled statement lines and other ordinary API transactions
|
||||
OrdinaryAPITransactions = 200,
|
||||
|
||||
// Recognise accumulated surplus as equity
|
||||
AccumulatedSurplusToEquity = 300,
|
||||
|
||||
// Interim income statement considering only DB and ordinary API transactions
|
||||
InterimIncomeStatement = 400,
|
||||
|
||||
// Income tax estimation
|
||||
//Tax = 500,
|
||||
|
||||
// Final income statement
|
||||
//IncomeStatement = 600,
|
||||
|
||||
// Final balance sheet
|
||||
BalanceSheet = 700,
|
||||
|
||||
FINAL_STAGE = BalanceSheet
|
||||
}
|
||||
|
||||
export class ReportingWorkflow {
|
||||
transactionsForStage: Map<ReportingStage, Transaction[]> = new Map();
|
||||
reportsForStage: Map<ReportingStage, DrcrReport[]> = new Map();
|
||||
|
||||
async generate(session: ExtendedDatabase, dt?: string, dtStart?: string) {
|
||||
// ------------------------
|
||||
// TransactionsFromDatabase
|
||||
|
||||
let balances: Map<string, number>;
|
||||
|
||||
{
|
||||
// Load balances from database
|
||||
if (dt) {
|
||||
balances = await totalBalancesAtDate(session, dt);
|
||||
} else {
|
||||
balances = await totalBalances(session);
|
||||
}
|
||||
this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]);
|
||||
|
||||
// Load transactions from database
|
||||
let joinedTransactionPostings: JoinedTransactionPosting[];
|
||||
if (dt) {
|
||||
joinedTransactionPostings = await session.select(
|
||||
`SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost, running_balance
|
||||
FROM transactions_with_running_balances
|
||||
WHERE DATE(dt) <= DATE($1)
|
||||
ORDER BY dt, transaction_id, id`,
|
||||
[dt]
|
||||
);
|
||||
} else {
|
||||
joinedTransactionPostings = await session.select(
|
||||
`SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost, running_balance
|
||||
FROM transactions_with_running_balances
|
||||
ORDER BY dt, transaction_id, id`
|
||||
);
|
||||
}
|
||||
const transactions = joinedToTransactions(joinedTransactionPostings);
|
||||
this.transactionsForStage.set(ReportingStage.TransactionsFromDatabase, transactions);
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// OrdinaryAPITransactions
|
||||
|
||||
{
|
||||
// Get unreconciled statement lines
|
||||
let unreconciledStatementLines: StatementLine[];
|
||||
if (dt) {
|
||||
unreconciledStatementLines = await session.select(
|
||||
// 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 AND DATE(dt) <= DATE($1)`,
|
||||
[dt]
|
||||
);
|
||||
} else {
|
||||
unreconciledStatementLines = await session.select(
|
||||
`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`
|
||||
);
|
||||
}
|
||||
|
||||
const transactions = [];
|
||||
for (const line of unreconciledStatementLines) {
|
||||
const unclassifiedAccount = line.quantity >= 0 ? 'Unclassified Statement Line Debits' : 'Unclassified Statement Line Credits';
|
||||
transactions.push(new Transaction(
|
||||
null,
|
||||
line.dt,
|
||||
line.description,
|
||||
[
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: line.source_account,
|
||||
quantity: line.quantity,
|
||||
commodity: line.commodity,
|
||||
quantity_ascost: asCost(line.quantity, line.commodity),
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: unclassifiedAccount,
|
||||
quantity: -line.quantity,
|
||||
commodity: line.commodity,
|
||||
quantity_ascost: asCost(-line.quantity, line.commodity),
|
||||
}
|
||||
]
|
||||
));
|
||||
}
|
||||
this.transactionsForStage.set(ReportingStage.OrdinaryAPITransactions, transactions);
|
||||
|
||||
// Recompute balances
|
||||
balances = applyTransactionsToBalances(balances, transactions);
|
||||
this.reportsForStage.set(ReportingStage.OrdinaryAPITransactions, [new TrialBalanceReport(balances)]);
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// AccumulatedSurplusToEquity
|
||||
|
||||
{
|
||||
// Compute balances at period start for TransactionsFromDatabase
|
||||
let dayBeforePeriodStart;
|
||||
if (dtStart) {
|
||||
dayBeforePeriodStart = dayjs(dtStart).subtract(1, 'day');
|
||||
} else {
|
||||
dayBeforePeriodStart = dayjs(db.metadata.eofy_date).subtract(1, 'year');
|
||||
}
|
||||
const balancesAtPeriodStart = await totalBalancesAtDate(session, dayBeforePeriodStart.format('YYYY-MM-DD'));
|
||||
|
||||
// Add balances at period start for OrdinaryAPITransactions
|
||||
for (const transaction of this.transactionsForStage.get(ReportingStage.OrdinaryAPITransactions)!) {
|
||||
if (!dayjs(transaction.dt).isAfter(dayBeforePeriodStart)) {
|
||||
for (const posting of transaction.postings) {
|
||||
balancesAtPeriodStart.set(posting.account, (balancesAtPeriodStart.get(posting.account) ?? 0) + posting.quantity_ascost!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get income and expense accounts
|
||||
const incomeAccounts = await getAccountsForKind(session, 'drcr.income');
|
||||
const expenseAccounts = await getAccountsForKind(session, 'drcr.expense');
|
||||
const pandlAccounts = [...incomeAccounts, ...expenseAccounts];
|
||||
pandlAccounts.sort();
|
||||
|
||||
// Prepare transactions
|
||||
const transactions = [];
|
||||
for (const account of pandlAccounts) {
|
||||
if (balancesAtPeriodStart.has(account)) {
|
||||
const balanceAtPeriodStart = balancesAtPeriodStart.get(account)!;
|
||||
if (balanceAtPeriodStart === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
transactions.push(new Transaction(
|
||||
null,
|
||||
dayBeforePeriodStart.format(DT_FORMAT),
|
||||
'Accumulated surplus/deficit',
|
||||
[
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: account,
|
||||
quantity: -balanceAtPeriodStart,
|
||||
commodity: db.metadata.reporting_commodity,
|
||||
quantity_ascost: asCost(-balanceAtPeriodStart, db.metadata.reporting_commodity),
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: 'Accumulated surplus (deficit)',
|
||||
quantity: balanceAtPeriodStart,
|
||||
commodity: db.metadata.reporting_commodity,
|
||||
quantity_ascost: asCost(balanceAtPeriodStart, db.metadata.reporting_commodity),
|
||||
},
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
this.transactionsForStage.set(ReportingStage.AccumulatedSurplusToEquity, transactions);
|
||||
|
||||
// Recompute balances
|
||||
balances = applyTransactionsToBalances(balances, transactions);
|
||||
this.reportsForStage.set(ReportingStage.AccumulatedSurplusToEquity, [new TrialBalanceReport(balances)]);
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// InterimIncomeStatement
|
||||
|
||||
let incomeStatementReport;
|
||||
{
|
||||
incomeStatementReport = new IncomeStatementReport();
|
||||
await incomeStatementReport.generate(balances);
|
||||
this.reportsForStage.set(ReportingStage.InterimIncomeStatement, [incomeStatementReport]);
|
||||
}
|
||||
|
||||
// ------------
|
||||
// BalanceSheet
|
||||
|
||||
{
|
||||
const balanceSheetReport = new BalanceSheetReport();
|
||||
await balanceSheetReport.generate(balances, incomeStatementReport);
|
||||
this.reportsForStage.set(ReportingStage.BalanceSheet, [balanceSheetReport]);
|
||||
}
|
||||
}
|
||||
|
||||
getReportAtStage(stage: ReportingStage, reportType: any): DrcrReport {
|
||||
// TODO: This function needs generics
|
||||
const reportsForTheStage = this.reportsForStage.get(stage);
|
||||
if (!reportsForTheStage) {
|
||||
throw new Error('Attempt to get report for unavailable stage');
|
||||
}
|
||||
|
||||
const report = reportsForTheStage.find((r) => r instanceof reportType);
|
||||
if (report) {
|
||||
return report;
|
||||
}
|
||||
|
||||
// Recurse earlier stages
|
||||
const stages = [...this.reportsForStage.keys()];
|
||||
stages.reverse();
|
||||
for (const earlierStage of stages) {
|
||||
if (earlierStage >= stage) {
|
||||
continue;
|
||||
}
|
||||
const report = this.reportsForStage.get(earlierStage)!.find((r) => r instanceof reportType);
|
||||
if (report) {
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Report does not exist at requested stage or any earlier stage');
|
||||
}
|
||||
|
||||
getTransactionsAtStage(stage: ReportingStage): Transaction[] {
|
||||
const transactions: Transaction[] = [];
|
||||
for (const [curStage, curTransactions] of this.transactionsForStage.entries()) {
|
||||
if (curStage <= stage) {
|
||||
transactions.push(...curTransactions);
|
||||
}
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransactionsToBalances(balances: Map<string, number>, transactions: Transaction[]): Map<string, number> {
|
||||
// Initialise new balances
|
||||
const newBalances: Map<string, number> = new Map([...balances.entries()]);
|
||||
|
||||
// Apply transactions
|
||||
for (const transaction of transactions) {
|
||||
for (const posting of transaction.postings) {
|
||||
const openingBalance = newBalances.get(posting.account) ?? 0;
|
||||
const runningBalance = openingBalance + posting.quantity_ascost!;
|
||||
newBalances.set(posting.account, runningBalance);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort accounts
|
||||
return new Map([...newBalances.entries()].sort((a, b) => a[0].localeCompare(b[0])));
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -16,52 +16,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
export class BalanceSheetReport extends DynamicReport {
|
||||
constructor() {
|
||||
super('Balance sheet');
|
||||
}
|
||||
|
||||
async generate(balances: Map<string, number>, incomeStatementReport: IncomeStatementReport) {
|
||||
this.entries = [
|
||||
new Section(
|
||||
'Assets',
|
||||
[
|
||||
...await DynamicReport.entriesForKind(balances, 'drcr.asset'),
|
||||
new Subtotal('Total assets', 'total_assets', true /* visible */, true /* bordered */)
|
||||
]
|
||||
),
|
||||
new Spacer(),
|
||||
new Section(
|
||||
'Liabilities',
|
||||
[
|
||||
...await DynamicReport.entriesForKind(balances, 'drcr.liability', true),
|
||||
new Subtotal('Total liabilities', 'total_liabilities', true /* visible */, true /* bordered */)
|
||||
]
|
||||
),
|
||||
new Spacer(),
|
||||
new Section(
|
||||
'Equity',
|
||||
[
|
||||
...await DynamicReport.entriesForKind(balances, 'drcr.equity', true),
|
||||
new Entry('Current year surplus (deficit)', (incomeStatementReport.byId('net_surplus') as Computed).quantity, null /* id */, true /* visible */, false /* autoHide */, '/income-statement'),
|
||||
new Entry('Accumulated surplus (deficit)', -(balances.get('Accumulated surplus (deficit)') ?? 0), null /* id */, true /* visible */, false /* autoHide */, '/transactions/Accumulated surplus (deficit)'),
|
||||
new Subtotal('Total equity', 'total_equity', true /* visible */, true /* bordered */)
|
||||
]
|
||||
)
|
||||
];
|
||||
|
||||
this.calculate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Report display -->
|
||||
|
||||
<template>
|
||||
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels">
|
||||
<DynamicReportComponent :report="report">
|
||||
<div class="my-2 py-2 flex">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<span class="whitespace-nowrap">As at</span>
|
||||
@ -88,55 +44,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComparativeDynamicReportComponent>
|
||||
</DynamicReportComponent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ExclamationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
import { Computed, DynamicReport, Entry, Section, Spacer, Subtotal } from './base.ts';
|
||||
import { IncomeStatementReport} from './IncomeStatementReport.vue';
|
||||
import { DynamicReport, LiteralRow, reportEntryById } from './base.ts';
|
||||
import { db } from '../db.ts';
|
||||
import { ExtendedDatabase } from '../dbutil.ts';
|
||||
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
|
||||
|
||||
const reports = ref([] as BalanceSheetReport[]);
|
||||
const reportLabels = ref([] as string[]);
|
||||
const report = ref(null as DynamicReport | null);
|
||||
|
||||
const dt = ref(null as string | null);
|
||||
const comparePeriods = ref(1);
|
||||
const compareUnit = ref('years');
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
await db.load();
|
||||
|
||||
dt.value = db.metadata.eofy_date;
|
||||
|
||||
await updateReport(session);
|
||||
await updateReport();
|
||||
|
||||
// Update report when dates etc. changed
|
||||
// We initialise the watcher here only after dt and dtStart are initialised above
|
||||
watch([dt, comparePeriods, compareUnit], async () => {
|
||||
const session = await db.load();
|
||||
await updateReport(session);
|
||||
});
|
||||
// We initialise the watcher here only after dt is initialised above
|
||||
watch([dt, comparePeriods, compareUnit], updateReport);
|
||||
}
|
||||
load();
|
||||
|
||||
async function updateReport(session: ExtendedDatabase) {
|
||||
const newReportPromises = [];
|
||||
const newReportLabels = [];
|
||||
async function updateReport() {
|
||||
const reportDates = [];
|
||||
for (let i = 0; i < comparePeriods.value; i++) {
|
||||
let thisReportDt, thisReportLabel;
|
||||
let thisReportDt;
|
||||
|
||||
// Get period end date
|
||||
if (compareUnit.value === 'years') {
|
||||
thisReportDt = dayjs(dt.value!).subtract(i, 'year');
|
||||
thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
|
||||
} else if (compareUnit.value === 'months') {
|
||||
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) {
|
||||
// If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month
|
||||
@ -144,47 +92,30 @@
|
||||
} else {
|
||||
thisReportDt = dayjs(dt.value!).subtract(i, 'month');
|
||||
}
|
||||
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
|
||||
} else {
|
||||
throw new Error('Unexpected compareUnit');
|
||||
}
|
||||
|
||||
// Get start of financial year date
|
||||
let sofyDayjs = dayjs(db.metadata.eofy_date).subtract(1, 'year').add(1, 'day');
|
||||
let thisReportDtStart = thisReportDt.set('date', sofyDayjs.get('date')).set('month', sofyDayjs.get('month'));
|
||||
if (thisReportDtStart.isAfter(thisReportDt)) {
|
||||
thisReportDtStart = thisReportDtStart.subtract(1, 'year');
|
||||
}
|
||||
|
||||
console.log([thisReportDt, thisReportDtStart]);
|
||||
|
||||
// Generate reports asynchronously
|
||||
newReportPromises.push((async () => {
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD'));
|
||||
return reportingWorkflow.getReportAtStage(ReportingStage.FINAL_STAGE, BalanceSheetReport) as BalanceSheetReport;
|
||||
})());
|
||||
|
||||
if (comparePeriods.value === 1) {
|
||||
// If only 1 report, the heading is simply "$"
|
||||
newReportLabels.push(db.metadata.reporting_commodity);
|
||||
} else {
|
||||
newReportLabels.push(thisReportLabel);
|
||||
}
|
||||
reportDates.push(thisReportDt.format('YYYY-MM-DD'));
|
||||
}
|
||||
|
||||
reports.value = await Promise.all(newReportPromises);
|
||||
reportLabels.value = newReportLabels;
|
||||
report.value = JSON.parse(await invoke('get_balance_sheet', { dates: reportDates }));
|
||||
}
|
||||
|
||||
const doesBalance = computed(function() {
|
||||
if (report.value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const totalAssets = (reportEntryById(report.value, 'total_assets') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
|
||||
const totalLiabilities = (reportEntryById(report.value, 'total_liabilities') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
|
||||
const totalEquity = (reportEntryById(report.value, 'total_equity') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
|
||||
|
||||
let doesBalance = true;
|
||||
for (const report of reports.value) {
|
||||
const totalAssets = (report.byId('total_assets') as Computed).quantity;
|
||||
const totalLiabilities = (report.byId('total_liabilities') as Computed).quantity;
|
||||
const totalEquity = (report.byId('total_equity') as Computed).quantity;
|
||||
if (totalAssets !== totalLiabilities + totalEquity) {
|
||||
for (let column = 0; column < report.value.columns.length; column++) {
|
||||
if (totalAssets[column] !== totalLiabilities[column] + totalEquity[column]) {
|
||||
doesBalance = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return doesBalance;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -16,48 +16,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
export class IncomeStatementReport extends DynamicReport {
|
||||
constructor() {
|
||||
super('Income statement');
|
||||
}
|
||||
|
||||
async generate(balances: Map<string, number>) {
|
||||
const report = this;
|
||||
this.entries = [
|
||||
new Section(
|
||||
'Income',
|
||||
[
|
||||
...await DynamicReport.entriesForKind(balances, 'drcr.income', true),
|
||||
new Subtotal('Total income', 'total_income', true /* visible */, true /* bordered */)
|
||||
]
|
||||
),
|
||||
new Spacer(),
|
||||
new Section(
|
||||
'Expenses',
|
||||
[
|
||||
...await DynamicReport.entriesForKind(balances, 'drcr.expense'),
|
||||
new Subtotal('Total expenses', 'total_expenses', true /* visible */, true /* bordered */)
|
||||
]
|
||||
),
|
||||
new Spacer(),
|
||||
new Computed(
|
||||
'Net surplus (deficit)',
|
||||
() => (report.byId('total_income') as Subtotal).quantity - (report.byId('total_expenses') as Subtotal).quantity,
|
||||
'net_surplus',
|
||||
true /* visible */, false /* autoHide */, null /* link */, true /* heading */, true /* bordered */
|
||||
)
|
||||
];
|
||||
|
||||
this.calculate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Report display -->
|
||||
|
||||
<template>
|
||||
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels">
|
||||
<DynamicReportComponent :report="report">
|
||||
<div class="my-2 py-2 flex">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<input type="date" class="bordered-field" v-model.lazy="dtStart">
|
||||
@ -75,21 +35,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComparativeDynamicReportComponent>
|
||||
</DynamicReportComponent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Computed, DynamicReport, Section, Spacer, Subtotal } from './base.ts';
|
||||
import { DynamicReport } from './base.ts';
|
||||
import { db } from '../db.ts';
|
||||
import { ExtendedDatabase } from '../dbutil.ts';
|
||||
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
|
||||
|
||||
const reports = ref([] as IncomeStatementReport[]);
|
||||
const reportLabels = ref([] as string[]);
|
||||
const report = ref(null as DynamicReport | null);
|
||||
|
||||
const dt = ref(null as string | null);
|
||||
const dtStart = ref(null as string | null);
|
||||
@ -98,32 +56,27 @@
|
||||
const compareUnit = ref('years');
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
await db.load();
|
||||
|
||||
dt.value = db.metadata.eofy_date;
|
||||
dtStart.value = dayjs(db.metadata.eofy_date).subtract(1, 'year').add(1, 'day').format('YYYY-MM-DD');
|
||||
|
||||
await updateReport(session);
|
||||
await updateReport();
|
||||
|
||||
// Update report when dates etc. changed
|
||||
// We initialise the watcher here only after dt and dtStart are initialised above
|
||||
watch([dt, dtStart, comparePeriods, compareUnit], async () => {
|
||||
const session = await db.load();
|
||||
await updateReport(session);
|
||||
});
|
||||
watch([dt, dtStart, comparePeriods, compareUnit], updateReport);
|
||||
}
|
||||
|
||||
async function updateReport(session: ExtendedDatabase) {
|
||||
const newReportPromises = [];
|
||||
const newReportLabels = [];
|
||||
async function updateReport() {
|
||||
const reportDates = [];
|
||||
for (let i = 0; i < comparePeriods.value; i++) {
|
||||
let thisReportDt, thisReportDtStart, thisReportLabel;
|
||||
let thisReportDt, thisReportDtStart;
|
||||
|
||||
// Get period start and end dates
|
||||
if (compareUnit.value === 'years') {
|
||||
thisReportDt = dayjs(dt.value!).subtract(i, 'year');
|
||||
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'year');
|
||||
thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
|
||||
} else if (compareUnit.value === 'months') {
|
||||
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) {
|
||||
// If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month
|
||||
@ -133,28 +86,14 @@
|
||||
thisReportDt = dayjs(dt.value!).subtract(i, 'month');
|
||||
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month');
|
||||
}
|
||||
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
|
||||
} else {
|
||||
throw new Error('Unexpected compareUnit');
|
||||
}
|
||||
|
||||
// Generate reports asynchronously
|
||||
newReportPromises.push((async () => {
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD'));
|
||||
return reportingWorkflow.getReportAtStage(ReportingStage.InterimIncomeStatement, IncomeStatementReport) as IncomeStatementReport;
|
||||
})());
|
||||
|
||||
if (comparePeriods.value === 1) {
|
||||
// If only 1 report, the heading is simply "$"
|
||||
newReportLabels.push(db.metadata.reporting_commodity);
|
||||
} else {
|
||||
newReportLabels.push(thisReportLabel);
|
||||
}
|
||||
reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]);
|
||||
}
|
||||
|
||||
reports.value = await Promise.all(newReportPromises);
|
||||
reportLabels.value = newReportLabels;
|
||||
report.value = JSON.parse(await invoke('get_income_statement', { dates: reportDates }));
|
||||
}
|
||||
|
||||
load();
|
||||
|
60
src/reports/TrialBalanceReport.vue
Normal file
60
src/reports/TrialBalanceReport.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<!--
|
||||
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/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<DynamicReportComponent :report="report">
|
||||
<div class="my-2 py-2 flex">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<span class="whitespace-nowrap">As at</span>
|
||||
<input type="date" class="bordered-field" v-model.lazy="dt">
|
||||
</div>
|
||||
</div>
|
||||
</DynamicReportComponent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { DynamicReport } from './base.ts';
|
||||
import { db } from '../db.ts';
|
||||
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
|
||||
|
||||
const report = ref(null as DynamicReport | null);
|
||||
|
||||
const dt = ref(null as string | null);
|
||||
|
||||
async function load() {
|
||||
await db.load();
|
||||
|
||||
dt.value = db.metadata.eofy_date;
|
||||
|
||||
await updateReport();
|
||||
|
||||
// Update report when dates etc. changed
|
||||
// We initialise the watcher here only after dt is initialised above
|
||||
watch([dt], updateReport);
|
||||
}
|
||||
load();
|
||||
|
||||
async function updateReport() {
|
||||
const reportDate = dayjs(dt.value!).format('YYYY-MM-DD');
|
||||
report.value = JSON.parse(await invoke('get_trial_balance', { date: reportDate }));
|
||||
}
|
||||
</script>
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
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
|
||||
@ -16,168 +16,50 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { db, getAccountsForKind } from '../db.ts';
|
||||
|
||||
export interface DrcrReport {
|
||||
// Cannot be a class as these are directly deserialised from JSON
|
||||
export interface DynamicReport {
|
||||
title: string;
|
||||
columns: string[];
|
||||
entries: DynamicReportEntry[];
|
||||
}
|
||||
|
||||
export interface DynamicReportNode {
|
||||
// serde_json serialises an enum like this
|
||||
export type DynamicReportEntry = { Section: Section } | { LiteralRow: LiteralRow } | 'Spacer';
|
||||
|
||||
export interface Section {
|
||||
text: string;
|
||||
id: string | null;
|
||||
calculate(parent: DynamicReport | DynamicReportNode): void;
|
||||
visible: boolean;
|
||||
auto_hide: boolean;
|
||||
entries: DynamicReportEntry[];
|
||||
}
|
||||
|
||||
export class DynamicReport implements DrcrReport {
|
||||
constructor(
|
||||
public title: string,
|
||||
public entries: DynamicReportNode[] = [],
|
||||
) {}
|
||||
|
||||
byId(id: string): DynamicReportNode | null {
|
||||
// Get the DynamicReportNode with the given ID
|
||||
for (const entry of this.entries) {
|
||||
if (entry.id === id) {
|
||||
export interface LiteralRow {
|
||||
text: string;
|
||||
quantity: number[];
|
||||
id: string;
|
||||
visible: boolean;
|
||||
auto_hide: boolean;
|
||||
link: string | null;
|
||||
heading: boolean;
|
||||
bordered: boolean;
|
||||
}
|
||||
|
||||
export interface Spacer {
|
||||
}
|
||||
|
||||
export function reportEntryById(report: DynamicReport | Section, id: string): DynamicReportEntry | null {
|
||||
for (const entry of report.entries) {
|
||||
if ((entry as { Section: Section }).Section) {
|
||||
const result = reportEntryById((entry as { Section: Section }).Section, id);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
} else if ((entry as { LiteralRow: LiteralRow }).LiteralRow) {
|
||||
if ((entry as { LiteralRow: LiteralRow }).LiteralRow.id === id) {
|
||||
return entry;
|
||||
}
|
||||
if (entry instanceof Section) {
|
||||
const result = entry.byId(id);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
calculate() {
|
||||
// Compute all subtotals
|
||||
for (const entry of this.entries) {
|
||||
entry.calculate(this);
|
||||
}
|
||||
}
|
||||
|
||||
static async entriesForKind(balances: Map<string, number>, kind: string, negate = false) {
|
||||
// Get accounts associated with this kind
|
||||
const accountsForKind = await getAccountsForKind(await db.load(), kind);
|
||||
|
||||
// Return one entry for each such account
|
||||
const entries = [];
|
||||
for (const account of accountsForKind) {
|
||||
if (balances.has(account)) {
|
||||
const quantity = balances.get(account)!;
|
||||
if (quantity === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(new Entry(
|
||||
account,
|
||||
negate ? -quantity : quantity,
|
||||
null /* id */,
|
||||
true /* visible */,
|
||||
false /* autoHide */,
|
||||
'/transactions/' + account
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
export class Entry implements DynamicReportNode {
|
||||
constructor(
|
||||
public text: string,
|
||||
public quantity: number,
|
||||
public id: string | null = null,
|
||||
public visible = true,
|
||||
public autoHide = false,
|
||||
public link: string | null = null,
|
||||
public heading = false,
|
||||
public bordered = false,
|
||||
) {}
|
||||
|
||||
calculate(_parent: DynamicReport | DynamicReportNode) {}
|
||||
}
|
||||
|
||||
export class Computed extends Entry {
|
||||
constructor(
|
||||
public text: string,
|
||||
public calc: Function,
|
||||
public id: string | null = null,
|
||||
public visible = true,
|
||||
public autoHide = false,
|
||||
public link: string | null = null,
|
||||
public heading = false,
|
||||
public bordered = false,
|
||||
) {
|
||||
super(text, null!, id, visible, autoHide, link, heading, bordered);
|
||||
}
|
||||
|
||||
calculate(_parent: DynamicReport | DynamicReportNode) {
|
||||
// Calculate the value of this entry
|
||||
this.quantity = this.calc();
|
||||
}
|
||||
}
|
||||
|
||||
export class Section implements DynamicReportNode {
|
||||
constructor(
|
||||
public title: string | null,
|
||||
public entries: DynamicReportNode[] = [],
|
||||
public id: string | null = null,
|
||||
public visible = true,
|
||||
public autoHide = false,
|
||||
) {}
|
||||
|
||||
calculate(_parent: DynamicReport | DynamicReportNode) {
|
||||
for (const entry of this.entries) {
|
||||
entry.calculate(this);
|
||||
}
|
||||
}
|
||||
|
||||
byId(id: string): DynamicReportNode | null {
|
||||
// Get the DynamicReportNode with the given ID
|
||||
for (const entry of this.entries) {
|
||||
if (entry.id === id) {
|
||||
return entry;
|
||||
}
|
||||
if (entry instanceof Section) {
|
||||
const result = entry.byId(id);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class Spacer implements DynamicReportNode {
|
||||
id = null;
|
||||
|
||||
calculate(_parent: DynamicReport | DynamicReportNode) {}
|
||||
}
|
||||
|
||||
export class Subtotal extends Entry {
|
||||
constructor(
|
||||
public text: string,
|
||||
public id: string | null = null,
|
||||
public visible = true,
|
||||
public bordered = false,
|
||||
public floor = 0,
|
||||
) {
|
||||
super(text, null!, id, visible, false /* autoHide */, null /* link */, true /* heading */, bordered);
|
||||
}
|
||||
|
||||
calculate(parent: DynamicReport | DynamicReportNode) {
|
||||
// Calculate total amount
|
||||
if (!(parent instanceof Section)) {
|
||||
throw new Error('Attempt to calculate Subtotal not in Section');
|
||||
}
|
||||
|
||||
this.quantity = 0;
|
||||
for (const entry of parent.entries) {
|
||||
if (entry instanceof Entry && entry !== this) {
|
||||
this.quantity += entry.quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user