From 770b22f2f0e70a81a26f4c07a0ade966b66a44a1 2020-10-26 18:57:15 From: Brett Smith Date: 2020-10-26 18:57:15 Subject: [PATCH] reports: Initial budget variance skeleton. RT#12680 This is a *very* rough initial draft of a report. As the docstring mentions, it's basically counting on the user to provide rewrite rules to provide the desired representation. Long-term I'm hoping maybe we can standardize the program metadata enough, or plan its replacement well enough, that this report can be written against that directly. But that will take more planning about books structure, and support from the plugin, before the report can be written that way. --- diff --git a/conservancy_beancount/reports/budget.py b/conservancy_beancount/reports/budget.py new file mode 100644 index 0000000000000000000000000000000000000000..3b53c7bf8469eda2dc3793409aabca6f66f73b67 --- /dev/null +++ b/conservancy_beancount/reports/budget.py @@ -0,0 +1,279 @@ +"""budget.py - Budget variance report skeleton + +This report sums income and expenses based on postings' ``budget-line`` +metadata. Usually this metadata is set by rewrite rules, rather than entered in +the books directly. If there is no ``budget-line`` metadata, it falls back to +using account classifications in the account definitions. +""" +# Copyright © 2020 Brett Smith +# +# 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 . + +import argparse +import collections +import datetime +import enum +import logging +import operator +import sys + +from decimal import Decimal +from pathlib import Path + +from typing import ( + Any, + Callable, + Collection, + Dict, + Hashable, + Iterable, + Iterator, + List, + Mapping, + NamedTuple, + Optional, + Sequence, + TextIO, + Tuple, + Union, +) + +import odf.style # type:ignore[import] +import odf.table # type:ignore[import] + +from beancount.parser import printer as bc_printer + +from . import core +from . import rewrite +from .. import books +from .. import cliutil +from .. import config as configmod +from .. import data +from .. import ranges + +PROGNAME = 'budget-report' +logger = logging.getLogger('conservancy_beancount.reports.budget') + +Fund = core.Fund +KWArgs = Mapping[str, Any] +Period = core.Period + +class Balances(core.Balances): + def _get_classification(self, post: data.Posting) -> data.Account: + try: + return self._get_meta_account(post.meta, 'budget-line') + except (KeyError, TypeError): + return super()._get_classification(post) + + +class Report(core.BaseODS[Sequence[None], None]): + def __init__(self, balances: core.Balances) -> None: + super().__init__() + self.balances = balances + self.last_totals_row = odf.table.TableRow() + + def section_key(self, row: Sequence[None]) -> None: + raise NotImplementedError("balance_sheet.Report.section_key") + + def init_styles(self) -> None: + super().init_styles() + self.style_header = self.merge_styles(self.style_bold, self.style_centertext) + self.style_huline = self.merge_styles( + self.style_header, + self.border_style(core.Border.BOTTOM, '1pt'), + ) + + def write_all(self) -> None: + headers = [self.string_cell(text, stylename=self.style_huline) for text in [ + "", + "Budgeted", + "Actual", + "% Above/Below Budget", + ]] + for header_count, cell in enumerate(headers): + col_style = self.column_style(2 if header_count else 4) + self.sheet.addElement(odf.table.TableColumn(stylename=col_style)) + header_count += 1 + self.add_row(*headers) + self.lock_first_row() + self.add_row() + date_range = self.balances.period_range + self.add_row(self.multiline_cell( + ["DRAFT Budget Variance Report", + f"from {date_range.start} to {date_range.stop}"], + stylename=self.style_header, + numbercolumnsspanned=header_count, + )) + self.write_section("Support", 'Income', 'Equity', 'Liabilities:UnearnedIncome') + self.write_section("Program Activity", 'Expenses', post_meta='program') + self.write_section("Fundraising Activity", 'Expenses', post_meta='fundraising') + self.write_section("Management & Administration", 'Expenses', post_meta='management') + + def write_section(self, + name: str, + *accounts: str, + post_meta: Optional[str]=None, + ) -> None: + self.add_row(self.string_cell(name, stylename=self.style_bold)) + self.add_row() + norm_func = core.normalize_amount_func(f'{accounts[0]}:RootsOK') + for classification in self.balances.classifications(accounts[0]): + balance = norm_func(self.balances.total( + accounts, + classification, + period=Period.PERIOD, + post_meta=post_meta, + )) + self.add_row( + self.string_cell(classification), + odf.table.TableCell(), # TODO: Budgeted amount + self.balance_cell(balance), + odf.table.TableCell(), # TODO: Variance formula + ) + self.add_row() + balance = norm_func(self.balances.total( + accounts, + period=Period.PERIOD, + post_meta=post_meta, + )) + self.add_row( + self.string_cell("Totals", stylename=self.style_bold), + odf.table.TableCell(stylename=self.style_endtotal), # TODO: Budgeted amount + self.balance_cell(balance, stylename=self.style_endtotal), + odf.table.TableCell(stylename=self.style_endtotal), # TODO: Variance formula + ) + self.add_row() + + +def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog=PROGNAME) + cliutil.add_version_argument(parser) + parser.add_argument( + '--begin', '--start', '-b', + dest='start_date', + metavar='DATE', + required=True, + type=cliutil.date_arg, + help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format. +""") + parser.add_argument( + '--end', '--stop', '-e', + dest='stop_date', + metavar='DATE', + required=True, + type=cliutil.date_arg, + help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format. +""") + cliutil.add_rewrite_rules_argument(parser) + parser.add_argument( + '--fund-metadata-key', '-m', + metavar='KEY', + default='project', + help="""Name of the fund metadata key. Default %(default)s. +""") + parser.add_argument( + '--unrestricted-fund', '-u', + metavar='PROJECT', + default='Conservancy', + help="""Name of the unrestricted fund. Default %(default)s. +""") + parser.add_argument( + '--output-file', '-O', + metavar='PATH', + type=Path, + help="""Write the report to this file, or stdout when PATH is `-`. +""") + cliutil.add_loglevel_argument(parser) + parser.add_argument( + 'search_terms', + metavar='FILTER', + type=cliutil.SearchTerm.arg_parser('project', 'rt-id'), + nargs=argparse.ZERO_OR_MORE, + help="""Report on postings that match this criteria. The format is +NAME=TERM. TERM is a link or word that must exist in a posting's NAME +metadata to match. A single ticket number is a shortcut for +`rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`. +If you specify no search terms, defaults to generating a budget for the +unrestricted fund. +""") + args = parser.parse_args(arglist) + if not args.search_terms: + args.search_terms = [cliutil.SearchTerm(args.fund_metadata_key, args.unrestricted_fund)] + return args + +def main(arglist: Optional[Sequence[str]]=None, + stdout: TextIO=sys.stdout, + stderr: TextIO=sys.stderr, + config: Optional[configmod.Config]=None, +) -> int: + args = parse_arguments(arglist) + cliutil.set_loglevel(logger, args.loglevel) + if config is None: + config = configmod.Config() + config.load_file() + + returncode = 0 + books_loader = config.books_loader() + if books_loader is None: + entries, load_errors, options_map = books.Loader.load_none(config.config_file_path()) + returncode = cliutil.ExitCode.NoConfiguration + else: + entries, load_errors, options_map = books_loader.load_fy_range( + args.start_date, args.stop_date, + ) + if load_errors: + returncode = cliutil.ExitCode.BeancountErrors + elif not entries: + returncode = cliutil.ExitCode.NoDataLoaded + for error in load_errors: + bc_printer.print_error(error, file=stderr) + + data.Account.load_from_books(entries, options_map) + postings = data.Posting.from_entries(entries) + for search_term in args.search_terms: + postings = search_term.filter_postings(postings) + for rewrite_path in args.rewrite_rules: + try: + ruleset = rewrite.RewriteRuleset.from_yaml(rewrite_path) + except ValueError as error: + logger.critical("failed loading rewrite rules from %s: %s", + rewrite_path, error.args[0]) + return cliutil.ExitCode.RewriteRulesError + postings = ruleset.rewrite(postings) + + balances = Balances( + postings, + args.start_date, + args.stop_date, + 'expense-type', + args.fund_metadata_key, + args.unrestricted_fund, + ) + report = Report(balances) + report.set_common_properties(config.books_repo()) + report.write_all() + if args.output_file is None: + out_dir_path = config.repository_path() or Path() + args.output_file = out_dir_path / 'BudgetReport_{}_{}.ods'.format( + args.start_date.isoformat(), args.stop_date.isoformat(), + ) + logger.info("Writing report to %s", args.output_file) + ods_file = cliutil.bytes_output(args.output_file, stdout) + report.save_file(ods_file) + return returncode + +entry_point = cliutil.make_entry_point(__name__, PROGNAME) + +if __name__ == '__main__': + exit(entry_point()) diff --git a/setup.py b/setup.py index 401168ce410285b1a69bccedd4f2e1afc706a6d0..19f9f9ce2ce6cb08a71819d71b8fc530df2b9859 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.12.5', + version='1.13.0', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', @@ -40,6 +40,7 @@ setup( 'accrual-report = conservancy_beancount.reports.accrual:entry_point', 'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point', 'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point', + 'budget-report = conservancy_beancount.reports.budget:entry_point', 'bean-sort = conservancy_beancount.tools.sort_entries:entry_point', 'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point', 'fund-report = conservancy_beancount.reports.fund:entry_point',