accrual: Outgoing report groups by rt-id. RT#11594.

This commit is contained in:
Brett Smith 2020-06-11 15:27:36 -04:00
parent 8d7a2b1eea
commit 175ac3bd7a
4 changed files with 78 additions and 56 deletions

View file

@ -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

View file

@ -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+',

View file

@ -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"

View file

@ -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', [