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)
|
||||
|
||||
def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds:
|
||||
rt_ids = list(posts.first_meta_links('rt-id'))
|
||||
rt_ids_count = len(rt_ids)
|
||||
if rt_ids_count != 1:
|
||||
raise ValueError(f"{rt_ids_count} rt-id links found")
|
||||
parsed = rtutil.RT.parse(rt_ids.pop())
|
||||
rt_id = posts.rt_id
|
||||
if rt_id is None:
|
||||
raise ValueError("no rt-id links found")
|
||||
elif isinstance(rt_id, Sentinel):
|
||||
raise ValueError("multiple rt-id links found")
|
||||
parsed = rtutil.RT.parse(rt_id)
|
||||
if parsed is None:
|
||||
raise ValueError("rt-id is not a valid RT reference")
|
||||
else:
|
||||
|
@ -598,20 +599,10 @@ class ReportType(enum.Enum):
|
|||
except KeyError:
|
||||
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):
|
||||
LOAD_ERRORS = 1
|
||||
# 2 was used in the past, it can probably be reclaimed.
|
||||
REPORT_ERRORS = 4
|
||||
NOTHING_TO_REPORT = 8
|
||||
|
||||
|
@ -687,6 +678,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
|||
config = configmod.Config()
|
||||
config.load_file()
|
||||
|
||||
returncode = 0
|
||||
books_loader = config.books_loader()
|
||||
if books_loader is None:
|
||||
entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
|
||||
|
@ -695,29 +687,37 @@ def main(arglist: Optional[Sequence[str]]=None,
|
|||
else:
|
||||
entries, load_errors, _ = books_loader.load_all(args.since)
|
||||
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:
|
||||
bc_printer.print_error(error, file=stderr)
|
||||
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")
|
||||
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
||||
|
||||
groups = {
|
||||
key: posts
|
||||
for source_posts in groups.values()
|
||||
for key, posts in source_posts.make_consistent()
|
||||
}
|
||||
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 = {
|
||||
key: group
|
||||
for _, source in AccrualPostings.group_by_first_meta_link(postings, 'invoice')
|
||||
for key, group in source.make_consistent()
|
||||
}
|
||||
if args.report_type is not ReportType.AGING:
|
||||
groups = {
|
||||
key: posts for key, posts in groups.items() if not posts.is_paid()
|
||||
} or groups
|
||||
del postings
|
||||
|
||||
if args.report_type is None:
|
||||
args.report_type = ReportType.default_for(groups)
|
||||
report: Optional[BaseReport] = None
|
||||
output_path: Optional[Path] = None
|
||||
if args.report_type is ReportType.AGING:
|
||||
|
@ -740,7 +740,7 @@ def main(arglist: Optional[Sequence[str]]=None,
|
|||
report = OutgoingReport(rt_client, out_file)
|
||||
else:
|
||||
out_file = cliutil.text_output(args.output_file, stdout)
|
||||
report = args.report_type.value(out_file)
|
||||
report = BalanceReport(out_file)
|
||||
|
||||
if report is None:
|
||||
returncode |= ReturnFlag.REPORT_ERRORS
|
||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
|||
setup(
|
||||
name='conservancy_beancount',
|
||||
description="Plugin, library, and reports for reading Conservancy's books",
|
||||
version='1.1.8',
|
||||
version='1.1.9',
|
||||
author='Software Freedom Conservancy',
|
||||
author_email='info@sfconservancy.org',
|
||||
license='GNU AGPLv3+',
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
Liabilities:Payable:Accounts 125 USD
|
||||
Assets:Checking -125 USD
|
||||
|
||||
2010-04-30 ! "Vendor" "Travel reimbursement"
|
||||
2010-04-25 ! "Vendor" "First trip travel reimbursement"
|
||||
rt-id: "rt:310"
|
||||
contract: "rt:310/3100"
|
||||
invoice: "FIXME" ; still waiting on them to send it
|
||||
|
@ -79,6 +79,14 @@
|
|||
Liabilities:Payable:Accounts -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"
|
||||
rt-id: "rt:505"
|
||||
invoice: "rt:505/5050"
|
||||
|
|
|
@ -106,7 +106,8 @@ class AgingRow(NamedTuple):
|
|||
AGING_AP = [
|
||||
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-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-18', 'EuroGov', 1100, 'rt:520/5200',
|
||||
orig_amount=[testutil.Amount(1000, 'EUR')]),
|
||||
|
@ -128,6 +129,10 @@ class RTClient(testutil.RTClient):
|
|||
'44': [
|
||||
('440', 'invoice feb.csv', 'text/csv', '40.4k'),
|
||||
],
|
||||
'310': [
|
||||
('3100', 'VendorContract.pdf', 'application/pdf', '1.7m'),
|
||||
('3120', 'VendorInvoiceB.pdf', 'application/pdf', '1.8m'),
|
||||
],
|
||||
'490': [],
|
||||
'505': [],
|
||||
'510': [
|
||||
|
@ -417,14 +422,14 @@ def check_output(output, expect_patterns):
|
|||
output.seek(0)
|
||||
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:
|
||||
rt_client = RTClient()
|
||||
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()
|
||||
report = accrual.OutgoingReport(rt_client, output)
|
||||
report.run({invoice: postings})
|
||||
report.run({rt_id: postings})
|
||||
return output
|
||||
|
||||
@pytest.mark.parametrize('invoice,expected', [
|
||||
|
@ -442,8 +447,7 @@ def test_balance_report(accrual_postings, invoice, expected, caplog):
|
|||
check_output(output, [invoice, expected])
|
||||
|
||||
def test_outgoing_report(accrual_postings, caplog):
|
||||
invoice = 'rt:510/6100'
|
||||
output = run_outgoing(invoice, accrual_postings)
|
||||
output = run_outgoing('rt:510', accrual_postings)
|
||||
rt_url = RTClient.DEFAULT_URL[:-9]
|
||||
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'
|
||||
|
@ -467,9 +471,8 @@ def test_outgoing_report(accrual_postings, caplog):
|
|||
])
|
||||
|
||||
def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
|
||||
invoice = 'rt:510/6100'
|
||||
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
|
||||
check_output(output, [
|
||||
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):
|
||||
invoice = 'rt:520/5200'
|
||||
output = run_outgoing(invoice, accrual_postings)
|
||||
output = run_outgoing('rt:520 rt:525', accrual_postings)
|
||||
assert not caplog.records
|
||||
check_output(output, [
|
||||
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\)$',
|
||||
])
|
||||
|
||||
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):
|
||||
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
|
||||
log = caplog.records[0]
|
||||
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:
|
||||
# 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, 4),
|
||||
(datetime.date(2010, 7, 14), 2, 4),
|
||||
(datetime.date(2010, 7, 10), 1, 5),
|
||||
(datetime.date(2010, 7, 14), 2, 5),
|
||||
])
|
||||
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end):
|
||||
expect_recv = AGING_AR[:recv_end]
|
||||
|
@ -606,23 +620,23 @@ def test_output_payments_when_only_match(arglist, expect_invoice):
|
|||
r' outstanding since ',
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize('arglist', [
|
||||
['510'],
|
||||
['510/6100'],
|
||||
['entity=Lawyer'],
|
||||
@pytest.mark.parametrize('arglist,expect_amount', [
|
||||
(['310'], 420),
|
||||
(['310/3120'], 220),
|
||||
(['entity=Vendor'], 420),
|
||||
])
|
||||
def test_main_outgoing_report(arglist):
|
||||
def test_main_outgoing_report(arglist, expect_amount):
|
||||
retcode, output, errors = run_main(arglist)
|
||||
assert not errors.getvalue()
|
||||
assert retcode == 0
|
||||
rt_url = RTClient.DEFAULT_URL[:-9]
|
||||
rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=510>')
|
||||
contract_url = re.escape(f'<{rt_url}Ticket/Attachment/4000/4000/contract.pdf>')
|
||||
rt_id_url = re.escape(f'<{rt_url}Ticket/Display.html?id=310>')
|
||||
contract_url = re.escape(f'<{rt_url}Ticket/Attachment/3120/3120/VendorContract.pdf>')
|
||||
check_output(output, [
|
||||
r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
|
||||
r'^TOTAL TO PAY: \$280\.00$',
|
||||
r'^\s*2010-06-12\s',
|
||||
r'^\s+Expenses:FilingFees\s+60\.00 USD$',
|
||||
r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
|
||||
rf'^TOTAL TO PAY: \${expect_amount}\.00$',
|
||||
r'^\s*2010-04-30\s',
|
||||
r'^\s+Expenses:Travel\s+220 USD$',
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize('arglist', [
|
||||
|
|
Loading…
Reference in a new issue