diff --git a/.env.bak b/.env.bak index 0d915ad..5fee4e8 100644 --- a/.env.bak +++ b/.env.bak @@ -38,7 +38,7 @@ GITHUB_REPO=ct/bmc_hub # OLLAMA AI INTEGRATION # ===================================================== OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk -OLLAMA_MODEL=qwen2.5:3b +OLLAMA_MODEL=qwen2.5-coder:7b # ===================================================== # e-conomic Integration (Optional) @@ -51,3 +51,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes + +# vTiger CRM Integration (for Time Tracking Module) +VTIGER_URL=https://bmcnetworks.od2.vtiger.com +VTIGER_USERNAME=ct@bmcnetworks.dk +VTIGER_API_KEY=bD8cW8zRFuKpPZ2S + +# Time Tracking Module Settings +TIMETRACKING_DEFAULT_HOURLY_RATE=1200.00 # Standard timepris i DKK +TIMETRACKING_AUTO_ROUND=true +TIMETRACKING_ROUND_INCREMENT=0.5 +TIMETRACKING_ROUND_METHOD=up + +# Time Tracking Safety Switches +TIMETRACKING_VTIGER_READ_ONLY=true +TIMETRACKING_VTIGER_DRY_RUN=true +TIMETRACKING_ECONOMIC_READ_ONLY=true +TIMETRACKING_ECONOMIC_DRY_RUN=true + diff --git a/.env.example b/.env.example index bc83e8d..5c62689 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,48 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes + +# ===================================================== +# vTiger CRM Integration (Optional) +# ===================================================== +VTIGER_URL=https://your-instance.od2.vtiger.com +VTIGER_USERNAME=your_username@yourdomain.com +VTIGER_API_KEY=your_api_key_or_access_key +VTIGER_PASSWORD=your_password_if_using_basic_auth + +# ===================================================== +# TIME TRACKING MODULE - Isolated Settings +# ===================================================== + +# vTiger Integration Safety Flags +TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger +TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere + +# e-conomic Integration Safety Flags +TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic +TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere +TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest) + +# Business Logic Settings +TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate) +TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval +TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0) +TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til) +TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse + +# ===================================================== +# OLLAMA AI Integration (Optional - for document extraction) +# ===================================================== +OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk +OLLAMA_MODEL=qwen2.5-coder:7b + +# ===================================================== +# COMPANY INFO +# ===================================================== +OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors + +# ===================================================== +# FILE UPLOAD +# ===================================================== +UPLOAD_DIR=uploads +MAX_FILE_SIZE_MB=50 diff --git a/README.md b/README.md index b026b6a..a05a00a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha ## 🌟 Features - **Customer Management**: Komplet kundedatabase med CRM integration +- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering + - Automatisk sync fra vTiger (billable timelogs) + - Step-by-step godkendelses-wizard + - Auto-afrunding til 0.5 timer + - Klippekort-funktionalitet + - e-conomic export (draft orders) - **Hardware Tracking**: Registrering og sporing af kundeudstyr - **Service Management**: Håndtering af services og abonnementer - **Billing Integration**: Automatisk fakturering via e-conomic @@ -123,12 +129,43 @@ bmc_hub/ ## 🔌 API Endpoints +### Main API - `GET /api/v1/customers` - List customers - `GET /api/v1/hardware` - List hardware - `GET /api/v1/billing/invoices` - List invoices - `GET /health` - Health check -Se fuld dokumentation: http://localhost:8000/api/docs +### Time Tracking Module +- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only) +- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog +- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog +- `POST /api/v1/timetracking/orders/generate` - Generate invoice order +- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags) +- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection + +Se fuld dokumentation: http://localhost:8001/api/docs + +## 🚨 e-conomic Write Mode + +Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**: + +### Safety Flags (default: SAFE) +```bash +TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes +TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only) +``` + +### Enable Write Mode +Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md) + +**Quick steps:** +1. Test connection: `GET /api/v1/timetracking/export/test-connection` +2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true` +3. Export test order: `POST /api/v1/timetracking/export` +4. Enable production: Set **both** flags to `false` +5. Verify first order in e-conomic before bulk operations + +**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field). ## 🧪 Testing diff --git a/app/core/config.py b/app/core/config.py index 17a59b7..1a7ca8c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -50,8 +50,9 @@ class Settings(BaseSettings): # Time Tracking Module - Business Logic TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback) - TIMETRACKING_AUTO_ROUND: bool = False # Auto-afrund til nærmeste 0.5 time + TIMETRACKING_AUTO_ROUND: bool = True # Auto-afrund til nærmeste 0.5 time TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0) + TIMETRACKING_ROUND_METHOD: str = "up" # up (op til), nearest (nærmeste), down (ned til) TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve) # Ollama AI Integration diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py index 1289f35..dd0f39c 100644 --- a/app/timetracking/backend/economic_export.py +++ b/app/timetracking/backend/economic_export.py @@ -11,6 +11,7 @@ Safety Flags: """ import logging +import json from typing import Optional, Dict, Any import aiohttp @@ -192,33 +193,80 @@ class EconomicExportService: # REAL EXPORT (kun hvis safety flags er disabled) logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}") - # TODO: Implementer rigtig e-conomic API call her - # Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False + # Hent e-conomic customer number fra vTiger customer + customer_number_query = """ + SELECT economic_customer_number + FROM tmodule_customers + WHERE id = %s + """ + customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True) + + if not customer_data or not customer_data.get('economic_customer_number'): + raise HTTPException( + status_code=400, + detail=f"Customer {order['customer_name']} has no e-conomic customer number" + ) + + customer_number = customer_data['economic_customer_number'] # Build e-conomic draft order payload economic_payload = { - "date": order['order_date'].isoformat(), + "date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']), "currency": "DKK", "exchangeRate": 100, - "netAmount": float(order['subtotal']), - "grossAmount": float(order['total_amount']), - "vatAmount": float(order['vat_amount']), - "notes": { - "heading": f"Tidsregistrering - {order['order_number']}", - "textLine1": order.get('notes', '') + "customer": { + "customerNumber": customer_number }, - "lines": [ - { - "lineNumber": line['line_number'], - "description": line['description'], - "quantity": float(line['quantity']), - "unitNetPrice": float(line['unit_price']), - "totalNetAmount": float(line['line_total']) + "recipient": { + "name": order['customer_name'], + "vatZone": { + "vatZoneNumber": 1 # Domestic Denmark } - for line in lines - ] + }, + "paymentTerms": { + "paymentTermsNumber": 1 # Default payment terms + }, + "layout": { + "layoutNumber": 19 # Default layout + }, + "notes": { + "heading": f"Tidsregistrering - {order['order_number']}" + }, + "lines": [] } + # Add notes if present + if order.get('notes'): + economic_payload['notes']['textLine1'] = order['notes'] + + # Build order lines + for idx, line in enumerate(lines, start=1): + economic_line = { + "lineNumber": idx, + "sortKey": idx, + "description": line['description'], + "quantity": float(line['quantity']), + "unitNetPrice": float(line['unit_price']), + "unit": { + "unitNumber": 1 # Default unit (stk/pcs) + } + } + + # Add product if specified + if line.get('product_number'): + product_number = str(line['product_number'])[:25] # Max 25 chars + economic_line['product'] = { + "productNumber": product_number + } + + # Add discount if present + if line.get('discount_percentage'): + economic_line['discountPercentage'] = float(line['discount_percentage']) + + economic_payload['lines'].append(economic_line) + + logger.info(f"📤 Sending to e-conomic: {json.dumps(economic_payload, indent=2, default=str)}") + # Call e-conomic API async with aiohttp.ClientSession() as session: async with session.post( @@ -227,23 +275,46 @@ class EconomicExportService: json=economic_payload, timeout=aiohttp.ClientTimeout(total=30) ) as response: + response_text = await response.text() + if response.status not in [200, 201]: - error_text = await response.text() - logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}") + logger.error(f"❌ e-conomic export failed: {response.status}") + logger.error(f"Response: {response_text}") + logger.error(f"Payload: {json.dumps(economic_payload, indent=2, default=str)}") + + # Try to parse error message + try: + error_data = json.loads(response_text) + error_msg = error_data.get('message', response_text) + + # Parse detailed validation errors if present + if 'errors' in error_data: + error_details = [] + for entity, entity_errors in error_data['errors'].items(): + if isinstance(entity_errors, dict) and 'errors' in entity_errors: + for err in entity_errors['errors']: + field = err.get('propertyName', entity) + msg = err.get('errorMessage', err.get('message', 'Unknown')) + error_details.append(f"{field}: {msg}") + if error_details: + error_msg = '; '.join(error_details) + except: + error_msg = response_text # Log failed export audit.log_export_failed( order_id=request.order_id, - error=error_text, + error=error_msg, user_id=user_id ) raise HTTPException( status_code=response.status, - detail=f"e-conomic API error: {error_text}" + detail=f"e-conomic API error: {error_msg}" ) result_data = await response.json() + logger.info(f"✅ e-conomic response: {json.dumps(result_data, indent=2, default=str)}") economic_draft_id = result_data.get('draftOrderNumber') economic_order_number = result_data.get('orderNumber', str(economic_draft_id)) diff --git a/app/timetracking/backend/models.py b/app/timetracking/backend/models.py index 07dbb83..b809023 100644 --- a/app/timetracking/backend/models.py +++ b/app/timetracking/backend/models.py @@ -144,9 +144,14 @@ class TModuleTime(TModuleTimeBase): 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 # ============================================================================ @@ -176,6 +181,8 @@ class TModuleOrderLine(TModuleOrderLineBase): 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 @@ -210,6 +217,7 @@ 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|sent|cancelled)$") economic_draft_id: Optional[int] = None economic_order_number: Optional[str] = None @@ -255,6 +263,7 @@ class TModuleApprovalStats(BaseModel): customer_id: int customer_name: str customer_vtiger_id: str + uses_time_card: bool = False total_entries: int pending_count: int approved_count: int diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py index a5b67d1..cd9929b 100644 --- a/app/timetracking/backend/order_service.py +++ b/app/timetracking/backend/order_service.py @@ -95,11 +95,16 @@ class OrderService: if not customer: raise HTTPException(status_code=404, detail="Customer not found") - # Hent godkendte tider for kunden + # Hent godkendte tider for kunden med case og contact detaljer query = """ - SELECT t.*, c.title as case_title + SELECT t.*, + c.title as case_title, + c.vtiger_id as case_vtiger_id, + c.vtiger_data->>'ticket_title' as vtiger_title, + CONCAT(cont.first_name, ' ', cont.last_name) as contact_name FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' WHERE t.customer_id = %s AND t.status = 'approved' AND t.billable = true @@ -121,16 +126,21 @@ class OrderService: customer.get('hub_customer_id') ) - # Group by case + # Group by case og gem ekstra metadata case_groups = {} for time_entry in approved_times: case_id = time_entry['case_id'] if case_id not in case_groups: case_groups[case_id] = { - 'case_title': time_entry['case_title'], - 'entries': [] + 'case_vtiger_id': time_entry.get('case_vtiger_id'), + 'contact_name': time_entry.get('contact_name'), + 'entries': [], + 'descriptions': [] # Samle alle beskrivelser } case_groups[case_id]['entries'].append(time_entry) + # Tilføj beskrivelse hvis den ikke er tom + if time_entry.get('description') and time_entry['description'].strip(): + case_groups[case_id]['descriptions'].append(time_entry['description'].strip()) # Build order lines order_lines = [] @@ -144,9 +154,33 @@ class OrderService: for entry in group['entries'] ) - # Build description - entry_count = len(group['entries']) - description = f"{group['case_title']} ({entry_count} tidsregistreringer)" + # Extract case number from vtiger_id (format: 39x42930 -> CC2930) + case_number = "" + if group['case_vtiger_id']: + vtiger_parts = group['case_vtiger_id'].split('x') + if len(vtiger_parts) > 1: + # Take last 4 digits + case_number = f"CC{vtiger_parts[1][-4:]}" + + # Brug tidsregistreringers beskrivelser som titel + # Tag første beskrivelse, eller alle hvis de er forskellige + case_title = "Ingen beskrivelse" + if group['descriptions']: + # Hvis alle beskrivelser er ens, brug kun én + unique_descriptions = list(set(group['descriptions'])) + if len(unique_descriptions) == 1: + case_title = unique_descriptions[0] + else: + # Hvis forskellige, join dem + case_title = ", ".join(unique_descriptions[:3]) # Max 3 for ikke at blive for lang + if len(unique_descriptions) > 3: + case_title += "..." + + # Build description med case nummer prefix + if case_number: + description = f"{case_number} - {case_title}" + else: + description = case_title # Calculate line total line_total = case_hours * hourly_rate @@ -260,18 +294,32 @@ class OrderService: def get_order_with_lines(order_id: int) -> TModuleOrderWithLines: """Hent ordre med linjer""" try: - # Get order - order_query = "SELECT * FROM tmodule_orders WHERE id = %s" + # Get order with customer name + order_query = """ + SELECT o.*, c.name as customer_name + FROM tmodule_orders o + LEFT JOIN customers c ON o.customer_id = c.id + WHERE o.id = %s + """ order = execute_query(order_query, (order_id,), fetchone=True) if not order: raise HTTPException(status_code=404, detail="Order not found") - # Get lines + # Get lines with additional context (contact, date) lines_query = """ - SELECT * FROM tmodule_order_lines - WHERE order_id = %s - ORDER BY line_number + SELECT ol.*, + STRING_AGG(DISTINCT CONCAT(cont.first_name, ' ', cont.last_name), ', ') as case_contact, + MIN(t.worked_date) as time_date + FROM tmodule_order_lines ol + LEFT JOIN tmodule_times t ON t.id = ANY(ol.time_entry_ids) + LEFT JOIN tmodule_cases c ON c.id = ol.case_id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE ol.order_id = %s + GROUP BY ol.id, ol.order_id, ol.case_id, ol.line_number, ol.description, + ol.quantity, ol.unit_price, ol.line_total, ol.time_entry_ids, + ol.product_number, ol.account_number, ol.created_at + ORDER BY ol.line_number """ lines = execute_query(lines_query, (order_id,)) @@ -298,19 +346,21 @@ class OrderService: params = [] if customer_id: - conditions.append("customer_id = %s") + conditions.append("o.customer_id = %s") params.append(customer_id) if status: - conditions.append("status = %s") + conditions.append("o.status = %s") params.append(status) where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" query = f""" - SELECT * FROM tmodule_orders + SELECT o.*, c.name as customer_name + FROM tmodule_orders o + LEFT JOIN customers c ON o.customer_id = c.id {where_clause} - ORDER BY order_date DESC, id DESC + ORDER BY o.order_date DESC, o.id DESC LIMIT %s """ params.append(limit) diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index d717dc0..d69d3ab 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -44,7 +44,10 @@ router = APIRouter() # ============================================================================ @router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) -async def sync_from_vtiger(user_id: Optional[int] = None): +async def sync_from_vtiger( + user_id: Optional[int] = None, + fetch_comments: bool = False +): """ 🔍 Synkroniser data fra vTiger (READ-ONLY). @@ -54,16 +57,53 @@ async def sync_from_vtiger(user_id: Optional[int] = None): - ModComments (tidsregistreringer) Gemmes i tmodule_* tabeller (isoleret). + + Args: + user_id: ID på bruger der kører sync + fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case) """ try: logger.info("🚀 Starting vTiger sync...") - result = await vtiger_service.full_sync(user_id=user_id) + result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments) return result except Exception as e: logger.error(f"❌ Sync failed: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.post("/sync/case/{case_id}/comments", tags=["Sync"]) +async def sync_case_comments(case_id: int): + """ + 🔍 Synkroniser kommentarer for en specifik case fra vTiger. + + Bruges til on-demand opdatering når man ser på en case i wizard. + """ + try: + # Hent case fra database + case = execute_query( + "SELECT vtiger_id FROM tmodule_cases WHERE id = %s", + (case_id,), + fetchone=True + ) + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + # Sync comments + result = await vtiger_service.sync_case_comments(case['vtiger_id']) + + if not result['success']: + raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments')) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to sync comments for case {case_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/sync/test-connection", tags=["Sync"]) async def test_vtiger_connection(): """Test forbindelse til vTiger""" @@ -93,37 +133,99 @@ async def get_all_customer_stats(): @router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"]) -async def get_next_pending_entry(customer_id: Optional[int] = None): +async def get_next_pending_entry( + customer_id: Optional[int] = None, + exclude_time_card: bool = True +): """ Hent næste pending tidsregistrering til godkendelse. Query params: - - customer_id: Valgfri - filtrer til specifik kunde + - customer_id: Filtrer til specifik kunde (optional) + - exclude_time_card: Ekskluder klippekort-kunder (default: true) """ try: - return wizard.get_next_pending_entry(customer_id=customer_id) + return wizard.get_next_pending_entry( + customer_id=customer_id, + exclude_time_card=exclude_time_card + ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"]) +@router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def approve_time_entry( - approval: TModuleTimeApproval, + time_id: int, + billable_hours: Optional[float] = None, + hourly_rate: Optional[float] = None, + rounding_method: Optional[str] = None, user_id: Optional[int] = None ): """ Godkend en tidsregistrering. - Body: + Path params: - time_id: ID på tidsregistreringen - - approved_hours: Timer efter godkendelse (kan være afrundet) - - rounded_to: Afrundingsinterval (0.5, 1.0, etc.) - - approval_note: Valgfri note - - billable: Skal faktureres? (default: true) + + Body (optional): + - billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding) + - hourly_rate: Timepris i DKK (override customer rate) + - rounding_method: "up", "down", "nearest" (override default) """ try: + from app.core.config import settings + from decimal import Decimal + + # Hent timelog + query = """ + SELECT t.*, c.title as case_title, c.status as case_status, + cust.name as customer_name, cust.hourly_rate as customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.id = %s + """ + entry = execute_query(query, (time_id,), fetchone=True) + + if not entry: + raise HTTPException(status_code=404, detail="Time entry not found") + + # Beregn approved_hours + if billable_hours is None: + approved_hours = Decimal(str(entry['original_hours'])) + + # Auto-afrund hvis enabled + if settings.TIMETRACKING_AUTO_ROUND: + increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) + method = rounding_method or settings.TIMETRACKING_ROUND_METHOD + + if method == "up": + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment + elif method == "down": + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment + else: + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment + else: + approved_hours = Decimal(str(billable_hours)) + + # Opdater med hourly_rate hvis angivet + if hourly_rate is not None: + execute_update( + "UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s", + (Decimal(str(hourly_rate)), time_id) + ) + + # Godkend + approval = TModuleTimeApproval( + time_id=time_id, + approved_hours=float(approved_hours) + ) + return wizard.approve_time_entry(approval, user_id=user_id) + except HTTPException: + raise except Exception as e: + logger.error(f"❌ Error approving entry: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -140,6 +242,59 @@ async def reject_time_entry( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"]) +async def get_case_entries( + case_id: int, + exclude_time_card: bool = True +): + """ + Hent alle pending timelogs for en case. + + Bruges til at vise alle tidsregistreringer i samme case grupperet. + """ + try: + return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/wizard/case/{case_id}/details", tags=["Wizard"]) +async def get_case_details(case_id: int): + """ + Hent komplet case information inkl. alle timelogs og kommentarer. + + Returnerer: + - case_id, case_title, case_description, case_status + - timelogs: ALLE tidsregistreringer (pending, approved, rejected) + - case_comments: Kommentarer fra vTiger + """ + try: + return wizard.get_case_details(case_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"]) +async def approve_all_case_entries( + case_id: int, + user_id: Optional[int] = None, + exclude_time_card: bool = True +): + """ + Bulk-godkend alle pending timelogs for en case. + + Afr under automatisk efter configured settings. + """ + try: + return wizard.approve_case_entries( + case_id=case_id, + user_id=user_id, + exclude_time_card=exclude_time_card + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"]) async def get_customer_progress(customer_id: int): """Hent wizard progress for en kunde""" @@ -336,14 +491,223 @@ async def module_health(): except Exception as e: logger.error(f"Health check error: {e}") return JSONResponse( - status_code=503, - content={ - "status": "unhealthy", - "error": str(e) - } + status_code=500, + content={"status": "error", "message": str(e)} ) +@router.get("/config", tags=["Admin"]) +async def get_config(): + """Hent modul konfiguration""" + from app.core.config import settings + + return { + "default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE), + "auto_round": settings.TIMETRACKING_AUTO_ROUND, + "round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT), + "round_method": settings.TIMETRACKING_ROUND_METHOD, + "vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY, + "vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN, + "economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY, + "economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN + } + + +# ============================================================================ +# CUSTOMER MANAGEMENT ENDPOINTS +# ============================================================================ + +@router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"]) +async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None): + """ + Opdater timepris for en kunde. + + Args: + customer_id: Kunde ID + hourly_rate: Ny timepris i DKK (f.eks. 850.00) + """ + try: + from decimal import Decimal + + # Validate rate + if hourly_rate < 0: + raise HTTPException(status_code=400, detail="Hourly rate must be positive") + + rate_decimal = Decimal(str(hourly_rate)) + + # Update customer hourly rate + execute_update( + "UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (rate_decimal, customer_id) + ) + + # Audit log + audit.log_event( + entity_type="customer", + entity_id=str(customer_id), + event_type="hourly_rate_updated", + details={"hourly_rate": float(hourly_rate)}, + user_id=user_id + ) + + # Return updated customer + customer = execute_query( + "SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + return { + "customer_id": customer_id, + "name": customer['name'], + "hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None, + "updated": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error updating hourly rate: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/customers/{customer_id}/time-card", tags=["Customers"]) +async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None): + """ + Skift klippekort-status for kunde. + + Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow. + """ + try: + # Update customer time card flag + execute_update( + "UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (enabled, customer_id) + ) + + # Audit log + audit.log_event( + entity_type="customer", + entity_id=str(customer_id), + event_type="time_card_toggled", + details={"enabled": enabled}, + user_id=user_id + ) + + # Return updated customer + customer = execute_query( + "SELECT * FROM tmodule_customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + return { + "customer_id": customer_id, + "name": customer['name'], + "uses_time_card": customer['uses_time_card'], + "updated": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error toggling time card: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/customers", tags=["Customers"]) +async def list_customers( + include_time_card: bool = True, + only_with_entries: bool = False +): + """ + List kunder med filtrering. + + Query params: + - include_time_card: Inkluder klippekort-kunder (default: true) + - only_with_entries: Kun kunder med pending tidsregistreringer (default: false) + """ + try: + if only_with_entries: + # Use view that includes entry counts + query = """ + SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card, + total_entries, pending_count + FROM tmodule_approval_stats + WHERE total_entries > 0 + """ + + if not include_time_card: + query += " AND uses_time_card = false" + + query += " ORDER BY customer_name" + + customers = execute_query(query) + else: + # Simple customer list + query = "SELECT * FROM tmodule_customers" + + if not include_time_card: + query += " WHERE uses_time_card = false" + + query += " ORDER BY name" + + customers = execute_query(query) + + return {"customers": customers, "total": len(customers)} + + except Exception as e: + logger.error(f"❌ Error listing customers: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/customers/{customer_id}/times", tags=["Customers"]) +async def get_customer_time_entries(customer_id: int, status: Optional[str] = None): + """ + Hent alle tidsregistreringer for en kunde. + + Path params: + - customer_id: Kunde ID + + Query params: + - status: Filtrer på status (pending, approved, rejected, billed) + """ + try: + query = """ + SELECT t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.vtiger_id AS case_vtiger_id, + c.description AS case_description, + cust.name AS customer_name + FROM tmodule_times t + LEFT JOIN tmodule_cases c ON t.case_id = c.id + LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.customer_id = %s + """ + + params = [customer_id] + + if status: + query += " AND t.status = %s" + params.append(status) + + query += " ORDER BY t.worked_date DESC, t.id DESC" + + times = execute_query(query, tuple(params)) + + return {"times": times, "total": len(times)} + + except Exception as e: + logger.error(f"❌ Error getting customer time entries: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"]) async def uninstall_module( request: TModuleUninstallRequest, diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py index 65a7db0..2a10012 100644 --- a/app/timetracking/backend/vtiger_sync.py +++ b/app/timetracking/backend/vtiger_sync.py @@ -19,6 +19,7 @@ Safety Flags: import logging import hashlib import json +import asyncio from datetime import datetime from typing import List, Dict, Optional, Any from decimal import Decimal @@ -210,10 +211,49 @@ class TimeTrackingVTigerService: logger.error(f"❌ vTiger connection error: {e}") return False + async def _fetch_user_name(self, user_id: str) -> str: + """ + Fetch user name from vTiger using retrieve API. + + Args: + user_id: vTiger user ID (e.g., "19x1") + + Returns: + User's full name or user_id if not found + """ + try: + user_data = await self._retrieve(user_id) + + if not user_data: + return user_id + + # Build full name from first + last, fallback to username + first_name = user_data.get('first_name', '').strip() + last_name = user_data.get('last_name', '').strip() + user_name = user_data.get('user_name', '').strip() + + if first_name and last_name: + return f"{first_name} {last_name}" + elif first_name: + return first_name + elif last_name: + return last_name + elif user_name: + return user_name + else: + return user_id + + except Exception as e: + logger.debug(f"Could not fetch user {user_id}: {e}") + return user_id + return False + async def sync_customers(self, limit: int = 1000) -> Dict[str, int]: """ Sync Accounts (customers) from vTiger to tmodule_customers. + Uses ID-based pagination to fetch all accounts. + Returns: {imported: X, updated: Y, skipped: Z} """ logger.info("🔍 Syncing customers from vTiger...") @@ -221,14 +261,36 @@ class TimeTrackingVTigerService: stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} try: - # Query vTiger for active accounts - # Start with simplest query to debug - query = "SELECT * FROM Accounts;" - accounts = await self._query(query) + # Fetch ALL accounts using pagination (vTiger has 200 record limit) + all_accounts = [] + last_id = None + page = 1 - logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger") + while True: + if last_id: + query = f"SELECT * FROM Accounts WHERE id > '{last_id}' ORDER BY id LIMIT 200;" + else: + query = "SELECT * FROM Accounts ORDER BY id LIMIT 200;" + + accounts = await self._query(query) + + if not accounts: + break + + all_accounts.extend(accounts) + last_id = accounts[-1]['id'] + + logger.info(f"📥 Fetched page {page}: {len(accounts)} accounts (last_id: {last_id})") + + # Safety: if we got less than 200, we're done + if len(accounts) < 200: + break + + page += 1 - for account in accounts: + logger.info(f"📥 Total fetched: {len(all_accounts)} accounts from vTiger") + + for account in all_accounts: try: vtiger_id = account.get('id', '') if not vtiger_id: @@ -252,16 +314,18 @@ class TimeTrackingVTigerService: logger.debug(f"⏭️ No changes for customer {vtiger_id}") stats["skipped"] += 1 continue - + if existing: # Update existing execute_update( """UPDATE tmodule_customers - SET name = %s, email = %s, vtiger_data = %s::jsonb, - sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP + SET name = %s, email = %s, economic_customer_number = %s, + vtiger_data = %s::jsonb, sync_hash = %s, + last_synced_at = CURRENT_TIMESTAMP WHERE vtiger_id = %s""", ( account.get('accountname', 'Unknown'), account.get('email1', None), + int(account.get('cf_854')) if account.get('cf_854') else None, json.dumps(account), data_hash, vtiger_id @@ -273,12 +337,14 @@ class TimeTrackingVTigerService: # Insert new execute_insert( """INSERT INTO tmodule_customers - (vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at) - VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", + (vtiger_id, name, email, economic_customer_number, + vtiger_data, sync_hash, last_synced_at) + VALUES (%s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", ( vtiger_id, account.get('accountname', 'Unknown'), account.get('email1', None), + int(account.get('cf_854')) if account.get('cf_854') else None, json.dumps(account), data_hash ) @@ -297,13 +363,20 @@ class TimeTrackingVTigerService: logger.error(f"❌ Customer sync failed: {e}") raise - async def sync_cases(self, limit: int = 5000) -> Dict[str, int]: + async def sync_cases(self, limit: int = 5000, fetch_comments: bool = False) -> Dict[str, int]: """ Sync HelpDesk tickets (cases) from vTiger to tmodule_cases. + Args: + limit: Maximum number of cases to sync + fetch_comments: Whether to fetch ModComments for each case (slow - rate limited) + Returns: {imported: X, updated: Y, skipped: Z} """ - logger.info(f"🔍 Syncing up to {limit} cases from vTiger...") + if fetch_comments: + logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITH comments (slow)...") + else: + logger.info(f"🔍 Syncing up to {limit} cases from vTiger WITHOUT comments (fast)...") stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} @@ -363,7 +436,21 @@ class TimeTrackingVTigerService: continue customer_id = customer['id'] - data_hash = self._calculate_hash(ticket) + + # Fetch internal comments for this case (with rate limiting) - ONLY if enabled + internal_comments = [] + if fetch_comments: + internal_comments = await self._get_case_comments(vtiger_id) + # Small delay to avoid rate limiting (vTiger allows ~2-3 requests/sec) + await asyncio.sleep(0.4) # 400ms between comment fetches + + # Merge comments into ticket data before storing + ticket_with_comments = ticket.copy() + if internal_comments: + ticket_with_comments['internal_comments'] = internal_comments + + # Calculate hash AFTER adding comments (so changes to comments trigger update) + data_hash = self._calculate_hash(ticket_with_comments) # Check if exists existing = execute_query( @@ -392,7 +479,7 @@ class TimeTrackingVTigerService: ticket.get('ticketstatus', None), ticket.get('ticketpriorities', None), 'HelpDesk', - json.dumps(ticket), + json.dumps(ticket_with_comments), data_hash, vtiger_id ) @@ -413,7 +500,7 @@ class TimeTrackingVTigerService: ticket.get('ticketstatus', None), ticket.get('ticketpriorities', None), 'HelpDesk', - json.dumps(ticket), + json.dumps(ticket_with_comments), data_hash ) ) @@ -430,6 +517,86 @@ class TimeTrackingVTigerService: logger.error(f"❌ Case sync failed: {e}") raise + async def _get_case_comments(self, case_id: str) -> List[Dict]: + """ + Fetch all ModComments (internal comments) for a specific case from vTiger. + + Args: + case_id: vTiger case ID (format: "32x1234") + + Returns: + List of comment dicts with structure: {text, author, date, created_at} + Sorted by creation date (newest first) + """ + try: + # Query ModComments where related_to = case_id + query = f"SELECT * FROM ModComments WHERE related_to = '{case_id}' ORDER BY createdtime DESC;" + comments = await self._query(query) + + if not comments: + return [] + + # Transform vTiger format to internal format + formatted_comments = [] + for comment in comments: + formatted_comments.append({ + "text": comment.get("commentcontent", ""), + "author": comment.get("assigned_user_id", "Unknown"), # Will be user ID - could enhance with name lookup + "date": comment.get("createdtime", "")[:10], # Format: YYYY-MM-DD from YYYY-MM-DD HH:MM:SS + "created_at": comment.get("createdtime", "") + }) + + logger.info(f"📝 Fetched {len(formatted_comments)} comments for case {case_id}") + return formatted_comments + + except HTTPException as e: + # Rate limit or API error - log but don't fail sync + if "429" in str(e.detail) or "TOO_MANY_REQUESTS" in str(e.detail): + logger.warning(f"⚠️ Rate limited fetching comments for case {case_id} - skipping") + else: + logger.error(f"❌ API error fetching comments for case {case_id}: {e.detail}") + return [] + except Exception as e: + logger.error(f"❌ Failed to fetch comments for case {case_id}: {e}") + return [] # Return empty list on error - don't fail entire sync + + async def sync_case_comments(self, case_vtiger_id: str) -> Dict[str, Any]: + """ + Sync comments for a specific case (for on-demand updates). + + Args: + case_vtiger_id: vTiger case ID (format: "39x1234") + + Returns: + Dict with success status and comment count + """ + try: + # Fetch comments + comments = await self._get_case_comments(case_vtiger_id) + + if not comments: + return {"success": True, "comments": 0, "message": "No comments found"} + + # Update case in database + execute_update( + """UPDATE tmodule_cases + SET vtiger_data = jsonb_set( + COALESCE(vtiger_data, '{}'::jsonb), + '{internal_comments}', + %s::jsonb + ), + last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s""", + (json.dumps(comments), case_vtiger_id) + ) + + logger.info(f"✅ Synced {len(comments)} comments for case {case_vtiger_id}") + return {"success": True, "comments": len(comments), "message": f"Synced {len(comments)} comments"} + + except Exception as e: + logger.error(f"❌ Failed to sync comments for case {case_vtiger_id}: {e}") + return {"success": False, "comments": 0, "error": str(e)} + async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]: """ Sync time entries from vTiger Timelog module to tmodule_times. @@ -438,13 +605,24 @@ class TimeTrackingVTigerService: - timelognumber: Unique ID (TL1234) - duration: Time in seconds - relatedto: Reference to Case/Account - - isbillable: Billable flag + - is_billable: '1' = yes, '0' = no + - cf_timelog_invoiced: '1' = has been invoiced + + We only sync entries where: + - relatedto is not empty (linked to a Case or Account) + - Has valid duration > 0 + + NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger, + so we sync all timelogs and let the approval workflow decide what to bill. """ - logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...") + logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...") stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} try: + # Cache for user names (avoid fetching same user multiple times) + user_name_cache = {} + # vTiger API doesn't support OFFSET - use id-based pagination instead all_timelogs = [] last_id = "0x0" # Start from beginning @@ -453,7 +631,8 @@ class TimeTrackingVTigerService: for batch_num in range(max_batches): # Use id > last_id for pagination (vTiger format: 43x1234) - query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};" + # NOTE: vTiger query API ignores WHERE on custom fields, so we fetch all and filter later + query = f"SELECT * FROM Timelog WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};" batch = await self._query(query) if not batch: # No more records @@ -470,8 +649,15 @@ class TimeTrackingVTigerService: if len(all_timelogs) >= limit: # Reached limit break + logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger") + + # We don't filter here - the existing code already filters by: + # 1. duration > 0 + # 2. relatedto not empty + # These filters happen in the processing loop below + timelogs = all_timelogs[:limit] # Trim to requested limit - logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger") + logger.info(f"📊 Processing {len(timelogs)} timelogs...") # NOTE: retrieve API is too slow for batch operations (1500+ individual calls) # We'll work with query data and accept that relatedto might be empty for some @@ -494,36 +680,47 @@ class TimeTrackingVTigerService: # Get related entity (Case or Account) related_to = timelog.get('relatedto', '') - if not related_to: - logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}") - stats["skipped"] += 1 - continue + case_id = None + customer_id = None - # Try to find case first, then account - case = execute_query( - "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", - (related_to,), - fetchone=True - ) - - if case: - case_id = case['id'] - customer_id = case['customer_id'] - else: - # Try to find customer directly - customer = execute_query( - "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + if related_to: + # Try to find case first, then account + case = execute_query( + "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", (related_to,), fetchone=True ) - if not customer: - logger.debug(f"⏭️ Related entity {related_to} not found") - stats["skipped"] += 1 - continue - - customer_id = customer['id'] - case_id = None # No specific case, just customer + if case: + case_id = case['id'] + customer_id = case['customer_id'] + else: + # Try to find customer directly + customer = execute_query( + "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + (related_to,), + fetchone=True + ) + + if customer: + customer_id = customer['id'] + case_id = None # No specific case, just customer + else: + logger.debug(f"⏭️ Related entity {related_to} not found in our database - will skip") + stats["skipped"] += 1 + continue + + # If no customer found at all, skip this timelog + if not customer_id: + logger.warning(f"⚠️ Timelog {vtiger_id} has no valid customer reference - skipping") + stats["skipped"] += 1 + continue + + # Get user name with caching + assigned_user_id = timelog.get('assigned_user_id', '') + if assigned_user_id and assigned_user_id not in user_name_cache: + user_name_cache[assigned_user_id] = await self._fetch_user_name(assigned_user_id) + user_name = user_name_cache.get(assigned_user_id, assigned_user_id) data_hash = self._calculate_hash(timelog) @@ -550,7 +747,7 @@ class TimeTrackingVTigerService: timelog.get('name', ''), hours, timelog.get('startedon', None), - timelog.get('assigned_user_id', None), + user_name, timelog.get('isbillable', '0') == '1', json.dumps(timelog), data_hash, @@ -578,7 +775,7 @@ class TimeTrackingVTigerService: timelog.get('name', ''), hours, timelog.get('startedon', None), - timelog.get('assigned_user_id', None), + user_name, timelog.get('isbillable', '0') == '1', json.dumps(timelog), data_hash @@ -599,14 +796,22 @@ class TimeTrackingVTigerService: async def full_sync( self, - user_id: Optional[int] = None + user_id: Optional[int] = None, + fetch_comments: bool = False ) -> TModuleSyncStats: """ Perform full sync of all data from vTiger. Order: Customers -> Cases -> Time Entries (dependencies) + + Args: + user_id: User performing the sync + fetch_comments: Whether to fetch ModComments (slow - adds ~0.4s per case) """ - logger.info("🚀 Starting FULL vTiger sync...") + if fetch_comments: + logger.info("🚀 Starting FULL vTiger sync WITH comments (this will be slow)...") + else: + logger.info("🚀 Starting FULL vTiger sync WITHOUT comments (fast mode)...") start_time = datetime.now() @@ -623,7 +828,7 @@ class TimeTrackingVTigerService: # Sync in order of dependencies customer_stats = await self.sync_customers() - case_stats = await self.sync_cases() + case_stats = await self.sync_cases(fetch_comments=fetch_comments) time_stats = await self.sync_time_entries() end_time = datetime.now() diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py index ab4d30c..bb3d05b 100644 --- a/app/timetracking/backend/wizard.py +++ b/app/timetracking/backend/wizard.py @@ -7,7 +7,7 @@ Brugeren godkender én tidsregistrering ad gangen. """ import logging -from typing import Optional +from typing import Optional, List, Dict, Any from decimal import Decimal from datetime import datetime @@ -62,13 +62,15 @@ class WizardService: @staticmethod def get_next_pending_entry( - customer_id: Optional[int] = None + customer_id: Optional[int] = None, + exclude_time_card: bool = True ) -> TModuleWizardNextEntry: """ Hent næste pending tidsregistrering til godkendelse. Args: customer_id: Valgfri - filtrer til specifik kunde + exclude_time_card: Ekskluder klippekort-kunder (default: true) Returns: TModuleWizardNextEntry med has_next=True hvis der er flere @@ -84,7 +86,16 @@ class WizardService: result = execute_query(query, (customer_id,), fetchone=True) else: # Hent næste generelt - query = "SELECT * FROM tmodule_next_pending LIMIT 1" + if exclude_time_card: + query = """ + SELECT np.* FROM tmodule_next_pending np + JOIN tmodule_customers c ON np.customer_id = c.id + WHERE c.uses_time_card = false + LIMIT 1 + """ + else: + query = "SELECT * FROM tmodule_next_pending LIMIT 1" + result = execute_query(query, fetchone=True) if not result: @@ -281,6 +292,98 @@ class WizardService: logger.error(f"❌ Error rejecting time entry: {e}") raise HTTPException(status_code=500, detail=str(e)) + @staticmethod + def approve_case_entries( + case_id: int, + user_id: Optional[int] = None, + exclude_time_card: bool = True + ) -> Dict[str, Any]: + """ + Bulk-godkend alle pending tidsregistreringer for en case. + + Args: + case_id: Case ID + user_id: ID på brugeren der godkender + exclude_time_card: Ekskluder klippekort-kunder + + Returns: + Dict med statistik: approved_count, total_hours, etc. + """ + try: + # Hent alle pending entries for case + entries = WizardService.get_case_entries(case_id, exclude_time_card) + + if not entries: + return { + "approved_count": 0, + "total_hours": 0.0, + "case_id": case_id, + "entries": [] + } + + approved_entries = [] + total_hours = 0.0 + + for entry in entries: + # Auto-approve med samme timer som original (eller afrundet hvis enabled) + from app.core.config import settings + from decimal import Decimal + + approved_hours = Decimal(str(entry.original_hours)) + + # Afrund hvis enabled + if settings.TIMETRACKING_AUTO_ROUND: + increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) + + if settings.TIMETRACKING_ROUND_METHOD == "up": + # Afrund op + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment + elif settings.TIMETRACKING_ROUND_METHOD == "down": + # Afrund ned + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment + else: + # Nærmeste + approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment + + # Godkend entry + approval = TModuleTimeApproval( + time_id=entry.id, + approved_hours=float(approved_hours) + ) + + approved = WizardService.approve_time_entry(approval, user_id) + approved_entries.append({ + "id": approved.id, + "original_hours": float(approved.original_hours), + "approved_hours": float(approved.approved_hours) + }) + total_hours += float(approved.approved_hours) + + # Log bulk approval + audit.log_event( + entity_type="case", + entity_id=str(case_id), + event_type="bulk_approval", + details={ + "approved_count": len(approved_entries), + "total_hours": total_hours + }, + user_id=user_id + ) + + return { + "approved_count": len(approved_entries), + "total_hours": total_hours, + "case_id": case_id, + "entries": approved_entries + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error bulk approving case: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @staticmethod def get_customer_progress(customer_id: int) -> TModuleWizardProgress: """Hent wizard progress for en kunde""" @@ -296,7 +399,7 @@ class WizardService: if stats.pending_count > 0: query = """ - SELECT DISTINCT c.id, c.title + SELECT c.id, c.title FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id WHERE t.customer_id = %s AND t.status = 'pending' @@ -324,6 +427,155 @@ class WizardService: except Exception as e: logger.error(f"❌ Error getting customer progress: {e}") raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_case_entries( + case_id: int, + exclude_time_card: bool = True + ) -> List[TModuleTimeWithContext]: + """ + Hent alle pending tidsregistreringer for en specifik case. + + Bruges til at vise alle timelogs i samme case samtidig. + + Args: + case_id: Case ID + exclude_time_card: Ekskluder klippekort-kunder + + Returns: + Liste af tidsregistreringer for casen + """ + try: + if exclude_time_card: + query = """ + SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, + t.original_hours, t.worked_date, t.user_name, t.status, + t.approved_hours, t.rounded_to, t.approval_note, t.billable, + t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, + t.created_at, t.updated_at, t.last_synced_at, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.description AS case_description, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate, + CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, + cont.user_company AS contact_company, + c.vtiger_data AS case_vtiger_data + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE t.case_id = %s + AND t.status = 'pending' + AND t.billable = true + AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + AND cust.uses_time_card = false + ORDER BY t.worked_date, t.id + """ + else: + query = """ + SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, + t.original_hours, t.worked_date, t.user_name, t.status, + t.approved_hours, t.rounded_to, t.approval_note, t.billable, + t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, + t.created_at, t.updated_at, t.last_synced_at, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.description AS case_description, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate, + CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, + cont.user_company AS contact_company, + c.vtiger_data AS case_vtiger_data + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' + WHERE t.case_id = %s + AND t.status = 'pending' + AND t.billable = true + AND t.vtiger_data->>'cf_timelog_invoiced' = '0' + ORDER BY t.worked_date, t.id + """ + + results = execute_query(query, (case_id,)) + return [TModuleTimeWithContext(**row) for row in results] + + except Exception as e: + logger.error(f"❌ Error getting case entries: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_case_details(case_id: int) -> Dict[str, Any]: + """ + Hent komplet case information inkl. alle timelogs og kommentarer. + + Returns: + Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data + """ + try: + # Hent case info + case_query = """ + SELECT id, vtiger_id, title, description, status, + vtiger_data, customer_id + FROM tmodule_cases + WHERE id = %s + """ + case = execute_query(case_query, (case_id,), fetchone=True) + + if not case: + raise HTTPException(status_code=404, detail="Case not found") + + # Hent ALLE timelogs for casen (ikke kun pending) + timelogs_query = """ + SELECT t.*, + COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, + c.status AS case_status, + c.vtiger_id AS case_vtiger_id, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.case_id = %s + ORDER BY t.worked_date DESC, t.created_at DESC + """ + timelogs = execute_query(timelogs_query, (case_id,)) + + # Parse case comments from vtiger_data JSON + case_comments = [] + if case.get('vtiger_data'): + vtiger_data = case['vtiger_data'] + # vTiger gemmer comments som en array i JSON + if isinstance(vtiger_data, dict): + raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', []) + + for comment in raw_comments: + if isinstance(comment, dict): + case_comments.append({ + 'id': comment.get('modcommentsid', comment.get('id')), + 'comment_text': comment.get('commentcontent', comment.get('comment', '')), + 'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')), + 'created_at': comment.get('createdtime', comment.get('created_at', '')) + }) + + return { + 'case_id': case['id'], + 'case_vtiger_id': case['vtiger_id'], + 'case_title': case['title'], + 'case_description': case['description'], + 'case_status': case['status'], + 'timelogs': [TModuleTimeWithContext(**t) for t in timelogs], + 'case_comments': case_comments + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error getting case details: {e}") + raise HTTPException(status_code=500, detail=str(e)) # Singleton instance diff --git a/app/timetracking/frontend/customers.html b/app/timetracking/frontend/customers.html new file mode 100644 index 0000000..d724585 --- /dev/null +++ b/app/timetracking/frontend/customers.html @@ -0,0 +1,893 @@ + + +
+ + +Administrer timepriser for kunder
+| Kunde | +vTiger ID | +Timepris (DKK) | +Status | +Handlinger | +
|---|---|---|---|---|
|
+
+ Indlæser...
+
+ |
+ ||||
Hent nye tidsregistreringer fra vTiger