refactor: remove obsolete issue management system in favor of issue-facade
Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system. Removed components: - TDD AI framework (tddai/ directory and tddai_cli.py) - Legacy issue management CLI commands and services - Issue-related Makefile targets and helper commands - Obsolete tests and infrastructure dependencies - Finance modules that depended on the old issue system Updated: - Makefile: Removed issue-*, tdd-*, and test-from-issue commands - CLI framework: Simplified to core functionality only - Documentation: Added deprecation notice for old config system The issue-facade now serves as the universal CLI for issue tracking, providing backend-agnostic interface to GitHub, GitLab, Gitea, and local SQLite storage as documented in issue-facade/README.md. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cb94c92fc0
commit
a8e5b4b044
58 changed files with 11 additions and 14628 deletions
|
|
@ -1,566 +0,0 @@
|
|||
"""
|
||||
Cost Allocation Engine for MarkiTect Issue Cost Distribution.
|
||||
|
||||
This module implements the core allocation engine that distributes operational
|
||||
costs across active issues using the defined algorithm from Issue #88.
|
||||
|
||||
The engine handles:
|
||||
- Equal distribution of costs across active issues in a period
|
||||
- Loss carried forward when no active issues exist
|
||||
- Transaction audit trail creation
|
||||
- Edge case handling and validation
|
||||
- Integration with period management and activity tracking
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from .models import FinanceModels
|
||||
from .cost_manager import CostItemManager
|
||||
from .period_manager import PeriodManager, Period, PeriodStatus
|
||||
from ..issues.activity_tracker import IssueActivityTracker, ActivityType
|
||||
|
||||
|
||||
class AllocationStatus(Enum):
|
||||
"""Status enumeration for allocation operations."""
|
||||
SUCCESS = "success"
|
||||
NO_ACTIVE_ISSUES = "no_active_issues"
|
||||
NO_COSTS_TO_ALLOCATE = "no_costs_to_allocate"
|
||||
PERIOD_CLOSED = "period_closed"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AllocationResult:
|
||||
"""Result of a cost allocation operation."""
|
||||
status: AllocationStatus
|
||||
period_id: int
|
||||
total_costs: Decimal = Decimal('0.00')
|
||||
active_issues: List[int] = None
|
||||
cost_per_issue: Decimal = Decimal('0.00')
|
||||
allocations_created: int = 0
|
||||
transactions_created: int = 0
|
||||
loss_carried_forward: Decimal = Decimal('0.00')
|
||||
message: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if self.active_issues is None:
|
||||
self.active_issues = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueAllocation:
|
||||
"""Represents a cost allocation to a specific issue."""
|
||||
issue_id: int
|
||||
allocated_amount: Decimal
|
||||
allocation_date: date
|
||||
period_id: int
|
||||
transaction_id: Optional[int] = None
|
||||
|
||||
|
||||
class TransactionManager:
|
||||
"""Manages cost transaction audit trails for allocations."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Initialize transaction manager.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.finance_models = FinanceModels(db_path)
|
||||
|
||||
def create_allocation_transaction(
|
||||
self,
|
||||
period_id: int,
|
||||
amount: Decimal,
|
||||
issue_id: int,
|
||||
transaction_date: date,
|
||||
description: str
|
||||
) -> int:
|
||||
"""
|
||||
Create a cost allocation transaction record.
|
||||
|
||||
Args:
|
||||
period_id: ID of the cost period
|
||||
amount: Amount allocated to the issue
|
||||
issue_id: ID of the issue receiving allocation
|
||||
transaction_date: Date of the transaction
|
||||
description: Description of the allocation
|
||||
|
||||
Returns:
|
||||
ID of the created transaction
|
||||
"""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, transaction_type, amount_eur, issue_id,
|
||||
transaction_date, description)
|
||||
VALUES (?, 'cost_allocated', ?, ?, ?, ?)
|
||||
''', (period_id, float(amount), issue_id, transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
def create_loss_forward_transaction(
|
||||
self,
|
||||
from_period_id: int,
|
||||
to_period_id: int,
|
||||
amount: Decimal,
|
||||
transaction_date: date,
|
||||
description: str
|
||||
) -> int:
|
||||
"""
|
||||
Create a loss carried forward transaction.
|
||||
|
||||
Args:
|
||||
from_period_id: Source period ID
|
||||
to_period_id: Destination period ID
|
||||
amount: Amount being carried forward
|
||||
transaction_date: Date of the transaction
|
||||
description: Description of the carry forward
|
||||
|
||||
Returns:
|
||||
ID of the created transaction
|
||||
"""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, transaction_type, amount_eur, transaction_date, description)
|
||||
VALUES (?, 'loss_forward', ?, ?, ?)
|
||||
''', (to_period_id, float(amount), transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
class AllocationEngine:
|
||||
"""
|
||||
Core cost allocation engine for distributing operational costs to active issues.
|
||||
|
||||
Implements the algorithm defined in Issue #88:
|
||||
1. Calculate total costs for the period (monthly + one-time + carried forward)
|
||||
2. Identify active issues (created/modified during period)
|
||||
3. Distribute costs equally among active issues
|
||||
4. Handle edge cases (no active issues -> carry forward loss)
|
||||
5. Create audit trail transactions
|
||||
6. Update period statistics
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "markitect.db"):
|
||||
"""
|
||||
Initialize the allocation engine.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self.finance_models = FinanceModels(db_path)
|
||||
self.cost_manager = CostItemManager(db_path)
|
||||
self.period_manager = PeriodManager(db_path)
|
||||
self.activity_tracker = IssueActivityTracker(db_path)
|
||||
self.transaction_manager = TransactionManager(db_path)
|
||||
|
||||
# Ensure database schema is initialized
|
||||
self.finance_models.initialize_finance_schema()
|
||||
|
||||
def allocate_period_costs(self, period_id: int) -> AllocationResult:
|
||||
"""
|
||||
Allocate costs for a specific period to active issues.
|
||||
|
||||
Args:
|
||||
period_id: ID of the period to process
|
||||
|
||||
Returns:
|
||||
AllocationResult with operation details and status
|
||||
"""
|
||||
try:
|
||||
# Get period details
|
||||
period = self._get_period(period_id)
|
||||
if not period:
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.ERROR,
|
||||
period_id=period_id,
|
||||
message=f"Period {period_id} not found"
|
||||
)
|
||||
|
||||
# Check if period is already closed
|
||||
if period.status == PeriodStatus.CLOSED.value:
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.PERIOD_CLOSED,
|
||||
period_id=period_id,
|
||||
message=f"Period {period_id} is already closed"
|
||||
)
|
||||
|
||||
# Set period status to calculating
|
||||
self._update_period_status(period_id, PeriodStatus.CALCULATING)
|
||||
|
||||
# Step 1: Calculate total costs for period
|
||||
total_costs = self._calculate_period_total_costs(period)
|
||||
|
||||
if total_costs == Decimal('0.00'):
|
||||
self._update_period_status(period_id, PeriodStatus.CLOSED)
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.NO_COSTS_TO_ALLOCATE,
|
||||
period_id=period_id,
|
||||
total_costs=total_costs,
|
||||
message="No costs to allocate for this period"
|
||||
)
|
||||
|
||||
# Step 2: Identify active issues for the period
|
||||
active_issues = self._get_active_issues_for_period(period)
|
||||
|
||||
if not active_issues:
|
||||
# No active issues - carry forward loss to next period
|
||||
next_period_id = self._get_or_create_next_period(period)
|
||||
if next_period_id:
|
||||
self._carry_forward_loss(period_id, next_period_id, total_costs)
|
||||
|
||||
# Update period and close
|
||||
self._update_period_totals(period_id, total_costs, 0, Decimal('0.00'), total_costs)
|
||||
self._update_period_status(period_id, PeriodStatus.CLOSED)
|
||||
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.NO_ACTIVE_ISSUES,
|
||||
period_id=period_id,
|
||||
total_costs=total_costs,
|
||||
active_issues=[],
|
||||
loss_carried_forward=total_costs,
|
||||
message=f"No active issues found. Carried forward €{total_costs:.2f} to next period"
|
||||
)
|
||||
|
||||
# Step 3: Calculate cost per issue (equal distribution)
|
||||
cost_per_issue = total_costs / len(active_issues)
|
||||
|
||||
# Step 4: Create allocations and transactions
|
||||
allocations_created = 0
|
||||
transactions_created = 0
|
||||
allocation_date = date.today()
|
||||
|
||||
for issue_id in active_issues:
|
||||
# Create allocation record
|
||||
allocation_id = self._create_issue_allocation(
|
||||
issue_id, period_id, cost_per_issue, allocation_date
|
||||
)
|
||||
|
||||
if allocation_id:
|
||||
allocations_created += 1
|
||||
|
||||
# Create audit transaction
|
||||
transaction_id = self.transaction_manager.create_allocation_transaction(
|
||||
period_id=period_id,
|
||||
amount=cost_per_issue,
|
||||
issue_id=issue_id,
|
||||
transaction_date=allocation_date,
|
||||
description=f"Cost allocation for period {period.period_start} to {period.period_end}"
|
||||
)
|
||||
|
||||
if transaction_id:
|
||||
transactions_created += 1
|
||||
# Link transaction to allocation
|
||||
self._update_allocation_transaction_id(allocation_id, transaction_id)
|
||||
|
||||
# Step 5: Update period totals
|
||||
self._update_period_totals(
|
||||
period_id, total_costs, len(active_issues), cost_per_issue, Decimal('0.00')
|
||||
)
|
||||
|
||||
# Step 6: Close the period
|
||||
self._update_period_status(period_id, PeriodStatus.CLOSED)
|
||||
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.SUCCESS,
|
||||
period_id=period_id,
|
||||
total_costs=total_costs,
|
||||
active_issues=active_issues,
|
||||
cost_per_issue=cost_per_issue,
|
||||
allocations_created=allocations_created,
|
||||
transactions_created=transactions_created,
|
||||
message=f"Successfully allocated €{total_costs:.2f} across {len(active_issues)} issues"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Reset period status on error
|
||||
self._update_period_status(period_id, PeriodStatus.OPEN)
|
||||
return AllocationResult(
|
||||
status=AllocationStatus.ERROR,
|
||||
period_id=period_id,
|
||||
message=f"Allocation failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_issue_allocations(self, issue_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all cost allocations for a specific issue.
|
||||
|
||||
Args:
|
||||
issue_id: ID of the issue
|
||||
|
||||
Returns:
|
||||
List of allocation records
|
||||
"""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
ica.id,
|
||||
ica.issue_id,
|
||||
ica.period_id,
|
||||
ica.allocated_amount,
|
||||
ica.allocation_date,
|
||||
ica.transaction_id,
|
||||
cp.period_start,
|
||||
cp.period_end,
|
||||
cp.period_type
|
||||
FROM issue_cost_allocations ica
|
||||
JOIN cost_periods cp ON ica.period_id = cp.id
|
||||
WHERE ica.issue_id = ?
|
||||
ORDER BY ica.allocation_date DESC
|
||||
''', (issue_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
allocations = []
|
||||
|
||||
for row in rows:
|
||||
allocation = {
|
||||
'id': row[0],
|
||||
'issue_id': row[1],
|
||||
'period_id': row[2],
|
||||
'allocated_amount': float(row[3]),
|
||||
'allocation_date': row[4],
|
||||
'transaction_id': row[5],
|
||||
'period_start': row[6],
|
||||
'period_end': row[7],
|
||||
'period_type': row[8]
|
||||
}
|
||||
allocations.append(allocation)
|
||||
|
||||
return allocations
|
||||
|
||||
def get_period_allocations(self, period_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all allocations for a specific period.
|
||||
|
||||
Args:
|
||||
period_id: ID of the period
|
||||
|
||||
Returns:
|
||||
List of allocation records
|
||||
"""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
ica.id,
|
||||
ica.issue_id,
|
||||
ica.allocated_amount,
|
||||
ica.allocation_date,
|
||||
ica.transaction_id
|
||||
FROM issue_cost_allocations ica
|
||||
WHERE ica.period_id = ?
|
||||
ORDER BY ica.issue_id
|
||||
''', (period_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
allocations = []
|
||||
|
||||
for row in rows:
|
||||
allocation = {
|
||||
'id': row[0],
|
||||
'issue_id': row[1],
|
||||
'allocated_amount': float(row[2]),
|
||||
'allocation_date': row[3],
|
||||
'transaction_id': row[4]
|
||||
}
|
||||
allocations.append(allocation)
|
||||
|
||||
return allocations
|
||||
|
||||
def reverse_allocation(self, allocation_id: int) -> bool:
|
||||
"""
|
||||
Reverse a cost allocation (for corrections).
|
||||
|
||||
Args:
|
||||
allocation_id: ID of the allocation to reverse
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get allocation details
|
||||
cursor.execute('''
|
||||
SELECT issue_id, period_id, allocated_amount, transaction_id
|
||||
FROM issue_cost_allocations
|
||||
WHERE id = ?
|
||||
''', (allocation_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False
|
||||
|
||||
issue_id, period_id, amount, transaction_id = result
|
||||
|
||||
# Create reversal transaction using adjustment type (allows negative amounts)
|
||||
with self.finance_models.get_connection() as conn2:
|
||||
cursor2 = conn2.cursor()
|
||||
cursor2.execute('''
|
||||
INSERT INTO cost_transactions
|
||||
(period_id, transaction_type, amount_eur, issue_id,
|
||||
transaction_date, description)
|
||||
VALUES (?, 'adjustment', ?, ?, ?, ?)
|
||||
''', (period_id, float(-amount), issue_id, date.today().isoformat(), f"Reversal of allocation #{allocation_id}"))
|
||||
|
||||
reversal_transaction_id = cursor2.lastrowid
|
||||
|
||||
# Only delete if reversal transaction was created successfully
|
||||
if reversal_transaction_id:
|
||||
cursor.execute('DELETE FROM issue_cost_allocations WHERE id = ?', (allocation_id,))
|
||||
return cursor.rowcount > 0
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# Log the exception for debugging in tests
|
||||
print(f"Reversal failed with exception: {e}")
|
||||
return False
|
||||
|
||||
def _get_period(self, period_id: int) -> Optional[Period]:
|
||||
"""Get period details by ID."""
|
||||
period_data = self.period_manager.get_period_by_id(period_id)
|
||||
if not period_data:
|
||||
return None
|
||||
|
||||
# Convert dict to Period object
|
||||
return Period(
|
||||
id=period_data['id'],
|
||||
period_start=datetime.strptime(period_data['period_start'], '%Y-%m-%d').date() if period_data['period_start'] else None,
|
||||
period_end=datetime.strptime(period_data['period_end'], '%Y-%m-%d').date() if period_data['period_end'] else None,
|
||||
period_type=period_data['period_type'],
|
||||
status=period_data['status'],
|
||||
total_costs=Decimal(str(period_data['total_costs'])),
|
||||
active_issues_count=period_data['active_issues_count'],
|
||||
cost_per_issue=Decimal(str(period_data['cost_per_issue'])),
|
||||
loss_carried_forward=Decimal(str(period_data['loss_carried_forward'] or 0))
|
||||
)
|
||||
|
||||
def _update_period_status(self, period_id: int, status: PeriodStatus):
|
||||
"""Update period status."""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE cost_periods SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(status.value, period_id)
|
||||
)
|
||||
|
||||
def _calculate_period_total_costs(self, period: Period) -> Decimal:
|
||||
"""Calculate total costs for a period including carried forward amounts."""
|
||||
calculations = self.cost_manager.calculate_period_costs(
|
||||
period.period_start, period.period_end
|
||||
)
|
||||
|
||||
period_costs = calculations['total_period']
|
||||
carried_forward = period.loss_carried_forward or Decimal('0.00')
|
||||
|
||||
return Decimal(str(period_costs)) + carried_forward
|
||||
|
||||
def _get_active_issues_for_period(self, period: Period) -> List[int]:
|
||||
"""Get list of active issue IDs for a period."""
|
||||
activities = self.activity_tracker.get_activities_by_period(
|
||||
period.id,
|
||||
activity_types=[
|
||||
ActivityType.CREATED,
|
||||
ActivityType.MODIFIED,
|
||||
ActivityType.COMMENTED,
|
||||
ActivityType.STATUS_CHANGED
|
||||
]
|
||||
)
|
||||
|
||||
# Get unique issue IDs
|
||||
active_issues = list(set(activity.issue_id for activity in activities))
|
||||
return active_issues
|
||||
|
||||
def _get_or_create_next_period(self, current_period: Period) -> Optional[int]:
|
||||
"""Get or create the next period for loss carry forward."""
|
||||
# For now, return None - next period creation will be handled separately
|
||||
# This is a placeholder for future automatic period creation
|
||||
return None
|
||||
|
||||
def _carry_forward_loss(self, from_period_id: int, to_period_id: int, amount: Decimal):
|
||||
"""Carry forward loss to next period."""
|
||||
# Update the destination period's carried forward amount
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE cost_periods
|
||||
SET loss_carried_forward = loss_carried_forward + ?
|
||||
WHERE id = ?
|
||||
''', (float(amount), to_period_id))
|
||||
|
||||
# Create audit transaction
|
||||
self.transaction_manager.create_loss_forward_transaction(
|
||||
from_period_id=from_period_id,
|
||||
to_period_id=to_period_id,
|
||||
amount=amount,
|
||||
transaction_date=date.today(),
|
||||
description=f"Loss carried forward from period {from_period_id}"
|
||||
)
|
||||
|
||||
def _create_issue_allocation(
|
||||
self, issue_id: int, period_id: int, amount: Decimal, allocation_date: date
|
||||
) -> Optional[int]:
|
||||
"""Create an issue cost allocation record."""
|
||||
try:
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO issue_cost_allocations
|
||||
(issue_id, period_id, allocated_amount, allocation_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (issue_id, period_id, float(amount), allocation_date.isoformat() if hasattr(allocation_date, 'isoformat') else allocation_date))
|
||||
|
||||
return cursor.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
# Allocation already exists for this issue/period
|
||||
return None
|
||||
|
||||
def _update_allocation_transaction_id(self, allocation_id: int, transaction_id: int):
|
||||
"""Link allocation to its audit transaction."""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE issue_cost_allocations
|
||||
SET transaction_id = ?
|
||||
WHERE id = ?
|
||||
''', (transaction_id, allocation_id))
|
||||
|
||||
def _update_period_totals(
|
||||
self,
|
||||
period_id: int,
|
||||
total_costs: Decimal,
|
||||
active_issues_count: int,
|
||||
cost_per_issue: Decimal,
|
||||
loss_carried_forward: Decimal
|
||||
):
|
||||
"""Update period summary statistics."""
|
||||
with self.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE cost_periods
|
||||
SET total_costs = ?,
|
||||
active_issues_count = ?,
|
||||
cost_per_issue = ?,
|
||||
loss_carried_forward = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (float(total_costs), active_issues_count, float(cost_per_issue), float(loss_carried_forward), period_id))
|
||||
|
|
@ -1,507 +0,0 @@
|
|||
"""
|
||||
Single Command Day Wrap-Up functionality.
|
||||
|
||||
This module provides a comprehensive end-of-day command that consolidates
|
||||
daily work summaries, activity tracking, cost distribution, and reporting
|
||||
into a single convenient command.
|
||||
"""
|
||||
|
||||
import click
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
from tabulate import tabulate
|
||||
import json
|
||||
|
||||
from .worktime_tracker import WorktimeTracker
|
||||
from ..issues.activity_tracker import IssueActivityTracker
|
||||
from .session_tracker import SessionCostTracker
|
||||
|
||||
|
||||
class DayWrapUpService:
|
||||
"""Service for comprehensive day wrap-up functionality."""
|
||||
|
||||
def __init__(self, db_path: str = "markitect.db"):
|
||||
"""Initialize the day wrap-up service."""
|
||||
self.db_path = db_path
|
||||
self.worktime_tracker = WorktimeTracker(db_path)
|
||||
self.activity_tracker = IssueActivityTracker(db_path)
|
||||
self.session_tracker = SessionCostTracker(db_path)
|
||||
|
||||
def generate_daily_summary(self, target_date: date) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate comprehensive daily summary.
|
||||
|
||||
Args:
|
||||
target_date: Date to generate summary for
|
||||
|
||||
Returns:
|
||||
Dictionary containing complete daily summary
|
||||
"""
|
||||
summary = {
|
||||
'date': target_date,
|
||||
'worktime': self._get_worktime_summary(target_date),
|
||||
'activities': self._get_activity_summary(target_date),
|
||||
'costs': self._get_cost_summary(target_date),
|
||||
'recommendations': []
|
||||
}
|
||||
|
||||
# Add recommendations based on data
|
||||
summary['recommendations'] = self._generate_recommendations(summary)
|
||||
|
||||
return summary
|
||||
|
||||
def _get_worktime_summary(self, target_date: date) -> Dict[str, Any]:
|
||||
"""Get worktime summary for the date."""
|
||||
daily_summary = self.worktime_tracker.get_daily_summary(target_date)
|
||||
|
||||
if not daily_summary:
|
||||
return {
|
||||
'total_minutes': 0,
|
||||
'total_hours': 0.0,
|
||||
'issues_worked': 0,
|
||||
'entries': [],
|
||||
'cost_allocated': None,
|
||||
'cost_per_minute': None
|
||||
}
|
||||
|
||||
# Get issue breakdown
|
||||
issue_breakdown = {}
|
||||
for entry in daily_summary.entries:
|
||||
if entry.issue_id not in issue_breakdown:
|
||||
issue_breakdown[entry.issue_id] = {
|
||||
'minutes': 0,
|
||||
'entries': 0,
|
||||
'descriptions': []
|
||||
}
|
||||
issue_breakdown[entry.issue_id]['minutes'] += entry.duration_minutes
|
||||
issue_breakdown[entry.issue_id]['entries'] += 1
|
||||
if entry.description:
|
||||
issue_breakdown[entry.issue_id]['descriptions'].append(entry.description)
|
||||
|
||||
return {
|
||||
'total_minutes': daily_summary.total_minutes,
|
||||
'total_hours': daily_summary.total_minutes / 60,
|
||||
'issues_worked': daily_summary.issue_count,
|
||||
'entries': len(daily_summary.entries),
|
||||
'issue_breakdown': issue_breakdown,
|
||||
'cost_allocated': float(daily_summary.total_cost_allocated) if daily_summary.total_cost_allocated else None,
|
||||
'cost_per_minute': float(daily_summary.cost_per_minute) if daily_summary.cost_per_minute else None
|
||||
}
|
||||
|
||||
def _get_activity_summary(self, target_date: date) -> Dict[str, Any]:
|
||||
"""Get activity summary for the date."""
|
||||
summary = self.activity_tracker.get_activity_summary(
|
||||
start_date=target_date,
|
||||
end_date=target_date
|
||||
)
|
||||
|
||||
# Get detailed activities for the day
|
||||
activities = []
|
||||
if summary['total_activities'] > 0:
|
||||
# Get activities by checking each issue that had activity
|
||||
with self.activity_tracker.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT issue_id, activity_type, activity_details, created_at
|
||||
FROM issue_activity_log
|
||||
WHERE activity_date = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
activities.append({
|
||||
'issue_id': row[0],
|
||||
'activity_type': row[1],
|
||||
'details': row[2],
|
||||
'created_at': row[3]
|
||||
})
|
||||
|
||||
return {
|
||||
'total_activities': summary['total_activities'],
|
||||
'unique_issues': summary['unique_issues'],
|
||||
'activities_by_type': summary['activities_by_type'],
|
||||
'activities': activities
|
||||
}
|
||||
|
||||
def _get_cost_summary(self, target_date: date) -> Dict[str, Any]:
|
||||
"""Get cost summary for the date."""
|
||||
# Get session costs from cost notes for the day
|
||||
cost_summary = self.session_tracker.get_issue_costs_summary()
|
||||
|
||||
# Filter for today's costs (this is approximate - would need better filtering in real implementation)
|
||||
daily_costs = 0.0
|
||||
issue_costs = {}
|
||||
|
||||
# Get worktime cost distribution if available
|
||||
with self.worktime_tracker.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT issue_id, cost_allocated
|
||||
FROM worktime_cost_distributions
|
||||
WHERE work_date = ?
|
||||
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
issue_id, cost = row
|
||||
issue_costs[issue_id] = cost
|
||||
daily_costs += cost
|
||||
|
||||
return {
|
||||
'daily_total': daily_costs,
|
||||
'issue_costs': issue_costs,
|
||||
'has_cost_allocation': len(issue_costs) > 0
|
||||
}
|
||||
|
||||
def _generate_recommendations(self, summary: Dict[str, Any]) -> List[str]:
|
||||
"""Generate recommendations based on daily summary."""
|
||||
recommendations = []
|
||||
|
||||
# Worktime recommendations
|
||||
worktime = summary['worktime']
|
||||
if worktime['total_minutes'] == 0:
|
||||
recommendations.append("⚠️ No worktime logged for today. Consider logging time spent on issues.")
|
||||
elif worktime['total_hours'] < 4:
|
||||
recommendations.append("⏰ Low worktime logged today. Is this accurate or should more time be added?")
|
||||
elif worktime['total_hours'] > 10:
|
||||
recommendations.append("🔥 High worktime logged today. Make sure to take breaks!")
|
||||
|
||||
# Activity recommendations
|
||||
activities = summary['activities']
|
||||
if activities['total_activities'] == 0:
|
||||
recommendations.append("📝 No issue activities logged today. Consider what issues you worked on.")
|
||||
elif activities['unique_issues'] > 5:
|
||||
recommendations.append("🤹 Many issues worked on today. Consider focusing on fewer issues for better productivity.")
|
||||
|
||||
# Cost recommendations
|
||||
costs = summary['costs']
|
||||
if worktime['total_minutes'] > 0 and not costs['has_cost_allocation']:
|
||||
recommendations.append("💰 Time logged but no costs distributed. Run cost distribution to allocate daily expenses.")
|
||||
|
||||
return recommendations
|
||||
|
||||
def perform_auto_estimation(self, target_date: date, total_hours: float = 8.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform automatic worktime estimation if no time is logged.
|
||||
|
||||
Args:
|
||||
target_date: Date to estimate for
|
||||
total_hours: Total hours to distribute
|
||||
|
||||
Returns:
|
||||
Estimation results
|
||||
"""
|
||||
# Check if any time is already logged
|
||||
summary = self.worktime_tracker.get_daily_summary(target_date)
|
||||
if summary and summary.total_minutes > 0:
|
||||
return {
|
||||
'estimated': False,
|
||||
'reason': 'Time already logged for this date',
|
||||
'existing_minutes': summary.total_minutes
|
||||
}
|
||||
|
||||
# Get active issues for the day from activity log
|
||||
with self.activity_tracker.finance_models.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT DISTINCT issue_id
|
||||
FROM issue_activity_log
|
||||
WHERE activity_date = ?
|
||||
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
|
||||
active_issues = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
if not active_issues:
|
||||
return {
|
||||
'estimated': False,
|
||||
'reason': 'No active issues found for this date',
|
||||
'active_issues': []
|
||||
}
|
||||
|
||||
# Perform estimation
|
||||
estimation_result = self.worktime_tracker.estimate_daily_worktime(
|
||||
work_date=target_date,
|
||||
total_hours=total_hours,
|
||||
issues=active_issues,
|
||||
distribution_method="activity_based"
|
||||
)
|
||||
|
||||
return {
|
||||
'estimated': True,
|
||||
'estimation_result': estimation_result
|
||||
}
|
||||
|
||||
def distribute_daily_costs(self, target_date: date, daily_cost: Decimal) -> Dict[str, Any]:
|
||||
"""
|
||||
Distribute daily costs based on worktime allocation.
|
||||
|
||||
Args:
|
||||
target_date: Date to distribute costs for
|
||||
daily_cost: Total daily cost to distribute
|
||||
|
||||
Returns:
|
||||
Distribution results
|
||||
"""
|
||||
return self.worktime_tracker.distribute_daily_costs(
|
||||
work_date=target_date,
|
||||
total_daily_cost=daily_cost
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def wrapup():
|
||||
"""Day wrap-up commands for end-of-day summaries and automation."""
|
||||
pass
|
||||
|
||||
|
||||
@wrapup.command()
|
||||
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False)
|
||||
@click.option('--auto-estimate', is_flag=True,
|
||||
help='Automatically estimate worktime if none logged')
|
||||
@click.option('--estimate-hours', type=float, default=8.0,
|
||||
help='Hours to estimate (used with --auto-estimate)')
|
||||
@click.option('--distribute-cost', type=float,
|
||||
help='Daily cost to distribute (€)')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['summary', 'detailed', 'json']),
|
||||
default='summary', help='Output format')
|
||||
def daily(date: Optional[datetime], auto_estimate: bool, estimate_hours: float,
|
||||
distribute_cost: Optional[float], output_format: str):
|
||||
"""Generate comprehensive daily wrap-up summary.
|
||||
|
||||
If no date is provided, uses today's date.
|
||||
"""
|
||||
from datetime import date as date_module
|
||||
target_date = date.date() if date else date_module.today()
|
||||
service = DayWrapUpService()
|
||||
|
||||
try:
|
||||
# Auto-estimate worktime if requested
|
||||
if auto_estimate:
|
||||
click.echo(f"🤖 Auto-estimating worktime for {target_date}...")
|
||||
estimation = service.perform_auto_estimation(target_date, estimate_hours)
|
||||
|
||||
if estimation['estimated']:
|
||||
result = estimation['estimation_result']
|
||||
click.echo(f"✅ Estimated {estimate_hours}h across {result['issues_count']} issues")
|
||||
else:
|
||||
click.echo(f"ℹ️ {estimation['reason']}")
|
||||
|
||||
# Distribute costs if requested
|
||||
if distribute_cost:
|
||||
click.echo(f"💰 Distributing €{distribute_cost:.2f} for {target_date}...")
|
||||
distribution = service.distribute_daily_costs(target_date, Decimal(str(distribute_cost)))
|
||||
|
||||
if 'message' in distribution:
|
||||
click.echo(f"⚠️ {distribution['message']}")
|
||||
else:
|
||||
click.echo(f"✅ Distributed €{distribute_cost:.2f} across {distribution['issues_count']} issues")
|
||||
|
||||
# Generate summary
|
||||
summary = service.generate_daily_summary(target_date)
|
||||
|
||||
if output_format == 'json':
|
||||
# Convert date to string for JSON serialization
|
||||
summary['date'] = summary['date'].isoformat()
|
||||
click.echo(json.dumps(summary, indent=2))
|
||||
return
|
||||
|
||||
# Display summary
|
||||
_display_daily_summary(summary, output_format)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Error generating daily wrap-up: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@wrapup.command()
|
||||
@click.argument('start_date', type=click.DateTime(formats=['%Y-%m-%d']))
|
||||
@click.argument('end_date', type=click.DateTime(formats=['%Y-%m-%d']))
|
||||
@click.option('--format', 'output_format', type=click.Choice(['summary', 'json']),
|
||||
default='summary', help='Output format')
|
||||
def period(start_date: datetime, end_date: datetime, output_format: str):
|
||||
"""Generate wrap-up summary for a date range."""
|
||||
service = DayWrapUpService()
|
||||
|
||||
try:
|
||||
# Get worktime report for period
|
||||
worktime_report = service.worktime_tracker.get_worktime_report(
|
||||
start_date=start_date.date(),
|
||||
end_date=end_date.date()
|
||||
)
|
||||
|
||||
# Get activity summary for period
|
||||
activity_summary = service.activity_tracker.get_activity_summary(
|
||||
start_date=start_date.date(),
|
||||
end_date=end_date.date()
|
||||
)
|
||||
|
||||
period_summary = {
|
||||
'period': f"{start_date.date()} to {end_date.date()}",
|
||||
'worktime': worktime_report,
|
||||
'activities': activity_summary
|
||||
}
|
||||
|
||||
if output_format == 'json':
|
||||
click.echo(json.dumps(period_summary, indent=2))
|
||||
else:
|
||||
_display_period_summary(period_summary)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Error generating period wrap-up: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@wrapup.command()
|
||||
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False)
|
||||
@click.option('--hours', type=float, default=8.0, help='Total hours worked')
|
||||
@click.option('--method', type=click.Choice(['equal', 'activity_based']),
|
||||
default='activity_based', help='Estimation method')
|
||||
def estimate(date: Optional[datetime], hours: float, method: str):
|
||||
"""Estimate and log worktime for a day based on issue activities."""
|
||||
from datetime import date as date_module
|
||||
target_date = date.date() if date else date_module.today()
|
||||
service = DayWrapUpService()
|
||||
|
||||
try:
|
||||
estimation = service.perform_auto_estimation(target_date, hours)
|
||||
|
||||
if not estimation['estimated']:
|
||||
click.echo(f"⚠️ {estimation['reason']}")
|
||||
return
|
||||
|
||||
result = estimation['estimation_result']
|
||||
click.echo(f"✅ Estimated worktime for {target_date}")
|
||||
click.echo(f"Total Hours: {hours}h")
|
||||
click.echo(f"Distribution Method: {method}")
|
||||
click.echo(f"Issues: {result['issues_count']}")
|
||||
|
||||
# Show breakdown
|
||||
headers = ['Issue', 'Time', 'Percentage']
|
||||
rows = []
|
||||
total_minutes = result['total_minutes']
|
||||
|
||||
for issue_id, minutes in result['issue_estimates'].items():
|
||||
percentage = (minutes / total_minutes) * 100
|
||||
hours_mins = f"{minutes//60}h{minutes%60}m" if minutes >= 60 else f"{minutes}m"
|
||||
rows.append([f"#{issue_id}", hours_mins, f"{percentage:.1f}%"])
|
||||
|
||||
click.echo("\nEstimated Time Distribution:")
|
||||
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Error estimating worktime: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _display_daily_summary(summary: Dict[str, Any], format_type: str):
|
||||
"""Display daily summary in formatted output."""
|
||||
date_str = summary['date']
|
||||
worktime = summary['worktime']
|
||||
activities = summary['activities']
|
||||
costs = summary['costs']
|
||||
recommendations = summary['recommendations']
|
||||
|
||||
click.echo(f"\n📊 Daily Wrap-Up for {date_str}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
# Worktime section
|
||||
click.echo(f"\n⏰ WORKTIME SUMMARY")
|
||||
if worktime['total_minutes'] > 0:
|
||||
hours = int(worktime['total_hours'])
|
||||
minutes = int((worktime['total_hours'] - hours) * 60)
|
||||
click.echo(f"Total Time: {hours}h {minutes}m ({worktime['total_minutes']} minutes)")
|
||||
click.echo(f"Issues Worked: {worktime['issues_worked']}")
|
||||
click.echo(f"Time Entries: {worktime['entries']}")
|
||||
|
||||
if worktime['cost_allocated']:
|
||||
click.echo(f"Cost Allocated: €{worktime['cost_allocated']:.2f}")
|
||||
click.echo(f"Cost per Minute: €{worktime['cost_per_minute']:.4f}")
|
||||
|
||||
if format_type == 'detailed' and worktime['issue_breakdown']:
|
||||
click.echo("\nTime by Issue:")
|
||||
headers = ['Issue', 'Time', 'Entries', 'Percentage']
|
||||
rows = []
|
||||
|
||||
for issue_id, data in worktime['issue_breakdown'].items():
|
||||
percentage = (data['minutes'] / worktime['total_minutes']) * 100
|
||||
time_str = f"{data['minutes']//60}h{data['minutes']%60}m" if data['minutes'] >= 60 else f"{data['minutes']}m"
|
||||
rows.append([f"#{issue_id}", time_str, data['entries'], f"{percentage:.1f}%"])
|
||||
|
||||
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
|
||||
else:
|
||||
click.echo("No worktime logged today")
|
||||
|
||||
# Activities section
|
||||
click.echo(f"\n📝 ACTIVITIES SUMMARY")
|
||||
if activities['total_activities'] > 0:
|
||||
click.echo(f"Total Activities: {activities['total_activities']}")
|
||||
click.echo(f"Issues with Activity: {activities['unique_issues']}")
|
||||
|
||||
if activities['activities_by_type']:
|
||||
click.echo("\nActivity Breakdown:")
|
||||
for activity_type, count in activities['activities_by_type'].items():
|
||||
click.echo(f" {activity_type.title()}: {count}")
|
||||
|
||||
if format_type == 'detailed' and activities['activities']:
|
||||
click.echo("\nRecent Activities:")
|
||||
for activity in activities['activities'][:5]: # Show last 5
|
||||
details = f" - {activity['details']}" if activity['details'] else ""
|
||||
click.echo(f" #{activity['issue_id']}: {activity['activity_type']}{details}")
|
||||
else:
|
||||
click.echo("No activities logged today")
|
||||
|
||||
# Costs section
|
||||
click.echo(f"\n💰 COST SUMMARY")
|
||||
if costs['has_cost_allocation']:
|
||||
click.echo(f"Daily Total: €{costs['daily_total']:.2f}")
|
||||
click.echo("Cost Allocation:")
|
||||
for issue_id, cost in costs['issue_costs'].items():
|
||||
click.echo(f" Issue #{issue_id}: €{cost:.2f}")
|
||||
else:
|
||||
click.echo("No cost allocation for today")
|
||||
|
||||
# Recommendations section
|
||||
if recommendations:
|
||||
click.echo(f"\n💡 RECOMMENDATIONS")
|
||||
for rec in recommendations:
|
||||
click.echo(f" {rec}")
|
||||
|
||||
click.echo()
|
||||
|
||||
|
||||
def _display_period_summary(summary: Dict[str, Any]):
|
||||
"""Display period summary in formatted output."""
|
||||
click.echo(f"\n📈 Period Wrap-Up: {summary['period']}")
|
||||
click.echo("=" * 60)
|
||||
|
||||
worktime = summary['worktime']
|
||||
activities = summary['activities']
|
||||
|
||||
# Worktime summary
|
||||
click.echo(f"\n⏰ WORKTIME OVERVIEW")
|
||||
click.echo(f"Total Time: {worktime['total_time']['hours']}h {worktime['total_time']['minutes']}m")
|
||||
click.echo(f"Total Entries: {worktime['total_entries']}")
|
||||
click.echo(f"Unique Issues: {worktime['unique_issues']}")
|
||||
click.echo(f"Unique Dates: {worktime['unique_dates']}")
|
||||
|
||||
if worktime['unique_dates'] > 0:
|
||||
avg_minutes = worktime['average_minutes_per_day']
|
||||
avg_hours = int(avg_minutes // 60)
|
||||
avg_mins = int(avg_minutes % 60)
|
||||
click.echo(f"Average per Day: {avg_hours}h {avg_mins}m")
|
||||
|
||||
# Activities summary
|
||||
click.echo(f"\n📝 ACTIVITIES OVERVIEW")
|
||||
click.echo(f"Total Activities: {activities['total_activities']}")
|
||||
click.echo(f"Unique Issues: {activities['unique_issues']}")
|
||||
|
||||
if activities['activities_by_type']:
|
||||
click.echo("\nActivity Types:")
|
||||
for activity_type, count in activities['activities_by_type'].items():
|
||||
percentage = (count / activities['total_activities']) * 100
|
||||
click.echo(f" {activity_type.title()}: {count} ({percentage:.1f}%)")
|
||||
|
||||
click.echo()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
wrapup()
|
||||
Loading…
Add table
Add a link
Reference in a new issue