Skip to content
This repository was archived by the owner on Jun 26, 2023. It is now read-only.

Commit dc9a13a

Browse files
committed
Generate report from invoice data
Why these changes are being introduced: This commit completes the refactor of process-invoices/print_reports_by_status.py by adding functionality to generate reports from the Alma invoice data that can be sent to Acquisitions staff for review or as final cover sheets. How this addresses that need: * Adds a function in sap.py, populate_fund_data(), that completes the invoice record data extraction process by extracting the necessary invoice line fund data, combining fund amounts with identical MIT account numbers, and returning the extracted fund data as a dict. * Adds a function in sap.py, generate_report(), that generates a human- readable report of data from an invoice data dict, structured according to requirements provided by stakeholders. * Adds unit tests and updated fixtures for new functionality. * Updates existing tests to match updated fixtures. * Updates the sap-invoices CLI command to generate reports and log them if the --dry-run flag is passed. Emailing reports will be handled in a future commit. Side effects of this change: The reports generated by this process are no longer written to output files, even during a dry run. It's possible we will want to change that back at some point if it turns out having them as files is useful during development, however it should not be needed in staging or production. In general, we would prefer not to write this information to files unless they are in a location that is excluded from version and control AND that gets automatically purged frequently, as these reports do contain sensitive data. Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/IMP-2339
1 parent 12a84b4 commit dc9a13a

File tree

7 files changed

+405
-28
lines changed

7 files changed

+405
-28
lines changed

llama/cli.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ def sap_invoices(ctx, dry_run, final_run):
150150
f" Final run: {final_run}\n"
151151
)
152152

153-
# Retrieve and sort invoices from Alma. Log result.
153+
# Retrieve and sort invoices from Alma. Log result or abort process if no invoices
154+
# retrieved.
154155
alma_client = Alma_API_Client(config.get_alma_api_key("ALMA_API_ACQ_READ_KEY"))
155156
alma_client.set_content_headers("application/json", "application/json")
156157
invoice_records = sap.retrieve_sorted_invoices(alma_client)
@@ -163,21 +164,49 @@ def sap_invoices(ctx, dry_run, final_run):
163164
raise click.Abort()
164165

165166
# For each invoice retrieved, parse and extract invoice data and save to either
166-
# monograph or serials invoice list depending on invoice type. Log result.
167+
# monograph or serial invoice list depending on purchase type. Log result.
167168
monograph_invoices = []
168169
serial_invoices = []
169-
for invoice_record in invoice_records:
170+
retrieved_vendors = {}
171+
retrieved_funds = {}
172+
for count, invoice_record in enumerate(invoice_records):
173+
logger.info(
174+
f"Extracting data for invoice #{invoice_record['id']}, "
175+
f"record {count} of {len(invoice_records)}"
176+
)
170177
invoice_data = sap.extract_invoice_data(alma_client, invoice_record)
178+
vendor_code = invoice_record["vendor"]["value"]
179+
try:
180+
invoice_data["vendor"] = retrieved_vendors[vendor_code]
181+
except KeyError:
182+
logger.info(f"Retrieving data for vendor {vendor_code}")
183+
retrieved_vendors[vendor_code] = sap.populate_vendor_data(
184+
alma_client, vendor_code
185+
)
186+
invoice_data["vendor"] = retrieved_vendors[vendor_code]
187+
invoice_data["funds"], retrieved_funds = sap.populate_fund_data(
188+
alma_client, invoice_record, retrieved_funds
189+
)
171190
if invoice_data["type"] == "monograph":
172191
monograph_invoices.append(invoice_data)
173192
else:
174193
serial_invoices.append(invoice_data)
175-
logger.info(f"{len(monograph_invoices)} monograph invoices processed.")
176-
logger.info(f"{len(serial_invoices)} serial invoices processed.")
194+
logger.info(
195+
f"{len(monograph_invoices)} monograph invoices retrieved and extracted."
196+
)
197+
logger.info(f"{len(serial_invoices)} serial invoices retrieved and extracted.")
177198

178199
# Generate formatted reports for review
179-
180-
# If not dry run:
200+
logger.info("Generating monographs report")
201+
monograph_report = sap.generate_report(ctx.obj["today"], monograph_invoices)
202+
logger.info("Generating serials report")
203+
serial_report = sap.generate_report(ctx.obj["today"], serial_invoices)
204+
205+
if dry_run:
206+
logger.info(f"Monograph report:\n{monograph_report}")
207+
logger.info(f"Serials report:\n{serial_report}")
208+
else:
209+
pass
181210
# Send email with reports as attachments (note that email subject and attachment
182211
# file names will differ for review vs. final run)
183212

@@ -191,3 +220,10 @@ def sap_invoices(ctx, dry_run, final_run):
191220
# Send data and control files to SAP dropbox via SFTP
192221
# Email summary files
193222
# Update invoice statuses in Alma
223+
224+
logger.info(
225+
"SAP invoice process completed for a review run:\n"
226+
f" {len(monograph_invoices)} monograph invoices retrieved and processed\n"
227+
f" {len(serial_invoices)} serial invoices retrieved and processed\n"
228+
f" {len(invoice_records)} total invoices retrieved and processed\n"
229+
)

llama/sap.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
from datetime import datetime
6+
from typing import List, Tuple
67

78
from llama.alma import Alma_API_Client
89

@@ -32,11 +33,11 @@ def extract_invoice_data(alma_client: Alma_API_Client, invoice_record: dict) ->
3233
invoice_data = {
3334
"date": datetime.strptime(invoice_record["invoice_date"], "%Y-%m-%dZ"),
3435
"id": invoice_record["id"],
36+
"number": invoice_record["number"],
3537
"type": purchase_type(vendor_code),
3638
"payment method": invoice_record["payment_method"]["value"],
3739
"total amount": invoice_record["total_amount"],
3840
"currency": invoice_record["currency"]["value"],
39-
"vendor": populate_vendor_data(alma_client, vendor_code),
4041
}
4142
return invoice_data
4243

@@ -115,3 +116,89 @@ def country_code_from_address(address: dict) -> str:
115116
return COUNTRIES[country]
116117
except KeyError:
117118
return "US"
119+
120+
121+
def populate_fund_data(
122+
alma_client: Alma_API_Client, invoice_record: dict, retrieved_funds: dict
123+
) -> Tuple[dict, dict]:
124+
"""Populate a dict with fund data needed for SAP.
125+
126+
Given an invoice record, a dict of already retrieved funds, and an authenticated
127+
Alma client, return a dict populated with the fund data needed for SAP.
128+
129+
Note: Also returns a dict of all fund records retrieved from Alma so we can pass
130+
that to subsequent calls to this function. That way we only call the Alma API once
131+
throughout the entire process for each fund we need, rather than retrieving the
132+
same fund record every time the fund appears in an invoice.
133+
"""
134+
fund_data = {}
135+
invoice_lines_total = 0
136+
for invoice_line in invoice_record["invoice_lines"]["invoice_line"]:
137+
for fund_distribution in invoice_line["fund_distribution"]:
138+
fund_code = fund_distribution["fund_code"]["value"]
139+
amount = fund_distribution["amount"]
140+
try:
141+
fund_record = retrieved_funds[fund_code]
142+
except KeyError:
143+
logger.info(f"Retrieving data for fund {fund_code}")
144+
retrieved_funds[fund_code] = alma_client.get_fund_by_code(fund_code)
145+
fund_record = retrieved_funds[fund_code]
146+
external_id = fund_record["fund"][0]["external_id"].strip()
147+
try:
148+
# Combine amounts for funds that have the same external ID (AKA the
149+
# same MIT G/L account and cost object)
150+
fund_data[external_id]["amount"] += amount
151+
except KeyError:
152+
fund_data[external_id] = {
153+
"amount": amount,
154+
"G/L account": external_id.split("-")[0],
155+
"cost object": external_id.split("-")[1],
156+
}
157+
invoice_lines_total += amount
158+
return fund_data, retrieved_funds
159+
160+
161+
def generate_report(today: datetime, invoices: List[dict]) -> str:
162+
today_string = today.strftime("%m/%d/%Y")
163+
report = ""
164+
for invoice in invoices:
165+
report += f"\n\n{'':33}MIT LIBRARIES\n\n\n"
166+
report += (
167+
f"Date: {today_string:<36}Vendor code : {invoice['vendor']['code']}\n"
168+
)
169+
report += f"{'Accounting ID :':>57}\n\n"
170+
report += f"Vendor: {invoice['vendor']['name']}\n"
171+
for line in invoice["vendor"]["address"]["lines"]:
172+
report += f" {line}\n"
173+
report += " "
174+
if invoice["vendor"]["address"]["city"]:
175+
report += f"{invoice['vendor']['address']['city']}, "
176+
if invoice["vendor"]["address"]["state or province"]:
177+
report += f"{invoice['vendor']['address']['state or province']} "
178+
if invoice["vendor"]["address"]["postal code"]:
179+
report += f"{invoice['vendor']['address']['postal code']}"
180+
report += f"\n {invoice['vendor']['address']['country']}\n\n"
181+
report += (
182+
"Invoice no. Fiscal Account Amount Inv. Date\n"
183+
)
184+
report += (
185+
"------------------ ----------------- ------------- ----------\n"
186+
)
187+
for fund in invoice["funds"]:
188+
report += f"{invoice['number'] + invoice['date'].strftime('%y%m%d'):<23}"
189+
report += (
190+
f"{invoice['funds'][fund]['G/L account']} "
191+
f"{invoice['funds'][fund]['cost object']} "
192+
)
193+
report += f"{invoice['funds'][fund]['amount']:<18,.2f}"
194+
report += f"{invoice['date'].strftime('%m/%d/%Y')}\n"
195+
report += "\n\n"
196+
report += (
197+
f"Total/Currency: {invoice['total amount']:,.2f} "
198+
f"{invoice['currency']}\n\n"
199+
)
200+
report += f"Payment Method: {invoice['payment method']}\n\n\n"
201+
report += f"{'Departmental Approval':>44} {'':_<34}\n\n"
202+
report += f"{'Financial Services Approval':>50} {'':_<28}\n\n\n"
203+
report += "\f"
204+
return report

tests/conftest.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,23 @@ def mocked_alma(po_line_record_all_fields):
3535
]
3636
}
3737
with open("tests/fixtures/funds.json") as f:
38-
m.get("http://example.com/acq/funds", json=json.load(f))
38+
funds = json.load(f)
39+
m.get(
40+
"http://example.com/acq/funds?q=fund_code~ABC",
41+
json={"fund": [funds["fund"][0]]},
42+
)
43+
m.get(
44+
"http://example.com/acq/funds?q=fund_code~DEF",
45+
json={"fund": [funds["fund"][1]]},
46+
)
47+
m.get(
48+
"http://example.com/acq/funds?q=fund_code~GHI",
49+
json={"fund": [funds["fund"][2]]},
50+
)
51+
m.get(
52+
"http://example.com/acq/funds?q=fund_code~JKL",
53+
json={"fund": [funds["fund"][3]]},
54+
)
3955
with open("tests/fixtures/invoices.json") as f:
4056
m.get(
4157
"http://example.com/acq/invoices/0501130657",
@@ -154,7 +170,7 @@ def po_line_record_multiple_funds():
154170
"created_date": "2021-05-13Z",
155171
"fund_distribution": [
156172
{"fund_code": {"value": "ABC"}, "amount": {"sum": "6.0"}},
157-
{"fund_code": {"value": "DEF"}, "amount": {"sum": "6.0"}},
173+
{"fund_code": {"value": "GHI"}, "amount": {"sum": "6.0"}},
158174
],
159175
"note": [{"note_text": ""}],
160176
}

tests/fixtures/funds.json

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,146 @@
11
{
2-
"total_record_count": 999,
2+
"total_record_count": 4,
33
"fund": [
44
{
55
"link": "string",
66
"code": "ABC",
77
"name": "Biology",
8-
"external_id": "1239410001021",
8+
"external_id": "1234567-000001",
9+
"type": {
10+
"value": "General"
11+
},
12+
"entity_type": {
13+
"value": "SUMMARY"
14+
},
15+
"owner": {
16+
"value": "LAW"
17+
},
18+
"description": "desc",
19+
"fiscal_period": {
20+
"value": "29"
21+
},
22+
"currency": {
23+
"value": "USD"
24+
},
25+
"available_for_library": [
26+
{
27+
"value": "string"
28+
}
29+
],
30+
"parent": {
31+
"value": "43417015650001021"
32+
},
33+
"overencumbrance_allowed": {
34+
"value": "yes"
35+
},
36+
"overexpenditure_allowed": {
37+
"value": "no"
38+
},
39+
"overencumbrance_warning_percent": 15,
40+
"overexpenditure_warning_sum": 17.44,
41+
"overencumbrance_limit_percent": 8,
42+
"overexpenditure_limit_sum": 10.44,
43+
"encumbrances_prior_to_fiscal_period": 11,
44+
"expenditures_prior_to_fiscal_period": 16,
45+
"transfers_prior_to_fiscal_period": 12,
46+
"fiscal_period_end_encumbrance_grace_period": 5,
47+
"fiscal_period_end_expenditure_grace_period": 8
48+
},
49+
{
50+
"link": "string",
51+
"code": "DEF",
52+
"name": "Fund DEF",
53+
"external_id": "1234567-000001",
54+
"type": {
55+
"value": "General"
56+
},
57+
"entity_type": {
58+
"value": "SUMMARY"
59+
},
60+
"owner": {
61+
"value": "LAW"
62+
},
63+
"description": "desc",
64+
"fiscal_period": {
65+
"value": "29"
66+
},
67+
"currency": {
68+
"value": "USD"
69+
},
70+
"available_for_library": [
71+
{
72+
"value": "string"
73+
}
74+
],
75+
"parent": {
76+
"value": "43417015650001021"
77+
},
78+
"overencumbrance_allowed": {
79+
"value": "yes"
80+
},
81+
"overexpenditure_allowed": {
82+
"value": "no"
83+
},
84+
"overencumbrance_warning_percent": 15,
85+
"overexpenditure_warning_sum": 17.44,
86+
"overencumbrance_limit_percent": 8,
87+
"overexpenditure_limit_sum": 10.44,
88+
"encumbrances_prior_to_fiscal_period": 11,
89+
"expenditures_prior_to_fiscal_period": 16,
90+
"transfers_prior_to_fiscal_period": 12,
91+
"fiscal_period_end_encumbrance_grace_period": 5,
92+
"fiscal_period_end_expenditure_grace_period": 8
93+
},
94+
{
95+
"link": "string",
96+
"code": "GHI",
97+
"name": "Fund GHI",
98+
"external_id": "1234567-000002",
99+
"type": {
100+
"value": "General"
101+
},
102+
"entity_type": {
103+
"value": "SUMMARY"
104+
},
105+
"owner": {
106+
"value": "LAW"
107+
},
108+
"description": "desc",
109+
"fiscal_period": {
110+
"value": "29"
111+
},
112+
"currency": {
113+
"value": "USD"
114+
},
115+
"available_for_library": [
116+
{
117+
"value": "string"
118+
}
119+
],
120+
"parent": {
121+
"value": "43417015650001021"
122+
},
123+
"overencumbrance_allowed": {
124+
"value": "yes"
125+
},
126+
"overexpenditure_allowed": {
127+
"value": "no"
128+
},
129+
"overencumbrance_warning_percent": 15,
130+
"overexpenditure_warning_sum": 17.44,
131+
"overencumbrance_limit_percent": 8,
132+
"overexpenditure_limit_sum": 10.44,
133+
"encumbrances_prior_to_fiscal_period": 11,
134+
"expenditures_prior_to_fiscal_period": 16,
135+
"transfers_prior_to_fiscal_period": 12,
136+
"fiscal_period_end_encumbrance_grace_period": 5,
137+
"fiscal_period_end_expenditure_grace_period": 8
138+
},
139+
{
140+
"link": "string",
141+
"code": "JKL",
142+
"name": "Fund JKL",
143+
"external_id": "1234567-000003",
9144
"type": {
10145
"value": "General"
11146
},

tests/test_alma.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def test_alma_get_brief_po_lines(mocked_alma, mocked_alma_api_client):
3333
def test_alma_get_fund_by_code(mocked_alma, mocked_alma_api_client):
3434
fund = mocked_alma_api_client.get_fund_by_code("ABC")
3535
assert fund["fund"][0]["code"] == "ABC"
36-
assert fund["fund"][0]["external_id"] == "1239410001021"
36+
assert fund["fund"][0]["external_id"] == "1234567-000001"
3737

3838

3939
def test_alma_get_invoice(mocked_alma, mocked_alma_api_client):

0 commit comments

Comments
 (0)