accrual: Add AccrualPostings.make_consistent() method.

This will help the aging report better render dirty data.
This commit is contained in:
Brett Smith 2020-06-02 10:45:22 -04:00
parent b37d7a3024
commit 7301bfc099
2 changed files with 83 additions and 1 deletions

View file

@ -157,7 +157,6 @@ class AccrualPostings(core.RelatedPostings):
'invoice': _meta_getter('invoice'),
'purchase_order': _meta_getter('purchase-order'),
}
_INVOICE_COUNTER: Dict[str, int] = collections.defaultdict(int)
INCONSISTENT = Sentinel()
__slots__ = (
'accrual_type',
@ -202,6 +201,26 @@ class AccrualPostings(core.RelatedPostings):
else:
self.accrual_type = AccrualAccount.classify(self)
def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
account_ok = isinstance(self.account, str)
# `'/' 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
if account_ok and invoice_ok:
yield (self.invoice, self)
return
groups = collections.defaultdict(list)
for post in self:
if invoice_ok:
key = f'{self.invoice} {post.account}'
else:
key = f'{post.account} {post.meta.get("entity")} {post.meta.get("invoice")}'
groups[key].append(post)
type_self = type(self)
for group_key, posts in groups.items():
yield group_key, type_self(posts, _can_own=True)
def report_inconsistencies(self) -> Iterable[Error]:
for field_name, get_func in self._FIELDS.items():
if getattr(self, field_name) is self.INCONSISTENT:

View file

@ -16,6 +16,7 @@
import collections
import copy
import datetime
import io
import itertools
import logging
@ -349,6 +350,68 @@ def test_consistency_check_cost():
assert err.entry is txn
assert err.source.get('lineno') == post.meta['lineno']
def test_make_consistent_not_needed():
invoice = 'Invoices/ConsistentDoc.pdf'
other_meta = {key: f'{key}.pdf' for key in CONSISTENT_METADATA}
# We intentionally make inconsistencies in "minor" metadata that shouldn't
# split out the group.
txn = testutil.Transaction(postings=[
(ACCOUNTS[0], 20, {**other_meta, 'invoice': invoice}),
(ACCOUNTS[0], 25, {'invoice': invoice}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
consistent = related.make_consistent()
actual_key, actual_postings = next(consistent)
assert actual_key == invoice
assert actual_postings is related
assert next(consistent, None) is None
@pytest.mark.parametrize('acct_name,invoice,day', testutil.combine_values(
ACCOUNTS,
['FIXME', '', None, *testutil.NON_STRING_METADATA_VALUES],
itertools.count(1),
))
def test_make_consistent_bad_invoice(acct_name, invoice, day):
txn = testutil.Transaction(date=datetime.date(2019, 1, day), postings=[
(acct_name, index * 10, {'invoice': invoice})
for index in range(1, 4)
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
consistent = dict(related.make_consistent())
assert len(consistent) == 1
actual = consistent.get(f'{acct_name} None {invoice}')
assert actual
assert len(actual) == 3
for act_post, exp_post in zip(actual, txn.postings):
assert act_post.units == exp_post.units
assert act_post.meta.get('invoice') == invoice
def test_make_consistent_across_accounts():
invoice = 'Invoices/CrossAccount.pdf'
txn = testutil.Transaction(date=datetime.date(2019, 2, 1), postings=[
(acct_name, 100, {'invoice': invoice})
for acct_name in ACCOUNTS
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
consistent = dict(related.make_consistent())
assert len(consistent) == len(ACCOUNTS)
for acct_name in ACCOUNTS:
actual = consistent[f'{invoice} {acct_name}']
assert len(actual) == 1
assert actual[0].account == acct_name
def test_make_consistent_both_invoice_and_account():
txn = testutil.Transaction(date=datetime.date(2019, 2, 2), postings=[
(acct_name, 150) for acct_name in ACCOUNTS
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
consistent = dict(related.make_consistent())
assert len(consistent) == len(ACCOUNTS)
for acct_name in ACCOUNTS:
actual = consistent[f'{acct_name} None None']
assert len(actual) == 1
assert actual[0].account == acct_name
def check_output(output, expect_patterns):
output.seek(0)
testutil.check_lines_match(iter(output), expect_patterns)