reports: Add PeriodPostings class.
This is AccountPostings from the ledger report, cleaned up to be more general.
This commit is contained in:
parent
7a9bc2da50
commit
6213bc1e5d
3 changed files with 126 additions and 20 deletions
|
@ -393,6 +393,57 @@ class RelatedPostings(Sequence[data.Posting]):
|
||||||
return {post.meta.get(key, default) for post in self}
|
return {post.meta.get(key, default) for post in self}
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodPostings(RelatedPostings):
|
||||||
|
"""Postings filtered and balanced over a date range
|
||||||
|
|
||||||
|
Create a subclass with ``PeriodPostings.with_start_date(date)``.
|
||||||
|
Note that there is no explicit stop date. The expectation is that the
|
||||||
|
caller has already filtered out posts past the stop date from the input.
|
||||||
|
|
||||||
|
Instances of that subclass will have three Balance attributes:
|
||||||
|
|
||||||
|
* ``start_bal`` is the balance at cost of postings to your start date
|
||||||
|
* ``period_bal`` is the balance at cost of postings from your start date
|
||||||
|
* ``stop_bal`` is the balance at cost of all postings
|
||||||
|
|
||||||
|
Use this subclass when your report includes a lot of balances over time to
|
||||||
|
help you get the math right.
|
||||||
|
"""
|
||||||
|
__slots__ = (
|
||||||
|
'begin_bal',
|
||||||
|
'end_bal',
|
||||||
|
'period_bal',
|
||||||
|
'start_bal',
|
||||||
|
'stop_bal',
|
||||||
|
)
|
||||||
|
START_DATE = datetime.date(datetime.MINYEAR, 1, 1)
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
source: Iterable[data.Posting]=(),
|
||||||
|
*,
|
||||||
|
_can_own: bool=False,
|
||||||
|
) -> None:
|
||||||
|
start_posts: List[data.Posting] = []
|
||||||
|
period_posts: List[data.Posting] = []
|
||||||
|
for post in source:
|
||||||
|
if post.meta.date < self.START_DATE:
|
||||||
|
start_posts.append(post)
|
||||||
|
else:
|
||||||
|
period_posts.append(post)
|
||||||
|
super().__init__(period_posts, _can_own=True)
|
||||||
|
self.start_bal = RelatedPostings(start_posts, _can_own=True).balance_at_cost()
|
||||||
|
self.period_bal = self.balance_at_cost()
|
||||||
|
self.stop_bal = self.start_bal + self.period_bal
|
||||||
|
# Convenience aliases
|
||||||
|
self.begin_bal = self.start_bal
|
||||||
|
self.end_bal = self.stop_bal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def with_start_date(cls: Type[RelatedType], start_date: datetime.date) -> Type[RelatedType]:
|
||||||
|
name = f'BalancePostings{start_date.strftime("%Y%m%d")}'
|
||||||
|
return type(name, (cls,), {'START_DATE': start_date})
|
||||||
|
|
||||||
|
|
||||||
class BaseSpreadsheet(Generic[RT, ST], metaclass=abc.ABCMeta):
|
class BaseSpreadsheet(Generic[RT, ST], metaclass=abc.ABCMeta):
|
||||||
"""Abstract base class to help write spreadsheets
|
"""Abstract base class to help write spreadsheets
|
||||||
|
|
||||||
|
|
|
@ -85,20 +85,6 @@ PostTally = List[Tuple[int, data.Account]]
|
||||||
PROGNAME = 'ledger-report'
|
PROGNAME = 'ledger-report'
|
||||||
logger = logging.getLogger('conservancy_beancount.reports.ledger')
|
logger = logging.getLogger('conservancy_beancount.reports.ledger')
|
||||||
|
|
||||||
class AccountPostings(core.RelatedPostings):
|
|
||||||
START_DATE: datetime.date
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
source: Iterable[data.Posting]=(),
|
|
||||||
*,
|
|
||||||
_can_own: bool=False,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(source, _can_own=_can_own)
|
|
||||||
self.start_bal = self.balance_at_cost_by_date(self.START_DATE)
|
|
||||||
self.stop_bal = self.balance_at_cost()
|
|
||||||
self.period_bal = self.stop_bal - self.start_bal
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
CORE_COLUMNS: Sequence[str] = [
|
CORE_COLUMNS: Sequence[str] = [
|
||||||
'Date',
|
'Date',
|
||||||
|
@ -310,9 +296,7 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
self._report_section_balance(key, 'stop')
|
self._report_section_balance(key, 'stop')
|
||||||
|
|
||||||
def write_row(self, row: data.Posting) -> None:
|
def write_row(self, row: data.Posting) -> None:
|
||||||
if row.meta.date not in self.date_range:
|
if row.cost is None:
|
||||||
return
|
|
||||||
elif row.cost is None:
|
|
||||||
amount_cell = odf.table.TableCell()
|
amount_cell = odf.table.TableCell()
|
||||||
else:
|
else:
|
||||||
amount_cell = self.currency_cell(self.norm_func(row.units))
|
amount_cell = self.currency_cell(self.norm_func(row.units))
|
||||||
|
@ -378,13 +362,13 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
self._combined_balance_row(balance_accounts, 'stop')
|
self._combined_balance_row(balance_accounts, 'stop')
|
||||||
|
|
||||||
def write(self, rows: Iterable[data.Posting]) -> None:
|
def write(self, rows: Iterable[data.Posting]) -> None:
|
||||||
AccountPostings.START_DATE = self.date_range.start
|
related_cls = core.PeriodPostings.with_start_date(self.date_range.start)
|
||||||
self.account_groups = dict(AccountPostings.group_by_account(
|
self.account_groups = dict(related_cls.group_by_account(
|
||||||
post for post in rows if post.meta.date < self.date_range.stop
|
post for post in rows if post.meta.date < self.date_range.stop
|
||||||
))
|
))
|
||||||
self.write_balance_sheet()
|
self.write_balance_sheet()
|
||||||
tally_by_account_iter = (
|
tally_by_account_iter = (
|
||||||
(account, sum(1 for post in related if post.meta.date in self.date_range))
|
(account, len(related))
|
||||||
for account, related in self.account_groups.items()
|
for account, related in self.account_groups.items()
|
||||||
)
|
)
|
||||||
tally_by_account = {
|
tally_by_account = {
|
||||||
|
|
71
tests/test_reports_period_postings.py
Normal file
71
tests/test_reports_period_postings.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
"""test_reports_period_postings - Unit tests for PeriodPostings"""
|
||||||
|
# Copyright © 2020 Brett Smith
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
from conservancy_beancount import data
|
||||||
|
from conservancy_beancount.reports import core
|
||||||
|
|
||||||
|
def check_balance_attrs(balance_postings, start_usd, stop_usd):
|
||||||
|
if start_usd:
|
||||||
|
expected = {'USD': testutil.Amount(start_usd)}
|
||||||
|
assert balance_postings.start_bal == expected
|
||||||
|
assert balance_postings.begin_bal == expected
|
||||||
|
else:
|
||||||
|
assert balance_postings.start_bal.is_zero()
|
||||||
|
assert balance_postings.begin_bal.is_zero()
|
||||||
|
expected = {'USD': testutil.Amount(stop_usd)}
|
||||||
|
assert balance_postings.stop_bal == expected
|
||||||
|
assert balance_postings.end_bal == expected
|
||||||
|
expected = {'USD': testutil.Amount(stop_usd - start_usd)}
|
||||||
|
assert balance_postings.period_bal == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('start_date,expect_start_bal', [
|
||||||
|
(datetime.date(2019, 2, 1), 0),
|
||||||
|
(datetime.date(2019, 4, 1), 30),
|
||||||
|
(datetime.date(2019, 6, 1), 120),
|
||||||
|
])
|
||||||
|
def test_balance_postings_attrs(start_date, expect_start_bal):
|
||||||
|
entries = [testutil.Transaction(date=datetime.date(2019, n, 15), postings=[
|
||||||
|
('Income:Donations', -n * 10),
|
||||||
|
('Assets:Cash', n * 10),
|
||||||
|
]) for n in range(3, 7)]
|
||||||
|
cls = core.PeriodPostings.with_start_date(start_date)
|
||||||
|
actual = dict(cls.group_by_account(data.Posting.from_entries(entries)))
|
||||||
|
assert len(actual) == 2
|
||||||
|
check_balance_attrs(actual['Assets:Cash'], expect_start_bal, 180)
|
||||||
|
check_balance_attrs(actual['Income:Donations'], -expect_start_bal, -180)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('start_date,expect_count', [
|
||||||
|
(datetime.date(2019, 2, 1), 4),
|
||||||
|
(datetime.date(2019, 4, 1), 3),
|
||||||
|
(datetime.date(2019, 6, 1), 1),
|
||||||
|
])
|
||||||
|
def test_balance_postings_filter(start_date, expect_count):
|
||||||
|
entries = [testutil.Transaction(date=datetime.date(2019, n, 15), postings=[
|
||||||
|
('Income:Donations', -n * 10),
|
||||||
|
('Assets:Cash', n * 10),
|
||||||
|
]) for n in range(3, 7)]
|
||||||
|
cls = core.PeriodPostings.with_start_date(start_date)
|
||||||
|
for _, related in cls.group_by_account(data.Posting.from_entries(entries)):
|
||||||
|
assert len(related) == expect_count
|
Loading…
Reference in a new issue