""" Fleet Calendar Service Business logic for: - Calculating unit availability on any given date - Calibration status tracking (valid, expiring soon, expired) - Job reservation management - Conflict detection (calibration expires mid-job) """ from datetime import date, datetime, timedelta from typing import Dict, List, Optional, Tuple from sqlalchemy.orm import Session from sqlalchemy import and_, or_ from backend.models import ( RosterUnit, JobReservation, JobReservationUnit, UserPreferences, Project ) def get_calibration_status( unit: RosterUnit, check_date: date, warning_days: int = 30 ) -> str: """ Determine calibration status for a unit on a specific date. Returns: "valid" - Calibration is good on this date "expiring_soon" - Within warning_days of expiry "expired" - Calibration has expired "needs_calibration" - No calibration date set """ if not unit.last_calibrated: return "needs_calibration" # Calculate expiry date (1 year from last calibration) expiry_date = unit.last_calibrated + timedelta(days=365) if check_date >= expiry_date: return "expired" elif check_date >= expiry_date - timedelta(days=warning_days): return "expiring_soon" else: return "valid" def get_unit_reservations_on_date( db: Session, unit_id: str, check_date: date ) -> List[JobReservation]: """Get all reservations that include this unit on the given date.""" # Get reservation IDs that have this unit assigned assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter( JobReservationUnit.unit_id == unit_id ).subquery() # Get reservations that: # 1. Have this unit assigned AND date is within range reservations = db.query(JobReservation).filter( JobReservation.id.in_(assigned_reservation_ids), JobReservation.start_date <= check_date, JobReservation.end_date >= check_date ).all() return reservations def is_unit_available_on_date( db: Session, unit: RosterUnit, check_date: date, warning_days: int = 30 ) -> Tuple[bool, str, Optional[str]]: """ Check if a unit is available on a specific date. Returns: (is_available, status, reservation_name) - is_available: True if unit can be assigned to new work - status: "available", "reserved", "expired", "retired", "needs_calibration" - reservation_name: Name of blocking reservation (if any) """ # Check if retired if unit.retired: return False, "retired", None # Check calibration status cal_status = get_calibration_status(unit, check_date, warning_days) if cal_status == "expired": return False, "expired", None if cal_status == "needs_calibration": return False, "needs_calibration", None # Check if already reserved reservations = get_unit_reservations_on_date(db, unit.id, check_date) if reservations: return False, "reserved", reservations[0].name # Unit is available (even if expiring soon - that's just a warning) return True, "available", None def get_day_summary( db: Session, check_date: date, device_type: str = "seismograph" ) -> Dict: """ Get a complete summary of fleet status for a specific day. Returns dict with: - available_units: List of available unit IDs with calibration info - reserved_units: List of reserved unit IDs with reservation info - expired_units: List of units with expired calibration - expiring_soon_units: List of units expiring within warning period - reservations: List of active reservations on this date - counts: Summary counts """ # Get user preferences for warning days prefs = db.query(UserPreferences).filter_by(id=1).first() warning_days = prefs.calibration_warning_days if prefs else 30 # Get all non-retired units of the specified device type units = db.query(RosterUnit).filter( RosterUnit.device_type == device_type, RosterUnit.retired == False ).all() available_units = [] reserved_units = [] expired_units = [] expiring_soon_units = [] needs_calibration_units = [] cal_expiring_today = [] # Units whose calibration expires ON this day for unit in units: is_avail, status, reservation_name = is_unit_available_on_date( db, unit, check_date, warning_days ) cal_status = get_calibration_status(unit, check_date, warning_days) expiry_date = None if unit.last_calibrated: expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat() unit_info = { "id": unit.id, "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None, "expiry_date": expiry_date, "calibration_status": cal_status, "deployed": unit.deployed, "note": unit.note or "" } # Check if calibration expires ON this specific day if unit.last_calibrated: unit_expiry_date = unit.last_calibrated + timedelta(days=365) if unit_expiry_date == check_date: cal_expiring_today.append(unit_info) if status == "available": available_units.append(unit_info) if cal_status == "expiring_soon": expiring_soon_units.append(unit_info) elif status == "reserved": unit_info["reservation_name"] = reservation_name reserved_units.append(unit_info) if cal_status == "expiring_soon": expiring_soon_units.append(unit_info) elif status == "expired": expired_units.append(unit_info) elif status == "needs_calibration": needs_calibration_units.append(unit_info) # Get active reservations on this date reservations = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= check_date, JobReservation.end_date >= check_date ).all() reservation_list = [] for res in reservations: # Count assigned units for this reservation assigned_count = db.query(JobReservationUnit).filter( JobReservationUnit.reservation_id == res.id ).count() reservation_list.append({ "id": res.id, "name": res.name, "start_date": res.start_date.isoformat(), "end_date": res.end_date.isoformat(), "assignment_type": res.assignment_type, "quantity_needed": res.quantity_needed, "assigned_count": assigned_count, "color": res.color, "project_id": res.project_id }) return { "date": check_date.isoformat(), "device_type": device_type, "available_units": available_units, "reserved_units": reserved_units, "expired_units": expired_units, "expiring_soon_units": expiring_soon_units, "needs_calibration_units": needs_calibration_units, "cal_expiring_today": cal_expiring_today, "reservations": reservation_list, "counts": { "available": len(available_units), "reserved": len(reserved_units), "expired": len(expired_units), "expiring_soon": len(expiring_soon_units), "needs_calibration": len(needs_calibration_units), "cal_expiring_today": len(cal_expiring_today), "total": len(units) } } def get_calendar_year_data( db: Session, year: int, device_type: str = "seismograph" ) -> Dict: """ Get calendar data for an entire year. For performance, this returns summary counts per day rather than full unit lists. Use get_day_summary() for detailed day data. """ # Get user preferences prefs = db.query(UserPreferences).filter_by(id=1).first() warning_days = prefs.calibration_warning_days if prefs else 30 # Get all units units = db.query(RosterUnit).filter( RosterUnit.device_type == device_type, RosterUnit.retired == False ).all() # Get all reservations that overlap with this year # Include TBD reservations (end_date is null) that started before year end year_start = date(year, 1, 1) year_end = date(year, 12, 31) reservations = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= year_end, or_( JobReservation.end_date >= year_start, JobReservation.end_date == None # TBD reservations ) ).all() # Get all unit assignments for these reservations reservation_ids = [r.id for r in reservations] assignments = db.query(JobReservationUnit).filter( JobReservationUnit.reservation_id.in_(reservation_ids) ).all() if reservation_ids else [] # Build a lookup: unit_id -> list of (start_date, end_date, reservation_name) # For TBD reservations, use estimated_end_date if available, or a far future date unit_reservations = {} for res in reservations: res_assignments = [a for a in assignments if a.reservation_id == res.id] for assignment in res_assignments: unit_id = assignment.unit_id # Use unit-specific dates if set, otherwise use reservation dates start_d = assignment.unit_start_date or res.start_date if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd): # TBD: use estimated date or far future for availability calculation end_d = res.estimated_end_date or date(year + 5, 12, 31) else: end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31) if unit_id not in unit_reservations: unit_reservations[unit_id] = [] unit_reservations[unit_id].append((start_d, end_d, res.name)) # Generate data for each month months_data = {} for month in range(1, 13): # Get first and last day of month first_day = date(year, month, 1) if month == 12: last_day = date(year, 12, 31) else: last_day = date(year, month + 1, 1) - timedelta(days=1) days_data = {} current_day = first_day while current_day <= last_day: available = 0 reserved = 0 expired = 0 expiring_soon = 0 needs_cal = 0 cal_expiring_on_day = 0 # Units whose calibration expires ON this day cal_expired_on_day = 0 # Units whose calibration expired ON this day for unit in units: # Check calibration cal_status = get_calibration_status(unit, current_day, warning_days) # Check if calibration expires/expired ON this specific day if unit.last_calibrated: unit_expiry = unit.last_calibrated + timedelta(days=365) if unit_expiry == current_day: cal_expiring_on_day += 1 # Check if expired yesterday (first day of being expired) elif unit_expiry == current_day - timedelta(days=1): cal_expired_on_day += 1 if cal_status == "expired": expired += 1 continue if cal_status == "needs_calibration": needs_cal += 1 continue # Check if reserved is_reserved = False if unit.id in unit_reservations: for start_d, end_d, _ in unit_reservations[unit.id]: if start_d <= current_day <= end_d: is_reserved = True break if is_reserved: reserved += 1 else: available += 1 if cal_status == "expiring_soon": expiring_soon += 1 days_data[current_day.day] = { "available": available, "reserved": reserved, "expired": expired, "expiring_soon": expiring_soon, "needs_calibration": needs_cal, "cal_expiring_on_day": cal_expiring_on_day, "cal_expired_on_day": cal_expired_on_day } current_day += timedelta(days=1) months_data[month] = { "name": first_day.strftime("%B"), "short_name": first_day.strftime("%b"), "days": days_data, "first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday "num_days": last_day.day } # Also include reservation summary for the year reservation_list = [] for res in reservations: assigned_count = len([a for a in assignments if a.reservation_id == res.id]) reservation_list.append({ "id": res.id, "name": res.name, "start_date": res.start_date.isoformat(), "end_date": res.end_date.isoformat(), "quantity_needed": res.quantity_needed, "assigned_count": assigned_count, "color": res.color }) return { "year": year, "device_type": device_type, "months": months_data, "reservations": reservation_list, "total_units": len(units) } def get_rolling_calendar_data( db: Session, start_year: int, start_month: int, device_type: str = "seismograph" ) -> Dict: """ Get calendar data for 12 months starting from a specific month/year. This supports the rolling calendar view where users can scroll through months one at a time, viewing any 12-month window. """ # Get user preferences prefs = db.query(UserPreferences).filter_by(id=1).first() warning_days = prefs.calibration_warning_days if prefs else 30 # Get all units units = db.query(RosterUnit).filter( RosterUnit.device_type == device_type, RosterUnit.retired == False ).all() # Calculate the date range for 12 months first_date = date(start_year, start_month, 1) # Calculate end date (12 months later) end_year = start_year + 1 if start_month == 1 else start_year end_month = 12 if start_month == 1 else start_month - 1 if start_month == 1: end_year = start_year end_month = 12 else: # 12 months from start_month means we end at start_month - 1 next year end_year = start_year + 1 end_month = start_month - 1 # Actually, simpler: go 11 months forward from start end_year = start_year + ((start_month + 10) // 12) end_month = ((start_month + 10) % 12) + 1 if end_month == 12: last_date = date(end_year, 12, 31) else: last_date = date(end_year, end_month + 1, 1) - timedelta(days=1) # Get all reservations that overlap with this 12-month range reservations = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= last_date, or_( JobReservation.end_date >= first_date, JobReservation.end_date == None # TBD reservations ) ).all() # Get all unit assignments for these reservations reservation_ids = [r.id for r in reservations] assignments = db.query(JobReservationUnit).filter( JobReservationUnit.reservation_id.in_(reservation_ids) ).all() if reservation_ids else [] # Build a lookup: unit_id -> list of (start_date, end_date, reservation_name) unit_reservations = {} for res in reservations: res_assignments = [a for a in assignments if a.reservation_id == res.id] for assignment in res_assignments: unit_id = assignment.unit_id start_d = assignment.unit_start_date or res.start_date if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd): end_d = res.estimated_end_date or date(start_year + 5, 12, 31) else: end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31) if unit_id not in unit_reservations: unit_reservations[unit_id] = [] unit_reservations[unit_id].append((start_d, end_d, res.name)) # Generate data for each of the 12 months months_data = [] current_year = start_year current_month = start_month for i in range(12): # Calculate this month's year and month m_year = start_year + ((start_month - 1 + i) // 12) m_month = ((start_month - 1 + i) % 12) + 1 first_day = date(m_year, m_month, 1) if m_month == 12: last_day = date(m_year, 12, 31) else: last_day = date(m_year, m_month + 1, 1) - timedelta(days=1) days_data = {} current_day = first_day while current_day <= last_day: available = 0 reserved = 0 expired = 0 expiring_soon = 0 needs_cal = 0 cal_expiring_on_day = 0 cal_expired_on_day = 0 for unit in units: cal_status = get_calibration_status(unit, current_day, warning_days) if unit.last_calibrated: unit_expiry = unit.last_calibrated + timedelta(days=365) if unit_expiry == current_day: cal_expiring_on_day += 1 elif unit_expiry == current_day - timedelta(days=1): cal_expired_on_day += 1 if cal_status == "expired": expired += 1 continue if cal_status == "needs_calibration": needs_cal += 1 continue is_reserved = False if unit.id in unit_reservations: for start_d, end_d, _ in unit_reservations[unit.id]: if start_d <= current_day <= end_d: is_reserved = True break if is_reserved: reserved += 1 else: available += 1 if cal_status == "expiring_soon": expiring_soon += 1 days_data[current_day.day] = { "available": available, "reserved": reserved, "expired": expired, "expiring_soon": expiring_soon, "needs_calibration": needs_cal, "cal_expiring_on_day": cal_expiring_on_day, "cal_expired_on_day": cal_expired_on_day } current_day += timedelta(days=1) months_data.append({ "year": m_year, "month": m_month, "name": first_day.strftime("%B"), "short_name": first_day.strftime("%b"), "year_short": first_day.strftime("%y"), "days": days_data, "first_weekday": first_day.weekday(), "num_days": last_day.day }) return { "start_year": start_year, "start_month": start_month, "device_type": device_type, "months": months_data, "total_units": len(units) } def check_calibration_conflicts( db: Session, reservation_id: str ) -> List[Dict]: """ Check if any units assigned to a reservation will have their calibration expire during the reservation period. Returns list of conflicts with unit info and expiry date. """ reservation = db.query(JobReservation).filter_by(id=reservation_id).first() if not reservation: return [] # Get assigned units assigned = db.query(JobReservationUnit).filter_by( reservation_id=reservation_id ).all() conflicts = [] for assignment in assigned: unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() if not unit or not unit.last_calibrated: continue expiry_date = unit.last_calibrated + timedelta(days=365) # Check if expiry falls within reservation period if reservation.start_date < expiry_date <= reservation.end_date: conflicts.append({ "unit_id": unit.id, "last_calibrated": unit.last_calibrated.isoformat(), "expiry_date": expiry_date.isoformat(), "reservation_name": reservation.name, "days_into_job": (expiry_date - reservation.start_date).days }) return conflicts def get_available_units_for_period( db: Session, start_date: date, end_date: date, device_type: str = "seismograph", exclude_reservation_id: Optional[str] = None ) -> List[Dict]: """ Get units that are available for the entire specified period. A unit is available if: - Not retired - Calibration is valid through the end date - Not assigned to any other reservation that overlaps the period """ prefs = db.query(UserPreferences).filter_by(id=1).first() warning_days = prefs.calibration_warning_days if prefs else 30 units = db.query(RosterUnit).filter( RosterUnit.device_type == device_type, RosterUnit.retired == False ).all() # Get reservations that overlap with this period overlapping_reservations = db.query(JobReservation).filter( JobReservation.device_type == device_type, JobReservation.start_date <= end_date, JobReservation.end_date >= start_date ) if exclude_reservation_id: overlapping_reservations = overlapping_reservations.filter( JobReservation.id != exclude_reservation_id ) overlapping_reservations = overlapping_reservations.all() # Get all units assigned to overlapping reservations reserved_unit_ids = set() for res in overlapping_reservations: assigned = db.query(JobReservationUnit).filter_by( reservation_id=res.id ).all() for a in assigned: reserved_unit_ids.add(a.unit_id) available_units = [] for unit in units: # Check if already reserved if unit.id in reserved_unit_ids: continue # Check calibration through end of period if not unit.last_calibrated: continue # Needs calibration expiry_date = unit.last_calibrated + timedelta(days=365) if expiry_date <= end_date: continue # Calibration expires during period cal_status = get_calibration_status(unit, end_date, warning_days) available_units.append({ "id": unit.id, "last_calibrated": unit.last_calibrated.isoformat(), "expiry_date": expiry_date.isoformat(), "calibration_status": cal_status, "deployed": unit.deployed, "note": unit.note or "" }) return available_units