feat: implement comprehensive Period Management Framework (issue #112)

Complete period lifecycle management system including:

- PeriodManager class with full lifecycle operations
- Period status management (open/calculating/closed) with validation
- Period overlap detection and conflict resolution
- Comprehensive cost calculation and aggregation engine
- Loss carried forward calculations between periods
- Period closure validation with audit trails
- Current period detection and auto-creation utilities

CLI Integration:
- Complete period command suite (create, list, show, calculate, status, close, current)
- Professional CLI output with detailed formatting
- Comprehensive error handling and validation
- Date filtering and status filtering capabilities

Testing:
- 25 core PeriodManager tests covering all functionality
- 24 CLI command tests ensuring proper integration
- Edge case testing for complex scenarios
- 49 total tests passing with comprehensive coverage

Database Integration:
- Utilizes existing cost_periods schema from FinanceModels
- Full SQLite integration with proper constraints
- Performance-optimized indexes and queries
- Seamless integration with existing cost tracking system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-04 01:41:58 +02:00
parent dab6b9fdef
commit 397b607442
4 changed files with 1874 additions and 1 deletions

View File

@@ -7,7 +7,7 @@ including report generation, cost item management, and period calculations.
import click
import sys
from datetime import date, datetime
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Optional
@@ -15,6 +15,7 @@ from typing import Optional
from .cost_manager import CostItemManager, CostItem
from .report_generator import CostReportGenerator, ReportConfig
from .session_tracker import SessionCostTracker
from .period_manager import PeriodManager, PeriodStatus
from ..config_manager import ConfigurationManager
@@ -546,4 +547,365 @@ def session_summary(issue_ids: Optional[str], db_path: Optional[str]):
except Exception as e:
click.echo(f"Error getting session summary: {e}", err=True)
sys.exit(1)
@cost_commands.group(name='period')
def cost_period():
"""Manage calculation periods and lifecycle."""
pass
@cost_period.command('create')
@click.option('--start-date', required=True, help='Period start date (YYYY-MM-DD)')
@click.option('--end-date', required=True, help='Period end date (YYYY-MM-DD)')
@click.option('--type', 'period_type', default='monthly', help='Period type (monthly, quarterly, yearly)')
@click.option('--loss-forward', type=float, help='Loss carried forward from previous period')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def create_period(start_date: str, end_date: str, period_type: str,
loss_forward: Optional[float], db_path: Optional[str]):
"""Create a new calculation period."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Parse dates
try:
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Dates must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Initialize period manager
period_manager = PeriodManager(db_path)
# Convert loss forward to Decimal
loss_forward_decimal = None
if loss_forward is not None:
loss_forward_decimal = Decimal(str(loss_forward))
# Create period
period_id = period_manager.create_period(
period_start=start_date_obj,
period_end=end_date_obj,
period_type=period_type,
loss_carried_forward=loss_forward_decimal
)
if period_id:
click.echo(f"✅ Created period #{period_id}")
click.echo(f"📅 Period: {start_date} to {end_date}")
click.echo(f"📊 Type: {period_type}")
if loss_forward:
click.echo(f"💸 Loss carried forward: €{loss_forward:.4f}")
else:
click.echo("❌ Failed to create period", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error creating period: {e}", err=True)
sys.exit(1)
@cost_period.command('list')
@click.option('--status', type=click.Choice(['open', 'calculating', 'closed']),
help='Filter by status')
@click.option('--start-from', help='Show periods starting from date (YYYY-MM-DD)')
@click.option('--end-before', help='Show periods ending before date (YYYY-MM-DD)')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def list_periods(status: Optional[str], start_from: Optional[str],
end_before: Optional[str], db_path: Optional[str]):
"""List calculation periods with optional filtering."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Parse date filters
start_date_obj = None
end_date_obj = None
if start_from:
try:
start_date_obj = datetime.strptime(start_from, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Start date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
if end_before:
try:
end_date_obj = datetime.strptime(end_before, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: End date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Get periods
period_manager = PeriodManager(db_path)
periods = period_manager.list_periods(
status_filter=status,
start_date=start_date_obj,
end_date=end_date_obj
)
if not periods:
click.echo("No periods found matching criteria.")
return
# Display periods
click.echo(f"📅 Calculation Periods")
click.echo("=" * 80)
click.echo(f"{'ID':<4} {'Start Date':<12} {'End Date':<12} {'Type':<10} {'Status':<12} {'Total Cost':<12}")
click.echo("-" * 80)
for period in periods:
click.echo(
f"{period['id']:<4} {period['period_start']:<12} {period['period_end']:<12} "
f"{period['period_type']:<10} {period['status']:<12}{float(period['total_costs']):<11.2f}"
)
click.echo(f"\nTotal: {len(periods)} periods")
except Exception as e:
click.echo(f"Error listing periods: {e}", err=True)
sys.exit(1)
@cost_period.command('show')
@click.argument('period_id', type=int)
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def show_period(period_id: int, db_path: Optional[str]):
"""Show detailed information for a specific period."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Get period details
period_manager = PeriodManager(db_path)
period = period_manager.get_period_by_id(period_id)
if not period:
click.echo(f"Period #{period_id} not found.", err=True)
sys.exit(1)
# Display period details
click.echo(f"📅 Period #{period['id']} Details")
click.echo("=" * 40)
click.echo(f"Start Date: {period['period_start']}")
click.echo(f"End Date: {period['period_end']}")
click.echo(f"Type: {period['period_type']}")
click.echo(f"Status: {period['status']}")
click.echo(f"Total Costs: €{float(period['total_costs']):.4f}")
click.echo(f"Active Issues: {period['active_issues_count']}")
if period['cost_per_issue'] > 0:
click.echo(f"Cost per Issue: €{float(period['cost_per_issue']):.4f}")
if period['loss_carried_forward'] > 0:
click.echo(f"Loss Carried Forward: €{float(period['loss_carried_forward']):.4f}")
click.echo(f"Created: {period['created_at']}")
if period['updated_at']:
click.echo(f"Updated: {period['updated_at']}")
except Exception as e:
click.echo(f"Error showing period: {e}", err=True)
sys.exit(1)
@cost_period.command('calculate')
@click.argument('period_id', type=int)
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def calculate_period(period_id: int, db_path: Optional[str]):
"""Calculate costs for a specific period."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Calculate period costs
period_manager = PeriodManager(db_path)
calculation = period_manager.calculate_period_costs(period_id)
click.echo(f"📊 Period #{calculation['period_id']} Cost Calculation")
click.echo("=" * 50)
click.echo(f"Period: {calculation['period_start']} to {calculation['period_end']}")
click.echo(f"Monthly Recurring: €{calculation['monthly_costs']:.2f}")
click.echo(f"One-time Expenses: €{calculation['one_time_costs']:.2f}")
if calculation['loss_carried_forward'] > 0:
click.echo(f"Loss Carried Forward: €{calculation['loss_carried_forward']:.2f}")
click.echo(f"Total Period Cost: €{calculation['total_costs']:.2f}")
click.echo(f"Calculated: {calculation['calculation_date'][:19]}")
except Exception as e:
click.echo(f"Error calculating period: {e}", err=True)
sys.exit(1)
@cost_period.command('status')
@click.argument('period_id', type=int)
@click.argument('new_status', type=click.Choice(['open', 'calculating', 'closed']))
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def update_status(period_id: int, new_status: str, db_path: Optional[str]):
"""Update period status."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Update status
period_manager = PeriodManager(db_path)
success = period_manager.update_period_status(period_id, new_status)
if success:
click.echo(f"✅ Period #{period_id} status updated to '{new_status}'")
else:
click.echo(f"❌ Failed to update period #{period_id} status", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error updating status: {e}", err=True)
sys.exit(1)
@cost_period.command('close')
@click.argument('period_id', type=int)
@click.option('--skip-validation', is_flag=True, help='Skip transaction validation')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def close_period(period_id: int, skip_validation: bool, db_path: Optional[str]):
"""Close a period after final calculations."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Close period
period_manager = PeriodManager(db_path)
success = period_manager.close_period(
period_id,
validate_transactions=not skip_validation
)
if success:
click.echo(f"✅ Period #{period_id} has been closed")
# Show final calculation
period = period_manager.get_period_by_id(period_id)
if period:
click.echo(f"💰 Final total cost: €{float(period['total_costs']):.4f}")
else:
click.echo(f"❌ Failed to close period #{period_id}", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error closing period: {e}", err=True)
sys.exit(1)
@cost_period.command('current')
@click.option('--date', 'date_str', help='Date to find period for (YYYY-MM-DD, defaults to today)')
@click.option('--database', 'db_path', help='Database path (defaults to config)')
def current_period(date_str: Optional[str], db_path: Optional[str]):
"""Show current active period for a given date."""
try:
# Get database path
if not db_path:
config_manager = ConfigurationManager()
config = config_manager.get_current_config()
db_path = config.get('database_path')
if not db_path:
click.echo("Error: No database path specified.", err=True)
sys.exit(1)
# Parse date if provided
target_date = None
if date_str:
try:
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
click.echo("Error: Date must be in YYYY-MM-DD format", err=True)
sys.exit(1)
# Get current period
period_manager = PeriodManager(db_path)
current = period_manager.get_current_period(target_date)
if not current:
date_display = date_str if date_str else "today"
click.echo(f"No active period found for {date_display}")
# Suggest creating a period
if not date_str:
today = date.today()
# Calculate last day of current month
if today.month == 12:
next_month = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month = today.replace(month=today.month + 1, day=1)
last_day = (next_month - timedelta(days=1)).day
click.echo(f"💡 Create one with: markitect cost period create --start-date {today.year}-{today.month:02d}-01 --end-date {today.year}-{today.month:02d}-{last_day:02d}")
return
# Display current period
click.echo(f"📅 Current Active Period")
click.echo("=" * 30)
click.echo(f"Period #{current['id']}")
click.echo(f"Dates: {current['period_start']} to {current['period_end']}")
click.echo(f"Status: {current['status']}")
click.echo(f"Total Costs: €{float(current['total_costs']):.4f}")
except Exception as e:
click.echo(f"Error getting current period: {e}", err=True)
sys.exit(1)