bmc_hub/app/timetracking/backend/models.py

432 lines
14 KiB
Python
Raw Normal View History

"""
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 TModuleBulkRateUpdate(BaseModel):
"""Model for bulk customer hourly rate update"""
customer_ids: List[int] = Field(..., min_length=1, description="List of customer IDs to update")
hourly_rate: Decimal = Field(..., ge=0, description="New hourly rate in DKK")
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
is_travel: 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?")
is_travel: bool = Field(False, description="Indeholder kørsel?")
@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_description: Optional[str] = None
case_status: Optional[str] = None
case_vtiger_id: Optional[str] = None
case_vtiger_data: Optional[dict] = None
customer_name: str
customer_rate: Optional[Decimal] = None
contact_name: Optional[str] = None
contact_company: Optional[str] = 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)
case_contact: Optional[str] = Field(None, max_length=255)
time_date: Optional[date] = None
is_travel: bool = False
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
case_contact: Optional[str] = None # Contact name from case
time_date: Optional[date] = None # Date from time entries
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|posted|sent|cancelled)$")
notes: Optional[str] = None
class TModuleOrder(TModuleOrderBase):
"""Full order model with DB fields"""
id: int
order_number: Optional[str] = None
customer_name: Optional[str] = None # From JOIN med customers table
status: str = Field("draft", pattern="^(draft|exported|posted|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
line_count: Optional[int] = Field(None, ge=0)
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
uses_time_card: bool = False
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