88from loguru import logger
99from sqlalchemy import Select , distinct , func , or_ , select , union
1010from sqlalchemy .exc import IntegrityError
11- from sqlalchemy .orm import Session
11+ from sqlalchemy .orm import Session , selectinload
1212
1313from models import (
1414 CAN ,
2525 OpsEventType ,
2626 Portfolio ,
2727 PortfolioTeamLeaders ,
28+ ProcurementShop ,
2829 Project ,
2930 ResearchMethodology ,
3031 ServicesComponent ,
3334 Vendor ,
3435)
3536from models .agreements import AgreementType
37+ from models .procurement_tracker import ProcurementTrackerStatus
3638from models .utils .fiscal_year import get_current_fiscal_year
3739from ops_api .ops .schemas .agreements import AgreementListFilterOptionResponseSchema
3840from ops_api .ops .services .change_requests import ChangeRequestService
@@ -414,14 +416,18 @@ def get(self, id: int) -> Agreement:
414416 return agreement
415417
416418 def get_list (
417- self , agreement_classes : list [Type [Agreement ]], data : dict [str , Any ]
419+ self ,
420+ agreement_classes : list [Type [Agreement ]],
421+ data : dict [str , Any ],
422+ include_procurement : bool = False ,
418423 ) -> tuple [list [Agreement ], dict [str , Any ]]:
419424 """
420425 Get list of agreements with optional filtering and pagination.
421426
422427 Args:
423428 agreement_classes: List of Agreement subclasses to query (e.g., ContractAgreement, GrantAgreement)
424429 data: Dictionary containing filter parameters including limit and offset
430+ include_procurement: When True, eager-load BLIs/trackers and compute procurement metrics
425431
426432 Returns:
427433 Tuple of (paginated agreements list, metadata dict with count/limit/offset)
@@ -432,7 +438,7 @@ def get_list(
432438 # Collect all agreements across types using existing resource helpers
433439 all_results = []
434440 for agreement_cls in agreement_classes :
435- agreements = _get_agreements (self .db_session , agreement_cls , data )
441+ agreements = _get_agreements (self .db_session , agreement_cls , data , include_procurement )
436442 all_results .extend (agreements )
437443
438444 # Filter by award_type (computed property, must be done post-query)
@@ -453,6 +459,16 @@ def get_list(
453459 # Calculate aggregate totals before pagination (for summary cards)
454460 totals = _compute_agreement_totals (all_results )
455461
462+ # Calculate procurement overview and step summary before pagination (only when requested)
463+ procurement_overview = None
464+ procurement_step_summary = None
465+ if include_procurement :
466+ overview_fiscal_year = (
467+ filters .fiscal_year [0 ] if filters .fiscal_year and len (filters .fiscal_year ) == 1 else None
468+ )
469+ procurement_overview = _compute_procurement_overview (all_results , overview_fiscal_year )
470+ procurement_step_summary = _compute_procurement_step_summary (all_results , overview_fiscal_year )
471+
456472 # Apply pagination slicing
457473 if filters .limit is not None and filters .offset is not None :
458474 limit_value = filters .limit [0 ]
@@ -468,6 +484,8 @@ def get_list(
468484 "limit" : limit_value ,
469485 "offset" : offset_value ,
470486 "totals" : totals ,
487+ "procurement_overview" : procurement_overview ,
488+ "procurement_step_summary" : procurement_step_summary ,
471489 }
472490
473491 return paginated_results , metadata
@@ -828,6 +846,13 @@ def _validate_special_topics(db_session: Session, special_topics: List[SpecialTo
828846 raise ValidationError ({"special_topics" : [f"Special Topic IDs do not exist: { invalid_special_topic_ids } " ]})
829847
830848
849+ def _percent (value , total ):
850+ """Return ``value / total`` as a rounded whole-number percentage, or 0.0 when *total* is zero."""
851+ if total == 0 :
852+ return 0.0
853+ return float (round ((float (value ) / float (total )) * 100 ))
854+
855+
831856def _compute_agreement_totals (all_results : list [Agreement ]) -> dict [str , Any ]:
832857 """Compute aggregate totals across all filtered agreements for summary cards."""
833858 totals = {
@@ -879,8 +904,113 @@ def _compute_agreement_totals(all_results: list[Agreement]) -> dict[str, Any]:
879904 return totals
880905
881906
882- def _get_agreements (session : Session , agreement_cls : Type [Agreement ], data : dict [str , Any ]) -> Sequence [Agreement ]:
883- query = _build_base_query (agreement_cls )
907+ def _compute_procurement_overview (all_results : list [Agreement ], fiscal_year : int | None ) -> dict [str , Any ]:
908+ """Compute procurement overview data grouped by BLI status for a given fiscal year.
909+
910+ Returns amount and agreement count breakdowns for PLANNED, IN_EXECUTION, and OBLIGATED statuses.
911+ """
912+ tracked_statuses = [
913+ BudgetLineItemStatus .PLANNED ,
914+ BudgetLineItemStatus .IN_EXECUTION ,
915+ BudgetLineItemStatus .OBLIGATED ,
916+ ]
917+
918+ amount_by_status : dict [BudgetLineItemStatus , Decimal ] = {s : Decimal ("0" ) for s in tracked_statuses }
919+ agreements_by_status : dict [BudgetLineItemStatus , set [int ]] = {s : set () for s in tracked_statuses }
920+
921+ for agreement in all_results :
922+ for bli in agreement .budget_line_items :
923+ if fiscal_year is not None and bli .fiscal_year != fiscal_year :
924+ continue
925+ if bli .status in amount_by_status :
926+ amount_by_status [bli .status ] += (bli .amount or Decimal ("0" )) + bli .fees
927+ agreements_by_status [bli .status ].add (agreement .id )
928+
929+ total_amount = sum (amount_by_status .values (), Decimal ("0" ))
930+ tracked_agreement_ids = set ().union (* agreements_by_status .values ())
931+ total_agreements = len (tracked_agreement_ids )
932+
933+ status_data = []
934+ for status in tracked_statuses :
935+ amount = amount_by_status [status ]
936+ agreement_count = len (agreements_by_status [status ])
937+ status_data .append (
938+ {
939+ "status" : status .name ,
940+ "label" : status .value ,
941+ "amount" : float (amount ),
942+ "amount_percent" : _percent (amount , total_amount ),
943+ "agreements" : agreement_count ,
944+ "agreements_percent" : _percent (agreement_count , total_agreements ),
945+ }
946+ )
947+
948+ return {
949+ "status_data" : status_data ,
950+ "total_amount" : float (total_amount ),
951+ "total_agreements" : total_agreements ,
952+ }
953+
954+
955+ def _get_active_step (agreement : Agreement ) -> int | None :
956+ """Return the active procurement tracker step number for an agreement, or None."""
957+ for tracker in agreement .procurement_trackers :
958+ if tracker .status == ProcurementTrackerStatus .ACTIVE and tracker .active_step_number is not None :
959+ return tracker .active_step_number
960+ return None
961+
962+
963+ def _compute_procurement_step_summary (all_results : list [Agreement ], fiscal_year : int | None ) -> dict [str , Any ]:
964+ """Compute procurement step summary: agreement counts and dollar amounts per step (1-6)."""
965+ amount_by_step : dict [int , Decimal ] = {step : Decimal ("0" ) for step in range (1 , 7 )}
966+ agreements_by_step : dict [int , int ] = {step : 0 for step in range (1 , 7 )}
967+
968+ for agreement in all_results :
969+ active_step = _get_active_step (agreement )
970+ if active_step is None or active_step < 1 or active_step > 6 :
971+ continue
972+
973+ executing_total = Decimal ("0" )
974+ has_executing_blis = False
975+ for bli in agreement .budget_line_items :
976+ if bli .status != BudgetLineItemStatus .IN_EXECUTION :
977+ continue
978+ if fiscal_year is not None and bli .fiscal_year != fiscal_year :
979+ continue
980+ has_executing_blis = True
981+ executing_total += (bli .amount or Decimal ("0" )) + bli .fees
982+
983+ if not has_executing_blis :
984+ continue
985+
986+ agreements_by_step [active_step ] += 1
987+ amount_by_step [active_step ] += executing_total
988+
989+ total_agreement_count = sum (agreements_by_step .values ())
990+
991+ step_data = [
992+ {
993+ "step" : step ,
994+ "agreements" : agreements_by_step [step ],
995+ "agreements_percent" : _percent (agreements_by_step [step ], total_agreement_count ),
996+ "amount" : float (amount_by_step [step ]),
997+ }
998+ for step in range (1 , 7 )
999+ ]
1000+
1001+ return {
1002+ "step_data" : step_data ,
1003+ "total_agreement_count" : total_agreement_count ,
1004+ }
1005+
1006+
1007+ def _get_agreements (
1008+ session : Session ,
1009+ agreement_cls : Type [Agreement ],
1010+ data : dict [str , Any ],
1011+ include_procurement : bool = False ,
1012+ ) -> Sequence [Agreement ]:
1013+ query = _build_base_query (agreement_cls , include_procurement )
8841014 query = _apply_filters (query , agreement_cls , data )
8851015
8861016 logger .debug (f"query: { query } " )
@@ -889,14 +1019,17 @@ def _get_agreements(session: Session, agreement_cls: Type[Agreement], data: dict
8891019 return _filter_by_ownership (all_results , data .get ("only_my" , []))
8901020
8911021
892- def _build_base_query (agreement_cls : Type [Agreement ]) -> Select [tuple [Agreement ]]:
893- return (
894- select (agreement_cls )
895- .distinct ()
896- .join (BudgetLineItem , isouter = True )
897- .join (CAN , isouter = True )
898- .order_by (agreement_cls .id )
899- )
1022+ def _build_base_query (agreement_cls : Type [Agreement ], include_procurement : bool = False ) -> Select [tuple [Agreement ]]:
1023+ query = select (agreement_cls ).distinct ().join (BudgetLineItem , isouter = True ).join (CAN , isouter = True )
1024+
1025+ if include_procurement :
1026+ query = query .options (
1027+ selectinload (agreement_cls .budget_line_items ).selectinload (BudgetLineItem .procurement_shop_fee ),
1028+ selectinload (agreement_cls .procurement_trackers ),
1029+ selectinload (agreement_cls .procurement_shop ).selectinload (ProcurementShop .procurement_shop_fees ),
1030+ )
1031+
1032+ return query .order_by (agreement_cls .id )
9001033
9011034
9021035def _apply_filters (query : Select [Agreement ], agreement_cls : Type [Agreement ], data : dict [str , Any ]) -> Select [Agreement ]:
0 commit comments