commit f15d19011255ea6755742bf8deb6617473572d1b Author: RunasSudo Date: Wed May 21 00:33:00 2025 +1000 Basic dependency resolution code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bf1c400 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,311 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "downcast-rs" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libdrcr" +version = "0.1.0" +dependencies = [ + "chrono", + "downcast-rs", + "solvent", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "solvent" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a50198e546f29eb0a4f977763c8277ec2184b801923c3be71eeaec05471f16" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c925807 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "libdrcr" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = "0.4.41" +downcast-rs = "2.0.1" +#dyn-clone = "1.0.19" +solvent = "0.8.3" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..24e0b08 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod reporting; +pub mod transaction; +pub mod util; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c728461 --- /dev/null +++ b/src/main.rs @@ -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 . +*/ + +use chrono::NaiveDate; +use libdrcr::reporting::{ + builders::register_dynamic_builders, + calculator::solve_for, + steps::{register_lookup_fns, AllTransactionsExceptRetainedEarnings, CalculateIncomeTax}, + ReportingContext, ReportingStep, +}; + +fn main() { + let mut context = ReportingContext::new(NaiveDate::from_ymd_opt(2025, 6, 30).unwrap()); + register_lookup_fns(&mut context); + register_dynamic_builders(&mut context); + + let targets: Vec> = vec![ + Box::new(CalculateIncomeTax { + date_eofy: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }), + Box::new(AllTransactionsExceptRetainedEarnings { + date_start: NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(), + date_end: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }), + ]; + + println!("{:?}", solve_for(targets, context)); +} diff --git a/src/reporting/builders.rs b/src/reporting/builders.rs new file mode 100644 index 0000000..3987725 --- /dev/null +++ b/src/reporting/builders.rs @@ -0,0 +1,245 @@ +/* + 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 . +*/ + +use chrono::NaiveDate; + +use super::{ + calculator::{has_step_or_can_build, HasStepOrCanBuild, ReportingGraphDependencies}, + ReportingContext, ReportingProductId, ReportingProductKind, ReportingStep, + ReportingStepDynamicBuilder, ReportingStepId, +}; + +pub fn register_dynamic_builders(context: &mut ReportingContext) { + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "BalancesAtToBalancesBetween", + can_build: BalancesAtToBalancesBetween::can_build, + build: BalancesAtToBalancesBetween::build, + }); + + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "UpdateBalancesBetween", + can_build: UpdateBalancesBetween::can_build, + build: UpdateBalancesBetween::build, + }); +} + +#[derive(Debug)] +pub struct BalancesAtToBalancesBetween { + step_name: &'static str, + date_start: NaiveDate, + date_end: NaiveDate, +} + +impl BalancesAtToBalancesBetween { + // Implements BalancesAt, BalancesAt -> BalancesBetween + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + args: Vec, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool { + // Check for BalancesAt, BalancesAt -> BalancesBetween + if kind == ReportingProductKind::BalancesBetween { + match has_step_or_can_build( + &ReportingProductId { + name, + kind: ReportingProductKind::BalancesAt, + args: vec![args[1].clone()], + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(_) + | HasStepOrCanBuild::CanLookup(_) + | HasStepOrCanBuild::CanBuild(_) => { + return true; + } + HasStepOrCanBuild::None => {} + } + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Vec, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(BalancesAtToBalancesBetween { + step_name: name, + date_start: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + date_end: NaiveDate::parse_from_str(&args[1], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for BalancesAtToBalancesBetween { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesBetween], + args: vec![ + self.date_start.format("%Y-%m-%d").to_string(), + self.date_end.format("%Y-%m-%d").to_string(), + ], + } + } + + fn init_graph( + &self, + _steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + ) { + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: vec![self.date_start.format("%Y-%m-%d").to_string()], + }, + ); + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: vec![self.date_end.format("%Y-%m-%d").to_string()], + }, + ); + } +} + +#[derive(Debug)] +pub struct UpdateBalancesBetween { + step_name: &'static str, + date_start: NaiveDate, + date_end: NaiveDate, +} + +impl UpdateBalancesBetween { + // Implements (BalancesBetween -> Transactions) -> BalancesBetween + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + _args: Vec, + steps: &Vec>, + 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].dependency.kind + == ReportingProductKind::BalancesBetween + { + return true; + } + } + + // Check lookup or builder - with args + /*match has_step_or_can_build( + &ReportingProductId { + name, + kind: ReportingProductKind::Transactions, + args: args.clone(), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(step) => unreachable!(), + HasStepOrCanBuild::CanLookup(_) + | HasStepOrCanBuild::CanBuild(_) + | HasStepOrCanBuild::None => {} + }*/ + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Vec, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(UpdateBalancesBetween { + step_name: name, + date_start: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + date_end: NaiveDate::parse_from_str(&args[1], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for UpdateBalancesBetween { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesBetween], + args: vec![ + self.date_start.format("%Y-%m-%d").to_string(), + self.date_end.format("%Y-%m-%d").to_string(), + ], + } + } + + fn init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + ) { + // 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(), + }, + ); + } +} diff --git a/src/reporting/calculator.rs b/src/reporting/calculator.rs new file mode 100644 index 0000000..e8fb151 --- /dev/null +++ b/src/reporting/calculator.rs @@ -0,0 +1,322 @@ +/* + 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 . +*/ + +use super::{ + ReportingContext, ReportingProductId, ReportingProductKind, ReportingStep, + ReportingStepDynamicBuilder, ReportingStepId, ReportingStepLookupFn, +}; + +#[derive(Debug)] +pub struct ReportingGraphDependencies { + vec: Vec, +} + +impl ReportingGraphDependencies { + pub fn vec(&self) -> &Vec { + &self.vec + } + + pub fn add_dependency(&mut self, step: ReportingStepId, dependency: ReportingProductId) { + if !self + .vec + .iter() + .any(|d| d.step == step && d.dependency == dependency) + { + self.vec.push(Dependency { step, dependency }); + } + } + + pub fn add_target_dependency(&mut self, target: ReportingStepId, dependency: ReportingStepId) { + for kind in target.product_kinds { + match kind { + ReportingProductKind::Transactions | ReportingProductKind::BalancesBetween => { + self.add_dependency( + target.clone(), + ReportingProductId { + name: dependency.name, + kind: *kind, + args: target.args.clone(), + }, + ); + } + ReportingProductKind::BalancesAt => todo!(), + ReportingProductKind::Generic => todo!(), + } + } + } + + pub fn dependencies_for_step(&self, step: &ReportingStepId) -> Vec<&Dependency> { + return self.vec.iter().filter(|d| d.step == *step).collect(); + } +} + +#[derive(Debug)] +pub struct Dependency { + pub step: ReportingStepId, + pub dependency: ReportingProductId, +} + +#[derive(Debug)] +pub enum ReportingCalculationError { + UnknownStep { message: String }, + NoStepForProduct { message: String }, + CircularDependencies, +} + +pub enum HasStepOrCanBuild<'a, 'b> { + HasStep(&'a Box), + CanLookup(ReportingStepLookupFn), + CanBuild(&'b ReportingStepDynamicBuilder), + None, +} + +pub fn has_step_or_can_build<'a, 'b>( + product: &ReportingProductId, + steps: &'a Vec>, + 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)) + { + return HasStepOrCanBuild::CanLookup(*context.step_lookup_fn.get(lookup_key).unwrap()); + } + + // 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.clone(), + steps, + dependencies, + context, + ) { + return HasStepOrCanBuild::CanBuild(builder); + } + } + + return HasStepOrCanBuild::None; +} + +fn would_be_ready_to_execute( + step: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + previous_steps: &Vec, +) -> bool { + //println!( + // "- would_be_ready_to_execute: {}, {:?}", + // step.id(), + // previous_steps + //); + + // Check whether the step would be ready to execute, if the previous steps have already completed + 'check_each_dependency: for dependency in dependencies.vec.iter() { + if dependency.step == step.id() { + //println!("-- {}", dependency.dependency); + + // Check if the dependency has been produced by a previous step + for previous_step in previous_steps { + if steps[*previous_step].id().name == dependency.dependency.name + && steps[*previous_step].id().args == dependency.dependency.args + && steps[*previous_step] + .id() + .product_kinds + .contains(&dependency.dependency.kind) + { + continue 'check_each_dependency; + } + } + + // Dependency is not met + return false; + } + } + true +} + +pub fn solve_for( + targets: Vec>, + context: ReportingContext, +) -> Result>, ReportingCalculationError> { + let mut steps: Vec> = Vec::new(); + let mut dependencies = ReportingGraphDependencies { vec: Vec::new() }; + + // Initialise targets + for target in targets { + steps.push(target); + let target = steps.last().unwrap(); + target.as_ref().init_graph(&steps, &mut dependencies); + } + + // Call after_init_graph on targets + for step in steps.iter() { + step.as_ref().after_init_graph(&steps, &mut dependencies); + } + + // Process dependencies + loop { + let mut new_steps = Vec::new(); + + for dependency in dependencies.vec.iter() { + if !steps.iter().any(|s| s.id() == dependency.step) { + // FIXME: Call the lookup function + todo!(); + } + if !steps.iter().any(|s| { + s.id().name == dependency.dependency.name + && s.id().args == dependency.dependency.args + && s.id().product_kinds.contains(&dependency.dependency.kind) + }) { + // Try lookup function + if let Some(lookup_key) = context.step_lookup_fn.keys().find(|(name, kinds)| { + *name == dependency.dependency.name + && kinds.contains(&dependency.dependency.kind) + }) { + let lookup_fn = context.step_lookup_fn.get(lookup_key).unwrap(); + let new_step = lookup_fn(dependency.dependency.args.clone()); + + // Check new step meets the dependency + if new_step.id().name != dependency.dependency.name { + panic!("Unexpected step returned from lookup function (expected name {}, got {})", dependency.dependency.name, new_step.id().name); + } + if new_step.id().args != dependency.dependency.args { + panic!("Unexpected step returned from lookup function {} (expected args {:?}, got {:?})", dependency.dependency.name, dependency.dependency.args, new_step.id().args); + } + if !new_step + .id() + .product_kinds + .contains(&dependency.dependency.kind) + { + panic!("Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})", dependency.dependency.name, dependency.dependency.kind, new_step.id().product_kinds); + } + + new_steps.push(new_step); + } else { + // No explicit step for product - try builders + for builder in context.step_dynamic_builders.iter() { + if (builder.can_build)( + dependency.dependency.name, + dependency.dependency.kind, + dependency.dependency.args.clone(), + &steps, + &dependencies, + &context, + ) { + new_steps.push((builder.build)( + dependency.dependency.name, + dependency.dependency.kind, + dependency.dependency.args.clone(), + &steps, + &dependencies, + &context, + )); + break; + } + } + } + } + } + + 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(); + new_step.as_ref().init_graph(&steps, &mut dependencies); + } + + // Call after_init_graph on new steps + for new_step_index in new_step_indexes { + steps[new_step_index].after_init_graph(&steps, &mut dependencies); + } + } + + // 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.dependency + ), + }); + } + if !steps.iter().any(|s| { + s.id().name == dependency.dependency.name + && s.id().args == dependency.dependency.args + && s.id().product_kinds.contains(&dependency.dependency.kind) + }) { + return Err(ReportingCalculationError::NoStepForProduct { + message: format!( + "No step builds product {} wanted by {}", + dependency.dependency, dependency.step + ), + }); + } + } + + // Sort + let mut sorted_step_indexes = Vec::new(); + let mut steps_remaining = steps.iter().enumerate().collect::>(); + + '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::>(); + sorted_steps.sort_unstable_by_key(|(_s, order)| *order); + let sorted_steps = sorted_steps + .into_iter() + .map(|(s, _idx)| s) + .collect::>(); + + Ok(sorted_steps) +} diff --git a/src/reporting/mod.rs b/src/reporting/mod.rs new file mode 100644 index 0000000..8bd4012 --- /dev/null +++ b/src/reporting/mod.rs @@ -0,0 +1,161 @@ +/* + 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 . +*/ + +use std::fmt::Debug; +use std::{collections::HashMap, fmt::Display}; + +use calculator::{ReportingGraphDependencies}; +use chrono::NaiveDate; +use downcast_rs::Downcast; + +pub mod builders; +pub mod calculator; +pub mod steps; + +pub struct ReportingContext { + _eofy_date: NaiveDate, + step_lookup_fn: HashMap<(&'static str, &'static [ReportingProductKind]), ReportingStepLookupFn>, + step_dynamic_builders: Vec, +} + +impl ReportingContext { + pub fn new(eofy_date: NaiveDate) -> Self { + Self { + _eofy_date: eofy_date, + step_lookup_fn: HashMap::new(), + step_dynamic_builders: Vec::new(), + } + } + + fn register_lookup_fn( + &mut self, + name: &'static str, + product_kinds: &'static [ReportingProductKind], + builder: ReportingStepLookupFn, + ) { + self.step_lookup_fn.insert((name, product_kinds), builder); + } + + 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); + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ReportingProductId { + name: &'static str, + kind: ReportingProductKind, + args: Vec, +} + +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)) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ReportingProductKind { + Transactions, + BalancesAt, + BalancesBetween, + Generic, +} + +//enum ReportingProduct { +// Transactions(Transactions), +// BalancesAt(BalancesAt), +// BalancesBetween(BalancesBetween), +// Generic(Box), +//} + +//struct Transactions {} +//struct BalancesAt {} +//struct BalancesBetween {} + +//trait GenericReportingProduct {} + +//type ReportingProducts = HashMap; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReportingStepId { + pub name: &'static str, + pub product_kinds: &'static [ReportingProductKind], + pub args: Vec, +} + +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 + )) + } +} + +pub trait ReportingStep: Debug + Downcast { + // Info + fn id(&self) -> ReportingStepId; + + // Methods + fn init_graph( + &self, + _steps: &Vec>, + _dependencies: &mut ReportingGraphDependencies, + ) { + } + fn after_init_graph( + &self, + _steps: &Vec>, + _dependencies: &mut ReportingGraphDependencies, + ) { + } + //fn execute(&self, _context: &ReportingContext, _products: &mut ReportingProducts) { + // todo!(); + //} +} + +downcast_rs::impl_downcast!(ReportingStep); + +pub type ReportingStepLookupFn = fn(args: Vec) -> Box; + +pub struct ReportingStepDynamicBuilder { + name: &'static str, + can_build: fn( + name: &'static str, + kind: ReportingProductKind, + args: Vec, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool, + build: fn( + name: &'static str, + kind: ReportingProductKind, + args: Vec, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> Box, +} diff --git a/src/reporting/steps.rs b/src/reporting/steps.rs new file mode 100644 index 0000000..575b3e7 --- /dev/null +++ b/src/reporting/steps.rs @@ -0,0 +1,198 @@ +/* + 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 . +*/ + +use chrono::NaiveDate; + +use crate::util::sofy_from_eofy; + +use super::{ + calculator::ReportingGraphDependencies, ReportingContext, ReportingProductId, + ReportingProductKind, ReportingStep, ReportingStepId, +}; + +pub fn register_lookup_fns(context: &mut ReportingContext) { + context.register_lookup_fn( + "AllTransactionsExceptRetainedEarnings", + &[ReportingProductKind::BalancesBetween], + AllTransactionsExceptRetainedEarnings::from_args, + ); + + context.register_lookup_fn( + "CalculateIncomeTax", + &[ReportingProductKind::Transactions], + CalculateIncomeTax::from_args, + ); + + context.register_lookup_fn( + "CombineOrdinaryTransactions", + &[ReportingProductKind::BalancesAt], + CombineOrdinaryTransactions::from_args, + ); + + context.register_lookup_fn( + "DBBalances", + &[ReportingProductKind::BalancesAt], + DBBalances::from_args, + ); +} + +#[derive(Debug)] +pub struct AllTransactionsExceptRetainedEarnings { + pub date_start: NaiveDate, + pub date_end: NaiveDate, +} + +impl AllTransactionsExceptRetainedEarnings { + fn from_args(args: Vec) -> Box { + Box::new(AllTransactionsExceptRetainedEarnings { + date_start: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + date_end: NaiveDate::parse_from_str(&args[1], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for AllTransactionsExceptRetainedEarnings { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "AllTransactionsExceptRetainedEarnings", + product_kinds: &[ReportingProductKind::BalancesBetween], + args: vec![ + self.date_start.format("%Y-%m-%d").to_string(), + self.date_end.format("%Y-%m-%d").to_string(), + ], + } + } +} + +#[derive(Debug)] +pub struct CalculateIncomeTax { + pub date_eofy: NaiveDate, +} + +impl CalculateIncomeTax { + fn from_args(args: Vec) -> Box { + Box::new(CalculateIncomeTax { + date_eofy: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for CalculateIncomeTax { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CalculateIncomeTax", + product_kinds: &[ReportingProductKind::Transactions], + args: vec![self.date_eofy.format("%Y-%m-%d").to_string()], + } + } + + fn init_graph( + &self, + _steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + ) { + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::BalancesBetween, + args: vec![ + sofy_from_eofy(self.date_eofy) + .format("%Y-%m-%d") + .to_string(), + self.date_eofy.format("%Y-%m-%d").to_string(), + ], + }, + ); + } + + fn after_init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + ) { + for other in steps { + if let Some(other) = other.downcast_ref::() { + if other.date_start <= self.date_eofy && other.date_end >= self.date_eofy { + dependencies.add_target_dependency(other.id(), self.id()); + } + } + } + } +} + +#[derive(Debug)] +pub struct CombineOrdinaryTransactions { + pub date: NaiveDate, +} + +impl CombineOrdinaryTransactions { + fn from_args(args: Vec) -> Box { + Box::new(CombineOrdinaryTransactions { + date: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for CombineOrdinaryTransactions { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CombineOrdinaryTransactions", + product_kinds: &[ReportingProductKind::BalancesAt], + args: vec![self.date.format("%Y-%m-%d").to_string()], + } + } + + fn init_graph( + &self, + _steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + ) { + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: "DBBalances", + kind: ReportingProductKind::BalancesAt, + args: vec![self.date.format("%Y-%m-%d").to_string()], + }, + ); + } +} + +#[derive(Debug)] +pub struct DBBalances { + pub date: NaiveDate, +} + +impl DBBalances { + fn from_args(args: Vec) -> Box { + Box::new(DBBalances { + date: NaiveDate::parse_from_str(&args[0], "%Y-%m-%d").unwrap(), + }) + } +} + +impl ReportingStep for DBBalances { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "DBBalances", + product_kinds: &[ReportingProductKind::BalancesAt], + args: vec![self.date.format("%Y-%m-%d").to_string()], + } + } +} diff --git a/src/transaction.rs b/src/transaction.rs new file mode 100644 index 0000000..a17ebbc --- /dev/null +++ b/src/transaction.rs @@ -0,0 +1,25 @@ +/* + 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 . +*/ + +use chrono::NaiveDateTime; + +pub struct Transaction { + pub id: Option, + pub dt: NaiveDateTime, + pub description: String, +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..c8129c4 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,24 @@ +/* + 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 . +*/ + +use chrono::{Datelike, NaiveDate}; + +pub fn sofy_from_eofy(date_eofy: NaiveDate) -> NaiveDate { + // Return the start date of the financial year, given the end date of the financial year + return date_eofy.with_year(date_eofy.year() - 1).unwrap().succ_opt().unwrap(); +}