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:
@@ -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)
|
||||
Reference in New Issue
Block a user