accrual: Outgoing report groups by rt-id. RT#11594.
This commit is contained in:
parent
8d7a2b1eea
commit
175ac3bd7a
4 changed files with 78 additions and 56 deletions
|
@ -507,11 +507,12 @@ class OutgoingReport(BaseReport):
|
||||||
self.rt_wrapper = rtutil.RT(rt_client)
|
self.rt_wrapper = rtutil.RT(rt_client)
|
||||||
|
|
||||||
def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds:
|
def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds:
|
||||||
rt_ids = list(posts.first_meta_links('rt-id'))
|
rt_id = posts.rt_id
|
||||||
rt_ids_count = len(rt_ids)
|
if rt_id is None:
|
||||||
if rt_ids_count != 1:
|
raise ValueError("no rt-id links found")
|
||||||
raise ValueError(f"{rt_ids_count} rt-id links found")
|
elif isinstance(rt_id, Sentinel):
|
||||||
parsed = rtutil.RT.parse(rt_ids.pop())
|
raise ValueError("multiple rt-id links found")
|
||||||
|
parsed = rtutil.RT.parse(rt_id)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
raise ValueError("rt-id is not a valid RT reference")
|
raise ValueError("rt-id is not a valid RT reference")
|
||||||
else:
|
else:
|
||||||
|
@ -598,20 +599,10 @@ class ReportType(enum.Enum):
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError(f"unknown report type {name!r}") from None
|
raise ValueError(f"unknown report type {name!r}") from None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def default_for(cls, groups: PostGroups) -> 'ReportType':
|
|
||||||
if len(groups) == 1 and all(
|
|
||||||
group.accrual_type is AccrualAccount.PAYABLE
|
|
||||||
and not group.is_paid()
|
|
||||||
for group in groups.values()
|
|
||||||
):
|
|
||||||
return cls.OUTGOING
|
|
||||||
else:
|
|
||||||
return cls.BALANCE
|
|
||||||
|
|
||||||
|
|
||||||
class ReturnFlag(enum.IntFlag):
|
class ReturnFlag(enum.IntFlag):
|
||||||
LOAD_ERRORS = 1
|
LOAD_ERRORS = 1
|
||||||
|
# 2 was used in the past, it can probably be reclaimed.
|
||||||
REPORT_ERRORS = 4
|
REPORT_ERRORS = 4
|
||||||
NOTHING_TO_REPORT = 8
|
NOTHING_TO_REPORT = 8
|
||||||
|
|
||||||
|
@ -687,6 +678,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
config = configmod.Config()
|
config = configmod.Config()
|
||||||
config.load_file()
|
config.load_file()
|
||||||
|
|
||||||
|
returncode = 0
|
||||||
books_loader = config.books_loader()
|
books_loader = config.books_loader()
|
||||||
if books_loader is None:
|
if books_loader is None:
|
||||||
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
||||||
|
@ -695,29 +687,37 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
else:
|
else:
|
||||||
entries, load_errors, _ = books_loader.load_all(args.since)
|
entries, load_errors, _ = books_loader.load_all(args.since)
|
||||||
filters.remove_opening_balance_txn(entries)
|
filters.remove_opening_balance_txn(entries)
|
||||||
|
|
||||||
returncode = 0
|
|
||||||
postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
|
|
||||||
groups: PostGroups = dict(AccrualPostings.group_by_first_meta_link(postings, 'invoice'))
|
|
||||||
for error in load_errors:
|
for error in load_errors:
|
||||||
bc_printer.print_error(error, file=stderr)
|
bc_printer.print_error(error, file=stderr)
|
||||||
returncode |= ReturnFlag.LOAD_ERRORS
|
returncode |= ReturnFlag.LOAD_ERRORS
|
||||||
if not groups:
|
|
||||||
|
postings = list(filter_search(
|
||||||
|
data.Posting.from_entries(entries), args.search_terms,
|
||||||
|
))
|
||||||
|
if not postings:
|
||||||
logger.warning("no matching entries found to report")
|
logger.warning("no matching entries found to report")
|
||||||
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
||||||
|
groups: PostGroups
|
||||||
|
if args.report_type is None or args.report_type is ReportType.OUTGOING:
|
||||||
|
groups = dict(AccrualPostings.group_by_first_meta_link(postings, 'rt-id'))
|
||||||
|
if (args.report_type is None
|
||||||
|
and len(groups) == 1
|
||||||
|
and all(g.accrual_type is AccrualAccount.PAYABLE and not g.is_paid()
|
||||||
|
for g in groups.values())
|
||||||
|
):
|
||||||
|
args.report_type = ReportType.OUTGOING
|
||||||
|
if args.report_type is not ReportType.OUTGOING:
|
||||||
groups = {
|
groups = {
|
||||||
key: posts
|
key: group
|
||||||
for source_posts in groups.values()
|
for _, source in AccrualPostings.group_by_first_meta_link(postings, 'invoice')
|
||||||
for key, posts in source_posts.make_consistent()
|
for key, group in source.make_consistent()
|
||||||
}
|
}
|
||||||
if args.report_type is not ReportType.AGING:
|
if args.report_type is not ReportType.AGING:
|
||||||
groups = {
|
groups = {
|
||||||
key: posts for key, posts in groups.items() if not posts.is_paid()
|
key: posts for key, posts in groups.items() if not posts.is_paid()
|
||||||
} or groups
|
} or groups
|
||||||
|
del postings
|
||||||
|
|
||||||
if args.report_type is None:
|
|
||||||
args.report_type = ReportType.default_for(groups)
|
|
||||||
report: Optional[BaseReport] = None
|
report: Optional[BaseReport] = None
|
||||||
output_path: Optional[Path] = None
|
output_path: Optional[Path] = None
|
||||||
if args.report_type is ReportType.AGING:
|
if args.report_type is ReportType.AGING:
|
||||||
|
@ -740,7 +740,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
||||||
report = OutgoingReport(rt_client, out_file)
|
report = OutgoingReport(rt_client, out_file)
|
||||||
else:
|
else:
|
||||||
out_file = cliutil.text_output(args.output_file, stdout)
|
out_file = cliutil.text_output(args.output_file, stdout)
|
||||||
report = args.report_type.value(out_file)
|
report = BalanceReport(out_file)
|
||||||
|
|
||||||
if report is None:
|
if report is None:
|
||||||
returncode |= ReturnFlag.REPORT_ERRORS
|
returncode |= ReturnFlag.REPORT_ERRORS
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='conservancy_beancount',
|
name='conservancy_beancount',
|
||||||
description="Plugin, library, and reports for reading Conservancy's books",
|
description="Plugin, library, and reports for reading Conservancy's books",
|
||||||
version='1.1.8',
|
version='1.1.9',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
|
|
@ -71,7 +71,7 @@
|
||||||
Liabilities:Payable:Accounts 125 USD
|
Liabilities:Payable:Accounts 125 USD
|
||||||
Assets:Checking -125 USD
|
Assets:Checking -125 USD
|
||||||
|
|
||||||
2010-04-30 ! "Vendor" "Travel reimbursement"
|
2010-04-25 ! "Vendor" "First trip travel reimbursement"
|
||||||
rt-id: "rt:310"
|
rt-id: "rt:310"
|
||||||
contract: "rt:310/3100"
|
contract: "rt:310/3100"
|
||||||
invoice: "FIXME" ; still waiting on them to send it
|
invoice: "FIXME" ; still waiting on them to send it
|
||||||
|
@ -79,6 +79,14 @@
|
||||||
Liabilities:Payable:Accounts -200 USD
|
Liabilities:Payable:Accounts -200 USD
|
||||||
Expenses:Travel 200 USD
|
Expenses:Travel 200 USD
|
||||||
|
|
||||||
|
2010-04-30 * "Vendor" "Second trip travel reimbursement"
|
||||||
|
rt-id: "rt:310"
|
||||||
|
contract: "rt:310/3100"
|
||||||
|
invoice: "rt:310/3120"
|
||||||
|
project: "Conservancy"
|
||||||
|
Liabilities:Payable:Accounts -220 USD
|
||||||
|
Expenses:Travel 220 USD
|
||||||
|
|
||||||
2010-05-05 * "DonorA" "Donation pledge"
|
2010-05-05 * "DonorA" "Donation pledge"
|
||||||
rt-id: "rt:505"
|
rt-id: "rt:505"
|
||||||
invoice: "rt:505/5050"
|
invoice: "rt:505/5050"
|
||||||
|
|
|
@ -106,7 +106,8 @@ class AgingRow(NamedTuple):
|
||||||
AGING_AP = [
|
AGING_AP = [
|
||||||
AgingRow.make_simple('2010-03-06', 'EarlyBird', -125, 'rt:44/440'),
|
AgingRow.make_simple('2010-03-06', 'EarlyBird', -125, 'rt:44/440'),
|
||||||
AgingRow.make_simple('2010-03-30', 'EarlyBird', 75, 'rt:490/4900'),
|
AgingRow.make_simple('2010-03-30', 'EarlyBird', 75, 'rt:490/4900'),
|
||||||
AgingRow.make_simple('2010-04-30', 'Vendor', 200, 'FIXME'),
|
AgingRow.make_simple('2010-04-25', 'Vendor', 200, 'FIXME'),
|
||||||
|
AgingRow.make_simple('2010-04-30', 'Vendor', 220, 'rt:310/3120'),
|
||||||
AgingRow.make_simple('2010-06-10', 'Lawyer', 280, 'rt:510/6100'),
|
AgingRow.make_simple('2010-06-10', 'Lawyer', 280, 'rt:510/6100'),
|
||||||
AgingRow.make_simple('2010-06-18', 'EuroGov', 1100, 'rt:520/5200',
|
AgingRow.make_simple('2010-06-18', 'EuroGov', 1100, 'rt:520/5200',
|
||||||
orig_amount=[testutil.Amount(1000, 'EUR')]),
|
orig_amount=[testutil.Amount(1000, 'EUR')]),
|
||||||
|
@ -128,6 +129,10 @@ class RTClient(testutil.RTClient):
|
||||||
'44': [
|
'44': [
|
||||||
('440', 'invoice feb.csv', 'text/csv', '40.4k'),
|
('440', 'invoice feb.csv', 'text/csv', '40.4k'),
|
||||||
],
|
],
|
||||||
|
'310': [
|
||||||
|
('3100', 'VendorContract.pdf', 'application/pdf', '1.7m'),
|
||||||
|
('3120', 'VendorInvoiceB.pdf', 'application/pdf', '1.8m'),
|
||||||
|
],
|
||||||
'490': [],
|
'490': [],
|
||||||
'505': [],
|
'505': [],
|
||||||
'510': [
|
'510': [
|
||||||
|
@ -417,14 +422,14 @@ 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)
|
||||||
|
|
||||||
def run_outgoing(invoice, postings, rt_client=None):
|
def run_outgoing(rt_id, postings, rt_client=None):
|
||||||
if rt_client is None:
|
if rt_client is None:
|
||||||
rt_client = RTClient()
|
rt_client = RTClient()
|
||||||
if not isinstance(postings, core.RelatedPostings):
|
if not isinstance(postings, core.RelatedPostings):
|
||||||
postings = accruals_by_meta(postings, invoice, wrap_type=accrual.AccrualPostings)
|
postings = accruals_by_meta(postings, rt_id, 'rt-id', wrap_type=accrual.AccrualPostings)
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
report = accrual.OutgoingReport(rt_client, output)
|
report = accrual.OutgoingReport(rt_client, output)
|
||||||
report.run({invoice: postings})
|
report.run({rt_id: postings})
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@pytest.mark.parametrize('invoice,expected', [
|
@pytest.mark.parametrize('invoice,expected', [
|
||||||
|
@ -442,8 +447,7 @@ def test_balance_report(accrual_postings, invoice, expected, caplog):
|
||||||
check_output(output, [invoice, expected])
|
check_output(output, [invoice, expected])
|
||||||
|
|
||||||
def test_outgoing_report(accrual_postings, caplog):
|
def test_outgoing_report(accrual_postings, caplog):
|
||||||
invoice = 'rt:510/6100'
|
output = run_outgoing('rt:510', accrual_postings)
|
||||||
output = run_outgoing(invoice, accrual_postings)
|
|
||||||
rt_url = RTClient.DEFAULT_URL[:-9]
|
rt_url = RTClient.DEFAULT_URL[:-9]
|
||||||
rt_id_url = rf'\b{re.escape(f"{rt_url}Ticket/Display.html?id=510")}\b'
|
rt_id_url = rf'\b{re.escape(f"{rt_url}Ticket/Display.html?id=510")}\b'
|
||||||
contract_url = rf'\b{re.escape(f"{rt_url}Ticket/Attachment/4000/4000/contract.pdf")}\b'
|
contract_url = rf'\b{re.escape(f"{rt_url}Ticket/Attachment/4000/4000/contract.pdf")}\b'
|
||||||
|
@ -467,9 +471,8 @@ def test_outgoing_report(accrual_postings, caplog):
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
|
def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
|
||||||
invoice = 'rt:510/6100'
|
|
||||||
rt_client = RTClient(want_cfs=False)
|
rt_client = RTClient(want_cfs=False)
|
||||||
output = run_outgoing(invoice, accrual_postings, rt_client)
|
output = run_outgoing('rt:510', accrual_postings, rt_client)
|
||||||
assert not caplog.records
|
assert not caplog.records
|
||||||
check_output(output, [
|
check_output(output, [
|
||||||
r'^PAYMENT FOR APPROVAL:$',
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
|
@ -479,8 +482,7 @@ def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_outgoing_report_fx_amounts(accrual_postings, caplog):
|
def test_outgoing_report_fx_amounts(accrual_postings, caplog):
|
||||||
invoice = 'rt:520/5200'
|
output = run_outgoing('rt:520 rt:525', accrual_postings)
|
||||||
output = run_outgoing(invoice, accrual_postings)
|
|
||||||
assert not caplog.records
|
assert not caplog.records
|
||||||
check_output(output, [
|
check_output(output, [
|
||||||
r'^PAYMENT FOR APPROVAL:$',
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
|
@ -488,9 +490,21 @@ def test_outgoing_report_fx_amounts(accrual_postings, caplog):
|
||||||
r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$',
|
r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def test_outgoing_report_multi_invoice(accrual_postings, caplog):
|
||||||
|
output = run_outgoing('rt:310', accrual_postings)
|
||||||
|
assert not caplog.records
|
||||||
|
check_output(output, [
|
||||||
|
r'^PAYMENT FOR APPROVAL:$',
|
||||||
|
r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
|
||||||
|
r'^TOTAL TO PAY: \$420.00$',
|
||||||
|
])
|
||||||
|
|
||||||
def test_outgoing_report_without_rt_id(accrual_postings, caplog):
|
def test_outgoing_report_without_rt_id(accrual_postings, caplog):
|
||||||
invoice = 'rt://ticket/515/attachments/5150'
|
invoice = 'rt://ticket/515/attachments/5150'
|
||||||
output = run_outgoing(invoice, accrual_postings)
|
related = accruals_by_meta(
|
||||||
|
accrual_postings, invoice, wrap_type=accrual.AccrualPostings,
|
||||||
|
)
|
||||||
|
output = run_outgoing(None, related)
|
||||||
assert caplog.records
|
assert caplog.records
|
||||||
log = caplog.records[0]
|
log = caplog.records[0]
|
||||||
assert log.message.startswith(
|
assert log.message.startswith(
|
||||||
|
@ -524,8 +538,8 @@ def test_aging_report(accrual_postings):
|
||||||
# Both these dates are chosen for their off-by-one potential:
|
# Both these dates are chosen for their off-by-one potential:
|
||||||
# the first is exactly 30 days after the 2010-06-10 payable;
|
# the first is exactly 30 days after the 2010-06-10 payable;
|
||||||
# the second is exactly 60 days after the 2010-05-15 receivable.
|
# the second is exactly 60 days after the 2010-05-15 receivable.
|
||||||
(datetime.date(2010, 7, 10), 1, 4),
|
(datetime.date(2010, 7, 10), 1, 5),
|
||||||
(datetime.date(2010, 7, 14), 2, 4),
|
(datetime.date(2010, 7, 14), 2, 5),
|
||||||
])
|
])
|
||||||
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end):
|
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end):
|
||||||
expect_recv = AGING_AR[:recv_end]
|
expect_recv = AGING_AR[:recv_end]
|
||||||
|
@ -606,23 +620,23 @@ def test_output_payments_when_only_match(arglist, expect_invoice):
|
||||||
r' outstanding since ',
|
r' outstanding since ',
|
||||||
])
|
])
|
||||||
|
|
||||||
@pytest.mark.parametrize('arglist', [
|
@pytest.mark.parametrize('arglist,expect_amount', [
|
||||||
['510'],
|
(['310'], 420),
|
||||||
['510/6100'],
|
(['310/3120'], 220),
|
||||||
['entity=Lawyer'],
|
(['entity=Vendor'], 420),
|
||||||
])
|
])
|
||||||
def test_main_outgoing_report(arglist):
|
def test_main_outgoing_report(arglist, expect_amount):
|
||||||
retcode, output, errors = run_main(arglist)
|
retcode, output, errors = run_main(arglist)
|
||||||
assert not errors.getvalue()
|
assert not errors.getvalue()
|
||||||
assert retcode == 0
|
assert retcode == 0
|
||||||
rt_url = RTClient.DEFAULT_URL[:-9]
|
rt_url = RTClient.DEFAULT_URL[:-9]
|
||||||
rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=510>')
|
rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=310>')
|
||||||
contract_url = re.escape(f'<{rt_url}Ticket/Attachment/4000/4000/contract.pdf>')
|
contract_url = re.escape(f'<{rt_url}Ticket/Attachment/3120/3120/VendorContract.pdf>')
|
||||||
check_output(output, [
|
check_output(output, [
|
||||||
r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
|
r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
|
||||||
r'^TOTAL TO PAY: \$280\.00$',
|
rf'^TOTAL TO PAY: \${expect_amount}\.00$',
|
||||||
r'^\s*2010-06-12\s',
|
r'^\s*2010-04-30\s',
|
||||||
r'^\s+Expenses:FilingFees\s+60\.00 USD$',
|
r'^\s+Expenses:Travel\s+220 USD$',
|
||||||
])
|
])
|
||||||
|
|
||||||
@pytest.mark.parametrize('arglist', [
|
@pytest.mark.parametrize('arglist', [
|
||||||
|
|
Loading…
Reference in a new issue