From 2e27a5609f8db7c94ab55838d8e7effce5a92271 Mon Sep 17 00:00:00 2001 From: Quan Pham Date: Wed, 3 Apr 2024 11:52:47 -0400 Subject: [PATCH] Added processing to apply project credits, determine institution name for each PI, and exporting HU and BU invoices --- process_report/institute_map.json | 27 +++++ process_report/process_report.py | 160 +++++++++++++++++++++++++++-- process_report/tests/unit_tests.py | 64 ++++++++++++ 3 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 process_report/institute_map.json diff --git a/process_report/institute_map.json b/process_report/institute_map.json new file mode 100644 index 0000000..0ae8f4f --- /dev/null +++ b/process_report/institute_map.json @@ -0,0 +1,27 @@ +{ + "northeastern.edu" : "Northeastern University", + "bu.edu" : "Boston University", + "bentley.edu" : "Bentley", + "uri.edu" : "University of Rhode Island", + "redhat.com" : "Red Hat", + "childrens.harvard.edu" : "Boston Childrens Hospital", + "mclean.harvard.edu" : "McLean Hospital", + "meei.harvard.edu" : "Massachusetts Eye & Ear", + "dfci.harvard.edu" : "Dana-Farber Cancer Institute", + "bwh.harvard.edu" : "Brigham and Women's Hospital", + "bidmc.harvard.edu" : "Beth Israel Deaconess Medical Center", + "harvard.edu" : "Harvard University", + "wpi.edu" : "Worcester Polytechnic Institute", + "mit.edu" : "Massachusetts Institute of Technology", + "umass.edu" : "University of Massachusetts Amherst", + "uml.edu" : "University of Massachusetts Lowell", + "codeforboston.org" : "Code For Boston", + "yale.edu" : "Yale University", + "mmsh" : "Harvard University", + "gstuart" : "University of Massachusetts Amherst", + "rudolph" : "Boston Childrens Hospital", + "robbaron" : "Boston University", + "kmdalton" : "Harvard University", + "mzink" : "University of Massachusetts Amherst", + "francesco.pontiggia" : "Harvard University" +} diff --git a/process_report/process_report.py b/process_report/process_report.py index cfcdc70..d1074c2 100644 --- a/process_report/process_report.py +++ b/process_report/process_report.py @@ -1,9 +1,47 @@ import argparse import os +import sys +import json import pandas +### Invoice field names +INVOICE_DATE_FIELD = 'Invoice Month' +PROJECT_FIELD = 'Project - Allocation' +PROJECT_ID_FIELD = 'Project - Allocation ID' +PI_FIELD = 'Manager (PI)' +INVOICE_EMAIL_FIELD = 'Invoice Email' +INVOICE_ADDRESS_FIELD = 'Invoice Address' +INSTITUTION_FIELD = 'Institution' +INSTITUTION_ID_FIELD = 'Institution - Specific Code' +SU_HOURS_FIELD = 'SU Hours (GBhr or SUhr)' +SU_TYPE_FIELD = 'SU Type' +COST_FIELD = 'Cost' +CREDIT_FIELD = 'Credit' +CREDIT_CODE_FIELD = 'Credit Code' +BALANCE_FIELD = 'Balance' +### + + +def get_institution_from_pi(pi_uname): + + dir_path = os.path.dirname(__file__) + with open(f'{dir_path}/institute_map.json', 'r') as f: + institute_map = json.load(f) + + if '@' in pi_uname: + domain = pi_uname.split('@')[1] + institute_name = institute_map.get(domain, '') + else: + institute_name = institute_map.get(pi_uname, '') + + if institute_name == '': + print(f"PI name {pi_uname} does not match any institution!") + + return institute_name + + def main(): """Remove non-billable PIs and projects""" @@ -41,6 +79,23 @@ def main(): default="pi_invoices", help="Name of output folder containing pi-specific invoice csvs" ) + parser.add_argument( + "--HU-invoice-file", + required=False, + default="HU_only.csv", + help="Name of output csv for HU invoices" + ) + parser.add_argument( + "--HU-BU-invoice-file", + required=False, + default="HU_BU.csv", + help="Name of output csv for HU and BU invoices" + ) + parser.add_argument( + "--old-pi-file", + required=False, + help="Name of csv file listing previously billed PIs" + ) args = parser.parse_args() merged_dataframe = merge_csv(args.csv_files) @@ -60,9 +115,14 @@ def main(): projects = list(set(projects + timed_projects_list)) - billable_projects = remove_non_billables(merged_dataframe, pi, projects, args.output_file) + merged_dataframe = add_institution(merged_dataframe) remove_billables(merged_dataframe, pi, projects, "non_billable.csv") + + credited_projects = apply_credits_new_pis(merged_dataframe, args.old_pi_file) + billable_projects = remove_non_billables(credited_projects, pi, projects, args.output_file) export_pi_billables(billable_projects, args.output_folder) + export_HU_only(billable_projects, args.HU_only) + export_HU_BU(billable_projects, args.HU_BU) def merge_csv(files): @@ -83,7 +143,7 @@ def get_invoice_date(dataframe): Note that it only checks the first entry because it should be the same for every row. """ - invoice_date_str = dataframe['Invoice Month'][0] + invoice_date_str = dataframe[INVOICE_DATE_FIELD][0] invoice_date = pandas.to_datetime(invoice_date_str, format='%Y-%m') return invoice_date @@ -102,7 +162,7 @@ def timed_projects(timed_projects_file, invoice_date): def remove_non_billables(dataframe, pi, projects, output_file): """Removes projects and PIs that should not be billed from the dataframe""" - filtered_dataframe = dataframe[~dataframe['Manager (PI)'].isin(pi) & ~dataframe['Project - Allocation'].isin(projects)] + filtered_dataframe = dataframe[~dataframe[PI_FIELD].isin(pi) & ~dataframe[PROJECT_FIELD].isin(projects)] filtered_dataframe.to_csv(output_file, index=False) return filtered_dataframe @@ -112,21 +172,103 @@ def remove_billables(dataframe, pi, projects, output_file): So this *keeps* the projects/pis that should not be billed. """ - filtered_dataframe = dataframe[dataframe['Manager (PI)'].isin(pi) | dataframe['Project - Allocation'].isin(projects)] + filtered_dataframe = dataframe[dataframe[PI_FIELD].isin(pi) | dataframe[PROJECT_FIELD].isin(projects)] filtered_dataframe.to_csv(output_file, index=False) + def export_pi_billables(dataframe: pandas.DataFrame, output_folder): if not os.path.exists(output_folder): os.mkdir(output_folder) - invoice_month = dataframe['Invoice Month'].iat[0] - pi_list = dataframe['Manager (PI)'].unique() + invoice_month = dataframe[INVOICE_DATE_FIELD].iat[0] + pi_list = dataframe[PI_FIELD].unique() for pi in pi_list: - pi_projects = dataframe[dataframe['Manager (PI)'] == pi] - pi_instituition = pi_projects['Institution'].iat[0] + if pandas.isna(pi): + continue + pi_projects = dataframe[dataframe[PI_FIELD] == pi] + pi_instituition = pi_projects[INSTITUTION_FIELD].iat[0] pi_projects.to_csv(output_folder + f"/{pi_instituition}_{pi}_{invoice_month}.csv") - + + +def apply_credits_new_pi(dataframe, old_pi_file): + """Applies the New PI Credit. This credit function expects the + command arguement `old-pi-file` to be set, pointing to a txt file containing old PIs""" + new_pi_credit_code = "0002" + new_pi_credit_amount = 1000 + + old_pi_list = set() + + try: + with open(old_pi_file) as f: + for pi in f: + old_pi_list.add(pi.strip()) + + except FileNotFoundError: + print("Applying credit 0002 failed. Old PI file does not exist") + sys.exit(1) + + pi_list = dataframe[PI_FIELD].unique() + + for pi in pi_list: + if pandas.isna(pi): + continue # NaN check + if pi in old_pi_list: + continue # Is the PI an old PI? + + pi_projects = dataframe[dataframe[PI_FIELD] == pi] + remaining_credit = new_pi_credit_amount + for i, row in pi_projects.iterrows(): + project_cost = row[COST_FIELD] + applied_credit = min(project_cost, remaining_credit) + + dataframe.at[i, CREDIT_FIELD] = applied_credit + dataframe.at[i, CREDIT_CODE_FIELD] = new_pi_credit_code + dataframe.at[i, BALANCE_FIELD] = row[COST_FIELD] - applied_credit + remaining_credit -= applied_credit + + if remaining_credit == 0: + break + + return dataframe + + +def add_institution(dataframe: pandas.DataFrame): + """Determine the PI's institution name, logging any PI whose institution cannot be determined + This is done by matching the PI's name to list of known institution email domains (i.e bu.edu), + as well as to several edge cases (i.e rudolph). Matches are then mapped to the corresponding + institution name. + + I.e "foo@bu.edu" would match with "bu.edu", which maps to the instition name "Boston University" + + The list of mappings are defined in `institute_map.json`. + The list is ordered, and matches are performed top to bottom. + This means if different email domains or strings contain substrings of others, + poor ordering could prevent them from being matched and be "skipped". + + I.e bwh.harvard.edu and harvard.edu. If harvard.edu was placed above bwh.harvard.edu, + bwh.harvard.edu would never be matched. + """ + for i, row in dataframe.iterrows(): + pi_name = row[PI_FIELD] + if pandas.isna(pi_name): + print(f"Project {row[PROJECT_FIELD]} has no PI") # Nan check + else: + dataframe.at[i, INSTITUTION_FIELD] = get_institution_from_pi(pi_name) + + return dataframe + + +def export_HU_only(dataframe, output_file): + HU_projects = dataframe[dataframe[INSTITUTION_FIELD] == 'Harvard University'] + HU_projects.to_csv(output_file) + + +def export_HU_BU(dataframe, output_file): + HU_BU_projects = dataframe[(dataframe[INSTITUTION_FIELD] == 'Harvard University') | + (dataframe[INSTITUTION_FIELD] == 'Boston University')] + HU_BU_projects.to_csv(output_file) + if __name__ == "__main__": main() diff --git a/process_report/tests/unit_tests.py b/process_report/tests/unit_tests.py index d63c7d4..f02a90c 100644 --- a/process_report/tests/unit_tests.py +++ b/process_report/tests/unit_tests.py @@ -1,4 +1,5 @@ from unittest import TestCase +from unittest import skipIf import tempfile import pandas import os @@ -175,3 +176,66 @@ def test_export_pi(self): self.assertNotIn('ProjectA', pi_df['Project - Allocation'].tolist()) self.assertNotIn('ProjectB', pi_df['Project - Allocation'].tolist()) self.assertNotIn('ProjectC', pi_df['Project - Allocation'].tolist()) + + +class TestGetInstitute(TestCase): + def test_get_pi_institution(self): + + self.assertEqual( + process_report.get_institution_from_pi("quanmp@bu.edu"), "Boston University" + ) + self.assertEqual( + process_report.get_institution_from_pi("c@mclean.harvard.edu"), "McLean Hospital" + ) + self.assertEqual( + process_report.get_institution_from_pi("b@harvard.edu"), "Harvard University" + ) + self.assertEqual( + process_report.get_institution_from_pi("fake"), "" + ) + self.assertEqual( + process_report.get_institution_from_pi("pi@northeastern.edu"), "Northeastern University" + ) + + +class TestCredit0002(TestCase): + def setUp(self): + + data = { + 'Manager (PI)': ['PI1', 'PI1', 'PI2', 'PI3', 'PI4', 'PI4'], + 'Project - Allocation': ['ProjectA', 'ProjectB', 'ProjectC', 'ProjectD', 'ProjectE', 'ProjectF'], + 'Cost': [10, 100, 10000, 5000, 800, 1000] + } + self.dataframe = pandas.DataFrame(data) + old_pi = ['PI2', 'PI3'] + old_pi_file = tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.csv') + for pi in old_pi: old_pi_file.write(pi + "\n") + self.old_pi_file = old_pi_file.name + + def tearDown(self): + os.remove(self.old_pi_file) + + def test_apply_credit_0002(self): + dataframe = process_report.apply_credits_new_pi(self.dataframe, self.old_pi_file) + + self.assertTrue('Credit' in dataframe) + self.assertTrue('Credit Code' in dataframe) + self.assertTrue('Balance' in dataframe) + + credited_projects = dataframe[dataframe['Credit Code'] == '0002'] + + self.assertEqual(4, len(credited_projects.index)) + self.assertTrue('PI2' not in credited_projects['Manager (PI)'].unique()) + self.assertTrue('PI3' not in credited_projects['Manager (PI)'].unique()) + + self.assertEqual(10, credited_projects[credited_projects['Project - Allocation'] == 'ProjectA']['Credit'].iloc[0]) + self.assertEqual(100, credited_projects[credited_projects['Project - Allocation'] == 'ProjectB']['Credit'].iloc[0]) + self.assertEqual(800, credited_projects[credited_projects['Project - Allocation'] == 'ProjectE']['Credit'].iloc[0]) + self.assertEqual(200, credited_projects[credited_projects['Project - Allocation'] == 'ProjectF']['Credit'].iloc[0]) + + self.assertEqual(0, credited_projects[credited_projects['Project - Allocation'] == 'ProjectA']['Balance'].iloc[0]) + self.assertEqual(0, credited_projects[credited_projects['Project - Allocation'] == 'ProjectB']['Balance'].iloc[0]) + self.assertEqual(0, credited_projects[credited_projects['Project - Allocation'] == 'ProjectE']['Balance'].iloc[0]) + self.assertEqual(800, credited_projects[credited_projects['Project - Allocation'] == 'ProjectF']['Balance'].iloc[0]) + +