Skip to content

Commit d68c612

Browse files
committed
Add support for non-billed service unit types
Support non-billed SU types from pi.yaml Apply credit code 0005 and zero balances for matching rows Update tests for the new YAML behavior
1 parent dde30af commit d68c612

File tree

7 files changed

+109
-5
lines changed

7 files changed

+109
-5
lines changed

process_report/loader.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,42 @@ 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) -> tuple[list[str], dict[str, list[str]]]:
117+
with open(filepath) as file:
118+
parsed = yaml.safe_load(file) or []
119+
120+
if not isinstance(parsed, list):
121+
raise ValueError("pi.yaml must contain a YAML list")
122+
123+
nonbillable_pis = []
124+
pi_non_billed_su_types = {}
125+
for entry in parsed:
126+
if not isinstance(entry, dict):
127+
continue
128+
129+
username = entry.get("username")
130+
if not username:
131+
continue
132+
133+
su_types = entry.get("non_billed_su_types")
134+
if su_types:
135+
pi_non_billed_su_types[username] = [
136+
str(item.get("name")).strip()
137+
for item in su_types
138+
if isinstance(item, dict) and item.get("name")
139+
]
140+
else:
141+
nonbillable_pis.append(username)
142+
143+
return nonbillable_pis, pi_non_billed_su_types
144+
115145
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]
146+
return self._load_pi_config(invoice_settings.nonbillable_pis_filepath)[0]
147+
148+
def get_pi_non_billed_su_types(self) -> dict[str, list[str]]:
149+
"""PI usernames -> list of SU types that receive credit (zeroed out)."""
150+
return self._load_pi_config(invoice_settings.nonbillable_pis_filepath)[1]
118151

119152
@functools.lru_cache
120153
def get_nonbillable_projects(self) -> pandas.DataFrame:

process_report/processors/validate_billable_pi_processor.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414

1515
NONBILLABLE_CLUSTERS = ["ocp-test", "barcelona"]
16+
_CREDIT_CODE_NON_BILLED_SU = "0005"
1617

1718

1819
def find_billable_projects(
@@ -131,8 +132,35 @@ def _get_billables(
131132

132133
return pi_mask & project_mask & courses_mask
133134

135+
def _apply_non_billed_su_type_credits(self):
136+
"""
137+
For PIs with non-billed-su-types in pi.yaml: Credit = Cost, Credit code 0005,
138+
zero PI Balance and Balance for matching (email, SU type) rows.
139+
"""
140+
pi_non_billed_su_types = loader.get_pi_non_billed_su_types()
141+
if not pi_non_billed_su_types:
142+
return
143+
144+
email = self.data[invoice.INVOICE_EMAIL_FIELD].fillna("").str.strip()
145+
su_type = self.data[invoice.SU_TYPE_FIELD].fillna("").str.strip()
146+
147+
for username, su_types in pi_non_billed_su_types.items():
148+
if not su_types:
149+
continue
150+
rule_mask = (email == username.strip()) & (su_type.isin(su_types))
151+
152+
self.data.loc[rule_mask, invoice.CREDIT_CODE_FIELD] = (
153+
_CREDIT_CODE_NON_BILLED_SU
154+
)
155+
self.data.loc[rule_mask, invoice.CREDIT_FIELD] = self.data.loc[
156+
rule_mask, invoice.COST_FIELD
157+
]
158+
self.data.loc[rule_mask, invoice.PI_BALANCE_FIELD] = 0
159+
self.data.loc[rule_mask, invoice.BALANCE_FIELD] = 0
160+
134161
def _process(self):
135162
self.data[invoice.IS_BILLABLE_FIELD] = self._get_billables(
136163
self.data, self.nonbillable_pis, self.nonbillable_projects
137164
)
138165
self.data[invoice.MISSING_PI_FIELD] = self._validate_pi_names(self.data)
166+
self._apply_non_billed_su_type_credits()

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- username: PI9

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

process_report/tests/unit/processors/test_validate_billable_pi_processor.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
from unittest import TestCase
2+
from unittest.mock import patch
23
import pandas
34
import uuid
45
import math
56

7+
from process_report.invoices import invoice
68
from process_report.tests import util as test_utils
79

810

911
class TestValidateBillablePIProcessor(TestCase):
12+
def setUp(self):
13+
self.get_pi_non_billed_su_types_patcher = patch(
14+
"process_report.processors.validate_billable_pi_processor.loader.get_pi_non_billed_su_types",
15+
return_value={},
16+
)
17+
self.get_pi_non_billed_su_types_patcher.start()
18+
self.addCleanup(self.get_pi_non_billed_su_types_patcher.stop)
19+
1020
def test_remove_nonbillables(self):
1121
pis = [uuid.uuid4().hex for _ in range(10)]
1222
projects = [
@@ -149,3 +159,36 @@ def test_is_course_marks_nonbillable(self):
149159
expected_billable = [False, True, True, True]
150160
actual_billable = output["Is Billable"].tolist()
151161
assert actual_billable == expected_billable
162+
163+
def test_applies_credit_code_0005_for_matching_non_billed_su_type(self):
164+
test_invoice = pandas.DataFrame(
165+
{
166+
invoice.PROJECT_FIELD: ["p1"],
167+
invoice.INVOICE_EMAIL_FIELD: ["emre_keskin@harvard.edu"],
168+
invoice.SU_TYPE_FIELD: ["Openstack Storage"],
169+
invoice.COST_FIELD: [100.0],
170+
invoice.CREDIT_CODE_FIELD: ["0001"],
171+
invoice.CREDIT_FIELD: [10.0],
172+
invoice.PI_BALANCE_FIELD: [90.0],
173+
invoice.BALANCE_FIELD: [80.0],
174+
invoice.PI_FIELD: ["emre"],
175+
"Cluster Name": ["c1"],
176+
"Is Course": [False],
177+
"Institution": ["Harvard"],
178+
}
179+
)
180+
181+
with patch(
182+
"process_report.processors.validate_billable_pi_processor.loader.get_pi_non_billed_su_types",
183+
return_value={"emre_keskin@harvard.edu": ["Openstack Storage"]},
184+
):
185+
proc = test_utils.new_validate_billable_pi_processor(data=test_invoice)
186+
proc.process()
187+
188+
assert proc.data.loc[0, invoice.CREDIT_CODE_FIELD] == "0005"
189+
assert (
190+
proc.data.loc[0, invoice.CREDIT_FIELD]
191+
== proc.data.loc[0, invoice.COST_FIELD]
192+
)
193+
assert proc.data.loc[0, invoice.PI_BALANCE_FIELD] == 0
194+
assert proc.data.loc[0, invoice.BALANCE_FIELD] == 0

0 commit comments

Comments
 (0)