""" Period Management Framework for MarkiTect Cost Tracking System. This module provides comprehensive period lifecycle management including: - Period creation, status management, and transitions - Period overlap validation and conflict resolution - Automatic period calculations and data aggregation - Loss carried forward calculations - Period closure validation and audit trails The framework supports the complete period lifecycle: 1. OPEN: Period is created and active for cost tracking 2. CALCULATING: Period is being processed for cost allocation 3. CLOSED: Period is finalized with all calculations complete """ import sqlite3 import os from datetime import datetime, date, timedelta from decimal import Decimal from typing import Optional, Dict, Any, List, Tuple from dataclasses import dataclass from enum import Enum from .models import FinanceModels class PeriodStatus(Enum): """Period status enumeration.""" OPEN = 'open' CALCULATING = 'calculating' CLOSED = 'closed' @dataclass class Period: """Period data model.""" id: Optional[int] = None period_start: Optional[date] = None period_end: Optional[date] = None period_type: str = 'monthly' status: str = 'open' total_costs: Decimal = Decimal('0.00') active_issues_count: int = 0 cost_per_issue: Decimal = Decimal('0.00') loss_carried_forward: Decimal = Decimal('0.00') created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class PeriodManager: """ Comprehensive period management system for cost tracking. Handles period lifecycle management, status transitions, calculations, and validation for the cost tracking system. """ def __init__(self, db_path: str): """ Initialize period manager. Args: db_path: Path to SQLite database file """ self.db_path = db_path self.finance_models = FinanceModels(db_path) def create_period(self, period_start: date, period_end: date, period_type: str = 'monthly', loss_carried_forward: Optional[Decimal] = None) -> Optional[int]: """ Create a new calculation period with validation. Args: period_start: Start date of the period period_end: End date of the period period_type: Type of period (monthly, quarterly, yearly) loss_carried_forward: Amount carried forward from previous period Returns: ID of created period, or None if creation failed Raises: ValueError: If period dates are invalid or overlapping periods exist """ # Validate period dates if period_end <= period_start: raise ValueError("Period end date must be after start date") # Check for overlapping periods overlapping = self.find_overlapping_periods(period_start, period_end) if overlapping: overlapping_periods = [f"#{p['id']} ({p['period_start']} to {p['period_end']})" for p in overlapping] raise ValueError(f"Period overlaps with existing periods: {', '.join(overlapping_periods)}") # Set loss carried forward to 0 if not provided if loss_carried_forward is None: loss_carried_forward = Decimal('0.00') conn = self.finance_models.get_connection() cursor = conn.cursor() try: cursor.execute(''' INSERT INTO cost_periods ( period_start, period_end, period_type, status, total_costs, active_issues_count, cost_per_issue, loss_carried_forward ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''', ( period_start.isoformat(), period_end.isoformat(), period_type, PeriodStatus.OPEN.value, 0, 0, 0, float(loss_carried_forward) )) conn.commit() period_id = cursor.lastrowid return period_id except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to create period: {e}") finally: conn.close() def get_period_by_id(self, period_id: int) -> Optional[Dict[str, Any]]: """ Get period by ID. Args: period_id: Period ID Returns: Period dictionary or None if not found """ conn = self.finance_models.get_connection() cursor = conn.cursor() try: cursor.execute(''' SELECT id, period_start, period_end, period_type, status, total_costs, active_issues_count, cost_per_issue, loss_carried_forward, created_at, updated_at FROM cost_periods WHERE id = ? ''', (period_id,)) row = cursor.fetchone() if not row: return None return { 'id': row[0], 'period_start': row[1], 'period_end': row[2], 'period_type': row[3], 'status': row[4], 'total_costs': Decimal(str(row[5])), 'active_issues_count': row[6], 'cost_per_issue': Decimal(str(row[7])), 'loss_carried_forward': Decimal(str(row[8])), 'created_at': row[9], 'updated_at': row[10] } except sqlite3.Error as e: raise RuntimeError(f"Failed to get period: {e}") finally: conn.close() def find_overlapping_periods(self, period_start: date, period_end: date, exclude_period_id: Optional[int] = None) -> List[Dict[str, Any]]: """ Find periods that overlap with the given date range. Args: period_start: Start date to check period_end: End date to check exclude_period_id: Period ID to exclude from check (for updates) Returns: List of overlapping periods """ conn = self.finance_models.get_connection() cursor = conn.cursor() try: sql = ''' SELECT id, period_start, period_end, status FROM cost_periods WHERE NOT (period_end < ? OR period_start > ?) ''' params = [period_start.isoformat(), period_end.isoformat()] if exclude_period_id: sql += ' AND id != ?' params.append(exclude_period_id) cursor.execute(sql, params) rows = cursor.fetchall() return [ { 'id': row[0], 'period_start': row[1], 'period_end': row[2], 'status': row[3] } for row in rows ] except sqlite3.Error as e: raise RuntimeError(f"Failed to check overlapping periods: {e}") finally: conn.close() def update_period_status(self, period_id: int, new_status: str) -> bool: """ Update period status with validation. Args: period_id: Period ID to update new_status: New status (open, calculating, closed) Returns: True if update successful, False otherwise Raises: ValueError: If status transition is invalid """ valid_statuses = [status.value for status in PeriodStatus] if new_status not in valid_statuses: raise ValueError(f"Invalid status '{new_status}'. Valid statuses: {valid_statuses}") # Get current period to validate status transition current_period = self.get_period_by_id(period_id) if not current_period: raise ValueError(f"Period #{period_id} not found") current_status = current_period['status'] # Validate status transitions valid_transitions = { PeriodStatus.OPEN.value: [PeriodStatus.CALCULATING.value, PeriodStatus.CLOSED.value], PeriodStatus.CALCULATING.value: [PeriodStatus.OPEN.value, PeriodStatus.CLOSED.value], PeriodStatus.CLOSED.value: [PeriodStatus.OPEN.value] # Allow reopening if needed } if new_status not in valid_transitions.get(current_status, []): raise ValueError(f"Invalid status transition from '{current_status}' to '{new_status}'") conn = self.finance_models.get_connection() cursor = conn.cursor() try: cursor.execute(''' UPDATE cost_periods SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (new_status, period_id)) conn.commit() return cursor.rowcount > 0 except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to update period status: {e}") finally: conn.close() def calculate_period_costs(self, period_id: int) -> Dict[str, Any]: """ Calculate total costs for a period based on active cost items. Args: period_id: Period ID to calculate Returns: Dictionary with calculation results """ period = self.get_period_by_id(period_id) if not period: raise ValueError(f"Period #{period_id} not found") period_start = datetime.strptime(period['period_start'], '%Y-%m-%d').date() period_end = datetime.strptime(period['period_end'], '%Y-%m-%d').date() # Import CostItemManager to get cost data from .cost_manager import CostItemManager cost_manager = CostItemManager(self.db_path) # Get all active cost items cost_items = cost_manager.list_cost_items(active_only=True) total_monthly = Decimal('0') total_one_time = Decimal('0') for item in cost_items: item_start = datetime.strptime(item['starting_from_date'], '%Y-%m-%d').date() # Skip items that start after our period if item_start > period_end: continue # Handle ending date item_end = None if item['ending_date']: item_end = datetime.strptime(item['ending_date'], '%Y-%m-%d').date() # Skip items that ended before our period if item_end < period_start: continue # Calculate cost for this period if item['cost_type'] == 'monthly': # For monthly items, include full amount if active during period total_monthly += Decimal(str(item['amount_eur'])) elif item['cost_type'] == 'one_time': # For one-time items, include only if starting date is within period if period_start <= item_start <= period_end: total_one_time += Decimal(str(item['amount_eur'])) total_costs = total_monthly + total_one_time + period['loss_carried_forward'] # Update period with calculated values conn = self.finance_models.get_connection() cursor = conn.cursor() try: cursor.execute(''' UPDATE cost_periods SET total_costs = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (float(total_costs), period_id)) conn.commit() except sqlite3.Error as e: conn.rollback() raise RuntimeError(f"Failed to update period calculations: {e}") finally: conn.close() return { 'period_id': period_id, 'period_start': period['period_start'], 'period_end': period['period_end'], 'monthly_costs': float(total_monthly), 'one_time_costs': float(total_one_time), 'loss_carried_forward': float(period['loss_carried_forward']), 'total_costs': float(total_costs), 'calculation_date': datetime.now().isoformat() } def close_period(self, period_id: int, validate_transactions: bool = True) -> bool: """ Close a period after validation. Args: period_id: Period ID to close validate_transactions: Whether to validate all transactions are recorded Returns: True if period closed successfully, False otherwise Raises: ValueError: If period cannot be closed due to validation errors """ period = self.get_period_by_id(period_id) if not period: raise ValueError(f"Period #{period_id} not found") if period['status'] == PeriodStatus.CLOSED.value: return True # Already closed # Optional transaction validation if validate_transactions: # Check if there are any unrecorded transactions # This is a placeholder for future transaction validation logic pass # Calculate final costs before closing calculation_result = self.calculate_period_costs(period_id) # Update status to closed return self.update_period_status(period_id, PeriodStatus.CLOSED.value) def list_periods(self, status_filter: Optional[str] = None, start_date: Optional[date] = None, end_date: Optional[date] = None) -> List[Dict[str, Any]]: """ List periods with optional filtering. Args: status_filter: Filter by status (open, calculating, closed) start_date: Filter periods starting from this date end_date: Filter periods ending before this date Returns: List of periods matching criteria """ conn = self.finance_models.get_connection() cursor = conn.cursor() try: sql = ''' SELECT id, period_start, period_end, period_type, status, total_costs, active_issues_count, cost_per_issue, loss_carried_forward, created_at, updated_at FROM cost_periods WHERE 1=1 ''' params = [] if status_filter: sql += ' AND status = ?' params.append(status_filter) if start_date: sql += ' AND period_start >= ?' params.append(start_date.isoformat()) if end_date: sql += ' AND period_end <= ?' params.append(end_date.isoformat()) sql += ' ORDER BY period_start DESC' cursor.execute(sql, params) rows = cursor.fetchall() return [ { 'id': row[0], 'period_start': row[1], 'period_end': row[2], 'period_type': row[3], 'status': row[4], 'total_costs': Decimal(str(row[5])), 'active_issues_count': row[6], 'cost_per_issue': Decimal(str(row[7])), 'loss_carried_forward': Decimal(str(row[8])), 'created_at': row[9], 'updated_at': row[10] } for row in rows ] except sqlite3.Error as e: raise RuntimeError(f"Failed to list periods: {e}") finally: conn.close() def get_current_period(self, target_date: Optional[date] = None) -> Optional[Dict[str, Any]]: """ Get the current active period for a given date. Args: target_date: Date to find period for (defaults to today) Returns: Current period dictionary or None if no period found """ if target_date is None: target_date = date.today() conn = self.finance_models.get_connection() cursor = conn.cursor() try: cursor.execute(''' SELECT id, period_start, period_end, period_type, status, total_costs, active_issues_count, cost_per_issue, loss_carried_forward, created_at, updated_at FROM cost_periods WHERE period_start <= ? AND period_end >= ? AND status IN ('open', 'calculating') ORDER BY period_start DESC LIMIT 1 ''', (target_date.isoformat(), target_date.isoformat())) row = cursor.fetchone() if not row: return None return { 'id': row[0], 'period_start': row[1], 'period_end': row[2], 'period_type': row[3], 'status': row[4], 'total_costs': Decimal(str(row[5])), 'active_issues_count': row[6], 'cost_per_issue': Decimal(str(row[7])), 'loss_carried_forward': Decimal(str(row[8])), 'created_at': row[9], 'updated_at': row[10] } except sqlite3.Error as e: raise RuntimeError(f"Failed to get current period: {e}") finally: conn.close() def create_monthly_period(self, year: int, month: int) -> Optional[int]: """ Convenience method to create a monthly period. Args: year: Year of the period month: Month of the period (1-12) Returns: ID of created period, or None if creation failed """ # Calculate period dates period_start = date(year, month, 1) # Calculate last day of month if month == 12: next_month_start = date(year + 1, 1, 1) else: next_month_start = date(year, month + 1, 1) period_end = next_month_start - timedelta(days=1) return self.create_period(period_start, period_end, 'monthly') def auto_create_period_for_date(self, target_date: date) -> Optional[int]: """ Automatically create a monthly period for a given date if it doesn't exist. Args: target_date: Date that should be covered by a period Returns: ID of existing or newly created period, or None if creation failed """ # Check if period already exists current_period = self.get_current_period(target_date) if current_period: return current_period['id'] # Create new monthly period for the date year = target_date.year month = target_date.month try: return self.create_monthly_period(year, month) except ValueError as e: # Period might already exist due to race condition, try to get it again current_period = self.get_current_period(target_date) if current_period: return current_period['id'] raise e