Skip to content

Commit c1da845

Browse files
jimmyswayQuanMPhm
authored andcommitted
Add support for PI-specific non-billed SU types
Introduced a new discount processor, PISUCreditProcessor, to handle the credit for PI-specific non-billed SU types. The credit amount of 100% of eligible SU costs, with credit code 0005 Updated unit and e2e tests for the new YAML behavior and processor
1 parent e50a436 commit c1da845

File tree

10 files changed

+221
-7
lines changed

10 files changed

+221
-7
lines changed

process_report/loader.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,28 @@ def get_alias_map(self) -> dict:
112112
def load_dataframe(self, filepath: str) -> pandas.DataFrame:
113113
return pandas.read_csv(filepath)
114114

115+
@functools.lru_cache
116+
def _load_pi_config(self, filepath: str) -> list[dict]:
117+
with open(filepath) as file:
118+
pi_list = yaml.safe_load(file)
119+
120+
if not isinstance(pi_list, list):
121+
raise ValueError("pi.yaml must contain a YAML list")
122+
123+
return pi_list
124+
115125
def get_nonbillable_pis(self) -> list[str]:
116-
with open(invoice_settings.nonbillable_pis_filepath) as file:
117-
return [line.rstrip() for line in file]
126+
pi_list = self._load_pi_config(invoice_settings.nonbillable_pis_filepath)
127+
return [pi["username"] for pi in pi_list if "non_billed_su_types" not in pi]
128+
129+
def get_pi_non_billed_su_types(self) -> dict[str, list[str]]:
130+
"""PI usernames -> list of SU types that receive credit (zeroed out)."""
131+
pi_list = self._load_pi_config(invoice_settings.nonbillable_pis_filepath)
132+
return {
133+
pi["username"]: [su["name"] for su in pi["non_billed_su_types"]]
134+
for pi in pi_list
135+
if "non_billed_su_types" in pi
136+
}
118137

119138
@functools.lru_cache
120139
def get_nonbillable_projects(self) -> pandas.DataFrame:

process_report/process_report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
add_institution_processor,
2727
lenovo_processor,
2828
validate_billable_pi_processor,
29+
pi_su_credit_processor,
2930
new_pi_credit_processor,
3031
bu_subsidy_processor,
3132
prepayment_processor,
@@ -74,6 +75,7 @@ def main():
7475
add_institution_processor.AddInstitutionProcessor,
7576
lenovo_processor.LenovoProcessor,
7677
validate_billable_pi_processor.ValidateBillablePIsProcessor,
78+
pi_su_credit_processor.PISUCreditProcessor,
7779
new_pi_credit_processor.NewPICreditProcessor,
7880
bu_subsidy_processor.BUSubsidyProcessor,
7981
prepayment_processor.PrepaymentProcessor,

process_report/processors/discount_processor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ def apply_flat_discount(
5050
def apply_discount_on_project(remaining_discount_amount, project_i, project):
5151
remaining_project_balance = project[pi_balance_field]
5252
applied_discount = min(remaining_project_balance, remaining_discount_amount)
53-
invoice.at[project_i, discount_field] = applied_discount
53+
54+
if invoice.at[project_i, discount_field] is None:
55+
invoice.at[project_i, discount_field] = applied_discount
56+
else:
57+
invoice.at[project_i, discount_field] += applied_discount
58+
5459
invoice.at[project_i, pi_balance_field] -= applied_discount
5560
if self.IS_DISCOUNT_BY_NERC:
5661
invoice.at[project_i, balance_field] -= applied_discount
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import logging
2+
3+
from dataclasses import dataclass, field
4+
5+
from process_report.loader import loader
6+
from process_report.invoices import invoice
7+
from process_report.processors import discount_processor
8+
9+
10+
logger = logging.getLogger(__name__)
11+
logging.basicConfig(level=logging.INFO)
12+
13+
14+
@dataclass
15+
class PISUCreditProcessor(discount_processor.DiscountProcessor):
16+
"""
17+
This processor operates on data processed by these Processors:
18+
- ValidateBillablePIsProcessor
19+
20+
Certain PIs using certain SU types receive a 100% discount on those projects
21+
"""
22+
23+
IS_DISCOUNT_BY_NERC = True
24+
PI_SU_CREDIT_CODE = "0005"
25+
26+
pi_su_mapping: dict[str, list[str]] = field(
27+
default_factory=loader.get_pi_non_billed_su_types
28+
)
29+
30+
def _process(self):
31+
for pi, su_types in self.pi_su_mapping.items():
32+
credit_eligible_projects = self.data[
33+
self.data[invoice.SU_TYPE_FIELD].isin(su_types)
34+
& (self.data[invoice.PI_FIELD] == pi)
35+
]
36+
self.apply_flat_discount(
37+
invoice=self.data,
38+
pi_projects=credit_eligible_projects,
39+
pi_balance_field=invoice.PI_BALANCE_FIELD,
40+
discount_amount=credit_eligible_projects[
41+
invoice.COST_FIELD
42+
].sum(), # Discount the entire cost of eligible projects
43+
discount_field=invoice.CREDIT_FIELD,
44+
balance_field=invoice.BALANCE_FIELD,
45+
code_field=invoice.CREDIT_CODE_FIELD,
46+
discount_code=self.PI_SU_CREDIT_CODE,
47+
)

process_report/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
2424
prepay_debits_remote_filepath: str = "Prepay/prepay_debits.csv"
2525

2626
# Local input files
27-
nonbillable_pis_filepath: str = "pi.txt"
27+
nonbillable_pis_filepath: str = "pi.yaml"
2828
nonbillable_projects_filepath: str = "projects.yaml"
2929
prepay_projects_filepath: str = "prepaid_projects.csv"
3030
prepay_credits_filepath: str = "prepaid_credits.csv"

process_report/tests/e2e/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The `test_data/` directory contains the minimal dataset required by the pipeline
4141

4242
- **Invoice Files**: `test_nerc-ocp-test 2025-04.csv`, `test_NERC OpenShift 2025-04.csv`
4343
- **Configuration Files**:
44-
- PI lists: `test_pi.txt`
44+
- PI lists: `test_pi.yaml`
4545
- Project lists: `test_projects.txt`, `test_timed_projects.txt`
4646
- Prepay configurations: `test_prepay_debits.csv`, `test_prepay_credits.csv`, `test_prepay_projects.csv`, `test_prepay_contacts.csv`
4747
- **Historical Data**: `test_PI.csv` (old PI file), `test_alias.csv`

process_report/tests/e2e/test_data/test_pi.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- username: PI9
2+
- username: PI10
3+
non_billed_su_types:
4+
- name: SU1

process_report/tests/e2e/test_e2e_pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def _prepare_pipeline_execution(
124124
env["PREPAY_CREDITS_FILEPATH"] = str(test_files["test_prepay_credits.csv"])
125125
env["PREPAY_PROJECTS_FILEPATH"] = str(test_files["test_prepay_projects.csv"])
126126
env["PREPAY_CONTACTS_FILEPATH"] = str(test_files["test_prepay_contacts.csv"])
127-
env["nonbillable_pis_filepath"] = str(test_files["test_pi.txt"])
127+
env["nonbillable_pis_filepath"] = str(test_files["test_pi.yaml"])
128128
env["nonbillable_projects_filepath"] = str(test_files["test_projects.yaml"])
129129

130130
# Fallback ensures test works even when CI environment doesn't set Chrome path
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from unittest import TestCase
2+
3+
import pandas
4+
5+
from process_report.invoices import invoice
6+
from process_report.processors.pi_su_credit_processor import PISUCreditProcessor
7+
8+
9+
class TestDiscountProcessor(TestCase):
10+
def _get_test_invoice(
11+
self,
12+
pi,
13+
costs,
14+
su_type,
15+
credit=None,
16+
credit_code=None,
17+
pi_balance=None,
18+
balance=None,
19+
):
20+
if credit is None:
21+
credit = [None for _ in range(len(pi))]
22+
if credit_code is None:
23+
credit_code = [None for _ in range(len(pi))]
24+
if pi_balance is None:
25+
pi_balance = costs
26+
if balance is None:
27+
balance = costs
28+
29+
return pandas.DataFrame(
30+
{
31+
invoice.PI_FIELD: pi,
32+
invoice.SU_TYPE_FIELD: su_type,
33+
invoice.COST_FIELD: costs,
34+
invoice.CREDIT_FIELD: credit,
35+
invoice.CREDIT_CODE_FIELD: credit_code,
36+
invoice.PI_BALANCE_FIELD: pi_balance,
37+
invoice.BALANCE_FIELD: balance,
38+
}
39+
)
40+
41+
def test_preexisting_credit(
42+
self,
43+
):
44+
"""Tests that if there is already a credit in the invoice, the discount is added to it rather than overwriting it"""
45+
invoice_data = self._get_test_invoice(
46+
pi=["PI"],
47+
costs=[100],
48+
su_type=["Openstack Storage"],
49+
credit=[10],
50+
credit_code=["0003"],
51+
pi_balance=[90],
52+
balance=[90],
53+
)
54+
55+
processor = PISUCreditProcessor(
56+
invoice_month="2024-06",
57+
data=invoice_data,
58+
name="test",
59+
pi_su_mapping={"PI": ["Openstack Storage"]},
60+
)
61+
processor.process()
62+
output_invoice = processor.data
63+
64+
expected_invoice = self._get_test_invoice(
65+
pi=["PI"],
66+
costs=[100],
67+
su_type=["Openstack Storage"],
68+
credit=[100],
69+
credit_code=["0003,0005"],
70+
pi_balance=[0],
71+
balance=[0],
72+
)
73+
74+
assert expected_invoice.equals(output_invoice)
75+
76+
def test_one_eligible_project_only(self):
77+
"""If PI has multiple projects but only one is eligible, only that project should get the credit"""
78+
invoice_data = self._get_test_invoice(
79+
pi=["PI", "PI"],
80+
costs=[50, 75],
81+
su_type=["Openstack Storage", "HPC"],
82+
)
83+
84+
processor = PISUCreditProcessor(
85+
invoice_month="2024-06",
86+
data=invoice_data,
87+
name="test",
88+
pi_su_mapping={
89+
"PI": ["Openstack Storage"]
90+
}, # Only Openstack Storage SU type is eligible for credit, not HPC
91+
)
92+
processor.process()
93+
output_invoice = processor.data
94+
95+
expected_invoice = self._get_test_invoice(
96+
pi=["PI", "PI"],
97+
costs=[50, 75],
98+
su_type=["Openstack Storage", "HPC"],
99+
credit=[50, None],
100+
credit_code=["0005", None],
101+
pi_balance=[0, 75],
102+
balance=[0, 75],
103+
)
104+
105+
expected_invoice = expected_invoice.astype(output_invoice.dtypes)
106+
assert expected_invoice.equals(output_invoice)
107+
108+
def test_all_eligible_projects(self):
109+
"""PI has multiple projects and all are eligible, so all should get the credit"""
110+
invoice_data = self._get_test_invoice(
111+
pi=["PI", "PI"],
112+
costs=[40, 60],
113+
su_type=["Openstack Storage", "Openstack Compute"],
114+
)
115+
116+
processor = PISUCreditProcessor(
117+
invoice_month="2024-06",
118+
data=invoice_data,
119+
name="test",
120+
pi_su_mapping={
121+
"PI": ["Openstack Storage", "Openstack Compute"]
122+
}, # Both SU types are eligible for credit
123+
)
124+
processor.process()
125+
output_invoice = processor.data
126+
127+
expected_invoice = self._get_test_invoice(
128+
pi=["PI", "PI"],
129+
costs=[40, 60],
130+
su_type=["Openstack Storage", "Openstack Compute"],
131+
credit=[40, 60],
132+
credit_code=["0005", "0005"],
133+
pi_balance=[0, 0],
134+
balance=[0, 0],
135+
)
136+
137+
expected_invoice = expected_invoice.astype(output_invoice.dtypes)
138+
assert expected_invoice.equals(output_invoice)

0 commit comments

Comments
 (0)