@@ -61,6 +61,8 @@ ACCOUNTS = [
'Liabilities:Payable:Vacation',
]
AGE_SUM_RE = re.compile(r'(?:\b(\d+) Years?)?(?: ?\b(\d+) Days?)?[–:]')
class AgingRow(NamedTuple):
date: datetime.date
entity: Sequence[str]
@@ -166,57 +168,59 @@ def find_row_by_text(row_source, want_text):
found_row = False
if found_row:
return row
return None
pytest.fail(f"did not find row with text {want_text!r}")
def check_age_sum(aging_rows, row, date):
text = row.firstChild.text
ages = [int(match.group(1) or 0) * 365 + int(match.group(2) or 0)
for match in AGE_SUM_RE.finditer(text)]
if len(ages) == 1:
# datetime only supports a 10K year range so this should cover all of it
if text.startswith('Total Aged Over '):
age_range = range(ages[0], 3650000)
else:
age_range = range(-3650000, ages[0])
elif len(ages) == 2:
age_range = range(*ages)
pytest.fail(f"row has incorrect age matches: {ages!r}")
assert row.lastChild.value == sum(
row.at_cost.number
for row in aging_rows
if row.at_cost.number > 0
and (date - row.date).days in age_range
)
return row.lastChild.value
def check_aging_sheet(sheet, aging_rows, date, accrue_date):
def check_aging_sheet(sheet, aging_rows, date):
if not aging_rows:
return
if isinstance(accrue_date, int):
accrue_date = date + datetime.timedelta(days=accrue_date)
rows = iter(sheet.getElementsByType(odf.table.TableRow))
for row in rows:
if "Aging Report" in row.text:
break
assert None, "Header row not found"
assert f"Accrued by {accrue_date.isoformat()}" in row.text
assert f"Unpaid by {date.isoformat()}" in row.text
expect_rows = iter(aging_rows)
row0 = find_row_by_text(rows, aging_rows[0].date.isoformat())
next(expect_rows).check_row_match(row0)
for actual, expected in zip(rows, expect_rows):
expected.check_row_match(actual)
row0 = find_row_by_text(rows, "Total Aged Over 1 Year: ")
aging_sum = check_age_sum(aging_rows, row0, date)
sums = 0
if row.text.startswith("Total Aged "):
assert None, "Totals rows not found"
actual_sum = Decimal(row.childNodes[-1].value)
actual_sum += Decimal(row.childNodes[-1].value)
if not row.firstChild:
pass
elif row.firstChild.text.startswith("Total Unpaid"):
assert row.lastChild.value == aging_sum
sums += 1
assert actual_sum == sum(
if row.date <= accrue_date
and row.at_cost.number > 0
aging_sum += check_age_sum(aging_rows, row, date)
assert sums > 1
def check_aging_ods(ods_file,
date=None,
recv_rows=AGING_AR,
pay_rows=AGING_AP,
):
if date is None:
date = datetime.date.today()
def check_aging_ods(ods_file, date, recv_rows=AGING_AR, pay_rows=AGING_AP):
ods_file.seek(0)
ods = odf.opendocument.load(ods_file)
sheets = ods.spreadsheet.getElementsByType(odf.table.Table)
assert len(sheets) == 2
check_aging_sheet(sheets[0], recv_rows, date, -60)
check_aging_sheet(sheets[1], pay_rows, date, -30)
check_aging_sheet(sheets[0], recv_rows, date)
check_aging_sheet(sheets[1], pay_rows, date)
@pytest.mark.parametrize('search_terms,expect_count,check_func', [
([], ACCRUALS_COUNT, lambda post: post.account.is_under(
@@ -576,9 +580,7 @@ def test_outgoing_report_without_rt_id(accrual_postings, caplog):
assert not output.getvalue()
def run_aging_report(postings, today=None):
if today is None:
today = datetime.date.today()
def run_aging_report(postings, today):
postings = (
post for post in postings
if post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
@@ -590,48 +592,35 @@ def run_aging_report(postings, today=None):
report.run(groups)
return output
def test_aging_report(accrual_postings):
output = run_aging_report(accrual_postings)
check_aging_ods(output)
@pytest.mark.parametrize('date,recv_end,pay_end', [
@pytest.mark.parametrize('date', [
datetime.date(2010, 3, 1),
# Both these dates are chosen for their off-by-one potential:
# the first is exactly 30 days after the 2010-06-10 payable;
# the second is exactly 60 days after the 2010-05-15 receivable.
(datetime.date(2010, 7, 10), 1, 5),
(datetime.date(2010, 7, 14), 2, 5),
datetime.date(2010, 7, 10),
datetime.date(2010, 7, 14),
# The remainder just shuffle the age buckets some.
datetime.date(2010, 12, 1),
datetime.date(2011, 6, 1),
datetime.date(2011, 12, 1),
datetime.date(2012, 3, 1),
])
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end):
expect_recv = AGING_AR[:recv_end]
expect_pay = AGING_AP[:pay_end]
def test_aging_report_date_cutoffs(accrual_postings, date):
output = run_aging_report(accrual_postings, date)
check_aging_ods(output, date, expect_recv, expect_pay)
check_aging_ods(output, date)
def test_aging_report_entity_consistency(accrual_postings):
output = run_aging_report((
post for post in accrual_postings
if post.meta.get('rt-id') == 'rt:480'
and post.units.number < 0
))
check_aging_ods(output, None, [], [
), date)
check_aging_ods(output, date, [], [
AgingRow.make_simple('2010-04-15', 'MultiPartyA', 125, 'rt:480/4800'),
AgingRow.make_simple('2010-04-15', 'MultiPartyB', 125, 'rt:480/4800'),
def test_aging_report_does_not_include_too_recent_postings(accrual_postings):
# This date is after the Q3 posting, but too soon after for that to be
# included in the aging report.
date = datetime.date(2010, 10, 1)
if post.meta.get('rt-id') == 'rt:470'
# Date+amount are both from the Q2 posting only.
check_aging_ods(output, date, [
AgingRow.make_simple('2010-06-15', 'GrantCo', 5500, 'rt:470/4700',
project='Development Grant'),
], [])
def run_main(arglist, config=None, out_type=io.StringIO):
if config is None:
config = testutil.TestConfig(
@@ -735,7 +724,7 @@ def test_main_aging_report(arglist):
retcode, output, errors = run_main(arglist, out_type=io.BytesIO)
assert not errors.getvalue()
assert retcode == 0
check_aging_ods(output, None, recv_rows, pay_rows)
check_aging_ods(output, datetime.date.today(), recv_rows, pay_rows)
def test_main_no_books():
errors = check_main_fails([], testutil.TestConfig(), 1 | 8)