accrual: Inconsistent entity is not an error.
This commit is contained in:
parent
39fa977f71
commit
0b3eb1d1d3
3 changed files with 85 additions and 14 deletions
|
@ -94,6 +94,7 @@ from typing import (
|
|||
Set,
|
||||
TextIO,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
from ..beancount_types import (
|
||||
|
@ -121,6 +122,7 @@ from .. import rtutil
|
|||
PROGNAME = 'accrual-report'
|
||||
STANDARD_PATH = Path('-')
|
||||
|
||||
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
|
||||
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
|
||||
RTObject = Mapping[str, str]
|
||||
|
||||
|
@ -132,7 +134,7 @@ class Sentinel:
|
|||
|
||||
class Account(NamedTuple):
|
||||
name: str
|
||||
norm_func: Callable[[core.Balance], core.Balance]
|
||||
norm_func: Callable[[CompoundAmount], CompoundAmount]
|
||||
aging_thresholds: Sequence[int]
|
||||
|
||||
|
||||
|
@ -175,21 +177,19 @@ class AccrualPostings(core.RelatedPostings):
|
|||
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
|
||||
'account': operator.attrgetter('account'),
|
||||
'contract': _meta_getter('contract'),
|
||||
'entity': _meta_getter('entity'),
|
||||
'invoice': _meta_getter('invoice'),
|
||||
'purchase_order': _meta_getter('purchase-order'),
|
||||
}
|
||||
INCONSISTENT = Sentinel()
|
||||
__slots__ = (
|
||||
'accrual_type',
|
||||
'accrued_entities',
|
||||
'end_balance',
|
||||
'paid_entities',
|
||||
'account',
|
||||
'accounts',
|
||||
'contract',
|
||||
'contracts',
|
||||
'entity',
|
||||
'entitys',
|
||||
'entities',
|
||||
'invoice',
|
||||
'invoices',
|
||||
'purchase_order',
|
||||
|
@ -205,8 +205,6 @@ class AccrualPostings(core.RelatedPostings):
|
|||
# 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.entity: Union[MetaValue, Sentinel]
|
||||
self.entitys: FrozenSet[MetaValue]
|
||||
self.invoice: Union[MetaValue, Sentinel]
|
||||
for name, get_func in self._FIELDS.items():
|
||||
values = frozenset(get_func(post) for post in self)
|
||||
|
@ -216,14 +214,34 @@ class AccrualPostings(core.RelatedPostings):
|
|||
else:
|
||||
one_value = self.INCONSISTENT
|
||||
setattr(self, name, one_value)
|
||||
# Correct spelling = bug prevention for future users of this class.
|
||||
self.entities = self.entitys
|
||||
if self.account is self.INCONSISTENT:
|
||||
self.accrual_type: Optional[AccrualAccount] = None
|
||||
self.end_balance = self.balance_at_cost()
|
||||
self.accrued_entities = self._collect_entities()
|
||||
self.paid_entities = self.accrued_entities
|
||||
else:
|
||||
self.accrual_type = AccrualAccount.classify(self)
|
||||
self.end_balance = self.accrual_type.value.norm_func(self.balance_at_cost())
|
||||
norm_func = self.accrual_type.value.norm_func
|
||||
self.end_balance = norm_func(self.balance_at_cost())
|
||||
self.accrued_entities = self._collect_entities(
|
||||
lambda post: norm_func(post.units).number > 0, # type:ignore[no-any-return]
|
||||
)
|
||||
self.paid_entities = self._collect_entities(
|
||||
lambda post: norm_func(post.units).number < 0, # type:ignore[no-any-return]
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
|
||||
account_ok = isinstance(self.account, str)
|
||||
|
@ -439,7 +457,7 @@ class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
|
|||
amount_cell = self.balance_cell(raw_balance)
|
||||
self.add_row(
|
||||
self.date_cell(row[0].meta.date),
|
||||
self.multiline_cell(row.entities),
|
||||
self.multiline_cell(row.entities()),
|
||||
amount_cell,
|
||||
self.balance_cell(row.end_balance),
|
||||
self.multilink_cell(self._link_seq(row, 'rt-id')),
|
||||
|
@ -464,7 +482,7 @@ class AgingReport(BaseReport):
|
|||
rows.sort(key=lambda related: (
|
||||
related.account,
|
||||
related[0].meta.date,
|
||||
min(related.entities) if related.entities else '',
|
||||
min(related.entities()) if related.accrued_entities else '',
|
||||
))
|
||||
self.ods.write(rows)
|
||||
self.ods.save_file(self.out_bin)
|
||||
|
|
|
@ -30,6 +30,27 @@
|
|||
Liabilities:Payable:Accounts -75 USD
|
||||
Expenses:Travel 75 USD
|
||||
|
||||
2010-04-15 * "Multiparty invoice"
|
||||
rt-id: "rt:480"
|
||||
invoice: "rt:480/4800"
|
||||
Expenses:Travel 250 USD
|
||||
Liabilities:Payable:Accounts -125 USD
|
||||
entity: "MultiPartyA"
|
||||
Liabilities:Payable:Accounts -125 USD
|
||||
entity: "MultiPartyB"
|
||||
|
||||
2010-04-18 * "MultiPartyA" "Payment for 480"
|
||||
rt-id: "rt:480"
|
||||
invoice: "rt:480/4800"
|
||||
Liabilities:Payable:Accounts 125 USD
|
||||
Assets:Checking -125 USD
|
||||
|
||||
2010-04-20 * "MultiPartyB" "Payment for 480"
|
||||
rt-id: "rt:480"
|
||||
invoice: "rt:480/4800"
|
||||
Liabilities:Payable:Accounts 125 USD
|
||||
Assets:Checking -125 USD
|
||||
|
||||
2010-04-30 ! "Vendor" "Travel reimbursement"
|
||||
rt-id: "rt:310"
|
||||
contract: "rt:310/3100"
|
||||
|
|
|
@ -63,7 +63,6 @@ ACCOUNTS = [
|
|||
|
||||
CONSISTENT_METADATA = [
|
||||
'contract',
|
||||
'entity',
|
||||
'purchase-order',
|
||||
]
|
||||
|
||||
|
@ -354,6 +353,39 @@ def test_accrual_postings_consistent_metadata(meta_key, acct_name):
|
|||
assert getattr(related, attr_name) == meta_value
|
||||
assert getattr(related, f'{attr_name}s') == {meta_value}
|
||||
|
||||
def test_accrual_postings_entity():
|
||||
txn = testutil.Transaction(postings=[
|
||||
(ACCOUNTS[0], 25, {'entity': 'Accruee'}),
|
||||
(ACCOUNTS[0], -15, {'entity': 'Payee15'}),
|
||||
(ACCOUNTS[0], -10, {'entity': 'Payee10'}),
|
||||
])
|
||||
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
|
||||
assert related.accrued_entities == {'Accruee'}
|
||||
assert related.paid_entities == {'Payee10', 'Payee15'}
|
||||
|
||||
def test_accrual_postings_entities():
|
||||
txn = testutil.Transaction(postings=[
|
||||
(ACCOUNTS[0], 25, {'entity': 'Accruee'}),
|
||||
(ACCOUNTS[0], -15, {'entity': 'Payee15'}),
|
||||
(ACCOUNTS[0], -10, {'entity': 'Payee10'}),
|
||||
])
|
||||
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
|
||||
actual = related.entities()
|
||||
assert next(actual, None) == 'Accruee'
|
||||
assert set(actual) == {'Payee10', 'Payee15'}
|
||||
|
||||
def test_accrual_postings_entities_no_duplicates():
|
||||
txn = testutil.Transaction(postings=[
|
||||
(ACCOUNTS[0], 25, {'entity': 'Accruee'}),
|
||||
(ACCOUNTS[0], -15, {'entity': 'Accruee'}),
|
||||
(ACCOUNTS[0], -10, {'entity': 'Other'}),
|
||||
])
|
||||
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
|
||||
actual = related.entities()
|
||||
assert next(actual, None) == 'Accruee'
|
||||
assert next(actual, None) == 'Other'
|
||||
assert next(actual, None) is None
|
||||
|
||||
def test_accrual_postings_inconsistent_account():
|
||||
meta = {'invoice': 'invoice.pdf'}
|
||||
txn = testutil.Transaction(postings=[
|
||||
|
@ -399,7 +431,7 @@ def test_consistency_check_when_consistent(meta_key, account):
|
|||
assert not list(related.report_inconsistencies())
|
||||
|
||||
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
|
||||
['approval', 'fx-rate', 'statement'],
|
||||
['approval', 'entity', 'fx-rate', 'statement'],
|
||||
ACCOUNTS,
|
||||
))
|
||||
def test_consistency_check_ignored_metadata(meta_key, account):
|
||||
|
|
Loading…
Reference in a new issue