
Most of these account for the fact that mypy now reports that Hashable is not an allowed return type for sort key functions. That, plus the new ignore for the regression in config.py.
247 lines
8.2 KiB
Python
247 lines
8.2 KiB
Python
"""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
|
|
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
|
|
#
|
|
# Full copyright and licensing details can be found at toplevel file
|
|
# LICENSE.txt in the repository.
|
|
|
|
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,
|
|
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()
|
|
|
|
books_load = books.Loader.dispatch(
|
|
config.books_loader(), args.start_date, args.stop_date,
|
|
)
|
|
books_load.print_errors(stderr)
|
|
books_load.load_account_metadata()
|
|
returncode = books_load.returncode()
|
|
|
|
balances = Balances(
|
|
books_load.iter_postings(args.rewrite_rules, args.search_terms),
|
|
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())
|