accrual: Add AccrualPostings.make_consistent() method.
This will help the aging report better render dirty data.
This commit is contained in:
parent
b37d7a3024
commit
7301bfc099
2 changed files with 83 additions and 1 deletions
|
@ -157,7 +157,6 @@ class AccrualPostings(core.RelatedPostings):
|
||||||
'invoice': _meta_getter('invoice'),
|
'invoice': _meta_getter('invoice'),
|
||||||
'purchase_order': _meta_getter('purchase-order'),
|
'purchase_order': _meta_getter('purchase-order'),
|
||||||
}
|
}
|
||||||
_INVOICE_COUNTER: Dict[str, int] = collections.defaultdict(int)
|
|
||||||
INCONSISTENT = Sentinel()
|
INCONSISTENT = Sentinel()
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'accrual_type',
|
'accrual_type',
|
||||||
|
@ -202,6 +201,26 @@ class AccrualPostings(core.RelatedPostings):
|
||||||
else:
|
else:
|
||||||
self.accrual_type = AccrualAccount.classify(self)
|
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]:
|
def report_inconsistencies(self) -> Iterable[Error]:
|
||||||
for field_name, get_func in self._FIELDS.items():
|
for field_name, get_func in self._FIELDS.items():
|
||||||
if getattr(self, field_name) is self.INCONSISTENT:
|
if getattr(self, field_name) is self.INCONSISTENT:
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import copy
|
import copy
|
||||||
|
import datetime
|
||||||
import io
|
import io
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
@ -349,6 +350,68 @@ def test_consistency_check_cost():
|
||||||
assert err.entry is txn
|
assert err.entry is txn
|
||||||
assert err.source.get('lineno') == post.meta['lineno']
|
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):
|
def check_output(output, expect_patterns):
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
testutil.check_lines_match(iter(output), expect_patterns)
|
testutil.check_lines_match(iter(output), expect_patterns)
|
||||||
|
|
Loading…
Reference in a new issue