diff --git a/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py b/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py new file mode 100644 index 000000000..e90e2eb27 --- /dev/null +++ b/backend/lcfs/tests/charging_equipment/test_charging_equipment_exporter.py @@ -0,0 +1,841 @@ +""" +Comprehensive unit tests for ChargingEquipmentExporter (export.py). + +Coverage areas: + - Column definition contracts (CE_INDEX, CE_MANAGE, CE_EXPORT) — labels, order, count + - _get_column_index / _get_column_letter helpers + - _build_charging_site_formulas — keys and formula template correctness + - load_charging_equipment_data — field mapping, null-safety + - export_filtered — user scoping, row building, column selection, filename, + pagination loop, empty results, null/missing ORM fields + - _current_pacific_date — format contract +""" +import io +import re +from datetime import date, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import openpyxl +import pytest + +from lcfs.db.models.compliance.ChargingEquipment import ChargingEquipment, PortsEnum +from lcfs.db.models.compliance.ChargingEquipmentStatus import ChargingEquipmentStatus +from lcfs.db.models.compliance.ChargingSite import ChargingSite +from lcfs.db.models.compliance.LevelOfEquipment import LevelOfEquipment +from lcfs.db.models.organization.Organization import Organization +from lcfs.db.models.user.UserProfile import UserProfile +from lcfs.utils.constants import FILE_MEDIA_TYPE +from lcfs.web.api.base import PaginationRequestSchema +from lcfs.web.api.charging_equipment.export import ( + CE_EXPORT_COLUMNS, + CE_INDEX_EXPORT_COLUMNS, + CE_MANAGE_EXPORT_COLUMNS, + MANAGE_FSE_EXPORT_FILENAME, + FSE_INDEX_EXPORT_FILENAME, + FSE_FILTERED_EXPORT_SHEETNAME, + ChargingEquipmentExporter, +) + + +# --------------------------------------------------------------------------- +# Test Factories +# --------------------------------------------------------------------------- + +def _make_exporter( + equipment_list: list = None, + total_count: int = None, +) -> ChargingEquipmentExporter: + """Return an exporter with a stubbed repo.""" + equipment_list = equipment_list or [] + total_count = total_count if total_count is not None else len(equipment_list) + repo = AsyncMock() + repo.get_charging_equipment_list.return_value = (equipment_list, total_count) + repo.get_all_equipment_by_organization_id.return_value = equipment_list + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + return exporter + + +def _make_user(is_government: bool, org_id: int = 1) -> UserProfile: + user = MagicMock(spec=UserProfile) + user.is_government = is_government + user.organization_id = None if is_government else org_id + return user + + +def _make_site( + org_name: str = "Supplier Co", + allocating_org_name: str | None = "Allocating Org", + site_code: str = "SITE1", + site_name: str = "Test Site", + latitude: float = 49.77, + longitude: float = -123.42, +) -> ChargingSite: + org = Organization(organization_id=1, name=org_name) + site = ChargingSite( + charging_site_id=1, + organization_id=1, + site_code=site_code, + site_name=site_name, + latitude=latitude, + longitude=longitude, + ) + site.organization = org + site.allocating_organization_name = allocating_org_name + return site + + +def _make_equipment( + *, + serial_number: str = "SN-001", + manufacturer: str = "Tesla", + model: str = "Supercharger V3", + ports: PortsEnum = PortsEnum.DUAL_PORT, + latitude: float = 49.77, + longitude: float = -123.42, + version: int = 1, + equipment_number: str = "001", + status_str: str = "Validated", + level_name: str = "Level 2", + intended_uses: list = None, + intended_users: list = None, + site: ChargingSite = None, + create_date: datetime = datetime(2024, 1, 1), + update_date: datetime = datetime(2024, 1, 2), + notes: str = "", +) -> ChargingEquipment: + status = ChargingEquipmentStatus(charging_equipment_status_id=1, status=status_str) + level = LevelOfEquipment(level_of_equipment_id=1, name=level_name) + + eq = ChargingEquipment( + charging_equipment_id=1, + charging_site_id=1, + status_id=1, + equipment_number=equipment_number, + serial_number=serial_number, + manufacturer=manufacturer, + model=model, + level_of_equipment_id=1, + ports=ports, + latitude=latitude, + longitude=longitude, + version=version, + notes=notes, + ) + eq.charging_site = site or _make_site() + eq.status = status + eq.level_of_equipment = level + eq.intended_uses = intended_uses or [] + eq.intended_users = intended_users or [] + eq.create_date = create_date + eq.update_date = update_date + return eq + + +def _labels(columns) -> list[str]: + return [col.label for col in columns] + + +async def _read_xlsx(response) -> tuple[list[str], list[list]]: + """Return (header_row, data_rows) from the first sheet of the streaming response. + + SpreadsheetBuilder pre-fills the sheet with up to 2000 empty rows, so we strip + trailing rows where every cell is None to get only the meaningful data rows. + """ + body = b"" + async for chunk in response.body_iterator: + body += chunk + wb = openpyxl.load_workbook(io.BytesIO(body)) + ws = wb.active + rows = [list(r) for r in ws.iter_rows(values_only=True)] + if not rows: + return [], [] + header = rows[0] + data_rows = [r for r in rows[1:] if any(cell is not None for cell in r)] + return header, data_rows + + +# --------------------------------------------------------------------------- +# 1. Column Definition Contracts +# --------------------------------------------------------------------------- + +class TestColumnDefinitions: + """The contract for each column list is locked — label names and ordering matter.""" + + def test_index_columns_complete_set(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + expected = [ + "Status", "Site name", "Organization", "Allocating organization", + "Registration #", "Version #", "Serial #", "Manufacturer", "Model", + "Level of equipment", "Ports", "Intended use", "Intended users", + "Latitude", "Longitude", "Created", "Last updated", + ] + assert labels == expected + + def test_manage_columns_complete_set(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + expected = [ + "Status", "Site name", "Allocating organization", + "Registration #", "Version #", "Serial #", "Manufacturer", "Model", + "Level of equipment", "Ports", "Intended use", "Intended users", + "Latitude", "Longitude", "Created", "Last updated", + ] + assert labels == expected + + def test_export_columns_complete_set(self): + labels = _labels(CE_EXPORT_COLUMNS) + expected = [ + "Charging Site", "Serial Number", "Manufacturer", "Model", + "Level of Equipment", "Ports", "Intended Uses", "Intended Users", + "Notes", "Latitude", "Longitude", + ] + assert labels == expected + + # ---- positional sanity checks for index columns ---- + + def test_index_allocating_org_immediately_after_organization(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert labels.index("Allocating organization") == labels.index("Organization") + 1 + + def test_index_allocating_org_before_registration(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert labels.index("Allocating organization") < labels.index("Registration #") + + # ---- positional sanity checks for manage columns ---- + + def test_manage_allocating_org_immediately_after_site_name(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + assert labels.index("Allocating organization") == labels.index("Site name") + 1 + + def test_manage_no_organization_column(self): + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + assert "Organization" not in labels + + def test_index_has_organization_column(self): + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert "Organization" in labels + + +# --------------------------------------------------------------------------- +# 2. _get_column_index / _get_column_letter +# --------------------------------------------------------------------------- + +class TestColumnHelpers: + def setup_method(self): + self.exporter = _make_exporter() + + def test_get_column_index_known_label(self): + # CE_EXPORT_COLUMNS: "Charging Site" is index 1 + assert self.exporter._get_column_index("Charging Site") == 1 + + def test_get_column_index_last_label(self): + # "Longitude" is the 11th column + assert self.exporter._get_column_index("Longitude") == 11 + + def test_get_column_index_unknown_raises(self): + with pytest.raises(ValueError, match="Missing column label"): + self.exporter._get_column_index("Nonexistent Column") + + def test_get_column_letter_a(self): + assert self.exporter._get_column_letter("Charging Site") == "A" + + def test_get_column_letter_k(self): + # Column 11 → "K" + assert self.exporter._get_column_letter("Longitude") == "K" + + +# --------------------------------------------------------------------------- +# 3. _build_charging_site_formulas +# --------------------------------------------------------------------------- + +class TestBuildChargingSiteFormulas: + def setup_method(self): + self.exporter = _make_exporter() + + def test_returns_two_entries(self): + formulas = self.exporter._build_charging_site_formulas(5) + assert len(formulas) == 2 + + def test_keys_are_latitude_and_longitude_indices(self): + formulas = self.exporter._build_charging_site_formulas(5) + lat_idx = self.exporter._get_column_index("Latitude") + lng_idx = self.exporter._get_column_index("Longitude") + assert lat_idx in formulas + assert lng_idx in formulas + + def test_formulas_contain_row_placeholder(self): + formulas = self.exporter._build_charging_site_formulas(5) + for formula in formulas.values(): + assert "{row}" in formula + + def test_lookup_range_uses_count_plus_one(self): + formulas = self.exporter._build_charging_site_formulas(10) + for formula in formulas.values(): + # lookup_end = 10 + 1 = 11 + assert "$G$11" in formula + + def test_latitude_formula_uses_column_6_lookup(self): + formulas = self.exporter._build_charging_site_formulas(3) + lat_idx = self.exporter._get_column_index("Latitude") + assert ",6,FALSE" in formulas[lat_idx] + + def test_longitude_formula_uses_column_7_lookup(self): + formulas = self.exporter._build_charging_site_formulas(3) + lng_idx = self.exporter._get_column_index("Longitude") + assert ",7,FALSE" in formulas[lng_idx] + + +# --------------------------------------------------------------------------- +# 4. load_charging_equipment_data +# --------------------------------------------------------------------------- + +class TestLoadChargingEquipmentData: + @pytest.mark.anyio + async def test_maps_all_fields_correctly(self): + use = MagicMock() + use.type = "Commercial" + user = MagicMock() + user.type_name = "Fleet" + + eq = _make_equipment( + serial_number="ABC123", + manufacturer="ChargePoint", + model="Express 250", + ports=PortsEnum.SINGLE_PORT, + latitude=48.5, + longitude=-123.1, + intended_uses=[use], + intended_users=[user], + notes="Some note", + ) + eq.charging_site.site_name = "My Site" + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + + assert len(data) == 1 + row = data[0] + assert row[0] == "My Site" # Charging Site + assert row[1] == "ABC123" # Serial Number + assert row[2] == "ChargePoint" # Manufacturer + assert row[3] == "Express 250" # Model + assert row[4] == "Level 2" # Level of Equipment + assert row[5] == "Single port" # Ports + assert row[6] == "Commercial" # Intended Uses + assert row[7] == "Fleet" # Intended Users + assert row[8] == "Some note" # Notes + assert row[9] == 48.5 # Latitude + assert row[10] == -123.1 # Longitude + + @pytest.mark.anyio + async def test_multiple_intended_uses_joined_with_comma(self): + use1, use2 = MagicMock(), MagicMock() + use1.type, use2.type = "Commercial", "Fleet" + eq = _make_equipment(intended_uses=[use1, use2]) + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][6] == "Commercial, Fleet" + + @pytest.mark.anyio + async def test_null_charging_site_gives_empty_site_name(self): + eq = _make_equipment() + eq.charging_site = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][0] == "" + + @pytest.mark.anyio + async def test_null_level_of_equipment_gives_empty_string(self): + eq = _make_equipment() + eq.level_of_equipment = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][4] == "" + + @pytest.mark.anyio + async def test_null_ports_gives_empty_string(self): + eq = _make_equipment() + eq.ports = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][5] == "" + + @pytest.mark.anyio + async def test_null_latitude_longitude_give_empty_string(self): + eq = _make_equipment() + eq.latitude = None + eq.longitude = None + + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][9] == "" + assert data[0][10] == "" + + @pytest.mark.anyio + async def test_empty_intended_uses_and_users(self): + eq = _make_equipment(intended_uses=[], intended_users=[]) + exporter = _make_exporter([eq]) + data = await exporter.load_charging_equipment_data(1) + assert data[0][6] == "" + assert data[0][7] == "" + + @pytest.mark.anyio + async def test_returns_empty_list_when_no_equipment(self): + exporter = _make_exporter([]) + data = await exporter.load_charging_equipment_data(1) + assert data == [] + + +# --------------------------------------------------------------------------- +# 5. export_filtered — core behaviour +# --------------------------------------------------------------------------- + +class TestExportFilteredColumnSelection: + @pytest.mark.anyio + async def test_government_uses_index_columns(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, _ = await _read_xlsx(response) + + assert headers == _labels(CE_INDEX_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_supplier_uses_manage_columns(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, _ = await _read_xlsx(response) + + assert headers == _labels(CE_MANAGE_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_government_filename_starts_with_fse_index(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + cd = response.headers.get("content-disposition", "") + assert FSE_INDEX_EXPORT_FILENAME in cd + assert ".xlsx" in cd + + @pytest.mark.anyio + async def test_supplier_filename_starts_with_manage_fse(self): + exporter = _make_exporter() + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + cd = response.headers.get("content-disposition", "") + assert MANAGE_FSE_EXPORT_FILENAME in cd + + @pytest.mark.anyio + async def test_filename_includes_date(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + with patch.object( + ChargingEquipmentExporter, "_current_pacific_date", return_value="2025-06-15" + ): + response = await exporter.export_filtered(user=user, pagination=pagination) + + cd = response.headers.get("content-disposition", "") + assert "2025-06-15" in cd + + @pytest.mark.anyio + async def test_response_media_type_is_xlsx(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + assert response.media_type == FILE_MEDIA_TYPE["XLSX"].value + + @pytest.mark.anyio + async def test_sheet_name_is_fse(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + body = b"".join([chunk async for chunk in response.body_iterator]) + wb = openpyxl.load_workbook(io.BytesIO(body)) + assert FSE_FILTERED_EXPORT_SHEETNAME in wb.sheetnames + + +class TestExportFilteredOrganizationScoping: + @pytest.mark.anyio + async def test_government_with_org_id_uses_provided_org_id(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination, organization_id=42) + + exporter.repo.get_charging_equipment_list.assert_called_once() + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id == 42 + + @pytest.mark.anyio + async def test_government_without_org_id_uses_none(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination, organization_id=None) + + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id is None + + @pytest.mark.anyio + async def test_supplier_always_uses_own_org_id(self): + exporter = _make_exporter() + user = _make_user(is_government=False, org_id=7) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + # Even if an org_id is passed, supplier's own org should be used + await exporter.export_filtered(user=user, pagination=pagination, organization_id=99) + + call_org_id = exporter.repo.get_charging_equipment_list.call_args[0][0] + assert call_org_id == 7 + + @pytest.mark.anyio + async def test_government_excludes_drafts(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + call_kwargs = exporter.repo.get_charging_equipment_list.call_args.kwargs + assert call_kwargs.get("exclude_draft") is True + + @pytest.mark.anyio + async def test_supplier_does_not_exclude_drafts(self): + exporter = _make_exporter() + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + call_kwargs = exporter.repo.get_charging_equipment_list.call_args.kwargs + assert call_kwargs.get("exclude_draft") is False + + +class TestExportFilteredRowBuilding: + @pytest.mark.anyio + async def test_government_row_maps_all_fields(self): + use = MagicMock() + use.type = "Commercial" + end_user = MagicMock() + end_user.type_name = "Fleet" + eq = _make_equipment( + status_str="Validated", + manufacturer="Tesla", + model="V3", + serial_number="XY-99", + level_name="Level 2", + ports=PortsEnum.DUAL_PORT, + latitude=49.77, + longitude=-123.4, + version=3, + intended_uses=[use], + intended_users=[end_user], + create_date=datetime(2024, 3, 15), + update_date=datetime(2024, 4, 20), + site=_make_site( + org_name="OrgA", + allocating_org_name="OrgB", + site_code="ABC01", + site_name="Alpha Site", + ), + ) + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + row = data_rows[0] + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert row[labels.index("Status")] == "Validated" + assert row[labels.index("Site name")] == "Alpha Site" + assert row[labels.index("Organization")] == "OrgA" + assert row[labels.index("Allocating organization")] == "OrgB" + assert row[labels.index("Serial #")] == "XY-99" + assert row[labels.index("Manufacturer")] == "Tesla" + assert row[labels.index("Model")] == "V3" + assert row[labels.index("Level of equipment")] == "Level 2" + assert row[labels.index("Ports")] == "Dual port" + assert row[labels.index("Intended use")] == "Commercial" + assert row[labels.index("Intended users")] == "Fleet" + assert row[labels.index("Latitude")] == 49.77 + assert row[labels.index("Longitude")] == -123.4 + # openpyxl reads date cells back as datetime (midnight), not date + created = row[labels.index("Created")] + updated = row[labels.index("Last updated")] + assert getattr(created, "date", lambda: created)() == date(2024, 3, 15) + assert getattr(updated, "date", lambda: updated)() == date(2024, 4, 20) + + @pytest.mark.anyio + async def test_supplier_row_does_not_include_organization_field(self): + eq = _make_equipment() + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, data_rows = await _read_xlsx(response) + + assert "Organization" not in headers + assert len(data_rows[0]) == len(CE_MANAGE_EXPORT_COLUMNS) + + @pytest.mark.anyio + async def test_supplier_row_allocating_org_after_site_name(self): + eq = _make_equipment( + site=_make_site(allocating_org_name="FortisBC", site_name="My Site") + ) + exporter = _make_exporter([eq]) + user = _make_user(is_government=False) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_MANAGE_EXPORT_COLUMNS) + row = data_rows[0] + assert row[labels.index("Site name")] == "My Site" + assert row[labels.index("Allocating organization")] == "FortisBC" + + @pytest.mark.anyio + async def test_null_allocating_org_writes_empty(self): + eq = _make_equipment( + site=_make_site(allocating_org_name=None) + ) + exporter = _make_exporter([eq]) + + for is_gov in (True, False): + user = _make_user(is_government=is_gov) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + cols = CE_INDEX_EXPORT_COLUMNS if is_gov else CE_MANAGE_EXPORT_COLUMNS + alloc_idx = _labels(cols).index("Allocating organization") + assert data_rows[0][alloc_idx] in (None, "") + + @pytest.mark.anyio + async def test_null_charging_site_gives_empty_site_and_org_fields(self): + eq = _make_equipment() + eq.charging_site = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + row = data_rows[0] + assert row[labels.index("Site name")] in (None, "") + assert row[labels.index("Organization")] in (None, "") + assert row[labels.index("Allocating organization")] in (None, "") + + @pytest.mark.anyio + async def test_null_status_gives_empty_status_field(self): + eq = _make_equipment() + eq.status = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Status")] in (None, "") + + @pytest.mark.anyio + async def test_null_level_gives_empty_level_field(self): + eq = _make_equipment() + eq.level_of_equipment = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Level of equipment")] in (None, "") + + @pytest.mark.anyio + async def test_null_ports_gives_empty_ports_field(self): + eq = _make_equipment() + eq.ports = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Ports")] in (None, "") + + @pytest.mark.anyio + async def test_null_latitude_longitude_give_empty_values(self): + eq = _make_equipment() + eq.latitude = None + eq.longitude = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Latitude")] in (None, "") + assert data_rows[0][labels.index("Longitude")] in (None, "") + + @pytest.mark.anyio + async def test_null_dates_write_empty(self): + eq = _make_equipment(create_date=None, update_date=None) + eq.create_date = None + eq.update_date = None + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Created")] is None + assert data_rows[0][labels.index("Last updated")] is None + + @pytest.mark.anyio + async def test_multiple_intended_uses_joined_with_comma(self): + u1, u2 = MagicMock(), MagicMock() + u1.type, u2.type = "Commercial", "Fleet" + eq = _make_equipment(intended_uses=[u1, u2]) + + exporter = _make_exporter([eq]) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + labels = _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows[0][labels.index("Intended use")] == "Commercial, Fleet" + + @pytest.mark.anyio + async def test_empty_result_set_writes_header_only(self): + exporter = _make_exporter(equipment_list=[], total_count=0) + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + headers, data_rows = await _read_xlsx(response) + + assert headers == _labels(CE_INDEX_EXPORT_COLUMNS) + assert data_rows == [] + + +class TestExportFilteredPagination: + @pytest.mark.anyio + async def test_fetches_all_pages_until_total_satisfied(self): + """When total_count > first page size, the loop should page through all results.""" + page1 = [_make_equipment(serial_number="SN-1")] + page2 = [_make_equipment(serial_number="SN-2")] + + repo = AsyncMock() + repo.get_charging_equipment_list.side_effect = [ + (page1, 2), # page 1: 1 row, total 2 + (page2, 2), # page 2: 1 row, total 2 — all collected now + ([], 2), # safety: should not reach here + ] + + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + assert len(data_rows) == 2 + assert repo.get_charging_equipment_list.call_count == 2 + + @pytest.mark.anyio + async def test_stops_when_empty_page_returned(self): + """If the repo returns an empty list before total is met, the loop must stop.""" + repo = AsyncMock() + repo.get_charging_equipment_list.side_effect = [ + ([_make_equipment()], 999), # large total, but next page is empty + ([], 999), + ] + + exporter = ChargingEquipmentExporter.__new__(ChargingEquipmentExporter) + exporter.repo = repo + + user = _make_user(is_government=True) + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + response = await exporter.export_filtered(user=user, pagination=pagination) + _, data_rows = await _read_xlsx(response) + + assert len(data_rows) == 1 + assert repo.get_charging_equipment_list.call_count == 2 + + @pytest.mark.anyio + async def test_pagination_uses_page_size_1000(self): + exporter = _make_exporter() + user = _make_user(is_government=True) + # Pass a different size; exporter should override to 1000 + pagination = PaginationRequestSchema(page=1, size=25, sort_orders=[]) + + await exporter.export_filtered(user=user, pagination=pagination) + + # Second positional arg to get_charging_equipment_list is the pagination object + call_pagination = exporter.repo.get_charging_equipment_list.call_args.args[1] + assert call_pagination.size == 1000 + + +# --------------------------------------------------------------------------- +# 6. _current_pacific_date +# --------------------------------------------------------------------------- + +class TestCurrentPacificDate: + def test_returns_yyyy_mm_dd_format(self): + result = ChargingEquipmentExporter._current_pacific_date() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", result), ( + f"Expected YYYY-MM-DD, got {result!r}" + ) + + def test_returns_string(self): + assert isinstance(ChargingEquipmentExporter._current_pacific_date(), str) diff --git a/backend/lcfs/tests/charging_equipment/test_charging_equipment_importer.py b/backend/lcfs/tests/charging_equipment/test_charging_equipment_importer.py index c12e409ca..58d94c4bc 100644 --- a/backend/lcfs/tests/charging_equipment/test_charging_equipment_importer.py +++ b/backend/lcfs/tests/charging_equipment/test_charging_equipment_importer.py @@ -1,27 +1,42 @@ from lcfs.web.api.charging_equipment.importer import _DuplicateSerialTracker -def test_duplicate_tracker_detects_file_duplicates(): +def test_duplicate_tracker_detects_file_duplicates_same_site(): tracker = _DuplicateSerialTracker() - assert tracker.is_duplicate("ABC123") is False # first occurrence allowed + assert tracker.is_duplicate("ABC123", 1) is False # first occurrence allowed assert tracker.summary_message() is None - assert tracker.is_duplicate("abc123") is True # case-insensitive duplicate + assert tracker.is_duplicate("abc123", 1) is True # case-insensitive duplicate at same site assert ( tracker.summary_message() == "1 record with duplicate serial numbers was not uploaded." ) -def test_duplicate_tracker_existing_serials_blocked(): - tracker = _DuplicateSerialTracker(existing_serials={"SER-9"}) - assert tracker.is_duplicate("SER-9") is True +def test_duplicate_tracker_allows_same_serial_different_site(): + """Same serial number at a different charging site should be allowed.""" + tracker = _DuplicateSerialTracker() + assert tracker.is_duplicate("ABC123", 1) is False + assert tracker.is_duplicate("ABC123", 2) is False # different site → allowed + assert tracker.summary_message() is None + + +def test_duplicate_tracker_existing_serials_blocked_same_site(): + tracker = _DuplicateSerialTracker(existing_serials=[("SER-9", 10)]) + assert tracker.is_duplicate("SER-9", 10) is True # same site → blocked assert ( tracker.summary_message() == "1 record with duplicate serial numbers was not uploaded." ) # Subsequent duplicates continue to increment - assert tracker.is_duplicate("ser-9") is True + assert tracker.is_duplicate("ser-9", 10) is True assert ( tracker.summary_message() == "2 records with duplicate serial numbers were not uploaded." ) + + +def test_duplicate_tracker_existing_serials_allowed_different_site(): + """Existing serial at a different site should not block the upload.""" + tracker = _DuplicateSerialTracker(existing_serials=[("SER-9", 10)]) + assert tracker.is_duplicate("SER-9", 20) is False # different site → allowed + assert tracker.summary_message() is None diff --git a/backend/lcfs/tests/charging_equipment/test_charging_equipment_repo.py b/backend/lcfs/tests/charging_equipment/test_charging_equipment_repo.py index 3d5599faa..3ebbeb840 100644 --- a/backend/lcfs/tests/charging_equipment/test_charging_equipment_repo.py +++ b/backend/lcfs/tests/charging_equipment/test_charging_equipment_repo.py @@ -67,18 +67,18 @@ async def test_get_charging_equipment_by_id_not_found(repo, mock_db): @pytest.mark.anyio async def test_get_serial_numbers_for_organization(repo, mock_db): - """Serial numbers for org should be normalized and deduplicated.""" + """Serial numbers for org should be normalized, paired with site_id, and deduplicated.""" mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [ - "SER-1", - " ser-2 ", - None, + mock_result.all.return_value = [ + ("SER-1", 10), + (" ser-2 ", 20), + (None, 30), ] mock_db.execute.return_value = mock_result serials = await repo.get_serial_numbers_for_organization(5) - assert serials == {"SER-1", "SER-2"} + assert serials == {("SER-1", 10), ("SER-2", 20)} mock_db.execute.assert_called_once() diff --git a/backend/lcfs/tests/charging_site/test_charging_site_services.py b/backend/lcfs/tests/charging_site/test_charging_site_services.py index b31a9fe3b..0ede0f05f 100644 --- a/backend/lcfs/tests/charging_site/test_charging_site_services.py +++ b/backend/lcfs/tests/charging_site/test_charging_site_services.py @@ -1225,19 +1225,15 @@ async def test_search_allocation_organizations_success( mock_org2, ] - # Mock transaction partners - mock_repo.get_transaction_partners_from_allocation_agreements.return_value = [ - "ABC Company", # Duplicate - should be filtered + # Mock all deduplicated names (sorted) from the consolidated repo method + mock_repo.get_allocating_organization_names.return_value = [ + "ABC Company", + "ABC Corporation", + "ABC Historical C", "ABC Partner A", "ABC Partner B", ] - # Mock historical names - mock_repo.get_distinct_allocating_organization_names.return_value = [ - "ABC Corporation", # Duplicate - should be filtered - "ABC Historical C", - ] - result = await charging_site_service.search_allocation_organizations(1, "abc") assert len(result) == 5 # 2 matched + 3 unmatched (duplicates removed) @@ -1251,10 +1247,7 @@ async def test_search_allocation_organizations_success( assert len(unmatched) == 3 mock_repo.get_allocation_agreement_organizations.assert_called_once_with(1) - mock_repo.get_transaction_partners_from_allocation_agreements.assert_called_once_with( - 1 - ) - mock_repo.get_distinct_allocating_organization_names.assert_called_once_with(1) + mock_repo.get_allocating_organization_names.assert_called_once_with(1) @pytest.mark.anyio async def test_search_allocation_organizations_with_query_filter( @@ -1273,11 +1266,11 @@ async def test_search_allocation_organizations_with_query_filter( mock_org1, mock_org2, ] - mock_repo.get_transaction_partners_from_allocation_agreements.return_value = [ - "ABC Partner" - ] - mock_repo.get_distinct_allocating_organization_names.return_value = [ - "XYZ Historical" + mock_repo.get_allocating_organization_names.return_value = [ + "ABC Company", + "ABC Partner", + "XYZ Corporation", + "XYZ Historical", ] # Search for "abc" - should only return ABC entries @@ -1296,11 +1289,10 @@ async def test_search_allocation_organizations_empty_query( mock_org.name = "Test Org" mock_repo.get_allocation_agreement_organizations.return_value = [mock_org] - mock_repo.get_transaction_partners_from_allocation_agreements.return_value = [ - "Partner A" - ] - mock_repo.get_distinct_allocating_organization_names.return_value = [ - "Historical B" + mock_repo.get_allocating_organization_names.return_value = [ + "Historical B", + "Partner A", + "Test Org", ] result = await charging_site_service.search_allocation_organizations(1, "") @@ -1321,8 +1313,9 @@ async def test_search_allocation_organizations_limits_results( mock_orgs.append(mock_org) mock_repo.get_allocation_agreement_organizations.return_value = mock_orgs - mock_repo.get_transaction_partners_from_allocation_agreements.return_value = [] - mock_repo.get_distinct_allocating_organization_names.return_value = [] + mock_repo.get_allocating_organization_names.return_value = [ + f"Org {i:02d}" for i in range(60) + ] result = await charging_site_service.search_allocation_organizations(1, "org") diff --git a/backend/lcfs/tests/compliance_report/test_compliance_report_export.py b/backend/lcfs/tests/compliance_report/test_compliance_report_export.py index 925c1c401..012b34dec 100644 --- a/backend/lcfs/tests/compliance_report/test_compliance_report_export.py +++ b/backend/lcfs/tests/compliance_report/test_compliance_report_export.py @@ -27,6 +27,8 @@ def mock_fse_repo(): repo = AsyncMock() # get_fse_paginated returns a tuple (data, total_count) repo.get_fse_paginated = AsyncMock(return_value=([], 0)) + repo.get_fse_reporting_list_paginated = AsyncMock(return_value=([], 0)) + repo.get_effective_fse_reporting_rows_for_export = AsyncMock(return_value=[]) return repo @@ -101,11 +103,13 @@ def mock_annual_report(): report = Mock() report.compliance_report_id = 1 report.compliance_report_group_uuid = "test-uuid" + report.organization_id = 1 report.version = 0 report.reporting_frequency = ReportingFrequency.ANNUAL # Mock organization organization = Mock() + organization.organization_id = 1 organization.name = "Test Organization" report.organization = organization @@ -433,6 +437,50 @@ async def test_load_fuel_supply_data_quarterly( assert total_row[1] == "Total" # "Total" label in Fuel type column assert len(total_row) == len(expected_headers) + @pytest.mark.anyio + async def test_export_uses_effective_fse_rows_for_bceid_users( + self, + compliance_report_exporter, + mock_annual_report, + ): + exporter = compliance_report_exporter + exporter.cr_repo.get_compliance_report_by_id.return_value = mock_annual_report + exporter.summary_service.calculate_fuel_supply_compliance_units = AsyncMock( + return_value=1000 + ) + exporter.summary_service.calculate_fuel_export_compliance_units = AsyncMock( + return_value=-500 + ) + + await exporter.export(1, is_government=False) + + exporter.fse_repo.get_effective_fse_reporting_rows_for_export.assert_awaited_once_with( + organization_id=1, + compliance_report_id=1, + compliance_report_group_uuid="test-uuid", + ) + exporter.fse_repo.get_fse_reporting_list_paginated.assert_not_called() + + @pytest.mark.anyio + async def test_export_keeps_summary_fse_query_for_government_users( + self, + compliance_report_exporter, + mock_annual_report, + ): + exporter = compliance_report_exporter + exporter.cr_repo.get_compliance_report_by_id.return_value = mock_annual_report + exporter.summary_service.calculate_fuel_supply_compliance_units = AsyncMock( + return_value=1000 + ) + exporter.summary_service.calculate_fuel_export_compliance_units = AsyncMock( + return_value=-500 + ) + + await exporter.export(1, is_government=True) + + exporter.fse_repo.get_fse_reporting_list_paginated.assert_called_once() + exporter.fse_repo.get_effective_fse_reporting_rows_for_export.assert_not_called() + @pytest.mark.anyio async def test_load_notional_transfer_data_annual( self, diff --git a/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_export.py b/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_export.py index 60c57b5c5..297f77858 100644 --- a/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_export.py +++ b/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_export.py @@ -66,6 +66,7 @@ def _make_exporter(fse_rows=None, report_found=True, group_uuid="group-abc"): def _fse_row( registration="ORG-AAAA1A-001", site_name="Charge Site 1", + serial_number="SN-12345", supply_from=datetime.date(2024, 1, 1), supply_to=datetime.date(2024, 12, 31), kwh_usage=1500.0, @@ -76,6 +77,7 @@ def _fse_row( row = MagicMock() row.registration_number = registration row.site_name = site_name + row.serial_number = serial_number row.supply_from_date = supply_from row.supply_to_date = supply_to row.kwh_usage = kwh_usage @@ -153,12 +155,16 @@ def test_headers_second_column_is_registration(): assert HEADERS[1] == "Registration #" +def test_headers_third_column_is_serial(): + assert HEADERS[2] == "Serial #" + + def test_header_labels(): """Spot-check all header labels.""" - assert HEADERS[2] == "Dates of supply from" - assert HEADERS[3] == "Dates of supply to" - assert HEADERS[4] == "kWh usage" - assert HEADERS[5] == "Compliance notes" + assert HEADERS[3] == "Dates of supply from" + assert HEADERS[4] == "Dates of supply to" + assert HEADERS[5] == "kWh usage" + assert HEADERS[6] == "Compliance notes" def test_column_count_matches_headers(): @@ -184,6 +190,7 @@ def _sample_row(): return [ "Charge Site 1", "ORG-001", + "SN-12345", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), 500, @@ -220,42 +227,57 @@ def test_registration_cell_is_locked(): assert ws.cell(row=2, column=2).protection.locked is True +def test_serial_cell_is_locked(): + """Col C (serial #) must be locked for existing data rows.""" + exp = _make_exporter() + period = _compliance_period() + wb = exp._build_workbook([_sample_row()], period) + ws = wb[FSE_UPDATE_SHEETNAME] + assert ws.cell(row=2, column=3).protection.locked is True + + def test_editable_columns_are_unlocked(): - """Cols C–F (dates, kWh, notes) must be unlocked for existing data rows.""" + """Cols D–G (dates, kWh, notes) must be unlocked for existing data rows.""" exp = _make_exporter() period = _compliance_period() wb = exp._build_workbook([_sample_row()], period) ws = wb[FSE_UPDATE_SHEETNAME] - for col in (3, 4, 5, 6): + for col in (4, 5, 6, 7): assert ws.cell(row=2, column=col).protection.locked is False, ( f"Column {col} should be unlocked" ) def test_empty_rows_for_new_entries_are_unlocked(): - """The 500 empty rows appended after data rows must all be unlocked.""" + """The 500 empty rows appended after data rows must be unlocked (except Serial #).""" exp = _make_exporter() period = _compliance_period() wb = exp._build_workbook([_sample_row()], period) ws = wb[FSE_UPDATE_SHEETNAME] # Data rows start at row 2; one data row → first empty row is row 3 first_empty = 3 - for col in range(1, 7): + for col in range(1, 8): cell = ws.cell(row=first_empty, column=col) - assert cell.protection.locked is False, ( - f"Empty row col {col} should be unlocked" - ) + if col == 3: + # Serial # column is always locked + assert cell.protection.locked is True, ( + "Empty row Serial # col should be locked" + ) + else: + assert cell.protection.locked is False, ( + f"Empty row col {col} should be unlocked" + ) def test_empty_rows_date_columns_have_date_format(): - """Empty rows' date columns (C, D) must carry the date number format.""" + """Empty rows' date columns (D, E) must carry the date number format.""" exp = _make_exporter() period = _compliance_period() wb = exp._build_workbook([], period) ws = wb[FSE_UPDATE_SHEETNAME] # No data rows → first empty row is row 2 - assert ws.cell(row=2, column=3).number_format == "yyyy-mm-dd" assert ws.cell(row=2, column=4).number_format == "yyyy-mm-dd" + assert ws.cell(row=2, column=5).number_format == "yyyy-mm-dd" def test_sheet_protection_is_enabled(): @@ -271,9 +293,9 @@ def test_date_columns_have_date_number_format(): period = _compliance_period() wb = exp._build_workbook([_sample_row()], period) ws = wb[FSE_UPDATE_SHEETNAME] - # Col C = 3, Col D = 4 - assert ws.cell(row=2, column=3).number_format == "yyyy-mm-dd" + # Col D = 4, Col E = 5 assert ws.cell(row=2, column=4).number_format == "yyyy-mm-dd" + assert ws.cell(row=2, column=5).number_format == "yyyy-mm-dd" def test_kwh_column_has_numeric_format(): @@ -281,8 +303,8 @@ def test_kwh_column_has_numeric_format(): period = _compliance_period() wb = exp._build_workbook([_sample_row()], period) ws = wb[FSE_UPDATE_SHEETNAME] - # Col E = 5 - assert ws.cell(row=2, column=5).number_format == "#,##0" + # Col F = 6 + assert ws.cell(row=2, column=6).number_format == "#,##0" def test_data_validators_are_added(): @@ -329,6 +351,14 @@ async def test_load_fse_data_registration_is_second_column(): assert rows[0][1] == "0000A-001" +@pytest.mark.anyio +async def test_load_fse_data_serial_is_third_column(): + row = _fse_row(serial_number="SN-99") + exporter = _make_exporter(fse_rows=[row]) + rows = await exporter._load_fse_data(1, "group-abc") + assert rows[0][2] == "SN-99" + + @pytest.mark.anyio async def test_load_fse_data_converts_datetime_to_date(): """datetime objects from the DB must be converted to plain date.""" @@ -339,8 +369,8 @@ async def test_load_fse_data_converts_datetime_to_date(): exporter = _make_exporter(fse_rows=[row]) rows = await exporter._load_fse_data(1, "group-abc") - assert rows[0][2] == datetime.date(2024, 3, 15) - assert rows[0][3] == datetime.date(2024, 9, 30) + assert rows[0][3] == datetime.date(2024, 3, 15) + assert rows[0][4] == datetime.date(2024, 9, 30) @pytest.mark.anyio @@ -349,7 +379,7 @@ async def test_load_fse_data_kwh_none_becomes_zero_for_active_rows(): row = _fse_row(kwh_usage=None) exporter = _make_exporter(fse_rows=[row]) rows = await exporter._load_fse_data(1, "group-abc") - assert rows[0][4] == 0 + assert rows[0][5] == 0 @pytest.mark.anyio @@ -361,10 +391,11 @@ async def test_load_fse_data_inactive_row_shows_only_site_and_reg(): assert rows[0][0] == "Charge Site 1" # site name preserved assert rows[0][1] == "ORG-AAAA1A-001" # reg # preserved - assert rows[0][2] is None # from date blank - assert rows[0][3] is None # to date blank - assert rows[0][4] is None # kWh blank - assert rows[0][5] is None # notes blank + assert rows[0][2] == "SN-12345" # serial # preserved + assert rows[0][3] is None # from date blank + assert rows[0][4] is None # to date blank + assert rows[0][5] is None # kWh blank + assert rows[0][6] is None # notes blank @pytest.mark.anyio @@ -377,8 +408,9 @@ async def test_load_fse_data_other_report_group_shows_only_site_and_reg(): assert rows[0][0] == "Charge Site 1" assert rows[0][1] == "ORG-AAAA1A-001" - assert rows[0][2] is None - assert rows[0][4] is None + assert rows[0][2] == "SN-12345" # serial # preserved + assert rows[0][3] is None + assert rows[0][5] is None @pytest.mark.anyio diff --git a/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_importer.py b/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_importer.py index 52ab26b40..4ccb0575d 100644 --- a/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_importer.py +++ b/backend/lcfs/tests/final_supply_equipment/test_fse_reporting_importer.py @@ -440,12 +440,12 @@ async def fake_update(redis_client, job_id, progress, status_msg, return progress_records -# col layout: [site_name, reg_num, from, to, kwh, notes] +# col layout: [site_name, reg_num, serial#, from, to, kwh, notes] @pytest.mark.anyio async def test_row_missing_registration_is_rejected(): """Empty registration number → rejected.""" - rows = [["Site A", None, "2024-01-01", "2024-12-31", 500, "note"]] + rows = [["Site A", None, "SN-1", "2024-01-01", "2024-12-31", 500, "note"]] records = await _run_import(rows) final = records[-1] assert final["rejected"] == 1 @@ -455,7 +455,7 @@ async def test_row_missing_registration_is_rejected(): @pytest.mark.anyio async def test_row_invalid_kwh_is_rejected(): """Non-numeric kWh value → rejected.""" - rows = [["Site A", "REG-001", "2024-01-01", "2024-12-31", "NOT_A_NUMBER", "note"]] + rows = [["Site A", "REG-001", "SN-1", "2024-01-01", "2024-12-31", "NOT_A_NUMBER", "note"]] records = await _run_import(rows) final = records[-1] assert final["rejected"] == 1 @@ -465,7 +465,7 @@ async def test_row_invalid_kwh_is_rejected(): @pytest.mark.anyio async def test_row_negative_kwh_is_rejected(): """Negative kWh value → rejected.""" - rows = [["Site A", "REG-001", "2024-01-01", "2024-12-31", -100, "note"]] + rows = [["Site A", "REG-001", "SN-1", "2024-01-01", "2024-12-31", -100, "note"]] records = await _run_import(rows) final = records[-1] assert final["rejected"] == 1 @@ -474,7 +474,7 @@ async def test_row_negative_kwh_is_rejected(): @pytest.mark.anyio async def test_row_invalid_date_is_rejected(): """Unparseable 'from' date → rejected.""" - rows = [["Site A", "REG-001", "not-a-date", "2024-12-31", 500, "note"]] + rows = [["Site A", "REG-001", "SN-1", "not-a-date", "2024-12-31", 500, "note"]] records = await _run_import(rows) final = records[-1] assert final["rejected"] == 1 @@ -483,7 +483,7 @@ async def test_row_invalid_date_is_rejected(): @pytest.mark.anyio async def test_row_inverted_date_range_is_rejected(): """supply_from > supply_to → rejected.""" - rows = [["Site A", "REG-001", "2024-12-31", "2024-01-01", 500, "note"]] + rows = [["Site A", "REG-001", "SN-1", "2024-12-31", "2024-01-01", 500, "note"]] records = await _run_import(rows) final = records[-1] assert final["rejected"] == 1 @@ -496,7 +496,7 @@ async def test_row_registration_not_found_is_rejected(): fse_repo.get_charging_equipment_by_registration_number = AsyncMock( return_value=None ) - rows = [["Site A", "UNKNOWN-REG", "2024-01-01", "2024-12-31", 500, "note"]] + rows = [["Site A", "UNKNOWN-REG", "SN-1", "2024-01-01", "2024-12-31", 500, "note"]] records = await _run_import(rows, fse_repo_override=fse_repo) final = records[-1] assert final["rejected"] == 1 @@ -519,7 +519,7 @@ async def test_valid_row_existing_record_is_updated_and_activated(): fse_repo.get_fse_reporting_record_for_group = AsyncMock(return_value=existing) fse_repo.bulk_update_fse_reporting_record = AsyncMock(return_value=None) - rows = [["Site A", "ORG-001-001", "2024-01-01", "2024-12-31", 500, "good note"]] + rows = [["Site A", "ORG-001-001", "SN-1", "2024-01-01", "2024-12-31", 500, "good note"]] records = await _run_import(rows, fse_repo_override=fse_repo) final = records[-1] @@ -558,7 +558,7 @@ async def test_row_all_editable_empty_with_existing_record_calls_deactivate(): fse_repo.bulk_update_fse_reporting_record = AsyncMock(return_value=None) # All editable columns blank: no from, no to, no kWh, no notes - rows = [["Site A", "ORG-001-001", None, None, None, None]] + rows = [["Site A", "ORG-001-001", "SN-1", None, None, None, None]] records = await _run_import(rows, fse_repo_override=fse_repo) fse_repo.bulk_update_fse_reporting_record.assert_called_once() @@ -584,7 +584,7 @@ async def test_row_all_editable_empty_no_existing_record_is_skipped(): fse_repo.get_fse_reporting_record_for_group = AsyncMock(return_value=None) fse_repo.bulk_update_fse_reporting_record = AsyncMock(return_value=None) - rows = [["Site A", "ORG-001-001", None, None, None, None]] + rows = [["Site A", "ORG-001-001", "SN-1", None, None, None, None]] records = await _run_import(rows, fse_repo_override=fse_repo) final = records[-1] @@ -612,7 +612,7 @@ async def test_row_note_only_activates_row_with_zero_kwh(): fse_repo.get_fse_reporting_record_for_group = AsyncMock(return_value=existing) fse_repo.bulk_update_fse_reporting_record = AsyncMock(return_value=None) - rows = [["Site A", "ORG-001-001", None, None, None, "Just a note"]] + rows = [["Site A", "ORG-001-001", "SN-1", None, None, None, "Just a note"]] records = await _run_import(rows, fse_repo_override=fse_repo) final = records[-1] @@ -626,7 +626,7 @@ async def test_row_note_only_activates_row_with_zero_kwh(): @pytest.mark.anyio async def test_fully_blank_row_is_silently_skipped(): """Rows where every cell is None are silently ignored (not counted).""" - rows = [[None, None, None, None, None, None]] + rows = [[None, None, None, None, None, None, None]] records = await _run_import(rows) final = records[-1] assert final["updated"] == 0 @@ -652,9 +652,9 @@ async def test_mixed_rows_counters_are_accurate(): fse_repo.bulk_update_fse_reporting_record = AsyncMock(return_value=None) rows = [ - ["Site A", "ORG-001", "2024-01-01", "2024-12-31", 300, "ok"], # updated - ["Site A", "ORG-001", None, None, None, None], # deactivated (also counted as updated) - [None, None, "2024-01-01", "2024-12-31", 100, "no reg"], # rejected + ["Site A", "ORG-001", "SN-1", "2024-01-01", "2024-12-31", 300, "ok"], # updated + ["Site A", "ORG-001", "SN-1", None, None, None, None], # deactivated (also counted as updated) + [None, None, None, "2024-01-01", "2024-12-31", 100, "no reg"], # rejected ] records = await _run_import(rows, fse_repo_override=fse_repo) final = records[-1] diff --git a/backend/lcfs/web/api/charging_equipment/export.py b/backend/lcfs/web/api/charging_equipment/export.py index 09310ddcd..423e5cda1 100644 --- a/backend/lcfs/web/api/charging_equipment/export.py +++ b/backend/lcfs/web/api/charging_equipment/export.py @@ -40,6 +40,7 @@ SpreadsheetColumn("Status", "text"), SpreadsheetColumn("Site name", "text"), SpreadsheetColumn("Organization", "text"), + SpreadsheetColumn("Allocating organization", "text"), SpreadsheetColumn("Registration #", "text"), SpreadsheetColumn("Version #", "int"), SpreadsheetColumn("Serial #", "text"), @@ -58,6 +59,7 @@ CE_MANAGE_EXPORT_COLUMNS = [ SpreadsheetColumn("Status", "text"), SpreadsheetColumn("Site name", "text"), + SpreadsheetColumn("Allocating organization", "text"), SpreadsheetColumn("Registration #", "text"), SpreadsheetColumn("Version #", "int"), SpreadsheetColumn("Serial #", "text"), @@ -375,9 +377,15 @@ async def export_filtered( equipment.update_date.date() if getattr(equipment, "update_date", None) else None ) + allocating_organization_name = ( + equipment.charging_site.allocating_organization_name + if equipment.charging_site + else "" + ) or "" common_values = [ status, site_name, + allocating_organization_name, registration_number, equipment.version, equipment.serial_number, @@ -398,6 +406,7 @@ async def export_filtered( status, site_name, organization_name, + allocating_organization_name, registration_number, equipment.version, equipment.serial_number, diff --git a/backend/lcfs/web/api/charging_equipment/importer.py b/backend/lcfs/web/api/charging_equipment/importer.py index 324dbe900..04c7a1819 100644 --- a/backend/lcfs/web/api/charging_equipment/importer.py +++ b/backend/lcfs/web/api/charging_equipment/importer.py @@ -376,7 +376,7 @@ async def import_async( f"Row {row_idx}: Intended User '{clean}' not found; skipping this value" ) - if duplicate_tracker.is_duplicate(serial_number): + if duplicate_tracker.is_duplicate(serial_number, charging_site_id): rejected += 1 continue @@ -485,31 +485,39 @@ async def _update_progress( class _DuplicateSerialTracker: """ - Tracks duplicate serial numbers within a single upload while - considering existing records for an organization. + Tracks duplicate serial numbers *per charging site* within a single upload + while considering existing records for an organization. + + Duplicate serial numbers are only blocked when they occur within the same + Charging Site. The same serial number at a different site is allowed + (equipment may be relocated between sites). """ - def __init__(self, existing_serials: Iterable[str] | None = None) -> None: - normalized_existing: set[str] = set() - for serial in existing_serials or []: + def __init__( + self, existing_serials: Iterable[tuple[str, int]] | None = None + ) -> None: + # Store as set of (normalized_serial, charging_site_id) tuples + normalized_existing: set[tuple[str, int]] = set() + for serial, site_id in existing_serials or []: normalized = _normalize_serial(serial) if normalized: - normalized_existing.add(normalized) + normalized_existing.add((normalized, site_id)) self._existing_serials = normalized_existing - self._current_upload_serials: set[str] = set() + self._current_upload_serials: set[tuple[str, int]] = set() self._duplicate_count = 0 - def is_duplicate(self, serial_number) -> bool: + def is_duplicate(self, serial_number, charging_site_id: int) -> bool: normalized = _normalize_serial(serial_number) if not normalized: return False + key = (normalized, charging_site_id) if ( - normalized in self._existing_serials - or normalized in self._current_upload_serials + key in self._existing_serials + or key in self._current_upload_serials ): self._duplicate_count += 1 return True - self._current_upload_serials.add(normalized) + self._current_upload_serials.add(key) return False def summary_message(self) -> str | None: diff --git a/backend/lcfs/web/api/charging_equipment/repo.py b/backend/lcfs/web/api/charging_equipment/repo.py index 0a343602f..ed2762d4a 100644 --- a/backend/lcfs/web/api/charging_equipment/repo.py +++ b/backend/lcfs/web/api/charging_equipment/repo.py @@ -864,13 +864,20 @@ async def get_charging_sites_by_organization( @repo_handler async def get_serial_numbers_for_organization( self, organization_id: int - ) -> set[str]: + ) -> set[tuple[str, int]]: """ - Retrieve serial numbers for all charging equipment owned by the organization. + Retrieve (serial_number, charging_site_id) pairs for all charging + equipment owned by the organization. Limited to non-deleted equipment and latest site versions. + + Duplicate serial numbers are scoped per charging site — the same + serial at a different site is allowed. """ stmt = ( - select(ChargingEquipment.serial_number) + select( + ChargingEquipment.serial_number, + ChargingEquipment.charging_site_id, + ) .join( ChargingSite, ChargingEquipment.charging_site_id == ChargingSite.charging_site_id, @@ -883,12 +890,13 @@ async def get_serial_numbers_for_organization( ) stmt = self._apply_latest_site_filter(stmt) result = await self.db.execute(stmt) - normalized = set() - for serial in result.scalars().all(): + normalized: set[tuple[str, int]] = set() + for row in result.all(): + serial, site_id = row if isinstance(serial, str): clean = serial.strip() if clean: - normalized.add(clean.upper()) + normalized.add((clean.upper(), site_id)) return normalized @repo_handler diff --git a/backend/lcfs/web/api/charging_site/export.py b/backend/lcfs/web/api/charging_site/export.py index 61a7a9286..fe0322970 100644 --- a/backend/lcfs/web/api/charging_site/export.py +++ b/backend/lcfs/web/api/charging_site/export.py @@ -91,11 +91,16 @@ async def export( async def _create_validators(self, organization, builder): validators: List[DataValidation] = [] - # Get allocating organization options (from allocation agreements) - allocating_org_options = await self.repo.get_allocation_agreement_organizations( + all_names = await self.repo.get_allocating_organization_names( organization.organization_id ) - allocating_org_names = [org.name for org in allocating_org_options] + # Exclude the exporting organization's own name (own-org exclusion is also + # enforced at the ID level inside get_allocation_agreement_organizations) + user_org_name_lower = organization.name.lower() if organization.name else None + allocating_org_names = [ + n for n in all_names + if not user_org_name_lower or n.lower() != user_org_name_lower + ] # Site Name column - helpful prompt site_name_validator = DataValidation( diff --git a/backend/lcfs/web/api/charging_site/importer.py b/backend/lcfs/web/api/charging_site/importer.py index b6f2672df..5440ee5c5 100644 --- a/backend/lcfs/web/api/charging_site/importer.py +++ b/backend/lcfs/web/api/charging_site/importer.py @@ -56,6 +56,7 @@ async def import_data( org_code: str, file: UploadFile, overwrite: bool = False, + organization_name: str = "", ) -> str: """ Initiates the import job in a separate thread executor. @@ -94,7 +95,13 @@ async def import_data( # Start the import task without blocking asyncio.create_task( import_async( - organization_id, user, org_code, copied_file, job_id, overwrite + organization_id, + user, + org_code, + copied_file, + job_id, + overwrite, + organization_name, ) ) @@ -134,6 +141,7 @@ async def import_async( file: UploadFile, job_id: str, overwrite: bool = False, + organization_name: str = "", ): """ Performs the actual import in an async context. @@ -233,7 +241,9 @@ async def import_async( # row[8] is now allocating_organization_name (optional string) # Validate row - error = _validate_row(row, row_idx, valid_org_names) + error = _validate_row( + row, row_idx, valid_org_names, organization_name + ) if error: errors.append(error) rejected += 1 @@ -320,6 +330,7 @@ def _validate_row( row: tuple, row_idx: int, valid_org_names: set[str], + organization_name: str = "", ) -> str | None: """ Validates a single row of data and returns an error string if invalid. @@ -383,10 +394,17 @@ def _validate_row( except (ValueError, TypeError): return f"Row {row_idx}: Invalid longitude value '{longitude}'. Must be a valid number" - # Validate allocating organization (optional field) - # Validation disabled - any value is now accepted - # if allocating_org_name and allocating_org_name not in valid_org_names: - # return f"Row {row_idx}: Invalid allocating organization: {allocating_org_name}. Must be from your allocation agreements." + # Validate that the allocating organization is not the user's own organization + if ( + allocating_org_name + and organization_name + and str(allocating_org_name).strip().lower() + == organization_name.strip().lower() + ): + return ( + f"Row {row_idx}: You cannot select your own organization " + f"as the Allocating organization." + ) return None diff --git a/backend/lcfs/web/api/charging_site/repo.py b/backend/lcfs/web/api/charging_site/repo.py index 85e1e9f14..c5e801032 100644 --- a/backend/lcfs/web/api/charging_site/repo.py +++ b/backend/lcfs/web/api/charging_site/repo.py @@ -159,7 +159,10 @@ async def get_allocation_agreement_organizations( AllocationAgreement.compliance_report_id == ComplianceReport.compliance_report_id, ) - .where(ComplianceReport.organization_id == organization_id) + .where( + ComplianceReport.organization_id == organization_id, + Organization.organization_id != organization_id, + ) .distinct() .order_by(Organization.name) ) @@ -917,6 +920,31 @@ async def search_organizations_by_name( ) return result.scalars().all() + @repo_handler + async def get_allocating_organization_names( + self, organization_id: int + ) -> List[str]: + matched_orgs = await self.get_allocation_agreement_organizations( + organization_id + ) + transaction_partners = ( + await self.get_transaction_partners_from_allocation_agreements( + organization_id + ) + ) + historical_names = await self.get_distinct_allocating_organization_names( + organization_id + ) + + seen: dict[str, str] = {} + for org in matched_orgs: + seen[org.name.lower()] = org.name + for name in transaction_partners + historical_names: + if name.lower() not in seen: + seen[name.lower()] = name + + return sorted(seen.values(), key=str.lower) + @repo_handler async def get_transaction_partners_from_allocation_agreements( self, organization_id: int diff --git a/backend/lcfs/web/api/charging_site/services.py b/backend/lcfs/web/api/charging_site/services.py index a8ceeec9c..3573b7b8b 100644 --- a/backend/lcfs/web/api/charging_site/services.py +++ b/backend/lcfs/web/api/charging_site/services.py @@ -119,44 +119,38 @@ async def search_allocation_organizations( self, organization_id: int, query: str ) -> List[dict]: """ - Search for allocating organization suggestions. + Return allocating organization suggestions filtered by query, + excluding the user's own organization. """ try: query_lower = query.lower().strip() + user_org = self.request.user.organization + user_org_name_lower = user_org.name.lower() if user_org else None - # Use existing method to get matched organizations matched_orgs = await self.repo.get_allocation_agreement_organizations( organization_id ) - - # Get unmatched names from allocation agreements and charging sites - transaction_partners = ( - await self.repo.get_transaction_partners_from_allocation_agreements( - organization_id - ) - ) - historical_names = ( - await self.repo.get_distinct_allocating_organization_names( - organization_id - ) + all_names = await self.repo.get_allocating_organization_names( + organization_id ) - # Build suggestions dict - matched orgs take precedence - suggestions = {} - for org in matched_orgs: - if query_lower in org.name.lower(): - suggestions[org.name.lower()] = { - "organizationId": org.organization_id, - "name": org.name, - } + # Build a lookup so matched orgs (with IDs) take precedence + org_id_by_name = {org.name.lower(): org.organization_id for org in matched_orgs} - # Add unmatched names (transaction partners + historical) - for name in transaction_partners + historical_names: + suggestions = [] + for name in all_names: name_lower = name.lower() - if name_lower not in suggestions and query_lower in name_lower: - suggestions[name_lower] = {"organizationId": None, "name": name} + if user_org_name_lower and name_lower == user_org_name_lower: + continue + if query_lower in name_lower: + suggestions.append( + { + "organizationId": org_id_by_name.get(name_lower), + "name": name, + } + ) - return sorted(suggestions.values(), key=lambda x: x["name"].lower())[:50] + return suggestions[:50] except Exception as e: logger.error("Error searching allocation organizations", error=str(e)) raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/lcfs/web/api/charging_site/validation.py b/backend/lcfs/web/api/charging_site/validation.py index 35b1577d7..6b6de51f0 100644 --- a/backend/lcfs/web/api/charging_site/validation.py +++ b/backend/lcfs/web/api/charging_site/validation.py @@ -57,6 +57,8 @@ async def charging_site_create_access( detail="Organization ID in URL and request body do not match", ) + self._validate_allocating_organization(organization_id, data) + return True async def charging_site_delete_update_access( @@ -104,8 +106,36 @@ async def charging_site_delete_update_access( detail="A charging site with this name already exists for your organization. Please use a unique site name.", ) + if data: + self._validate_allocating_organization(organization_id, data) + return True + def _validate_allocating_organization( + self, organization_id: int, data: ChargingSiteCreateSchema + ): + """ + Validates that the allocating organization is not the user's own organization. + """ + if ( + data.allocating_organization_id + and data.allocating_organization_id == organization_id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot select your own organization as the Allocating organization.", + ) + user_org = self.request.user.organization + if user_org and data.allocating_organization_name: + if ( + data.allocating_organization_name.strip().lower() + == user_org.name.strip().lower() + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot select your own organization as the Allocating organization.", + ) + async def validate_organization_access(self, charging_site_id: int): """ Validates that the charging site exists and the user has access to it. diff --git a/backend/lcfs/web/api/charging_site/views.py b/backend/lcfs/web/api/charging_site/views.py index 41cabe8df..d2d91dee8 100644 --- a/backend/lcfs/web/api/charging_site/views.py +++ b/backend/lcfs/web/api/charging_site/views.py @@ -411,6 +411,7 @@ async def import_charging_sites( organization.organization_code, file, overwrite, + organization_name=organization.name or "", ) return JSONResponse(content={"jobId": job_id}) diff --git a/backend/lcfs/web/api/compliance_report/export.py b/backend/lcfs/web/api/compliance_report/export.py index 5326b48fe..61563a34d 100644 --- a/backend/lcfs/web/api/compliance_report/export.py +++ b/backend/lcfs/web/api/compliance_report/export.py @@ -108,7 +108,9 @@ def __init__( } @service_handler - async def export(self, compliance_report_id: int) -> StreamingResponse: + async def export( + self, compliance_report_id: int, is_government: bool = True + ) -> StreamingResponse: wb = Workbook() wb.remove(wb.active) @@ -128,7 +130,9 @@ async def export(self, compliance_report_id: int) -> StreamingResponse: # Add all schedule data sheets - run sequentially to avoid DB connection issues for sheet_name, loader in self.data_loaders.items(): - if sheet_name in [FSE_EXPORT_SHEET, ALLOCATION_AGREEMENTS_SHEET]: + if sheet_name == FSE_EXPORT_SHEET: + data = await loader(cid, is_quarterly, is_government) + elif sheet_name == ALLOCATION_AGREEMENTS_SHEET: data = await loader(cid, is_quarterly) else: data = await loader(uuid, cid, report.version, is_quarterly) @@ -790,7 +794,9 @@ async def _load_allocation_agreement_data( return [headers] + rows - async def _load_fse_data(self, cid, is_quarterly) -> List[List[Any]]: + async def _load_fse_data( + self, cid, is_quarterly, is_government: bool = True + ) -> List[List[Any]]: """Load final supply equipment data.""" # FSE doesn't have quarterly data, always use annual columns headers = [col.label for col in FSE_EXPORT_COLUMNS] @@ -801,9 +807,10 @@ async def _load_fse_data(self, cid, is_quarterly) -> List[List[Any]]: organization_name = ( report.organization.name if report and report.organization else None ) + report_organization_id = getattr(report, "organization_id", None) if report else None organization_id = ( - report.organization_id - if report and getattr(report, "organization_id", None) + report_organization_id + if isinstance(report_organization_id, int) else ( report.organization.organization_id if report and report.organization @@ -813,15 +820,24 @@ async def _load_fse_data(self, cid, is_quarterly) -> List[List[Any]]: if not organization_id: return [headers] - reporting_result = await self.fse_repo.get_fse_reporting_list_paginated( - organization_id=organization_id, - pagination=PaginationRequestSchema( - page=1, size=1000, filters=[], sort_orders=[] - ), - compliance_report_id=cid, - mode="summary", - ) - reporting_rows = reporting_result[0] + if is_government: + reporting_result = await self.fse_repo.get_fse_reporting_list_paginated( + organization_id=organization_id, + pagination=PaginationRequestSchema( + page=1, size=1000, filters=[], sort_orders=[] + ), + compliance_report_id=cid, + mode="summary", + ) + reporting_rows = reporting_result[0] + else: + reporting_rows = ( + await self.fse_repo.get_effective_fse_reporting_rows_for_export( + organization_id=organization_id, + compliance_report_id=cid, + compliance_report_group_uuid=report_group_uuid, + ) + ) rows = [] for item in reporting_rows: diff --git a/backend/lcfs/web/api/compliance_report/views.py b/backend/lcfs/web/api/compliance_report/views.py index 07cd0c684..1edad323f 100644 --- a/backend/lcfs/web/api/compliance_report/views.py +++ b/backend/lcfs/web/api/compliance_report/views.py @@ -284,7 +284,7 @@ async def export_compliance_report( Retrieve the comprehensive compliance report summary for a specific report by ID. """ await validate.validate_organization_access(report_id) - return await export_service.export(report_id) + return await export_service.export(report_id, request.user.is_government) @router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/lcfs/web/api/final_supply_equipment/fse_reporting_export.py b/backend/lcfs/web/api/final_supply_equipment/fse_reporting_export.py index 1c104368b..48526b7df 100644 --- a/backend/lcfs/web/api/final_supply_equipment/fse_reporting_export.py +++ b/backend/lcfs/web/api/final_supply_equipment/fse_reporting_export.py @@ -21,13 +21,14 @@ HEADERS = [ "Site name", "Registration #", + "Serial #", "Dates of supply from", "Dates of supply to", "kWh usage", "Compliance notes", ] -COLUMN_WIDTHS = [25, 18, 22, 22, 14, 40] +COLUMN_WIDTHS = [25, 18, 18, 22, 22, 14, 40] class FSEReportingExporter: @@ -101,11 +102,13 @@ async def _load_fse_data( not getattr(record, "is_active", True) or record_group != compliance_report_group_uuid ) + serial_number = getattr(record, "serial_number", None) or "" if is_inactive: rows.append( [ getattr(record, "site_name", None) or "", record.registration_number or "", + serial_number, None, None, None, @@ -123,6 +126,7 @@ async def _load_fse_data( [ getattr(record, "site_name", None) or "", record.registration_number or "", + serial_number, supply_from, supply_to, record.kwh_usage if record.kwh_usage is not None else 0, @@ -151,24 +155,25 @@ def _build_workbook(self, rows: list, compliance_period) -> Workbook: # Column layout: # Col A (1): Site name – locked for existing rows, unlocked for new rows # Col B (2): Registration # – locked for existing rows, unlocked for new rows - # Col C (3): Supply from – editable, date - # Col D (4): Supply to – editable, date - # Col E (5): kWh usage – editable, integer - # Col F (6): Compliance notes – editable, text + # Col C (3): Serial # – locked (read-only) always + # Col D (4): Supply from – editable, date + # Col E (5): Supply to – editable, date + # Col F (6): kWh usage – editable, integer + # Col G (7): Compliance notes – editable, text # Pre-populated data rows for row_idx, row_data in enumerate(rows, start=2): for col_idx, value in enumerate(row_data, start=1): cell = ws.cell(row=row_idx, column=col_idx, value=value) - if col_idx in (1, 2): - # Site name & Registration # – locked (read-only) + if col_idx in (1, 2, 3): + # Site name, Registration # & Serial # – locked (read-only) cell.protection = Protection(locked=True) cell.alignment = Alignment(horizontal="left") - elif col_idx in (3, 4): + elif col_idx in (4, 5): cell.protection = Protection(locked=False) cell.number_format = "yyyy-mm-dd" cell.alignment = Alignment(horizontal="left") - elif col_idx == 5: + elif col_idx == 6: cell.protection = Protection(locked=False) cell.number_format = "#,##0" cell.alignment = Alignment(horizontal="right") @@ -176,15 +181,19 @@ def _build_workbook(self, rows: list, compliance_period) -> Workbook: cell.protection = Protection(locked=False) cell.alignment = Alignment(horizontal="left", wrap_text=True) - # Empty rows for new entries – unlock all 6 columns so users can add new FSE + # Empty rows for new entries – unlock editable columns; Serial # stays locked first_empty_row = len(rows) + 2 for row_idx in range(first_empty_row, first_empty_row + 500): - for col_idx in range(1, 7): + for col_idx in range(1, 8): cell = ws.cell(row=row_idx, column=col_idx) - cell.protection = Protection(locked=False) - if col_idx in (3, 4): + if col_idx == 3: + # Serial # – always locked + cell.protection = Protection(locked=True) + else: + cell.protection = Protection(locked=False) + if col_idx in (4, 5): cell.number_format = "yyyy-mm-dd" - elif col_idx == 5: + elif col_idx == 6: cell.number_format = "#,##0" # Data validators (allow blank – do not block partial updates) @@ -205,8 +214,8 @@ def _build_workbook(self, rows: list, compliance_period) -> Workbook: "calendar year." ), ) - date_validator.add("C2:C10000") date_validator.add("D2:D10000") + date_validator.add("E2:E10000") ws.add_data_validation(date_validator) kwh_validator = DataValidation( @@ -215,7 +224,7 @@ def _build_workbook(self, rows: list, compliance_period) -> Workbook: showErrorMessage=True, error="Please enter a valid integer.", ) - kwh_validator.add("E2:E10000") + kwh_validator.add("F2:F10000") ws.add_data_validation(kwh_validator) # Protect the sheet – only rows with locked=False cells will be editable diff --git a/backend/lcfs/web/api/final_supply_equipment/fse_reporting_importer.py b/backend/lcfs/web/api/final_supply_equipment/fse_reporting_importer.py index f255d700f..e0f9f088e 100644 --- a/backend/lcfs/web/api/final_supply_equipment/fse_reporting_importer.py +++ b/backend/lcfs/web/api/final_supply_equipment/fse_reporting_importer.py @@ -215,14 +215,15 @@ async def _import_async( if all(cell is None for cell in row): continue - # Column layout: A=Site name, B=Reg#, C=From, D=To, E=kWh, F=Notes - expanded = list(row) + [None] * max(0, 6 - len(list(row))) + # Column layout: A=Site name, B=Reg#, C=Serial#, D=From, E=To, F=kWh, G=Notes + expanded = list(row) + [None] * max(0, 7 - len(list(row))) # col A (index 0) = site name — read-only identifier, not used registration_number = expanded[1] - supply_from_raw = expanded[2] - supply_to_raw = expanded[3] - kwh_usage_raw = expanded[4] - compliance_notes = expanded[5] + # col C (index 2) = serial # — read-only identifier, not used + supply_from_raw = expanded[3] + supply_to_raw = expanded[4] + kwh_usage_raw = expanded[5] + compliance_notes = expanded[6] # Registration number is required if not registration_number: diff --git a/backend/lcfs/web/api/final_supply_equipment/repo.py b/backend/lcfs/web/api/final_supply_equipment/repo.py index 34f5ca8a6..d22965684 100644 --- a/backend/lcfs/web/api/final_supply_equipment/repo.py +++ b/backend/lcfs/web/api/final_supply_equipment/repo.py @@ -1183,6 +1183,229 @@ async def get_fse_reporting_list_paginated( return data, total or 0 + @repo_handler + async def get_effective_fse_reporting_rows_for_export( + self, + organization_id: int, + compliance_report_id: int, + compliance_report_group_uuid: str, + ) -> list: + """ + Return export rows using the latest equipment/site version while + preserving the most relevant reporting data for the current report. + """ + latest_sites = latest_charging_site_version_subquery() + latest_site = aliased(ChargingSite, name="latest_site_export") + source_site = aliased(ChargingSite, name="source_site_export") + reporting_equipment = aliased( + ChargingEquipment, name="reporting_equipment_export" + ) + + latest_equipment = ( + select( + ChargingEquipment.group_uuid.label("charging_equipment_group_uuid"), + ChargingEquipment.charging_equipment_id.label("charging_equipment_id"), + ChargingEquipment.version.label("charging_equipment_version"), + func.row_number() + .over( + partition_by=ChargingEquipment.group_uuid, + order_by=ChargingEquipment.version.desc(), + ) + .label("row_num"), + ) + .join( + source_site, + ChargingEquipment.charging_site_id == source_site.charging_site_id, + ) + .join( + latest_sites, + source_site.group_uuid == latest_sites.c.group_uuid, + ) + .join( + ChargingEquipmentStatus, + ChargingEquipment.status_id + == ChargingEquipmentStatus.charging_equipment_status_id, + ) + .where( + and_( + source_site.organization_id == organization_id, + ChargingEquipmentStatus.status != "Decommissioned", + ) + ) + .subquery() + ) + + reporting_priority = case( + ( + ComplianceReportChargingEquipment.compliance_report_id + == compliance_report_id, + 0, + ), + else_=1, + ) + + reporting_rows = ( + select( + reporting_equipment.group_uuid.label("charging_equipment_group_uuid"), + ComplianceReportChargingEquipment.charging_equipment_compliance_id.label( + "charging_equipment_compliance_id" + ), + ComplianceReportChargingEquipment.supply_from_date.label( + "supply_from_date" + ), + ComplianceReportChargingEquipment.supply_to_date.label( + "supply_to_date" + ), + ComplianceReportChargingEquipment.kwh_usage.label("kwh_usage"), + ComplianceReportChargingEquipment.compliance_notes.label( + "compliance_notes" + ), + ComplianceReportChargingEquipment.is_active.label("is_active"), + func.row_number() + .over( + partition_by=reporting_equipment.group_uuid, + order_by=( + reporting_priority, + case( + ( + ComplianceReport.compliance_report_group_uuid + == compliance_report_group_uuid, + 0, + ), + else_=1, + ), + ComplianceReport.version.desc(), + ComplianceReportChargingEquipment.charging_equipment_compliance_id.desc(), + ), + ) + .label("row_num"), + ) + .select_from(ComplianceReportChargingEquipment) + .join( + reporting_equipment, + ComplianceReportChargingEquipment.charging_equipment_id + == reporting_equipment.charging_equipment_id, + ) + .join( + ComplianceReport, + ComplianceReportChargingEquipment.compliance_report_id + == ComplianceReport.compliance_report_id, + ) + .where( + and_( + reporting_equipment.organization_id == organization_id, + ComplianceReportChargingEquipment.is_active.is_(True), + ) + ) + .subquery() + ) + + intended_uses = ( + select(func.array_agg(EndUseType.type)) + .select_from(charging_equipment_intended_use_association) + .join( + EndUseType, + charging_equipment_intended_use_association.c.end_use_type_id + == EndUseType.end_use_type_id, + ) + .where( + charging_equipment_intended_use_association.c.charging_equipment_id + == ChargingEquipment.charging_equipment_id + ) + .scalar_subquery() + ) + + intended_users = ( + select(func.array_agg(EndUserType.type_name)) + .select_from(charging_equipment_intended_user_association) + .join( + EndUserType, + charging_equipment_intended_user_association.c.end_user_type_id + == EndUserType.end_user_type_id, + ) + .where( + charging_equipment_intended_user_association.c.charging_equipment_id + == ChargingEquipment.charging_equipment_id + ) + .scalar_subquery() + ) + + stmt = ( + select( + Organization.name.label("organization_name"), + latest_site.allocating_organization_name.label( + "allocating_organization_name" + ), + reporting_rows.c.supply_from_date, + reporting_rows.c.supply_to_date, + reporting_rows.c.kwh_usage, + ChargingEquipment.serial_number.label("serial_number"), + ChargingEquipment.manufacturer.label("manufacturer"), + ChargingEquipment.model.label("model"), + LevelOfEquipment.name.label("level_of_equipment"), + ChargingEquipment.ports.label("ports"), + intended_uses.label("intended_uses"), + intended_users.label("intended_users"), + latest_site.street_address.label("street_address"), + latest_site.city.label("city"), + latest_site.postal_code.label("postal_code"), + latest_site.latitude.label("latitude"), + latest_site.longitude.label("longitude"), + reporting_rows.c.compliance_notes, + ChargingEquipment.notes.label("equipment_notes"), + ) + .select_from(ChargingEquipment) + .join( + latest_equipment, + and_( + ChargingEquipment.charging_equipment_id + == latest_equipment.c.charging_equipment_id, + ChargingEquipment.version + == latest_equipment.c.charging_equipment_version, + latest_equipment.c.row_num == 1, + ), + ) + .join( + source_site, + ChargingEquipment.charging_site_id == source_site.charging_site_id, + ) + .join( + latest_sites, + source_site.group_uuid == latest_sites.c.group_uuid, + ) + .join( + latest_site, + and_( + latest_site.group_uuid == latest_sites.c.group_uuid, + latest_site.version == latest_sites.c.latest_version, + ), + ) + .join( + Organization, + latest_site.organization_id == Organization.organization_id, + ) + .join( + LevelOfEquipment, + ChargingEquipment.level_of_equipment_id + == LevelOfEquipment.level_of_equipment_id, + ) + .join( + reporting_rows, + and_( + reporting_rows.c.charging_equipment_group_uuid + == latest_equipment.c.charging_equipment_group_uuid, + reporting_rows.c.row_num == 1, + ), + ) + .order_by( + asc(latest_site.site_name), + asc(latest_site.site_code + "-" + ChargingEquipment.equipment_number), + ) + ) + + result = await self.db.execute(stmt) + return result.fetchall() + @repo_handler async def get_charging_power_output( self, @@ -1388,6 +1611,7 @@ async def get_fse_for_bulk_update_template( ChargingSite.site_code + "-" + ChargingEquipment.equipment_number ).label("registration_number"), ChargingSite.site_name.label("site_name"), + ChargingEquipment.serial_number.label("serial_number"), ComplianceReportChargingEquipment.charging_equipment_compliance_id, ComplianceReportChargingEquipment.supply_from_date, ComplianceReportChargingEquipment.supply_to_date, @@ -1465,6 +1689,7 @@ async def get_fse_for_bulk_update_template( all_rows_subquery.c.charging_equipment_version, all_rows_subquery.c.registration_number, all_rows_subquery.c.site_name, + all_rows_subquery.c.serial_number, all_rows_subquery.c.charging_equipment_compliance_id, all_rows_subquery.c.supply_from_date, all_rows_subquery.c.supply_to_date, diff --git a/frontend/src/views/ChargingSite/__tests__/components/ChargingSiteProfile.test.jsx b/frontend/src/views/ChargingSite/__tests__/components/ChargingSiteProfile.test.jsx index 3afee68b0..d643443f8 100644 --- a/frontend/src/views/ChargingSite/__tests__/components/ChargingSiteProfile.test.jsx +++ b/frontend/src/views/ChargingSite/__tests__/components/ChargingSiteProfile.test.jsx @@ -127,7 +127,7 @@ describe('ChargingSiteProfile', () => { expect(screen.queryByRole('button', { name: setValidatedLabel })).not.toBeInTheDocument() }) - it('shows "Submit updates" when BCeID Compliance and status is Draft', () => { + it('does not show "Submit updates" when BCeID Compliance and status is Draft', () => { const hasAnyRole = vi.fn((...roles) => roles.includes('Compliance Reporting')) render( { />, { wrapper } ) + expect(screen.queryByRole('button', { name: submitUpdatesLabel })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: setValidatedLabel })).not.toBeInTheDocument() + }) + + it('shows "Submit updates" when BCeID Compliance and status is Updated', () => { + const hasAnyRole = vi.fn((...roles) => roles.includes('Compliance Reporting')) + const updatedData = { ...mockData, status: { status: 'Updated' } } + render( + false)} + isIDIR={false} + refetch={vi.fn()} + />, + { wrapper } + ) expect(screen.getByRole('button', { name: submitUpdatesLabel })).toBeInTheDocument() expect(screen.queryByRole('button', { name: setValidatedLabel })).not.toBeInTheDocument() }) @@ -181,9 +198,10 @@ describe('ChargingSiteProfile', () => { it('calls mutation with Submitted when "Submit updates" is clicked', () => { const hasAnyRole = vi.fn((...roles) => roles.includes('Compliance Reporting')) + const updatedData = { ...mockData, status: { status: 'Updated' } } render( false)} isIDIR={false} @@ -198,4 +216,4 @@ describe('ChargingSiteProfile', () => { ) }) }) -}) \ No newline at end of file +}) diff --git a/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx b/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx index 40fa5a566..6983c5275 100644 --- a/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx +++ b/frontend/src/views/ChargingSite/__tests__/components/_schema.test.jsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { chargingSiteColDefs, chargingEquipmentColDefs, @@ -7,18 +7,17 @@ import { indexDefaultColDef } from '../../components/_schema' +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key) => key - }) + useTranslation: () => ({ t: (key) => key }) })) -// Fix the i18n mock to include a default export vi.mock('@/i18n', () => ({ - default: { - t: (key) => key - }, - t: (key) => key // Also export t directly in case it's used as named export + default: { t: (key) => key }, + t: (key) => key })) vi.mock('@/hooks/useChargingSite', () => ({ @@ -26,131 +25,529 @@ vi.mock('@/hooks/useChargingSite', () => ({ useChargingSiteStatuses: vi.fn() })) -// Mock any other dependencies that might be needed -vi.mock( - '@/components/BCDataGrid/FloatingFilters/BCSelectFloatingFilter', - () => ({ - default: vi.fn() - }) -) - -describe('_schema', () => { - const mockErrors = {} - const mockWarnings = {} - const mockT = (key) => key - const mockAllocationOrganizations = [ - { organization_id: 1, name: 'Org 1' }, - { organization_id: 2, name: 'Org 2' } - ] - - describe('chargingSiteColDefs', () => { - it('returns column definitions array', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) +vi.mock('@/components/BCDataGrid/FloatingFilters/BCSelectFloatingFilter', () => ({ + default: vi.fn() +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) +const mockT = (key) => key +const mockErrors = {} +const mockWarnings = {} + +/** Extract field names from a column-def array, skipping cols with no field. */ +const fields = (colDefs) => colDefs.map((c) => c.field).filter(Boolean) + +// --------------------------------------------------------------------------- +// chargingSiteColDefs +// --------------------------------------------------------------------------- + +describe('chargingSiteColDefs', () => { + const colDefs = () => chargingSiteColDefs(mockErrors, mockWarnings, true) + + it('returns a non-empty array', () => { + expect(Array.isArray(colDefs())).toBe(true) + expect(colDefs().length).toBeGreaterThan(0) + }) + + describe('required fields', () => { + it.each([ + 'siteName', + 'streetAddress', + 'city', + 'postalCode', + 'latitude', + 'longitude', + 'allocatingOrganization', + 'notes' + ])('includes field "%s"', (field) => { + expect(fields(colDefs())).toContain(field) }) + }) - it('includes required fields', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) + describe('hidden / utility columns', () => { + it('hides the id column', () => { + const col = colDefs().find((c) => c.field === 'id') + expect(col.hide).toBe(true) + }) - const fieldNames = colDefs.map((col) => col.field) - expect(fieldNames).toContain('siteName') - expect(fieldNames).toContain('streetAddress') - expect(fieldNames).toContain('city') - expect(fieldNames).toContain('postalCode') - expect(fieldNames).toContain('latitude') - expect(fieldNames).toContain('longitude') - }) - - it('configures editable fields correctly', () => { - const colDefs = chargingSiteColDefs( - mockAllocationOrganizations, - mockErrors, - mockWarnings, - true - ) + it('hides the chargingSiteId column', () => { + const col = colDefs().find((c) => c.field === 'chargingSiteId') + expect(col.hide).toBe(true) + }) + }) + + describe('editable fields', () => { + it.each(['siteName', 'streetAddress', 'city', 'postalCode', 'latitude', 'longitude', 'allocatingOrganization', 'notes'])( + 'marks "%s" as editable', + (field) => { + const col = colDefs().find((c) => c.field === field) + expect(col?.editable).toBe(true) + } + ) + }) + + describe('postalCode valueSetter', () => { + it('converts input to uppercase', () => { + const col = colDefs().find((c) => c.field === 'postalCode') + const data = {} + col.valueSetter({ newValue: 'v5k 1a1', data, colDef: { field: 'postalCode' } }) + expect(data.postalCode).toBe('V5K 1A1') + }) + + it('returns true (AG-Grid expects truthy to accept the change)', () => { + const col = colDefs().find((c) => c.field === 'postalCode') + const result = col.valueSetter({ newValue: 'a1b2c3', data: {}, colDef: { field: 'postalCode' } }) + expect(result).toBe(true) + }) + }) + + describe('streetAddress valueSetter', () => { + let col, data + + beforeEach(() => { + col = colDefs().find((c) => c.field === 'streetAddress') + data = {} + }) + + it('clears all address fields when newValue is empty string', () => { + data = { city: 'Victoria', postalCode: 'V8V1A1', latitude: 48.4, longitude: -123.3 } + col.valueSetter({ newValue: '', data }) + expect(data.streetAddress).toBe('') + expect(data.city).toBe('') + expect(data.postalCode).toBe('') + expect(data.latitude).toBe('') + expect(data.longitude).toBe('') + }) + + it('sets only streetAddress when newValue is a plain string', () => { + col.valueSetter({ newValue: '123 Main St', data }) + expect(data.streetAddress).toBe('123 Main St') + // city / postal should not be overwritten by a raw string + expect(data.city).toBeUndefined() + }) + + it('populates all fields from autocomplete object with fullAddress', () => { + col.valueSetter({ + newValue: { + fullAddress: '123 Main St, Victoria, BC V8V1A1', + streetAddress: '123 Main St', + city: 'Victoria', + postalCode: 'V8V 1A1', + latitude: 48.4, + longitude: -123.3 + }, + data + }) + expect(data.streetAddress).toBe('123 Main St') + expect(data.city).toBe('Victoria') + expect(data.postalCode).toBe('V8V 1A1') + expect(data.latitude).toBe(48.4) + expect(data.longitude).toBe(-123.3) + }) + + it('returns true to accept the change', async () => { + const result = await col.valueSetter({ newValue: '123 Main', data }) + expect(result).toBe(true) + }) + }) + + describe('allocatingOrganization valueGetter and valueSetter', () => { + let col + + beforeEach(() => { + col = colDefs().find((c) => c.field === 'allocatingOrganization') + }) + + it('valueGetter returns allocatingOrganizationName', () => { + expect(col.valueGetter({ data: { allocatingOrganizationName: 'BC Hydro' } })).toBe('BC Hydro') + }) + + it('valueGetter returns empty string when field is absent', () => { + expect(col.valueGetter({ data: {} })).toBe('') + }) - const siteNameCol = colDefs.find((col) => col.field === 'siteName') - expect(siteNameCol.editable).toBe(true) + it('valueSetter with org object sets id and name', () => { + const data = {} + col.valueSetter({ newValue: { organizationId: 5, name: 'FortisBC' }, data }) + expect(data.allocatingOrganizationId).toBe(5) + expect(data.allocatingOrganizationName).toBe('FortisBC') + }) + + it('valueSetter with plain string sets only name, clears id', () => { + const data = { allocatingOrganizationId: 99 } + col.valueSetter({ newValue: 'Custom Org Name', data }) + expect(data.allocatingOrganizationId).toBeNull() + expect(data.allocatingOrganizationName).toBe('Custom Org Name') + }) - const idCol = colDefs.find((col) => col.field === 'id') - expect(idCol.hide).toBe(true) + it('valueSetter returns true', () => { + const result = col.valueSetter({ newValue: 'test', data: {} }) + expect(result).toBe(true) }) }) +}) - describe('chargingEquipmentColDefs', () => { - it('returns equipment column definitions', () => { - const colDefs = chargingEquipmentColDefs(mockT, false) +// --------------------------------------------------------------------------- +// chargingEquipmentColDefs +// --------------------------------------------------------------------------- - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) +describe('chargingEquipmentColDefs', () => { + describe('core fields always present', () => { + it.each(['status', 'siteName', 'registrationNumber', 'version', 'serialNumber', 'manufacturer', 'model', 'levelOfEquipment', 'ports', 'allocatingOrganizationName'])( + 'always includes "%s"', + (field) => { + expect(fields(chargingEquipmentColDefs(mockT))).toContain(field) + } + ) + }) + + describe('showOrganizationColumn option', () => { + it('excludes organizationName when false (BCeID)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: false })) + expect(f).not.toContain('organizationName') }) + it('includes organizationName when true (IDIR)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: true })) + expect(f).toContain('organizationName') + }) + }) + describe('allocatingOrganizationName column positioning', () => { + it('immediately follows organizationName for IDIR users', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: true })) + expect(f.indexOf('allocatingOrganizationName')).toBe(f.indexOf('organizationName') + 1) + }) - it('includes equipment fields', () => { - const colDefs = chargingEquipmentColDefs(mockT, true) + it('immediately follows siteName for BCeID users', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: false })) + expect(f.indexOf('allocatingOrganizationName')).toBe(f.indexOf('siteName') + 1) + }) - const fieldNames = colDefs.map((col) => col.field) - expect(fieldNames).toContain('status') - expect(fieldNames).toContain('registrationNumber') - expect(fieldNames).toContain('manufacturer') - expect(fieldNames).toContain('model') + it('appears before registrationNumber in both user modes', () => { + for (const showOrg of [true, false]) { + const f = fields(chargingEquipmentColDefs(mockT, false, { showOrganizationColumn: showOrg })) + expect(f.indexOf('allocatingOrganizationName')).toBeLessThan(f.indexOf('registrationNumber')) + } }) }) - describe('indexChargingSitesColDefs', () => { - const mockOrgIdToName = { 1: 'Organization 1', 2: 'Organization 2' } + describe('allocatingOrganizationName valueGetter', () => { + const allocCol = () => + chargingEquipmentColDefs(mockT).find((c) => c.field === 'allocatingOrganizationName') + + it('prefers chargingSite.allocatingOrganizationName', () => { + const result = allocCol().valueGetter({ + data: { + chargingSite: { allocatingOrganizationName: 'Site Level Org' }, + allocatingOrganizationName: 'Direct Org' + } + }) + expect(result).toBe('Site Level Org') + }) + + it('falls back to direct allocatingOrganizationName', () => { + const result = allocCol().valueGetter({ + data: { allocatingOrganizationName: 'Direct Org' } + }) + expect(result).toBe('Direct Org') + }) + + it('returns empty string when neither field is present', () => { + expect(allocCol().valueGetter({ data: {} })).toBe('') + }) + }) - it('returns index column definitions', () => { - const colDefs = indexChargingSitesColDefs(false, mockOrgIdToName) + describe('enableSelection option', () => { + it('adds a leading __select__ checkbox column', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { enableSelection: true })) + expect(f[0]).toBe('__select__') + }) - expect(Array.isArray(colDefs)).toBe(true) - expect(colDefs.length).toBeGreaterThan(0) + it('does not add checkbox column when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { enableSelection: false })) + expect(f).not.toContain('__select__') }) + }) - it('hides organization column for non-IDIR users', () => { - const colDefs = indexChargingSitesColDefs(false, mockOrgIdToName) + describe('showDateColumns option', () => { + it('adds createdDate and updatedDate when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showDateColumns: true })) + expect(f).toContain('createdDate') + expect(f).toContain('updatedDate') + }) - const orgCol = colDefs.find((col) => col.field === 'organization') - expect(orgCol.hide).toBe(true) + it('omits date columns when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showDateColumns: false })) + expect(f).not.toContain('createdDate') + expect(f).not.toContain('updatedDate') }) + }) - it('shows organization column for IDIR users', () => { - const colDefs = indexChargingSitesColDefs(true, mockOrgIdToName) + describe('showIntendedUsers option', () => { + it('includes intendedUsers column when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true })) + expect(f).toContain('intendedUsers') + }) - const orgCol = colDefs.find((col) => col.field === 'organization') - expect(orgCol.hide).toBe(false) + it('excludes intendedUsers column when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showIntendedUsers: false })) + expect(f).not.toContain('intendedUsers') }) }) - describe('defaultColDef', () => { - it('has correct default properties', () => { - expect(defaultColDef.editable).toBe(false) - expect(defaultColDef.resizable).toBe(true) - expect(defaultColDef.filter).toBe(false) - expect(defaultColDef.sortable).toBe(false) - expect(defaultColDef.singleClickEdit).toBe(true) + describe('showLocationFields option', () => { + it('includes latitude and longitude when true (default)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showLocationFields: true })) + expect(f).toContain('latitude') + expect(f).toContain('longitude') + }) + + it('excludes latitude and longitude when false', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showLocationFields: false })) + expect(f).not.toContain('latitude') + expect(f).not.toContain('longitude') }) }) - describe('indexDefaultColDef', () => { - it('has correct index default properties', () => { - expect(indexDefaultColDef.editable).toBe(false) - expect(indexDefaultColDef.resizable).toBe(true) - expect(indexDefaultColDef.floatingFilter).toBe(true) - expect(indexDefaultColDef.suppressFloatingFilterButton).toBe(true) + describe('showNotes option', () => { + it('includes notes column when true', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showNotes: true })) + expect(f).toContain('notes') + }) + + it('excludes notes column when false (default)', () => { + const f = fields(chargingEquipmentColDefs(mockT, false, { showNotes: false })) + expect(f).not.toContain('notes') }) }) + + describe('status column valueGetter', () => { + const statusCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'status') + + it('reads nested status.status', () => { + expect(statusCol().valueGetter({ data: { status: { status: 'Validated' } } })).toBe('Validated') + }) + + it('reads flat status string', () => { + expect(statusCol().valueGetter({ data: { status: 'Draft' } })).toBe('Draft') + }) + + it('returns empty string when status is absent', () => { + expect(statusCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('siteName column valueGetter', () => { + const siteCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'siteName') + + it('prefers chargingSite.siteName', () => { + expect(siteCol().valueGetter({ + data: { chargingSite: { siteName: 'Alpha' }, siteName: 'Beta' } + })).toBe('Alpha') + }) + + it('falls back to direct siteName', () => { + expect(siteCol().valueGetter({ data: { siteName: 'Beta' } })).toBe('Beta') + }) + + it('returns empty string when absent', () => { + expect(siteCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('levelOfEquipment column valueGetter', () => { + const levelCol = () => chargingEquipmentColDefs(mockT).find((c) => c.field === 'levelOfEquipment') + + it('reads nested levelOfEquipment.name', () => { + expect(levelCol().valueGetter({ data: { levelOfEquipment: { name: 'Level 2' } } })).toBe('Level 2') + }) + + it('falls back to flat levelOfEquipmentName', () => { + expect(levelCol().valueGetter({ data: { levelOfEquipmentName: 'Level 1' } })).toBe('Level 1') + }) + + it('returns empty string when absent', () => { + expect(levelCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('intendedUse column valueGetter and valueFormatter', () => { + const intendedCol = () => + chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true }).find( + (c) => c.field === 'intendedUse' + ) + + it('maps array of intendedUseTypes to type strings', () => { + const col = intendedCol() + const result = col.valueGetter({ + data: { intendedUseTypes: [{ type: 'Commercial' }, { type: 'Fleet' }] } + }) + // valueGetter extracts .type, returning an array of strings + expect(result).toEqual(['Commercial', 'Fleet']) + }) + + it('falls back to intendedUses array and extracts type strings', () => { + const col = intendedCol() + const result = col.valueGetter({ + data: { intendedUses: [{ type: 'Public' }] } + }) + expect(result).toEqual(['Public']) + }) + + it('valueFormatter joins type strings with ", "', () => { + const col = intendedCol() + const formatted = col.valueFormatter({ + value: ['Commercial', 'Fleet'] + }) + expect(formatted).toBe('Commercial, Fleet') + }) + + it('valueFormatter returns empty string for non-array', () => { + const col = intendedCol() + expect(col.valueFormatter({ value: null })).toBe('') + expect(col.valueFormatter({ value: undefined })).toBe('') + }) + }) + + describe('intendedUsers column valueGetter', () => { + const intendedUsersCol = () => + chargingEquipmentColDefs(mockT, false, { showIntendedUsers: true }).find( + (c) => c.field === 'intendedUsers' + ) + + it('maps intendedUsers array to typeName values', () => { + const result = intendedUsersCol().valueGetter({ + data: { intendedUsers: [{ typeName: 'Fleet' }, { typeName: 'Public' }] } + }) + expect(result).toEqual(['Fleet', 'Public']) + }) + + it('falls back to intendedUserTypes', () => { + const result = intendedUsersCol().valueGetter({ + data: { intendedUserTypes: [{ typeName: 'Multi-unit residential' }] } + }) + expect(result).toEqual(['Multi-unit residential']) + }) + + it('valueFormatter joins with ", "', () => { + const col = intendedUsersCol() + expect(col.valueFormatter({ value: ['Fleet', 'Public'] })).toBe('Fleet, Public') + }) + }) +}) + +// --------------------------------------------------------------------------- +// indexChargingSitesColDefs +// --------------------------------------------------------------------------- + +describe('indexChargingSitesColDefs', () => { + const orgMap = { 1: 'Org One', 2: 'Org Two' } + + describe('required fields', () => { + it.each([ + 'status', 'organization', 'siteName', 'siteCode', + 'streetAddress', 'city', 'postalCode', 'allocatingOrganization', 'notes' + ])('includes field "%s"', (field) => { + expect(fields(indexChargingSitesColDefs(false, orgMap))).toContain(field) + }) + }) + + describe('organization column visibility', () => { + it('is hidden for BCeID (isIDIR=false)', () => { + const col = indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'organization') + expect(col.hide).toBe(true) + }) + + it('is visible for IDIR (isIDIR=true)', () => { + const col = indexChargingSitesColDefs(true, orgMap).find((c) => c.field === 'organization') + expect(col.hide).toBe(false) + }) + }) + + describe('organization column valueGetter', () => { + const orgCol = (isIDIR = false) => + indexChargingSitesColDefs(isIDIR, orgMap).find((c) => c.field === 'organization') + + it('prefers organization.name from nested object', () => { + expect(orgCol().valueGetter({ + data: { organization: { name: 'From Object' }, organizationId: 1 } + })).toBe('From Object') + }) + + it('falls back to orgIdToName lookup', () => { + expect(orgCol().valueGetter({ + data: { organizationId: 2 } + })).toBe('Org Two') + }) + + it('returns empty string when neither source is available', () => { + expect(orgCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('allocatingOrganization column valueGetter', () => { + const allocCol = () => + indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'allocatingOrganization') + + it('prefers allocatingOrganization.name from nested object', () => { + expect(allocCol().valueGetter({ + data: { + allocatingOrganization: { name: 'BC Hydro' }, + allocatingOrganizationName: 'FortisBC' + } + })).toBe('BC Hydro') + }) + + it('falls back to allocatingOrganizationName text field', () => { + expect(allocCol().valueGetter({ + data: { allocatingOrganizationName: 'FortisBC' } + })).toBe('FortisBC') + }) + + it('returns empty string when neither is present', () => { + expect(allocCol().valueGetter({ data: {} })).toBe('') + }) + }) + + describe('column properties', () => { + it('allocatingOrganization column supports filter and sort', () => { + const col = indexChargingSitesColDefs(false, orgMap).find((c) => c.field === 'allocatingOrganization') + expect(col.filter).toBe(true) + expect(col.sortable).toBe(true) + }) + + it('organization column supports filter and sort', () => { + const col = indexChargingSitesColDefs(true, orgMap).find((c) => c.field === 'organization') + expect(col.filter).toBe(true) + expect(col.sortable).toBe(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// defaultColDef +// --------------------------------------------------------------------------- + +describe('defaultColDef', () => { + it('is not editable', () => expect(defaultColDef.editable).toBe(false)) + it('is resizable', () => expect(defaultColDef.resizable).toBe(true)) + it('has no filter', () => expect(defaultColDef.filter).toBe(false)) + it('is not sortable', () => expect(defaultColDef.sortable).toBe(false)) + it('uses single-click edit', () => expect(defaultColDef.singleClickEdit).toBe(true)) + it('has no floating filter', () => expect(defaultColDef.floatingFilter).toBe(false)) +}) + +// --------------------------------------------------------------------------- +// indexDefaultColDef +// --------------------------------------------------------------------------- + +describe('indexDefaultColDef', () => { + it('is not editable', () => expect(indexDefaultColDef.editable).toBe(false)) + it('is resizable', () => expect(indexDefaultColDef.resizable).toBe(true)) + it('enables floating filter', () => expect(indexDefaultColDef.floatingFilter).toBe(true)) + it('suppresses floating filter button', () => expect(indexDefaultColDef.suppressFloatingFilterButton).toBe(true)) }) diff --git a/frontend/src/views/ChargingSite/components/ChargingSiteProfile.jsx b/frontend/src/views/ChargingSite/components/ChargingSiteProfile.jsx index 00385d1dd..a67694205 100644 --- a/frontend/src/views/ChargingSite/components/ChargingSiteProfile.jsx +++ b/frontend/src/views/ChargingSite/components/ChargingSiteProfile.jsx @@ -42,9 +42,8 @@ export const ChargingSiteProfile = ({ // IDIR Analyst only: show "Set as validated" when site is Submitted (backend enforces Analyst) const canSetValidated = isIDIR && isAnalyst && currentStatus === 'Submitted' - // BCeID: show "Submit updates" for Compliance Reporting/Signing Authority when Draft or Updated - const canSubmitSite = - !isIDIR && isBCeIDCompliance && (currentStatus === 'Draft' || currentStatus === 'Updated') + // BCeID: show "Submit updates" for Compliance Reporting/Signing Authority only when Updated + const canSubmitSite = !isIDIR && isBCeIDCompliance && currentStatus === 'Updated' const handleSetValidated = () => { if (!canSetValidated || !siteId) return diff --git a/frontend/src/views/ChargingSite/components/_schema.jsx b/frontend/src/views/ChargingSite/components/_schema.jsx index c52a3be5f..7a8b7f117 100644 --- a/frontend/src/views/ChargingSite/components/_schema.jsx +++ b/frontend/src/views/ChargingSite/components/_schema.jsx @@ -354,6 +354,16 @@ export const chargingEquipmentColDefs = (t, isIDIR = false, options = {}) => { minWidth: 200 }) } + cols.push({ + field: 'allocatingOrganizationName', + headerName: t('chargingSite:fseColumnLabels.allocatingOrg'), + minWidth: 250, + sortable: false, + valueGetter: (params) => + params.data.chargingSite?.allocatingOrganizationName || + params.data.allocatingOrganizationName || + '' + }) // Registration Number cols.push({ @@ -412,16 +422,6 @@ export const chargingEquipmentColDefs = (t, isIDIR = false, options = {}) => { minWidth: 160, sortable: false }) - cols.push({ - field: 'allocatingOrganizationName', - headerName: t('chargingSite:fseColumnLabels.allocatingOrg'), - minWidth: 250, - sortable: false, - valueGetter: (params) => - params.data.chargingSite?.allocatingOrganizationName || - params.data.allocatingOrganizationName || - '' - }) // Intended Uses cols.push({