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.
This commit is contained in:
Brett Smith 2020-10-26 14:57:15 -04:00
parent 2b23eba549
commit 770b22f2f0
2 changed files with 281 additions and 1 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
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())

View file

@ -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',