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,
|
Set,
|
||||||
TextIO,
|
TextIO,
|
||||||
Tuple,
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
from ..beancount_types import (
|
from ..beancount_types import (
|
||||||
|
@ -121,6 +122,7 @@ from .. import rtutil
|
||||||
PROGNAME = 'accrual-report'
|
PROGNAME = 'accrual-report'
|
||||||
STANDARD_PATH = Path('-')
|
STANDARD_PATH = Path('-')
|
||||||
|
|
||||||
|
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
|
||||||
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
|
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
|
||||||
RTObject = Mapping[str, str]
|
RTObject = Mapping[str, str]
|
||||||
|
|
||||||
|
@ -132,7 +134,7 @@ class Sentinel:
|
||||||
|
|
||||||
class Account(NamedTuple):
|
class Account(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
norm_func: Callable[[core.Balance], core.Balance]
|
norm_func: Callable[[CompoundAmount], CompoundAmount]
|
||||||
aging_thresholds: Sequence[int]
|
aging_thresholds: Sequence[int]
|
||||||
|
|
||||||
|
|
||||||
|
@ -175,21 +177,19 @@ class AccrualPostings(core.RelatedPostings):
|
||||||
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
|
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
|
||||||
'account': operator.attrgetter('account'),
|
'account': operator.attrgetter('account'),
|
||||||
'contract': _meta_getter('contract'),
|
'contract': _meta_getter('contract'),
|
||||||
'entity': _meta_getter('entity'),
|
|
||||||
'invoice': _meta_getter('invoice'),
|
'invoice': _meta_getter('invoice'),
|
||||||
'purchase_order': _meta_getter('purchase-order'),
|
'purchase_order': _meta_getter('purchase-order'),
|
||||||
}
|
}
|
||||||
INCONSISTENT = Sentinel()
|
INCONSISTENT = Sentinel()
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'accrual_type',
|
'accrual_type',
|
||||||
|
'accrued_entities',
|
||||||
'end_balance',
|
'end_balance',
|
||||||
|
'paid_entities',
|
||||||
'account',
|
'account',
|
||||||
'accounts',
|
'accounts',
|
||||||
'contract',
|
'contract',
|
||||||
'contracts',
|
'contracts',
|
||||||
'entity',
|
|
||||||
'entitys',
|
|
||||||
'entities',
|
|
||||||
'invoice',
|
'invoice',
|
||||||
'invoices',
|
'invoices',
|
||||||
'purchase_order',
|
'purchase_order',
|
||||||
|
@ -205,8 +205,6 @@ class AccrualPostings(core.RelatedPostings):
|
||||||
# The following type declarations tell mypy about values set in the for
|
# The following type declarations tell mypy about values set in the for
|
||||||
# loop that are important enough to be referenced directly elsewhere.
|
# loop that are important enough to be referenced directly elsewhere.
|
||||||
self.account: Union[data.Account, Sentinel]
|
self.account: Union[data.Account, Sentinel]
|
||||||
self.entity: Union[MetaValue, Sentinel]
|
|
||||||
self.entitys: FrozenSet[MetaValue]
|
|
||||||
self.invoice: Union[MetaValue, Sentinel]
|
self.invoice: Union[MetaValue, Sentinel]
|
||||||
for name, get_func in self._FIELDS.items():
|
for name, get_func in self._FIELDS.items():
|
||||||
values = frozenset(get_func(post) for post in self)
|
values = frozenset(get_func(post) for post in self)
|
||||||
|
@ -216,14 +214,34 @@ class AccrualPostings(core.RelatedPostings):
|
||||||
else:
|
else:
|
||||||
one_value = self.INCONSISTENT
|
one_value = self.INCONSISTENT
|
||||||
setattr(self, name, one_value)
|
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:
|
if self.account is self.INCONSISTENT:
|
||||||
self.accrual_type: Optional[AccrualAccount] = None
|
self.accrual_type: Optional[AccrualAccount] = None
|
||||||
self.end_balance = self.balance_at_cost()
|
self.end_balance = self.balance_at_cost()
|
||||||
|
self.accrued_entities = self._collect_entities()
|
||||||
|
self.paid_entities = self.accrued_entities
|
||||||
else:
|
else:
|
||||||
self.accrual_type = AccrualAccount.classify(self)
|
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']]:
|
def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
|
||||||
account_ok = isinstance(self.account, str)
|
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)
|
amount_cell = self.balance_cell(raw_balance)
|
||||||
self.add_row(
|
self.add_row(
|
||||||
self.date_cell(row[0].meta.date),
|
self.date_cell(row[0].meta.date),
|
||||||
self.multiline_cell(row.entities),
|
self.multiline_cell(row.entities()),
|
||||||
amount_cell,
|
amount_cell,
|
||||||
self.balance_cell(row.end_balance),
|
self.balance_cell(row.end_balance),
|
||||||
self.multilink_cell(self._link_seq(row, 'rt-id')),
|
self.multilink_cell(self._link_seq(row, 'rt-id')),
|
||||||
|
@ -464,7 +482,7 @@ class AgingReport(BaseReport):
|
||||||
rows.sort(key=lambda related: (
|
rows.sort(key=lambda related: (
|
||||||
related.account,
|
related.account,
|
||||||
related[0].meta.date,
|
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.write(rows)
|
||||||
self.ods.save_file(self.out_bin)
|
self.ods.save_file(self.out_bin)
|
||||||
|
|
|
@ -30,6 +30,27 @@
|
||||||
Liabilities:Payable:Accounts -75 USD
|
Liabilities:Payable:Accounts -75 USD
|
||||||
Expenses:Travel 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"
|
2010-04-30 ! "Vendor" "Travel reimbursement"
|
||||||
rt-id: "rt:310"
|
rt-id: "rt:310"
|
||||||
contract: "rt:310/3100"
|
contract: "rt:310/3100"
|
||||||
|
|
|
@ -63,7 +63,6 @@ ACCOUNTS = [
|
||||||
|
|
||||||
CONSISTENT_METADATA = [
|
CONSISTENT_METADATA = [
|
||||||
'contract',
|
'contract',
|
||||||
'entity',
|
|
||||||
'purchase-order',
|
'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, attr_name) == meta_value
|
||||||
assert getattr(related, f'{attr_name}s') == {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():
|
def test_accrual_postings_inconsistent_account():
|
||||||
meta = {'invoice': 'invoice.pdf'}
|
meta = {'invoice': 'invoice.pdf'}
|
||||||
txn = testutil.Transaction(postings=[
|
txn = testutil.Transaction(postings=[
|
||||||
|
@ -399,7 +431,7 @@ def test_consistency_check_when_consistent(meta_key, account):
|
||||||
assert not list(related.report_inconsistencies())
|
assert not list(related.report_inconsistencies())
|
||||||
|
|
||||||
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
|
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
|
||||||
['approval', 'fx-rate', 'statement'],
|
['approval', 'entity', 'fx-rate', 'statement'],
|
||||||
ACCOUNTS,
|
ACCOUNTS,
|
||||||
))
|
))
|
||||||
def test_consistency_check_ignored_metadata(meta_key, account):
|
def test_consistency_check_ignored_metadata(meta_key, account):
|
||||||
|
|
Loading…
Reference in a new issue