""" Pydantic Models for Time Tracking Module ========================================= Alle models repræsenterer data fra tmodule_* tabeller. Ingen afhængigheder til eksisterende Hub-models. """ from datetime import date, datetime from decimal import Decimal from typing import List, Optional from pydantic import BaseModel, Field, field_validator # ============================================================================ # KUNDE MODELS # ============================================================================ class TModuleCustomerBase(BaseModel): """Base model for customer""" vtiger_id: str = Field(..., description="vTiger Account ID") name: str = Field(..., min_length=1, max_length=255) email: Optional[str] = Field(None, max_length=255) hourly_rate: Optional[Decimal] = Field(None, ge=0, description="DKK pr. time") hub_customer_id: Optional[int] = Field(None, description="Reference til customers.id (read-only)") class TModuleCustomerCreate(TModuleCustomerBase): """Model for creating a customer""" vtiger_data: Optional[dict] = Field(default_factory=dict) class TModuleCustomer(TModuleCustomerBase): """Full customer model with DB fields""" id: int sync_hash: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None last_synced_at: datetime class Config: from_attributes = True # ============================================================================ # CASE MODELS # ============================================================================ class TModuleCaseBase(BaseModel): """Base model for case/project""" vtiger_id: str = Field(..., description="vTiger HelpDesk/ProjectTask ID") customer_id: int = Field(..., gt=0) title: str = Field(..., min_length=1, max_length=500) description: Optional[str] = None status: Optional[str] = Field(None, max_length=50) priority: Optional[str] = Field(None, max_length=50) module_type: Optional[str] = Field(None, max_length=50, description="HelpDesk, ProjectTask, etc.") class TModuleCaseCreate(TModuleCaseBase): """Model for creating a case""" vtiger_data: Optional[dict] = Field(default_factory=dict) class TModuleCase(TModuleCaseBase): """Full case model with DB fields""" id: int sync_hash: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None last_synced_at: datetime class Config: from_attributes = True # ============================================================================ # TIDSREGISTRERING MODELS # ============================================================================ class TModuleTimeBase(BaseModel): """Base model for time entry""" vtiger_id: str = Field(..., description="vTiger ModComments ID") case_id: int = Field(..., gt=0) customer_id: int = Field(..., gt=0) description: Optional[str] = None original_hours: Decimal = Field(..., gt=0, description="Original timer fra vTiger") worked_date: Optional[date] = None user_name: Optional[str] = Field(None, max_length=255, description="vTiger bruger") class TModuleTimeCreate(TModuleTimeBase): """Model for creating a time entry""" vtiger_data: Optional[dict] = Field(default_factory=dict) class TModuleTimeUpdate(BaseModel): """Model for updating time entry (godkendelse)""" approved_hours: Optional[Decimal] = Field(None, gt=0) rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval") approval_note: Optional[str] = None billable: Optional[bool] = None status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$") class TModuleTimeApproval(BaseModel): """Model for wizard approval action""" time_id: int = Field(..., gt=0) approved_hours: Decimal = Field(..., gt=0, description="Timer efter godkendelse") rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval brugt") approval_note: Optional[str] = Field(None, description="Brugerens note") billable: bool = Field(True, description="Skal faktureres?") @field_validator('approved_hours') @classmethod def validate_approved_hours(cls, v: Decimal) -> Decimal: if v <= 0: raise ValueError("Approved hours must be positive") # Max 24 timer pr. dag er rimeligt if v > 24: raise ValueError("Approved hours cannot exceed 24 per entry") return v class TModuleTime(TModuleTimeBase): """Full time entry model with DB fields""" id: int status: str = Field("pending", pattern="^(pending|approved|rejected|billed)$") approved_hours: Optional[Decimal] = None rounded_to: Optional[Decimal] = None approval_note: Optional[str] = None billable: bool = True approved_at: Optional[datetime] = None approved_by: Optional[int] = None sync_hash: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None last_synced_at: datetime class Config: from_attributes = True class TModuleTimeWithContext(TModuleTime): """Time entry with case and customer context (for wizard)""" case_title: str case_status: Optional[str] = None customer_name: str customer_rate: Optional[Decimal] = None # ============================================================================ # ORDRE MODELS # ============================================================================ class TModuleOrderLineBase(BaseModel): """Base model for order line""" line_number: int = Field(..., gt=0) description: str = Field(..., min_length=1) quantity: Decimal = Field(..., gt=0, description="Timer") unit_price: Decimal = Field(..., ge=0, description="DKK pr. time") line_total: Decimal = Field(..., ge=0, description="Total for linje") case_id: Optional[int] = Field(None, gt=0) time_entry_ids: List[int] = Field(default_factory=list) product_number: Optional[str] = Field(None, max_length=50) account_number: Optional[str] = Field(None, max_length=50) class TModuleOrderLineCreate(TModuleOrderLineBase): """Model for creating order line""" pass class TModuleOrderLine(TModuleOrderLineBase): """Full order line model""" id: int order_id: int created_at: datetime class Config: from_attributes = True class TModuleOrderBase(BaseModel): """Base model for order""" customer_id: int = Field(..., gt=0) hub_customer_id: Optional[int] = Field(None, gt=0) order_date: date = Field(default_factory=date.today) total_hours: Decimal = Field(0, ge=0) hourly_rate: Decimal = Field(..., gt=0, description="DKK pr. time") subtotal: Decimal = Field(0, ge=0) vat_rate: Decimal = Field(Decimal("25.00"), ge=0, le=100, description="Moms %") vat_amount: Decimal = Field(0, ge=0) total_amount: Decimal = Field(0, ge=0) notes: Optional[str] = None class TModuleOrderCreate(TModuleOrderBase): """Model for creating order""" lines: List[TModuleOrderLineCreate] = Field(default_factory=list) class TModuleOrderUpdate(BaseModel): """Model for updating order""" status: Optional[str] = Field(None, pattern="^(draft|exported|sent|cancelled)$") notes: Optional[str] = None class TModuleOrder(TModuleOrderBase): """Full order model with DB fields""" id: int order_number: Optional[str] = None status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$") economic_draft_id: Optional[int] = None economic_order_number: Optional[str] = None exported_at: Optional[datetime] = None exported_by: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None created_by: Optional[int] = None class Config: from_attributes = True class TModuleOrderWithLines(TModuleOrder): """Order with lines included""" lines: List[TModuleOrderLine] = Field(default_factory=list) class TModuleOrderDetails(BaseModel): """Order details from view (aggregated data)""" order_id: int order_number: Optional[str] = None order_date: date order_status: str total_hours: Decimal total_amount: Decimal economic_draft_id: Optional[int] = None customer_name: str customer_vtiger_id: str line_count: int time_entry_count: int class Config: from_attributes = True # ============================================================================ # STATISTIK & WIZARD MODELS # ============================================================================ class TModuleApprovalStats(BaseModel): """Approval statistics per customer (from view)""" customer_id: int customer_name: str customer_vtiger_id: str total_entries: int pending_count: int approved_count: int rejected_count: int billed_count: int total_original_hours: Optional[Decimal] = None total_approved_hours: Optional[Decimal] = None latest_work_date: Optional[date] = None last_sync: Optional[datetime] = None class Config: from_attributes = True class TModuleWizardProgress(BaseModel): """Progress for wizard flow""" customer_id: int customer_name: str total_entries: int approved_entries: int pending_entries: int rejected_entries: int current_case_id: Optional[int] = None current_case_title: Optional[str] = None progress_percent: float = Field(0, ge=0, le=100) @field_validator('progress_percent', mode='before') @classmethod def calculate_progress(cls, v, info): """Auto-calculate progress if not provided""" if v == 0 and 'total_entries' in info.data and info.data['total_entries'] > 0: approved = info.data.get('approved_entries', 0) rejected = info.data.get('rejected_entries', 0) total = info.data['total_entries'] return round(((approved + rejected) / total) * 100, 2) return v class TModuleWizardNextEntry(BaseModel): """Next entry for wizard approval""" has_next: bool time_entry: Optional[TModuleTimeWithContext] = None progress: Optional[TModuleWizardProgress] = None # ============================================================================ # SYNC & AUDIT MODELS # ============================================================================ class TModuleSyncStats(BaseModel): """Statistics from sync operation""" customers_imported: int = 0 customers_updated: int = 0 customers_skipped: int = 0 cases_imported: int = 0 cases_updated: int = 0 cases_skipped: int = 0 times_imported: int = 0 times_updated: int = 0 times_skipped: int = 0 errors: int = 0 duration_seconds: float = 0.0 started_at: datetime completed_at: Optional[datetime] = None class TModuleSyncLogCreate(BaseModel): """Model for creating audit log entry""" event_type: str = Field(..., max_length=50) entity_type: Optional[str] = Field(None, max_length=50) entity_id: Optional[int] = None user_id: Optional[int] = None details: Optional[dict] = Field(default_factory=dict) ip_address: Optional[str] = None user_agent: Optional[str] = None class TModuleSyncLog(TModuleSyncLogCreate): """Full audit log model""" id: int created_at: datetime class Config: from_attributes = True # ============================================================================ # e-conomic EXPORT MODELS # ============================================================================ class TModuleEconomicExportRequest(BaseModel): """Request for exporting order to e-conomic""" order_id: int = Field(..., gt=0) force: bool = Field(False, description="Force export even if already exported") class TModuleEconomicExportResult(BaseModel): """Result of e-conomic export""" success: bool dry_run: bool = False order_id: int economic_draft_id: Optional[int] = None economic_order_number: Optional[str] = None message: str details: Optional[dict] = None # ============================================================================ # MODULE METADATA # ============================================================================ class TModuleMetadata(BaseModel): """Module metadata""" id: int module_version: str installed_at: datetime installed_by: Optional[int] = None last_sync_at: Optional[datetime] = None is_active: bool settings: dict = Field(default_factory=dict) class Config: from_attributes = True class TModuleUninstallRequest(BaseModel): """Request for module uninstall""" confirm: bool = Field(..., description="Must be True to proceed") delete_all_data: bool = Field(..., description="Confirm deletion of ALL module data") @field_validator('confirm') @classmethod def validate_confirm(cls, v: bool) -> bool: if not v: raise ValueError("You must confirm uninstall by setting confirm=True") return v @field_validator('delete_all_data') @classmethod def validate_delete(cls, v: bool) -> bool: if not v: raise ValueError("You must confirm data deletion by setting delete_all_data=True") return v class TModuleUninstallResult(BaseModel): """Result of module uninstall""" success: bool message: str tables_dropped: List[str] = Field(default_factory=list) views_dropped: List[str] = Field(default_factory=list) functions_dropped: List[str] = Field(default_factory=list) rows_deleted: int = 0