accrual: Refactor reports into classes.
Preparation for introducing the aging report. This helps us distinguish each report's setup requirements (different __init__ arguments).
This commit is contained in:
parent
9223940213
commit
8b2683d962
2 changed files with 171 additions and 164 deletions
|
@ -96,10 +96,6 @@ from .. import filters
|
||||||
from .. import rtutil
|
from .. import rtutil
|
||||||
|
|
||||||
PostGroups = Mapping[Optional[MetaValue], core.RelatedPostings]
|
PostGroups = Mapping[Optional[MetaValue], core.RelatedPostings]
|
||||||
ReportFunc = Callable[
|
|
||||||
[PostGroups, TextIO, TextIO, Optional[rt.Rt], Optional[rtutil.RT]],
|
|
||||||
None
|
|
||||||
]
|
|
||||||
RTObject = Mapping[str, str]
|
RTObject = Mapping[str, str]
|
||||||
|
|
||||||
class Account(NamedTuple):
|
class Account(NamedTuple):
|
||||||
|
@ -132,37 +128,161 @@ class AccrualAccount(enum.Enum):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReportType:
|
class BaseReport:
|
||||||
NAMES: Set[str] = set()
|
def __init__(self, out_file: TextIO, err_file: TextIO) -> None:
|
||||||
BY_NAME: Dict[str, ReportFunc] = {}
|
self.out_file = out_file
|
||||||
|
self.err_file = err_file
|
||||||
|
|
||||||
@classmethod
|
def _since_last_nonzero(self, posts: core.RelatedPostings) -> core.RelatedPostings:
|
||||||
def register(cls, *names: str) -> Callable[[ReportFunc], ReportFunc]:
|
retval = core.RelatedPostings()
|
||||||
def register_wrapper(func: ReportFunc) -> ReportFunc:
|
for post in posts:
|
||||||
for name in names:
|
if retval.balance().is_zero():
|
||||||
cls.BY_NAME[name] = func
|
retval.clear()
|
||||||
cls.NAMES.add(names[0])
|
retval.add(post)
|
||||||
return func
|
return retval
|
||||||
return register_wrapper
|
|
||||||
|
|
||||||
@classmethod
|
def _report(self,
|
||||||
def by_name(cls, name: str) -> ReportFunc:
|
invoice: str,
|
||||||
|
posts: core.RelatedPostings,
|
||||||
|
index: int,
|
||||||
|
) -> Iterable[str]:
|
||||||
|
raise NotImplementedError("BaseReport._report")
|
||||||
|
|
||||||
|
def run(self, groups: PostGroups) -> None:
|
||||||
|
for index, invoice in enumerate(groups):
|
||||||
|
for line in self._report(str(invoice), groups[invoice], index):
|
||||||
|
print(line, file=self.out_file)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceReport(BaseReport):
|
||||||
|
def _report(self,
|
||||||
|
invoice: str,
|
||||||
|
posts: core.RelatedPostings,
|
||||||
|
index: int,
|
||||||
|
) -> Iterable[str]:
|
||||||
|
posts = self._since_last_nonzero(posts)
|
||||||
|
balance = posts.balance()
|
||||||
|
date_s = posts[0].meta.date.strftime('%Y-%m-%d')
|
||||||
|
if index:
|
||||||
|
yield ""
|
||||||
|
yield f"{invoice}:"
|
||||||
|
yield f" {balance} outstanding since {date_s}"
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingReport(BaseReport):
|
||||||
|
def __init__(self, rt_client: rt.Rt, out_file: TextIO, err_file: TextIO) -> None:
|
||||||
|
self.rt_client = rt_client
|
||||||
|
self.rt_wrapper = rtutil.RT(rt_client)
|
||||||
|
self.out_file = out_file
|
||||||
|
self.err_file = err_file
|
||||||
|
|
||||||
|
def _primary_rt_id(self, posts: core.RelatedPostings) -> rtutil.TicketAttachmentIds:
|
||||||
|
rt_ids = posts.all_meta_links('rt-id')
|
||||||
|
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
|
||||||
|
|
||||||
|
def _report(self,
|
||||||
|
invoice: str,
|
||||||
|
posts: core.RelatedPostings,
|
||||||
|
index: int,
|
||||||
|
) -> Iterable[str]:
|
||||||
|
posts = self._since_last_nonzero(posts)
|
||||||
try:
|
try:
|
||||||
return cls.BY_NAME[name.lower()]
|
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:
|
||||||
|
print("error: can't generate outgoings report for {}"
|
||||||
|
" because no RT ticket available: {}".format(
|
||||||
|
invoice, errmsg,
|
||||||
|
), file=self.err_file)
|
||||||
|
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()
|
||||||
|
|
||||||
|
raw_balance = -posts.balance()
|
||||||
|
cost_balance = -posts.balance_at_cost()
|
||||||
|
cost_balance_s = cost_balance.format(None)
|
||||||
|
if raw_balance == cost_balance:
|
||||||
|
balance_s = cost_balance_s
|
||||||
|
else:
|
||||||
|
balance_s = f'{raw_balance} ({cost_balance_s})'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(enum.Enum):
|
||||||
|
BALANCE = BalanceReport
|
||||||
|
OUTGOING = OutgoingReport
|
||||||
|
BAL = BALANCE
|
||||||
|
OUT = OUTGOING
|
||||||
|
OUTGOINGS = OUTGOING
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def by_name(cls, name: str) -> 'ReportType':
|
||||||
|
try:
|
||||||
|
return cls[name.upper()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError(f"unknown report type {name!r}") from None
|
raise ValueError(f"unknown report type {name!r}") from None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_for(cls, groups: PostGroups) -> ReportFunc:
|
def default_for(cls, groups: PostGroups) -> 'ReportType':
|
||||||
if len(groups) == 1 and all(
|
if len(groups) == 1 and all(
|
||||||
AccrualAccount.classify(group) is AccrualAccount.PAYABLE
|
AccrualAccount.classify(group) is AccrualAccount.PAYABLE
|
||||||
and not AccrualAccount.PAYABLE.value.balance_paid(group.balance())
|
and not AccrualAccount.PAYABLE.value.balance_paid(group.balance())
|
||||||
for group in groups.values()
|
for group in groups.values()
|
||||||
):
|
):
|
||||||
report_name = 'outgoing'
|
return cls.OUTGOING
|
||||||
else:
|
else:
|
||||||
report_name = 'balance'
|
return cls.BALANCE
|
||||||
return cls.BY_NAME[report_name]
|
|
||||||
|
|
||||||
|
|
||||||
class ReturnFlag(enum.IntFlag):
|
class ReturnFlag(enum.IntFlag):
|
||||||
|
@ -217,123 +337,6 @@ def consistency_check(groups: PostGroups) -> Iterable[Error]:
|
||||||
post.meta.txn,
|
post.meta.txn,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _since_last_nonzero(posts: core.RelatedPostings) -> core.RelatedPostings:
|
|
||||||
retval = core.RelatedPostings()
|
|
||||||
for post in posts:
|
|
||||||
if retval.balance().is_zero():
|
|
||||||
retval.clear()
|
|
||||||
retval.add(post)
|
|
||||||
return retval
|
|
||||||
|
|
||||||
@ReportType.register('balance', 'bal')
|
|
||||||
def balance_report(groups: PostGroups,
|
|
||||||
out_file: TextIO,
|
|
||||||
err_file: TextIO=sys.stderr,
|
|
||||||
rt_client: Optional[rt.Rt]=None,
|
|
||||||
rt_wrapper: Optional[rtutil.RT]=None,
|
|
||||||
) -> None:
|
|
||||||
prefix = ''
|
|
||||||
for invoice, related in groups.items():
|
|
||||||
related = _since_last_nonzero(related)
|
|
||||||
balance = related.balance()
|
|
||||||
date_s = related[0].meta.date.strftime('%Y-%m-%d')
|
|
||||||
print(
|
|
||||||
f"{prefix}{invoice}:",
|
|
||||||
f" {balance} outstanding since {date_s}",
|
|
||||||
sep='\n', file=out_file,
|
|
||||||
)
|
|
||||||
prefix = '\n'
|
|
||||||
|
|
||||||
def _primary_rt_id(related: core.RelatedPostings) -> rtutil.TicketAttachmentIds:
|
|
||||||
rt_ids = related.all_meta_links('rt-id')
|
|
||||||
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
|
|
||||||
|
|
||||||
@ReportType.register('outgoing', 'outgoings', 'out')
|
|
||||||
def outgoing_report(groups: PostGroups,
|
|
||||||
out_file: TextIO,
|
|
||||||
err_file: TextIO=sys.stderr,
|
|
||||||
rt_client: Optional[rt.Rt]=None,
|
|
||||||
rt_wrapper: Optional[rtutil.RT]=None,
|
|
||||||
) -> None:
|
|
||||||
if rt_client is None or rt_wrapper is None:
|
|
||||||
raise ValueError("RT client is required but not configured")
|
|
||||||
for invoice, related in groups.items():
|
|
||||||
related = _since_last_nonzero(related)
|
|
||||||
try:
|
|
||||||
ticket_id, _ = _primary_rt_id(related)
|
|
||||||
ticket = 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:
|
|
||||||
print("error: can't generate outgoings report for {}"
|
|
||||||
" because no RT ticket available: {}".format(
|
|
||||||
invoice, errmsg,
|
|
||||||
), file=err_file)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
rt_requestor = 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()
|
|
||||||
|
|
||||||
raw_balance = -related.balance()
|
|
||||||
cost_balance = -related.balance_at_cost()
|
|
||||||
cost_balance_s = cost_balance.format(None)
|
|
||||||
if raw_balance == cost_balance:
|
|
||||||
balance_s = cost_balance_s
|
|
||||||
else:
|
|
||||||
balance_s = f'{raw_balance} ({cost_balance_s})'
|
|
||||||
|
|
||||||
contract_links = related.all_meta_links('contract')
|
|
||||||
if contract_links:
|
|
||||||
contract_s = ' , '.join(rt_wrapper.iter_urls(
|
|
||||||
contract_links, missing_fmt='<BROKEN RT LINK: {}>',
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
contract_s = "NO CONTRACT GOVERNS THIS TRANSACTION"
|
|
||||||
projects = [v for v in related.meta_values('project')
|
|
||||||
if isinstance(v, str)]
|
|
||||||
|
|
||||||
print(
|
|
||||||
"PAYMENT FOR APPROVAL:",
|
|
||||||
f"REQUESTOR: {requestor}",
|
|
||||||
f"TOTAL TO PAY: {balance_s}",
|
|
||||||
f"AGREEMENT: {contract_s}",
|
|
||||||
f"PAYMENT TO: {ticket.get('CF.{payment-to}') or requestor_name}",
|
|
||||||
f"PAYMENT METHOD: {ticket.get('CF.{payment-method}', '')}",
|
|
||||||
f"PROJECT: {', '.join(projects)}",
|
|
||||||
"\nBEANCOUNT ENTRIES:\n",
|
|
||||||
sep='\n', file=out_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
last_txn: Optional[Transaction] = None
|
|
||||||
for post in related:
|
|
||||||
txn = post.meta.txn
|
|
||||||
if txn is not last_txn:
|
|
||||||
last_txn = txn
|
|
||||||
txn = rt_wrapper.txn_with_urls(txn, '{}')
|
|
||||||
bc_printer.print_entry(txn, file=out_file)
|
|
||||||
|
|
||||||
def filter_search(postings: Iterable[data.Posting],
|
def filter_search(postings: Iterable[data.Posting],
|
||||||
search_terms: Iterable[SearchTerm],
|
search_terms: Iterable[SearchTerm],
|
||||||
) -> Iterable[data.Posting]:
|
) -> Iterable[data.Posting]:
|
||||||
|
@ -411,21 +414,22 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
if not groups:
|
if not groups:
|
||||||
print("warning: no matching entries found to report", file=stderr)
|
print("warning: no matching entries found to report", file=stderr)
|
||||||
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
||||||
else:
|
report: Optional[BaseReport] = None
|
||||||
try:
|
if args.report_type is ReportType.OUTGOING:
|
||||||
args.report_type(
|
rt_client = config.rt_client()
|
||||||
groups,
|
if rt_client is None:
|
||||||
stdout,
|
print(
|
||||||
stderr,
|
"error: unable to generate outgoing report: RT client is required",
|
||||||
config.rt_client(),
|
file=stderr,
|
||||||
config.rt_wrapper(),
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
else:
|
||||||
print("error: unable to generate {}: {}".format(
|
report = OutgoingReport(rt_client, stdout, stderr)
|
||||||
args.report_type.__name__.replace('_', ' '),
|
else:
|
||||||
exc.args[0],
|
report = args.report_type.value(stdout, stderr)
|
||||||
), file=stderr)
|
if report is None:
|
||||||
returncode |= ReturnFlag.REPORT_ERRORS
|
returncode |= ReturnFlag.REPORT_ERRORS
|
||||||
|
else:
|
||||||
|
report.run(groups)
|
||||||
return 0 if returncode == 0 else 16 + returncode
|
return 0 if returncode == 0 else 16 + returncode
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -178,16 +178,16 @@ def test_filter_search(accrual_postings, search_terms, expect_count, check_func)
|
||||||
assert check_func(post)
|
assert check_func(post)
|
||||||
|
|
||||||
@pytest.mark.parametrize('arg,expected', [
|
@pytest.mark.parametrize('arg,expected', [
|
||||||
('balance', accrual.balance_report),
|
('balance', accrual.BalanceReport),
|
||||||
('outgoing', accrual.outgoing_report),
|
('outgoing', accrual.OutgoingReport),
|
||||||
('bal', accrual.balance_report),
|
('bal', accrual.BalanceReport),
|
||||||
('out', accrual.outgoing_report),
|
('out', accrual.OutgoingReport),
|
||||||
('outgoings', accrual.outgoing_report),
|
('outgoings', accrual.OutgoingReport),
|
||||||
])
|
])
|
||||||
def test_report_type_by_name(arg, expected):
|
def test_report_type_by_name(arg, expected):
|
||||||
assert accrual.ReportType.by_name(arg.lower()) is expected
|
assert accrual.ReportType.by_name(arg.lower()).value is expected
|
||||||
assert accrual.ReportType.by_name(arg.title()) is expected
|
assert accrual.ReportType.by_name(arg.title()).value is expected
|
||||||
assert accrual.ReportType.by_name(arg.upper()) is expected
|
assert accrual.ReportType.by_name(arg.upper()).value is expected
|
||||||
|
|
||||||
@pytest.mark.parametrize('arg', [
|
@pytest.mark.parametrize('arg', [
|
||||||
'unknown',
|
'unknown',
|
||||||
|
@ -260,8 +260,8 @@ def run_outgoing(invoice, postings, rt_client=None):
|
||||||
postings = relate_accruals_by_meta(postings, invoice)
|
postings = relate_accruals_by_meta(postings, invoice)
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
errors = io.StringIO()
|
errors = io.StringIO()
|
||||||
rt_cache = rtutil.RT(rt_client)
|
report = accrual.OutgoingReport(rt_client, output, errors)
|
||||||
accrual.outgoing_report({invoice: postings}, output, errors, rt_client, rt_cache)
|
report.run({invoice: postings})
|
||||||
return output, errors
|
return output, errors
|
||||||
|
|
||||||
@pytest.mark.parametrize('invoice,expected', [
|
@pytest.mark.parametrize('invoice,expected', [
|
||||||
|
@ -273,7 +273,10 @@ def run_outgoing(invoice, postings, rt_client=None):
|
||||||
def test_balance_report(accrual_postings, invoice, expected):
|
def test_balance_report(accrual_postings, invoice, expected):
|
||||||
related = relate_accruals_by_meta(accrual_postings, invoice)
|
related = relate_accruals_by_meta(accrual_postings, invoice)
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
accrual.balance_report({invoice: related}, output)
|
errors = io.StringIO()
|
||||||
|
report = accrual.BalanceReport(output, errors)
|
||||||
|
report.run({invoice: related})
|
||||||
|
assert not errors.getvalue()
|
||||||
check_output(output, [invoice, expected])
|
check_output(output, [invoice, expected])
|
||||||
|
|
||||||
def test_outgoing_report(accrual_postings):
|
def test_outgoing_report(accrual_postings):
|
||||||
|
|
Loading…
Reference in a new issue