2020-05-04 20:36:59 +00:00
|
|
|
#!/usr/bin/env python3
|
2020-05-20 14:52:08 +00:00
|
|
|
"""accrual-report - Status reports for accruals
|
|
|
|
|
|
|
|
accrual-report checks accruals (postings under Assets:Receivable and
|
|
|
|
Liabilities:Payable) for errors and metadata consistency, and reports any
|
|
|
|
problems on stderr. Then it writes a report about the status of those
|
2020-06-03 20:54:22 +00:00
|
|
|
accruals.
|
2020-05-20 14:52:08 +00:00
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
If you run it with no arguments, it will generate an aging report in ODS format
|
|
|
|
in the current directory.
|
|
|
|
|
|
|
|
Otherwise, the typical way to run it is to pass an RT ticket number or
|
|
|
|
invoice link as an argument, to report about accruals that match those
|
|
|
|
criteria::
|
2020-05-20 14:52:08 +00:00
|
|
|
|
|
|
|
# Report all accruals associated with RT#1230:
|
|
|
|
accrual-report 1230
|
|
|
|
# Report all accruals with the invoice link rt:45/670.
|
|
|
|
accrual-report 45/670
|
|
|
|
# Report all accruals with the invoice link Invoice980.pdf.
|
|
|
|
accrual-report Invoice980.pdf
|
|
|
|
|
|
|
|
By default, to stay fast, accrual-report only looks for postings from the
|
|
|
|
beginning of the last fiscal year. You can search further back in history
|
|
|
|
by passing the ``--since`` argument. The argument can be a fiscal year, or
|
|
|
|
a negative number of how many years back to search::
|
|
|
|
|
|
|
|
# Search for accruals since 2016
|
|
|
|
accrual-report --since 2016 [search terms …]
|
|
|
|
# Search for accruals from the beginning of three fiscal years ago
|
|
|
|
accrual-report --since -3 [search terms …]
|
|
|
|
|
|
|
|
If you want to further limit what accruals are reported, you can match on
|
|
|
|
other metadata by passing additional arguments in ``name=value`` format.
|
|
|
|
You can pass any number of search terms. For example::
|
|
|
|
|
|
|
|
# Report accruals associated with RT#1230 and Jane Doe
|
|
|
|
accrual-report 1230 entity=Doe-Jane
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
accrual-report will automatically decide what kind of report to generate
|
|
|
|
from the search terms you provide and the results they return. If you pass
|
|
|
|
no search terms, it generates an aging report. If your search terms match a
|
|
|
|
single outstanding payable, it writes an outgoing approval report.
|
|
|
|
Otherwise, it writes a basic balance report. You can specify what report
|
|
|
|
type you want with the ``--report-type`` option::
|
2020-05-20 14:52:08 +00:00
|
|
|
|
|
|
|
# Write an outgoing approval report for all outstanding accruals for
|
|
|
|
# Jane Doe, even if there's more than one
|
|
|
|
accrual-report --report-type outgoing entity=Doe-Jane
|
2020-06-03 20:54:22 +00:00
|
|
|
# Write an aging report for a specific project
|
|
|
|
accrual-report --report-type aging project=ProjectName
|
2020-05-20 14:52:08 +00:00
|
|
|
"""
|
2020-05-04 20:36:59 +00:00
|
|
|
# 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
|
2020-05-23 14:13:02 +00:00
|
|
|
import collections
|
2020-05-04 20:36:59 +00:00
|
|
|
import datetime
|
|
|
|
import enum
|
2020-05-28 20:41:55 +00:00
|
|
|
import logging
|
2020-05-30 21:31:21 +00:00
|
|
|
import operator
|
2020-05-04 20:36:59 +00:00
|
|
|
import re
|
|
|
|
import sys
|
2020-06-03 20:54:22 +00:00
|
|
|
import urllib.parse as urlparse
|
|
|
|
|
|
|
|
from pathlib import Path
|
2020-05-04 20:36:59 +00:00
|
|
|
|
|
|
|
from typing import (
|
2020-06-03 20:54:22 +00:00
|
|
|
cast,
|
2020-05-30 14:35:29 +00:00
|
|
|
Any,
|
2020-06-03 20:54:22 +00:00
|
|
|
BinaryIO,
|
2020-05-04 20:36:59 +00:00
|
|
|
Callable,
|
|
|
|
Dict,
|
|
|
|
Iterable,
|
|
|
|
Iterator,
|
2020-05-30 21:31:21 +00:00
|
|
|
FrozenSet,
|
|
|
|
List,
|
2020-05-04 20:36:59 +00:00
|
|
|
Mapping,
|
|
|
|
NamedTuple,
|
|
|
|
Optional,
|
|
|
|
Sequence,
|
|
|
|
Set,
|
|
|
|
TextIO,
|
|
|
|
Tuple,
|
2020-06-05 13:10:48 +00:00
|
|
|
TypeVar,
|
2020-05-30 21:31:21 +00:00
|
|
|
Union,
|
2020-05-04 20:36:59 +00:00
|
|
|
)
|
|
|
|
from ..beancount_types import (
|
2020-06-03 20:54:22 +00:00
|
|
|
Entries,
|
2020-05-04 20:36:59 +00:00
|
|
|
Error,
|
2020-06-03 20:54:22 +00:00
|
|
|
Errors,
|
2020-05-04 20:36:59 +00:00
|
|
|
MetaKey,
|
|
|
|
MetaValue,
|
|
|
|
Transaction,
|
|
|
|
)
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
import odf.style # type:ignore[import]
|
|
|
|
import odf.table # type:ignore[import]
|
2020-05-04 20:36:59 +00:00
|
|
|
import rt
|
|
|
|
|
|
|
|
from beancount.parser import printer as bc_printer
|
|
|
|
|
|
|
|
from . import core
|
2020-05-30 02:02:47 +00:00
|
|
|
from .. import cliutil
|
2020-05-04 20:36:59 +00:00
|
|
|
from .. import config as configmod
|
|
|
|
from .. import data
|
|
|
|
from .. import filters
|
|
|
|
from .. import rtutil
|
|
|
|
|
2020-05-30 02:02:47 +00:00
|
|
|
PROGNAME = 'accrual-report'
|
2020-06-03 20:54:22 +00:00
|
|
|
STANDARD_PATH = Path('-')
|
2020-05-30 02:02:47 +00:00
|
|
|
|
2020-06-05 13:10:48 +00:00
|
|
|
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
|
2020-05-30 21:31:21 +00:00
|
|
|
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
|
2020-05-04 20:36:59 +00:00
|
|
|
RTObject = Mapping[str, str]
|
|
|
|
|
2020-05-30 02:02:47 +00:00
|
|
|
logger = logging.getLogger('conservancy_beancount.reports.accrual')
|
2020-05-28 20:41:55 +00:00
|
|
|
|
2020-05-30 21:31:21 +00:00
|
|
|
class Sentinel:
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2020-05-23 14:13:17 +00:00
|
|
|
class Account(NamedTuple):
|
|
|
|
name: str
|
2020-06-05 13:10:48 +00:00
|
|
|
norm_func: Callable[[CompoundAmount], CompoundAmount]
|
2020-06-03 20:54:22 +00:00
|
|
|
aging_thresholds: Sequence[int]
|
2020-05-23 14:13:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
class AccrualAccount(enum.Enum):
|
2020-06-03 20:54:22 +00:00
|
|
|
# Note the aging report uses the same order accounts are defined here.
|
|
|
|
# See AgingODS.start_spreadsheet().
|
|
|
|
RECEIVABLE = Account(
|
|
|
|
'Assets:Receivable', lambda bal: bal, [365, 120, 90, 60],
|
|
|
|
)
|
|
|
|
PAYABLE = Account(
|
|
|
|
'Liabilities:Payable', operator.neg, [365, 90, 60, 30],
|
|
|
|
)
|
2020-05-23 14:13:17 +00:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def account_names(cls) -> Iterator[str]:
|
|
|
|
return (acct.value.name for acct in cls)
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
@classmethod
|
|
|
|
def by_account(cls, name: data.Account) -> 'AccrualAccount':
|
|
|
|
for account in cls:
|
|
|
|
if name.is_under(account.value.name):
|
|
|
|
return account
|
|
|
|
raise ValueError(f"unrecognized account {name!r}")
|
|
|
|
|
2020-05-23 14:13:17 +00:00
|
|
|
@classmethod
|
|
|
|
def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount':
|
|
|
|
for account in cls:
|
|
|
|
account_name = account.value.name
|
|
|
|
if all(post.account.is_under(account_name) for post in related):
|
|
|
|
return account
|
|
|
|
raise ValueError("unrecognized account set in related postings")
|
|
|
|
|
|
|
|
|
2020-05-30 21:31:21 +00:00
|
|
|
class AccrualPostings(core.RelatedPostings):
|
|
|
|
def _meta_getter(key: MetaKey) -> Callable[[data.Posting], MetaValue]: # type:ignore[misc]
|
|
|
|
def meta_getter(post: data.Posting) -> MetaValue:
|
|
|
|
return post.meta.get(key)
|
|
|
|
return meta_getter
|
|
|
|
|
|
|
|
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
|
|
|
|
'account': operator.attrgetter('account'),
|
|
|
|
'contract': _meta_getter('contract'),
|
|
|
|
'invoice': _meta_getter('invoice'),
|
|
|
|
'purchase_order': _meta_getter('purchase-order'),
|
|
|
|
}
|
|
|
|
INCONSISTENT = Sentinel()
|
|
|
|
__slots__ = (
|
|
|
|
'accrual_type',
|
2020-06-05 13:10:48 +00:00
|
|
|
'accrued_entities',
|
2020-06-03 20:54:22 +00:00
|
|
|
'end_balance',
|
2020-06-05 13:10:48 +00:00
|
|
|
'paid_entities',
|
2020-05-30 21:31:21 +00:00
|
|
|
'account',
|
|
|
|
'accounts',
|
|
|
|
'contract',
|
|
|
|
'contracts',
|
|
|
|
'invoice',
|
|
|
|
'invoices',
|
|
|
|
'purchase_order',
|
|
|
|
'purchase_orders',
|
|
|
|
)
|
|
|
|
|
|
|
|
def __init__(self,
|
|
|
|
source: Iterable[data.Posting]=(),
|
|
|
|
*,
|
|
|
|
_can_own: bool=False,
|
|
|
|
) -> None:
|
|
|
|
super().__init__(source, _can_own=_can_own)
|
|
|
|
# The following type declarations tell mypy about values set in the for
|
|
|
|
# loop that are important enough to be referenced directly elsewhere.
|
|
|
|
self.account: Union[data.Account, Sentinel]
|
|
|
|
self.invoice: Union[MetaValue, Sentinel]
|
|
|
|
for name, get_func in self._FIELDS.items():
|
|
|
|
values = frozenset(get_func(post) for post in self)
|
|
|
|
setattr(self, f'{name}s', values)
|
|
|
|
if len(values) == 1:
|
|
|
|
one_value = next(iter(values))
|
|
|
|
else:
|
|
|
|
one_value = self.INCONSISTENT
|
|
|
|
setattr(self, name, one_value)
|
|
|
|
if self.account is self.INCONSISTENT:
|
|
|
|
self.accrual_type: Optional[AccrualAccount] = None
|
2020-06-03 20:54:22 +00:00
|
|
|
self.end_balance = self.balance_at_cost()
|
2020-06-05 13:10:48 +00:00
|
|
|
self.accrued_entities = self._collect_entities()
|
|
|
|
self.paid_entities = self.accrued_entities
|
2020-05-30 21:31:21 +00:00
|
|
|
else:
|
|
|
|
self.accrual_type = AccrualAccount.classify(self)
|
2020-06-05 14:01:36 +00:00
|
|
|
accrual_acct: Account = self.accrual_type.value
|
|
|
|
norm_func = accrual_acct.norm_func
|
2020-06-05 13:10:48 +00:00
|
|
|
self.end_balance = norm_func(self.balance_at_cost())
|
|
|
|
self.accrued_entities = self._collect_entities(
|
2020-06-05 14:01:36 +00:00
|
|
|
lambda post: norm_func(post.units).number > 0,
|
2020-06-05 13:10:48 +00:00
|
|
|
)
|
|
|
|
self.paid_entities = self._collect_entities(
|
2020-06-05 14:01:36 +00:00
|
|
|
lambda post: norm_func(post.units).number < 0,
|
2020-06-05 13:10:48 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
def _collect_entities(self,
|
|
|
|
pred: Callable[[data.Posting], bool]=bool,
|
|
|
|
default: str='<empty>',
|
|
|
|
) -> FrozenSet[MetaValue]:
|
|
|
|
return frozenset(
|
|
|
|
post.meta.get('entity') or default
|
|
|
|
for post in self if pred(post)
|
|
|
|
)
|
|
|
|
|
|
|
|
def entities(self) -> Iterator[MetaValue]:
|
|
|
|
yield from self.accrued_entities
|
|
|
|
yield from self.paid_entities.difference(self.accrued_entities)
|
2020-05-30 21:31:21 +00:00
|
|
|
|
2020-06-02 14:45:22 +00:00
|
|
|
def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
|
|
|
|
account_ok = isinstance(self.account, str)
|
2020-06-05 14:54:35 +00:00
|
|
|
if len(self.accrued_entities) == 1:
|
|
|
|
entity = next(iter(self.accrued_entities))
|
|
|
|
else:
|
|
|
|
entity = None
|
2020-06-02 14:45:22 +00:00
|
|
|
# `'/' in self.invoice` is just our heuristic to ensure that the
|
|
|
|
# invoice metadata is "unique enough," and not just a placeholder
|
|
|
|
# value like "FIXME". It can be refined if needed.
|
|
|
|
invoice_ok = isinstance(self.invoice, str) and '/' in self.invoice
|
2020-06-05 14:54:35 +00:00
|
|
|
if account_ok and entity is not None and invoice_ok:
|
2020-06-02 14:45:22 +00:00
|
|
|
yield (self.invoice, self)
|
|
|
|
return
|
|
|
|
groups = collections.defaultdict(list)
|
|
|
|
for post in self:
|
2020-06-05 14:54:35 +00:00
|
|
|
post_invoice = self.invoice if invoice_ok else (
|
|
|
|
post.meta.get('invoice') or 'BlankInvoice'
|
|
|
|
)
|
|
|
|
post_entity = entity if entity is not None else (
|
|
|
|
post.meta.get('entity') or 'BlankEntity'
|
|
|
|
)
|
|
|
|
groups[f'{post.account} {post_invoice} {post_entity}'].append(post)
|
2020-06-02 14:45:22 +00:00
|
|
|
type_self = type(self)
|
|
|
|
for group_key, posts in groups.items():
|
|
|
|
yield group_key, type_self(posts, _can_own=True)
|
|
|
|
|
2020-05-30 21:31:21 +00:00
|
|
|
def report_inconsistencies(self) -> Iterable[Error]:
|
|
|
|
for field_name, get_func in self._FIELDS.items():
|
|
|
|
if getattr(self, field_name) is self.INCONSISTENT:
|
|
|
|
for post in self:
|
|
|
|
errmsg = 'inconsistent {} for invoice {}: {}'.format(
|
|
|
|
field_name.replace('_', '-'),
|
|
|
|
self.invoice or "<none>",
|
|
|
|
get_func(post),
|
|
|
|
)
|
|
|
|
yield Error(post.meta, errmsg, post.meta.txn)
|
2020-06-04 01:23:52 +00:00
|
|
|
costs = collections.defaultdict(set)
|
|
|
|
for post in self:
|
|
|
|
costs[post.units.currency].add(post.cost)
|
|
|
|
for code, currency_costs in costs.items():
|
|
|
|
if len(currency_costs) > 1:
|
|
|
|
for post in self:
|
|
|
|
if post.units.currency == code:
|
|
|
|
errmsg = 'inconsistent cost for invoice {}: {}'.format(
|
|
|
|
self.invoice or "<none>", post.cost,
|
|
|
|
)
|
|
|
|
yield Error(post.meta, errmsg, post.meta.txn)
|
2020-05-30 21:31:21 +00:00
|
|
|
|
2020-06-02 18:11:01 +00:00
|
|
|
def is_paid(self, default: Optional[bool]=None) -> Optional[bool]:
|
|
|
|
if self.accrual_type is None:
|
|
|
|
return default
|
|
|
|
else:
|
2020-06-03 20:54:22 +00:00
|
|
|
return self.end_balance.le_zero()
|
2020-05-30 21:31:21 +00:00
|
|
|
|
2020-06-02 18:11:01 +00:00
|
|
|
def is_zero(self, default: Optional[bool]=None) -> Optional[bool]:
|
|
|
|
if self.accrual_type is None:
|
|
|
|
return default
|
|
|
|
else:
|
2020-06-03 20:54:22 +00:00
|
|
|
return self.end_balance.is_zero()
|
2020-05-28 19:49:43 +00:00
|
|
|
|
2020-06-02 18:11:01 +00:00
|
|
|
def since_last_nonzero(self) -> 'AccrualPostings':
|
|
|
|
for index, (post, balance) in enumerate(self.iter_with_balance()):
|
2020-05-30 21:31:21 +00:00
|
|
|
if balance.is_zero():
|
|
|
|
start_index = index
|
|
|
|
try:
|
|
|
|
empty = start_index == index
|
|
|
|
except NameError:
|
|
|
|
empty = True
|
2020-06-02 18:11:01 +00:00
|
|
|
return self if empty else self[start_index + 1:]
|
|
|
|
|
|
|
|
|
|
|
|
class BaseReport:
|
|
|
|
def __init__(self, out_file: TextIO) -> None:
|
|
|
|
self.out_file = out_file
|
|
|
|
self.logger = logger.getChild(type(self).__name__)
|
2020-05-28 19:49:43 +00:00
|
|
|
|
2020-06-05 14:54:35 +00:00
|
|
|
def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]:
|
2020-05-28 19:49:43 +00:00
|
|
|
raise NotImplementedError("BaseReport._report")
|
|
|
|
|
|
|
|
def run(self, groups: PostGroups) -> None:
|
|
|
|
for index, invoice in enumerate(groups):
|
2020-06-05 14:54:35 +00:00
|
|
|
for line in self._report(groups[invoice], index):
|
2020-05-28 19:49:43 +00:00
|
|
|
print(line, file=self.out_file)
|
|
|
|
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
|
|
|
|
COLUMNS = [
|
|
|
|
'Date',
|
|
|
|
'Entity',
|
|
|
|
'Invoice Amount',
|
|
|
|
'Booked Amount',
|
|
|
|
'Ticket',
|
|
|
|
'Invoice',
|
|
|
|
]
|
|
|
|
COL_COUNT = len(COLUMNS)
|
|
|
|
|
|
|
|
def __init__(self,
|
|
|
|
rt_client: rt.Rt,
|
|
|
|
date: datetime.date,
|
|
|
|
logger: logging.Logger,
|
|
|
|
) -> None:
|
|
|
|
super().__init__()
|
|
|
|
self.rt_client = rt_client
|
|
|
|
self.rt_wrapper = rtutil.RT(self.rt_client)
|
|
|
|
self.date = date
|
|
|
|
self.logger = logger
|
|
|
|
|
|
|
|
def init_styles(self) -> None:
|
|
|
|
super().init_styles()
|
|
|
|
self.style_widecol = self.replace_child(
|
|
|
|
self.document.automaticstyles,
|
|
|
|
odf.style.Style,
|
|
|
|
name='WideCol',
|
|
|
|
)
|
|
|
|
self.style_widecol.setAttribute('family', 'table-column')
|
|
|
|
self.style_widecol.addElement(odf.style.TableColumnProperties(
|
|
|
|
columnwidth='1.25in',
|
|
|
|
))
|
|
|
|
|
|
|
|
def section_key(self, row: AccrualPostings) -> Optional[data.Account]:
|
|
|
|
if isinstance(row.account, str):
|
|
|
|
return row.account
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
|
|
|
def start_spreadsheet(self) -> None:
|
|
|
|
for accrual_type in AccrualAccount:
|
|
|
|
self.use_sheet(accrual_type.name.title())
|
|
|
|
for index in range(self.COL_COUNT):
|
|
|
|
stylename = self.style_widecol if index else ''
|
|
|
|
self.sheet.addElement(odf.table.TableColumn(stylename=stylename))
|
|
|
|
self.add_row(*(
|
|
|
|
self.string_cell(name, stylename=self.style_bold)
|
|
|
|
for name in self.COLUMNS
|
|
|
|
))
|
|
|
|
self.lock_first_row()
|
|
|
|
|
|
|
|
def start_section(self, key: Optional[data.Account]) -> None:
|
|
|
|
if key is None:
|
|
|
|
return
|
|
|
|
self.age_thresholds = list(AccrualAccount.by_account(key).value.aging_thresholds)
|
|
|
|
self.age_balances = [core.MutableBalance() for _ in self.age_thresholds]
|
|
|
|
accrual_date = self.date - datetime.timedelta(days=self.age_thresholds[-1])
|
|
|
|
acct_parts = key.split(':')
|
|
|
|
self.use_sheet(acct_parts[1])
|
|
|
|
self.add_row()
|
|
|
|
self.add_row(self.string_cell(
|
|
|
|
f"{' '.join(acct_parts[2:])} {acct_parts[1]} Aging Report"
|
|
|
|
f" Accrued by {accrual_date.isoformat()} Unpaid by {self.date.isoformat()}",
|
|
|
|
stylename=self.merge_styles(self.style_bold, self.style_centertext),
|
|
|
|
numbercolumnsspanned=self.COL_COUNT,
|
|
|
|
))
|
|
|
|
self.add_row()
|
|
|
|
|
|
|
|
def end_section(self, key: Optional[data.Account]) -> None:
|
|
|
|
if key is None:
|
|
|
|
return
|
|
|
|
self.add_row()
|
|
|
|
text_style = self.merge_styles(self.style_bold, self.style_endtext)
|
|
|
|
text_span = self.COL_COUNT - 1
|
|
|
|
for threshold, balance in zip(self.age_thresholds, self.age_balances):
|
|
|
|
years, days = divmod(threshold, 365)
|
|
|
|
years_text = f"{years} {'Year' if years == 1 else 'Years'}"
|
|
|
|
days_text = f"{days} Days"
|
|
|
|
if years and days:
|
|
|
|
age_text = f"{years_text} {days_text}"
|
|
|
|
elif years:
|
|
|
|
age_text = years_text
|
|
|
|
else:
|
|
|
|
age_text = days_text
|
|
|
|
self.add_row(
|
|
|
|
self.string_cell(
|
|
|
|
f"Total Aged Over {age_text}: ",
|
|
|
|
stylename=text_style,
|
|
|
|
numbercolumnsspanned=text_span,
|
|
|
|
),
|
|
|
|
*(odf.table.TableCell() for _ in range(1, text_span)),
|
|
|
|
self.balance_cell(balance),
|
|
|
|
)
|
|
|
|
|
|
|
|
def _link_seq(self, row: AccrualPostings, key: MetaKey) -> Iterator[Tuple[str, str]]:
|
|
|
|
for href in row.all_meta_links(key):
|
|
|
|
text: Optional[str] = None
|
|
|
|
rt_ids = self.rt_wrapper.parse(href)
|
|
|
|
if rt_ids is not None:
|
|
|
|
ticket_id, attachment_id = rt_ids
|
|
|
|
if attachment_id is None:
|
|
|
|
text = f'RT#{ticket_id}'
|
|
|
|
href = self.rt_wrapper.url(ticket_id, attachment_id) or href
|
|
|
|
else:
|
|
|
|
# '..' pops the ODS filename off the link path. In other words,
|
|
|
|
# make the link relative to the directory the ODS is in.
|
|
|
|
href = f'../{href}'
|
|
|
|
if text is None:
|
|
|
|
href_path = Path(urlparse.urlparse(href).path)
|
|
|
|
text = urlparse.unquote(href_path.name)
|
|
|
|
yield (href, text)
|
|
|
|
|
|
|
|
def write_row(self, row: AccrualPostings) -> None:
|
|
|
|
age = (self.date - row[0].meta.date).days
|
|
|
|
if row.end_balance.ge_zero():
|
|
|
|
for index, threshold in enumerate(self.age_thresholds):
|
|
|
|
if age >= threshold:
|
|
|
|
self.age_balances[index] += row.end_balance
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
return
|
|
|
|
raw_balance = row.balance()
|
|
|
|
if row.accrual_type is not None:
|
|
|
|
raw_balance = row.accrual_type.value.norm_func(raw_balance)
|
|
|
|
if raw_balance == row.end_balance:
|
|
|
|
amount_cell = odf.table.TableCell()
|
|
|
|
else:
|
|
|
|
amount_cell = self.balance_cell(raw_balance)
|
|
|
|
self.add_row(
|
|
|
|
self.date_cell(row[0].meta.date),
|
2020-06-05 13:10:48 +00:00
|
|
|
self.multiline_cell(row.entities()),
|
2020-06-03 20:54:22 +00:00
|
|
|
amount_cell,
|
|
|
|
self.balance_cell(row.end_balance),
|
|
|
|
self.multilink_cell(self._link_seq(row, 'rt-id')),
|
|
|
|
self.multilink_cell(self._link_seq(row, 'invoice')),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class AgingReport(BaseReport):
|
|
|
|
def __init__(self,
|
|
|
|
rt_client: rt.Rt,
|
|
|
|
out_file: BinaryIO,
|
|
|
|
date: Optional[datetime.date]=None,
|
|
|
|
) -> None:
|
|
|
|
if date is None:
|
|
|
|
date = datetime.date.today()
|
|
|
|
self.out_bin = out_file
|
|
|
|
self.logger = logger.getChild(type(self).__name__)
|
|
|
|
self.ods = AgingODS(rt_client, date, self.logger)
|
|
|
|
|
|
|
|
def run(self, groups: PostGroups) -> None:
|
|
|
|
rows = list(group for group in groups.values() if not group.is_zero())
|
|
|
|
rows.sort(key=lambda related: (
|
|
|
|
related.account,
|
|
|
|
related[0].meta.date,
|
2020-06-05 13:10:48 +00:00
|
|
|
min(related.entities()) if related.accrued_entities else '',
|
2020-06-03 20:54:22 +00:00
|
|
|
))
|
|
|
|
self.ods.write(rows)
|
|
|
|
self.ods.save_file(self.out_bin)
|
|
|
|
|
|
|
|
|
2020-05-28 19:49:43 +00:00
|
|
|
class BalanceReport(BaseReport):
|
2020-06-05 14:54:35 +00:00
|
|
|
def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]:
|
2020-06-02 18:11:01 +00:00
|
|
|
posts = posts.since_last_nonzero()
|
2020-05-28 19:49:43 +00:00
|
|
|
date_s = posts[0].meta.date.strftime('%Y-%m-%d')
|
|
|
|
if index:
|
|
|
|
yield ""
|
2020-06-05 14:54:35 +00:00
|
|
|
yield f"{posts.invoice}:"
|
2020-06-03 20:54:22 +00:00
|
|
|
yield f" {posts.balance_at_cost()} outstanding since {date_s}"
|
2020-05-28 19:49:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
class OutgoingReport(BaseReport):
|
2020-05-28 20:41:55 +00:00
|
|
|
def __init__(self, rt_client: rt.Rt, out_file: TextIO) -> None:
|
|
|
|
super().__init__(out_file)
|
2020-05-28 19:49:43 +00:00
|
|
|
self.rt_client = rt_client
|
|
|
|
self.rt_wrapper = rtutil.RT(rt_client)
|
|
|
|
|
2020-05-30 21:31:21 +00:00
|
|
|
def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds:
|
2020-06-08 20:37:51 +00:00
|
|
|
rt_ids: Set[str] = set()
|
|
|
|
for post in posts:
|
|
|
|
try:
|
|
|
|
rt_ids.add(post.meta.get_links('rt-id')[0])
|
|
|
|
except (IndexError, TypeError):
|
|
|
|
pass
|
2020-05-28 19:49:43 +00:00
|
|
|
rt_ids_count = len(rt_ids)
|
|
|
|
if rt_ids_count != 1:
|
|
|
|
raise ValueError(f"{rt_ids_count} rt-id links found")
|
|
|
|
parsed = rtutil.RT.parse(rt_ids.pop())
|
|
|
|
if parsed is None:
|
|
|
|
raise ValueError("rt-id is not a valid RT reference")
|
|
|
|
else:
|
|
|
|
return parsed
|
|
|
|
|
2020-06-05 14:54:35 +00:00
|
|
|
def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]:
|
2020-06-02 18:11:01 +00:00
|
|
|
posts = posts.since_last_nonzero()
|
2020-05-28 19:49:43 +00:00
|
|
|
try:
|
|
|
|
ticket_id, _ = self._primary_rt_id(posts)
|
|
|
|
ticket = self.rt_client.get_ticket(ticket_id)
|
|
|
|
# Note we only use this when ticket is None.
|
|
|
|
errmsg = f"ticket {ticket_id} not found"
|
|
|
|
except (ValueError, rt.RtError) as error:
|
|
|
|
ticket = None
|
|
|
|
errmsg = error.args[0]
|
|
|
|
if ticket is None:
|
2020-05-28 20:41:55 +00:00
|
|
|
self.logger.error(
|
|
|
|
"can't generate outgoings report for %s because no RT ticket available: %s",
|
2020-06-05 14:54:35 +00:00
|
|
|
posts.invoice, errmsg,
|
2020-05-28 20:41:55 +00:00
|
|
|
)
|
2020-05-28 19:49:43 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
rt_requestor = self.rt_client.get_user(ticket['Requestors'][0])
|
|
|
|
except (IndexError, rt.RtError):
|
|
|
|
rt_requestor = None
|
|
|
|
if rt_requestor is None:
|
|
|
|
requestor = ''
|
|
|
|
requestor_name = ''
|
|
|
|
else:
|
|
|
|
requestor_name = (
|
|
|
|
rt_requestor.get('RealName')
|
|
|
|
or ticket.get('CF.{payment-to}')
|
|
|
|
or ''
|
|
|
|
)
|
|
|
|
requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip()
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
balance_s = posts.end_balance.format(None)
|
|
|
|
raw_balance = -posts.balance()
|
|
|
|
if raw_balance != posts.end_balance:
|
|
|
|
balance_s = f'{raw_balance} ({balance_s})'
|
2020-05-28 19:49:43 +00:00
|
|
|
|
|
|
|
contract_links = posts.all_meta_links('contract')
|
|
|
|
if contract_links:
|
|
|
|
contract_s = ' , '.join(self.rt_wrapper.iter_urls(
|
|
|
|
contract_links, missing_fmt='<BROKEN RT LINK: {}>',
|
|
|
|
))
|
|
|
|
else:
|
|
|
|
contract_s = "NO CONTRACT GOVERNS THIS TRANSACTION"
|
|
|
|
projects = [v for v in posts.meta_values('project')
|
|
|
|
if isinstance(v, str)]
|
|
|
|
|
|
|
|
yield "PAYMENT FOR APPROVAL:"
|
|
|
|
yield f"REQUESTOR: {requestor}"
|
|
|
|
yield f"TOTAL TO PAY: {balance_s}"
|
|
|
|
yield f"AGREEMENT: {contract_s}"
|
|
|
|
yield f"PAYMENT TO: {ticket.get('CF.{payment-to}') or requestor_name}"
|
|
|
|
yield f"PAYMENT METHOD: {ticket.get('CF.{payment-method}', '')}"
|
|
|
|
yield f"PROJECT: {', '.join(projects)}"
|
|
|
|
yield "\nBEANCOUNT ENTRIES:\n"
|
|
|
|
|
|
|
|
last_txn: Optional[Transaction] = None
|
|
|
|
for post in posts:
|
|
|
|
txn = post.meta.txn
|
|
|
|
if txn is not last_txn:
|
|
|
|
last_txn = txn
|
|
|
|
txn = self.rt_wrapper.txn_with_urls(txn, '{}')
|
|
|
|
yield bc_printer.format_entry(txn)
|
2020-05-04 20:36:59 +00:00
|
|
|
|
2020-05-28 19:49:43 +00:00
|
|
|
|
|
|
|
class ReportType(enum.Enum):
|
2020-06-03 20:54:22 +00:00
|
|
|
AGING = AgingReport
|
2020-05-28 19:49:43 +00:00
|
|
|
BALANCE = BalanceReport
|
|
|
|
OUTGOING = OutgoingReport
|
2020-06-03 20:54:22 +00:00
|
|
|
AGE = AGING
|
2020-05-28 19:49:43 +00:00
|
|
|
BAL = BALANCE
|
|
|
|
OUT = OUTGOING
|
|
|
|
OUTGOINGS = OUTGOING
|
2020-05-04 20:36:59 +00:00
|
|
|
|
|
|
|
@classmethod
|
2020-05-28 19:49:43 +00:00
|
|
|
def by_name(cls, name: str) -> 'ReportType':
|
2020-05-04 20:36:59 +00:00
|
|
|
try:
|
2020-05-28 19:49:43 +00:00
|
|
|
return cls[name.upper()]
|
2020-05-04 20:36:59 +00:00
|
|
|
except KeyError:
|
|
|
|
raise ValueError(f"unknown report type {name!r}") from None
|
|
|
|
|
|
|
|
@classmethod
|
2020-05-28 19:49:43 +00:00
|
|
|
def default_for(cls, groups: PostGroups) -> 'ReportType':
|
2020-05-23 14:13:17 +00:00
|
|
|
if len(groups) == 1 and all(
|
2020-06-02 18:11:01 +00:00
|
|
|
group.accrual_type is AccrualAccount.PAYABLE
|
|
|
|
and not group.is_paid()
|
2020-05-23 14:13:17 +00:00
|
|
|
for group in groups.values()
|
2020-05-04 20:36:59 +00:00
|
|
|
):
|
2020-05-28 19:49:43 +00:00
|
|
|
return cls.OUTGOING
|
2020-05-04 20:36:59 +00:00
|
|
|
else:
|
2020-05-28 19:49:43 +00:00
|
|
|
return cls.BALANCE
|
2020-05-04 20:36:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
class ReturnFlag(enum.IntFlag):
|
|
|
|
LOAD_ERRORS = 1
|
|
|
|
CONSISTENCY_ERRORS = 2
|
|
|
|
REPORT_ERRORS = 4
|
|
|
|
NOTHING_TO_REPORT = 8
|
|
|
|
|
|
|
|
|
|
|
|
class SearchTerm(NamedTuple):
|
|
|
|
meta_key: MetaKey
|
|
|
|
pattern: str
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def parse(cls, s: str) -> 'SearchTerm':
|
|
|
|
key_match = re.match(r'^[a-z][-\w]*=', s)
|
|
|
|
key: Optional[str]
|
|
|
|
if key_match:
|
|
|
|
key, _, raw_link = s.partition('=')
|
|
|
|
else:
|
|
|
|
key = None
|
|
|
|
raw_link = s
|
|
|
|
rt_ids = rtutil.RT.parse(raw_link)
|
|
|
|
if rt_ids is None:
|
|
|
|
rt_ids = rtutil.RT.parse('rt:' + raw_link)
|
|
|
|
if rt_ids is None:
|
|
|
|
if key is None:
|
|
|
|
key = 'invoice'
|
|
|
|
pattern = r'(?:^|\s){}(?:\s|$)'.format(re.escape(raw_link))
|
|
|
|
else:
|
|
|
|
ticket_id, attachment_id = rt_ids
|
|
|
|
if key is None:
|
|
|
|
key = 'rt-id' if attachment_id is None else 'invoice'
|
|
|
|
pattern = rtutil.RT.metadata_regexp(
|
|
|
|
ticket_id,
|
|
|
|
attachment_id,
|
|
|
|
first_link_only=key == 'rt-id' and attachment_id is None,
|
|
|
|
)
|
|
|
|
return cls(key, pattern)
|
|
|
|
|
|
|
|
|
|
|
|
def filter_search(postings: Iterable[data.Posting],
|
|
|
|
search_terms: Iterable[SearchTerm],
|
|
|
|
) -> Iterable[data.Posting]:
|
2020-05-23 14:13:17 +00:00
|
|
|
accounts = tuple(AccrualAccount.account_names())
|
|
|
|
postings = (post for post in postings if post.account.is_under(*accounts))
|
2020-05-04 20:36:59 +00:00
|
|
|
for meta_key, pattern in search_terms:
|
|
|
|
postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern))
|
|
|
|
return postings
|
|
|
|
|
|
|
|
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
|
2020-05-30 02:02:47 +00:00
|
|
|
parser = argparse.ArgumentParser(prog=PROGNAME)
|
|
|
|
cliutil.add_version_argument(parser)
|
2020-05-04 20:36:59 +00:00
|
|
|
parser.add_argument(
|
|
|
|
'--report-type', '-t',
|
|
|
|
metavar='NAME',
|
|
|
|
type=ReportType.by_name,
|
2020-06-03 20:54:22 +00:00
|
|
|
help="""The type of report to generate, one of `aging`, `balance`, or
|
|
|
|
`outgoing`. If not specified, the default is `aging` when no search terms are
|
|
|
|
given, `outgoing` for search terms that return a single outstanding payable,
|
|
|
|
and `balance` any other time.
|
2020-05-04 20:36:59 +00:00
|
|
|
""")
|
|
|
|
parser.add_argument(
|
|
|
|
'--since',
|
|
|
|
metavar='YEAR',
|
|
|
|
type=int,
|
|
|
|
default=-1,
|
|
|
|
help="""How far back to search the books for related transactions.
|
|
|
|
You can either specify a fiscal year, or a negative offset from the current
|
|
|
|
fiscal year, to start loading entries from. The default is -1 (start from the
|
|
|
|
previous fiscal year).
|
2020-06-03 20:54:22 +00:00
|
|
|
""")
|
|
|
|
parser.add_argument(
|
|
|
|
'--output-file', '-O',
|
|
|
|
metavar='PATH',
|
|
|
|
type=Path,
|
|
|
|
help="""Write the report to this file, or stdout when PATH is `-`.
|
|
|
|
The default is stdout for the balance and outgoing reports, and a generated
|
|
|
|
filename for other reports.
|
2020-05-04 20:36:59 +00:00
|
|
|
""")
|
2020-05-30 02:02:47 +00:00
|
|
|
cliutil.add_loglevel_argument(parser)
|
2020-05-04 20:36:59 +00:00
|
|
|
parser.add_argument(
|
|
|
|
'search',
|
|
|
|
nargs=argparse.ZERO_OR_MORE,
|
|
|
|
help="""Report on accruals 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 link, including an RT attachment link in
|
|
|
|
`TIK/ATT` format, is a shortcut for `invoice=LINK`.
|
|
|
|
""")
|
|
|
|
args = parser.parse_args(arglist)
|
|
|
|
args.search_terms = [SearchTerm.parse(s) for s in args.search]
|
2020-06-03 20:54:22 +00:00
|
|
|
if args.report_type is None and not args.search_terms:
|
|
|
|
args.report_type = ReportType.AGING
|
2020-05-04 20:36:59 +00:00
|
|
|
return args
|
|
|
|
|
2020-06-03 20:54:22 +00:00
|
|
|
def get_output_path(output_path: Optional[Path],
|
|
|
|
default_path: Path=STANDARD_PATH,
|
|
|
|
) -> Optional[Path]:
|
|
|
|
if output_path is None:
|
|
|
|
output_path = default_path
|
|
|
|
if output_path == STANDARD_PATH:
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
return output_path
|
|
|
|
|
|
|
|
def get_output_bin(path: Optional[Path], stdout: TextIO) -> BinaryIO:
|
|
|
|
if path is None:
|
|
|
|
return open(stdout.fileno(), 'wb')
|
|
|
|
else:
|
|
|
|
return path.open('wb')
|
|
|
|
|
|
|
|
def get_output_text(path: Optional[Path], stdout: TextIO) -> TextIO:
|
|
|
|
if path is None:
|
|
|
|
return stdout
|
|
|
|
else:
|
|
|
|
return path.open('w')
|
|
|
|
|
2020-05-04 20:36:59 +00:00
|
|
|
def main(arglist: Optional[Sequence[str]]=None,
|
|
|
|
stdout: TextIO=sys.stdout,
|
|
|
|
stderr: TextIO=sys.stderr,
|
|
|
|
config: Optional[configmod.Config]=None,
|
|
|
|
) -> int:
|
2020-05-30 03:39:27 +00:00
|
|
|
if cliutil.is_main_script(PROGNAME):
|
2020-05-30 02:02:47 +00:00
|
|
|
global logger
|
|
|
|
logger = logging.getLogger(PROGNAME)
|
|
|
|
sys.excepthook = cliutil.ExceptHook(logger)
|
2020-05-04 20:36:59 +00:00
|
|
|
args = parse_arguments(arglist)
|
2020-05-30 02:02:47 +00:00
|
|
|
cliutil.setup_logger(logger, args.loglevel, stderr)
|
2020-05-04 20:36:59 +00:00
|
|
|
if config is None:
|
|
|
|
config = configmod.Config()
|
|
|
|
config.load_file()
|
2020-06-03 20:54:22 +00:00
|
|
|
|
2020-05-04 20:36:59 +00:00
|
|
|
books_loader = config.books_loader()
|
2020-06-03 20:54:22 +00:00
|
|
|
if books_loader is None:
|
|
|
|
entries: Entries = []
|
2020-05-04 20:36:59 +00:00
|
|
|
source = {
|
|
|
|
'filename': str(config.config_file_path()),
|
|
|
|
'lineno': 1,
|
|
|
|
}
|
2020-06-03 20:54:22 +00:00
|
|
|
load_errors: Errors = [Error(source, "no books to load in configuration", None)]
|
|
|
|
elif args.report_type is ReportType.AGING:
|
|
|
|
entries, load_errors, _ = books_loader.load_all()
|
|
|
|
else:
|
2020-06-04 13:03:37 +00:00
|
|
|
entries, load_errors, _ = books_loader.load_all(args.since)
|
2020-06-02 17:40:21 +00:00
|
|
|
filters.remove_opening_balance_txn(entries)
|
2020-06-03 20:54:22 +00:00
|
|
|
|
|
|
|
returncode = 0
|
2020-05-04 20:36:59 +00:00
|
|
|
postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
|
2020-05-30 21:31:21 +00:00
|
|
|
groups: PostGroups = dict(AccrualPostings.group_by_meta(postings, 'invoice'))
|
2020-05-04 20:36:59 +00:00
|
|
|
for error in load_errors:
|
|
|
|
bc_printer.print_error(error, file=stderr)
|
|
|
|
returncode |= ReturnFlag.LOAD_ERRORS
|
2020-05-30 21:31:21 +00:00
|
|
|
for related in groups.values():
|
|
|
|
for error in related.report_inconsistencies():
|
|
|
|
bc_printer.print_error(error, file=stderr)
|
|
|
|
returncode |= ReturnFlag.CONSISTENCY_ERRORS
|
2020-05-04 20:36:59 +00:00
|
|
|
if not groups:
|
2020-05-28 20:41:55 +00:00
|
|
|
logger.warning("no matching entries found to report")
|
2020-05-04 20:36:59 +00:00
|
|
|
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
2020-06-03 20:54:22 +00:00
|
|
|
|
|
|
|
groups = {
|
|
|
|
key: posts
|
|
|
|
for source_posts in groups.values()
|
|
|
|
for key, posts in source_posts.make_consistent()
|
|
|
|
}
|
|
|
|
if args.report_type is not ReportType.AGING:
|
|
|
|
groups = {
|
|
|
|
key: posts for key, posts in groups.items() if not posts.is_paid()
|
|
|
|
} or groups
|
|
|
|
|
|
|
|
if args.report_type is None:
|
|
|
|
args.report_type = ReportType.default_for(groups)
|
2020-05-28 19:49:43 +00:00
|
|
|
report: Optional[BaseReport] = None
|
2020-06-03 20:54:22 +00:00
|
|
|
output_path: Optional[Path] = None
|
|
|
|
if args.report_type is ReportType.AGING:
|
|
|
|
rt_client = config.rt_client()
|
|
|
|
if rt_client is None:
|
|
|
|
logger.error("unable to generate aging report: RT client is required")
|
|
|
|
else:
|
|
|
|
now = datetime.datetime.now()
|
|
|
|
default_path = Path(now.strftime('AgingReport_%Y-%m-%d_%H:%M.ods'))
|
|
|
|
output_path = get_output_path(args.output_file, default_path)
|
|
|
|
out_bin = get_output_bin(output_path, stdout)
|
|
|
|
report = AgingReport(rt_client, out_bin)
|
|
|
|
elif args.report_type is ReportType.OUTGOING:
|
2020-05-28 19:49:43 +00:00
|
|
|
rt_client = config.rt_client()
|
|
|
|
if rt_client is None:
|
2020-05-28 20:41:55 +00:00
|
|
|
logger.error("unable to generate outgoing report: RT client is required")
|
2020-05-28 19:49:43 +00:00
|
|
|
else:
|
2020-06-03 20:54:22 +00:00
|
|
|
output_path = get_output_path(args.output_file)
|
|
|
|
out_file = get_output_text(output_path, stdout)
|
|
|
|
report = OutgoingReport(rt_client, out_file)
|
2020-05-28 19:49:43 +00:00
|
|
|
else:
|
2020-06-03 20:54:22 +00:00
|
|
|
output_path = get_output_path(args.output_file)
|
|
|
|
out_file = get_output_text(output_path, stdout)
|
|
|
|
report = args.report_type.value(out_file)
|
|
|
|
|
2020-05-28 19:49:43 +00:00
|
|
|
if report is None:
|
|
|
|
returncode |= ReturnFlag.REPORT_ERRORS
|
|
|
|
else:
|
|
|
|
report.run(groups)
|
2020-06-03 20:54:22 +00:00
|
|
|
if args.output_file != output_path:
|
|
|
|
logger.info("Report saved to %s", output_path)
|
2020-05-04 20:36:59 +00:00
|
|
|
return 0 if returncode == 0 else 16 + returncode
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
exit(main())
|