diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 3163a29..157f1a9 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -9,7 +9,7 @@ from typing import List, Optional, Dict from pydantic import BaseModel import logging -from app.core.database import execute_query, execute_query_single +from app.core.database import execute_query, execute_query_single, execute_update from app.services.cvr_service import get_cvr_service from app.services.customer_activity_logger import CustomerActivityLogger from app.services.customer_consistency import CustomerConsistencyService diff --git a/app/services/customer_consistency.py b/app/services/customer_consistency.py index 2b16061..b3640f0 100644 --- a/app/services/customer_consistency.py +++ b/app/services/customer_consistency.py @@ -5,7 +5,7 @@ Compares customer data across BMC Hub, vTiger Cloud, and e-conomic import logging import asyncio from typing import Dict, List, Optional, Tuple, Any -from app.core.database import execute_query_single +from app.core.database import execute_query_single, execute_update from app.services.vtiger_service import VTigerService from app.services.economic_service import EconomicService from app.core.config import settings @@ -82,12 +82,12 @@ class CustomerConsistencyService: vtiger_task = None economic_task = None - # Fetch vTiger data if we have an ID - if hub_data.get('vtiger_id') and settings.VTIGER_ENABLED: + # Fetch vTiger data if we have an ID and vTiger is configured + if hub_data.get('vtiger_id') and settings.VTIGER_URL: vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id']) - # Fetch e-conomic data if we have a customer number - if hub_data.get('economic_customer_number') and settings.ECONOMIC_ENABLED: + # Fetch e-conomic data if we have a customer number and e-conomic is configured + if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN: economic_task = self.economic.get_customer(hub_data['economic_customer_number']) # Parallel fetch with error handling @@ -153,8 +153,17 @@ class CustomerConsistencyService: economic_norm = cls.normalize_value(economic_value) # Check if all values are the same - values = [v for v in [hub_norm, vtiger_norm, economic_norm] if v is not None] - has_discrepancy = len(set(values)) > 1 if values else False + # Only compare systems that are available + available_values = [] + if hub_data: + available_values.append(hub_norm) + if vtiger_data: + available_values.append(vtiger_norm) + if economic_data: + available_values.append(economic_norm) + + # Has discrepancy if there are different non-None values + has_discrepancy = len(set(available_values)) > 1 if len(available_values) > 1 else False discrepancies[hub_field] = { 'hub': hub_value, @@ -203,7 +212,6 @@ class CustomerConsistencyService: # Update Hub if not the source if source_system != 'hub': try: - from app.core.database import execute_update update_query = f"UPDATE customers SET {field_name} = %s WHERE id = %s" await asyncio.to_thread(execute_update, update_query, (source_value, customer_id)) results['hub'] = True @@ -218,9 +226,13 @@ class CustomerConsistencyService: if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'): try: update_data = {vtiger_field: source_value} - await self.vtiger.update_account(hub_data['vtiger_id'], update_data) - results['vtiger'] = True - logger.info(f"✅ vTiger {vtiger_field} updated") + success = await self.vtiger.update_account(hub_data['vtiger_id'], update_data) + if success: + results['vtiger'] = True + logger.info(f"✅ vTiger {vtiger_field} updated") + else: + results['vtiger'] = False + logger.error(f"❌ vTiger update failed - API returned False") except Exception as e: logger.error(f"❌ Failed to update vTiger: {e}") results['vtiger'] = False diff --git a/app/services/vtiger_service.py b/app/services/vtiger_service.py index c0993d3..1c7369d 100644 --- a/app/services/vtiger_service.py +++ b/app/services/vtiger_service.py @@ -126,14 +126,26 @@ class VTigerService: raise ValueError("VTIGER_URL not configured") try: + # Fetch current account first - vTiger requires modifiedtime for updates + current_account = await self.get_account_by_id(account_id) + if not current_account: + logger.error(f"❌ Account {account_id} not found for update") + return False + auth = self._get_auth() - # vTiger requires the ID in the data + # Build payload with current data + updates + # Include essential fields for vTiger update validation payload = { 'id': account_id, + 'accountname': current_account.get('accountname'), + 'assigned_user_id': current_account.get('assigned_user_id'), + 'modifiedtime': current_account.get('modifiedtime'), **update_data } + logger.info(f"🔄 vTiger update payload: {payload}") + async with aiohttp.ClientSession() as session: async with session.post( f"{self.rest_endpoint}/update", @@ -151,12 +163,14 @@ class VTigerService: return True else: logger.error(f"❌ vTiger update failed: {data.get('error')}") + logger.error(f"Full response: {text}") return False except json.JSONDecodeError: logger.error(f"❌ Invalid JSON in update response: {text[:200]}") return False else: - logger.error(f"❌ vTiger update HTTP error {response.status}: {text[:500]}") + logger.error(f"❌ vTiger update HTTP error {response.status}") + logger.error(f"Full response: {text[:2000]}") return False except Exception as e: diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py index 57b2452..1007943 100644 --- a/app/timetracking/backend/economic_export.py +++ b/app/timetracking/backend/economic_export.py @@ -412,16 +412,17 @@ class EconomicExportService: (economic_draft_id, economic_order_number, user_id, request.order_id) ) - # Marker time entries som billed + # Marker time entries som billed og opdater billed_via_thehub_id execute_update( """UPDATE tmodule_times - SET status = 'billed' + SET status = 'billed', + billed_via_thehub_id = %s WHERE id IN ( SELECT UNNEST(time_entry_ids) FROM tmodule_order_lines WHERE order_id = %s )""", - (request.order_id,) + (request.order_id, request.order_id) ) # Hent vTiger IDs for tidsregistreringerne diff --git a/app/timetracking/backend/models.py b/app/timetracking/backend/models.py index 4437881..ab6bb3e 100644 --- a/app/timetracking/backend/models.py +++ b/app/timetracking/backend/models.py @@ -138,8 +138,10 @@ class TModuleTime(TModuleTimeBase): rounded_to: Optional[Decimal] = None approval_note: Optional[str] = None billable: bool = True + is_travel: bool = False approved_at: Optional[datetime] = None approved_by: Optional[int] = None + billed_via_thehub_id: Optional[int] = Field(None, description="Hub order ID this time was billed through") sync_hash: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py index 767589b..c9c7336 100644 --- a/app/timetracking/backend/order_service.py +++ b/app/timetracking/backend/order_service.py @@ -279,22 +279,8 @@ class OrderService: logger.info(f"✅ Created {len(created_lines)} order lines") - # Update time entries to 'billed' status - time_entry_ids = [ - entry_id - for line in order_lines - for entry_id in line.time_entry_ids - ] - - if time_entry_ids: - placeholders = ','.join(['%s'] * len(time_entry_ids)) - execute_update( - f"""UPDATE tmodule_times - SET status = 'billed' - WHERE id IN ({placeholders})""", - time_entry_ids - ) - logger.info(f"✅ Marked {len(time_entry_ids)} time entries as billed") + # NOTE: Time entries remain 'approved' status until exported to e-conomic + # They will be updated to 'billed' with billed_via_thehub_id in economic_export.py # Log order creation audit.log_order_created( diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py index 9c9606b..3dc2be8 100644 --- a/app/timetracking/backend/vtiger_sync.py +++ b/app/timetracking/backend/vtiger_sync.py @@ -795,13 +795,15 @@ class TimeTrackingVTigerService: stats["skipped"] += 1 continue - # Update only if NOT yet approved + # Update only if NOT yet approved AND NOT yet billed result = execute_update( """UPDATE tmodule_times SET description = %s, original_hours = %s, worked_date = %s, user_name = %s, billable = %s, vtiger_data = %s::jsonb, sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP - WHERE vtiger_id = %s AND status = 'pending'""", + WHERE vtiger_id = %s + AND status = 'pending' + AND billed_via_thehub_id IS NULL""", ( timelog.get('name', ''), hours, @@ -817,7 +819,7 @@ class TimeTrackingVTigerService: if result > 0: stats["updated"] += 1 else: - logger.debug(f"⏭️ Time entry {vtiger_id} already approved") + logger.debug(f"⏭️ Time entry {vtiger_id} already approved or billed") stats["skipped"] += 1 else: # Insert new diff --git a/docs/TIMETRACKING_BILLED_VIA_HUB.md b/docs/TIMETRACKING_BILLED_VIA_HUB.md new file mode 100644 index 0000000..01e7de7 --- /dev/null +++ b/docs/TIMETRACKING_BILLED_VIA_HUB.md @@ -0,0 +1,179 @@ +# Time Tracking - Billed Via Hub Order ID + +## Oversigt + +Implementeret tracking af hvilken Hub ordre (og dermed e-conomic ordre) hver tidsregistrering er blevet faktureret gennem. + +## Database Ændringer + +### Migration: `060_add_billed_via_thehub_id.sql` + +Tilføjet nyt felt til `tmodule_times` tabellen: + +```sql +ALTER TABLE tmodule_times ADD COLUMN billed_via_thehub_id INTEGER; +ALTER TABLE tmodule_times + ADD CONSTRAINT tmodule_times_billed_via_thehub_id_fkey + FOREIGN KEY (billed_via_thehub_id) + REFERENCES tmodule_orders(id) + ON DELETE SET NULL; +CREATE INDEX idx_tmodule_times_billed_via_thehub_id ON tmodule_times(billed_via_thehub_id); +``` + +**Felt beskrivelse:** +- `billed_via_thehub_id`: Hub ordre ID som tidsregistreringen er faktureret gennem +- Foreign key til `tmodule_orders.id` +- Via Hub ordren kan man finde `economic_order_number` som er ordrenummeret i e-conomic + +## Kode Ændringer + +### 1. `app/timetracking/backend/models.py` + +Tilføjet felt til `TModuleTime` Pydantic model: + +```python +billed_via_thehub_id: Optional[int] = Field(None, description="Hub order ID this time was billed through") +is_travel: bool = False # Også tilføjet manglende felt +``` + +### 2. `app/timetracking/backend/economic_export.py` + +Opdateret `export_order_to_economic()` til at sætte `billed_via_thehub_id` når ordren eksporteres: + +```python +# Marker time entries som billed og opdater billed_via_thehub_id +execute_update( + """UPDATE tmodule_times + SET status = 'billed', + billed_via_thehub_id = %s + WHERE id IN ( + SELECT UNNEST(time_entry_ids) + FROM tmodule_order_lines + WHERE order_id = %s + )""", + (request.order_id, request.order_id) +) +``` + +**Vigtig note:** vTiger opdateres også via `update_timelog_billed()` som sætter `billed_via_thehub_id` i vTiger's Timelog records. + +### 3. `app/timetracking/backend/order_service.py` + +**FJERNET** for tidlig status opdatering: + +```python +# BEFORE: Time entries blev sat til 'billed' når Hub ordre blev oprettet +# AFTER: Time entries forbliver 'approved' indtil e-conomic eksporten er succesfuld +``` + +Nu opdateres `status='billed'` og `billed_via_thehub_id` KUN i `economic_export.py` efter succesfuld eksport. + +### 4. `app/timetracking/backend/vtiger_sync.py` + +**BESKYTTELSE MOD OVERSKRIVNING:** Tidsregistreringer med `billed_via_thehub_id` opdateres IKKE ved sync: + +```python +# Update only if NOT yet approved AND NOT yet billed +result = execute_update( + """UPDATE tmodule_times + SET description = %s, original_hours = %s, worked_date = %s, + user_name = %s, billable = %s, vtiger_data = %s::jsonb, + sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s + AND status = 'pending' + AND billed_via_thehub_id IS NULL""", + ... +) +``` + +Dette sikrer at allerede fakturerede tidsregistreringer forbliver låst og ikke overskrevet af vTiger sync. + +## Workflow + +### Før ændringerne: +1. Godkendte tider → `status='approved'` +2. **Opret ordre** i Hub → `status='billed'` ⚠️ (for tidligt!) +3. Eksporter til e-conomic → `economic_order_number` sættes på Hub ordre + +### Efter ændringerne: +1. Godkendte tider → `status='approved'` +2. **Opret ordre** i Hub → tider forbliver `status='approved'` +3. **Eksporter til e-conomic** → `status='billed'` + `billed_via_thehub_id` sættes +4. vTiger opdateres også med `billed_via_thehub_id` + +## Data Relations + +``` +tmodule_times.billed_via_thehub_id + ↓ (foreign key) +tmodule_orders.id + → tmodule_orders.economic_order_number (e-conomic ordre nummer) + → tmodule_orders.economic_draft_id (e-conomic kladde ID) +``` + +## Queries + +### Find alle tidsregistreringer for en e-conomic ordre: + +```sql +SELECT t.*, o.economic_order_number +FROM tmodule_times t +JOIN tmodule_orders o ON t.billed_via_thehub_id = o.id +WHERE o.economic_order_number = '12345'; +``` + +### Find tider der er faktureret via en specifik Hub ordre: + +```sql +SELECT * FROM tmodule_times +WHERE billed_via_thehub_id = 123; +``` + +### Find tider der IKKE er faktureret endnu: + +```sql +SELECT * FROM tmodule_times +WHERE status = 'approved' +AND billed_via_thehub_id IS NULL; +``` + +## vTiger Integration + +vTiger's `Timelog` records opdateres også via `app/timetracking/backend/vtiger_sync.py`: + +```python +async def update_timelog_billed(self, vtiger_ids: List[str], hub_order_id: int): + payload = { + "elementType": "Timelog", + "element": { + "id": vtiger_id, + "billed_via_thehub_id": str(hub_order_id), + "cf_timelog_invoiced": "1" + } + } +``` + +**Note:** `cf_timelog_invoiced` (IS BILLED) feltet i vTiger kan vi IKKE ændre fra Hub (vTiger begrænsning), men vi opdaterer vores eget `billed_via_thehub_id` felt. + +## Testing + +Migrationen er kørt og verificeret: +```bash +docker exec bmc-hub-postgres psql -U bmc_hub -d bmc_hub -c "\d tmodule_times" +# Viser: billed_via_thehub_id | integer med foreign key +``` + +## Deployment + +1. Migration er kørt på dev (060_add_billed_via_thehub_id.sql) +2. Kode ændringer deployed i samme commit +3. Eksisterende tidsregistreringer har `billed_via_thehub_id = NULL` +4. Fremtidige eksporter vil populere feltet korrekt + +## Relaterede Filer + +- `migrations/060_add_billed_via_thehub_id.sql` +- `app/timetracking/backend/models.py` +- `app/timetracking/backend/economic_export.py` +- `app/timetracking/backend/order_service.py` +- `app/timetracking/backend/vtiger_sync.py` diff --git a/migrations/060_add_billed_via_thehub_id.sql b/migrations/060_add_billed_via_thehub_id.sql new file mode 100644 index 0000000..411e524 --- /dev/null +++ b/migrations/060_add_billed_via_thehub_id.sql @@ -0,0 +1,48 @@ +-- Migration: Add billed_via_thehub_id to tmodule_times +-- This tracks which Hub order (and indirectly e-conomic order) each time entry was billed through + +DO $$ +BEGIN + -- Add billed_via_thehub_id column if it doesn't exist + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tmodule_times' AND column_name = 'billed_via_thehub_id' + ) THEN + ALTER TABLE tmodule_times ADD COLUMN billed_via_thehub_id INTEGER; + RAISE NOTICE 'Added column billed_via_thehub_id to tmodule_times'; + ELSE + RAISE NOTICE 'Column billed_via_thehub_id already exists'; + END IF; + + -- Add foreign key constraint to tmodule_orders + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'tmodule_times' + AND constraint_name = 'tmodule_times_billed_via_thehub_id_fkey' + ) THEN + ALTER TABLE tmodule_times + ADD CONSTRAINT tmodule_times_billed_via_thehub_id_fkey + FOREIGN KEY (billed_via_thehub_id) + REFERENCES tmodule_orders(id) + ON DELETE SET NULL; + RAISE NOTICE 'Added foreign key constraint for billed_via_thehub_id'; + ELSE + RAISE NOTICE 'Foreign key constraint already exists'; + END IF; + + -- Add index for faster lookups + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE tablename = 'tmodule_times' + AND indexname = 'idx_tmodule_times_billed_via_thehub_id' + ) THEN + CREATE INDEX idx_tmodule_times_billed_via_thehub_id ON tmodule_times(billed_via_thehub_id); + RAISE NOTICE 'Created index idx_tmodule_times_billed_via_thehub_id'; + ELSE + RAISE NOTICE 'Index idx_tmodule_times_billed_via_thehub_id already exists'; + END IF; +END $$; + +-- Add comment explaining the column +COMMENT ON COLUMN tmodule_times.billed_via_thehub_id IS +'Hub order ID that this time entry was billed through. Links to tmodule_orders.id which contains economic_order_number.'; diff --git a/test_billed_field.py b/test_billed_field.py new file mode 100644 index 0000000..d0db78d --- /dev/null +++ b/test_billed_field.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Test billed_via_thehub_id opdatering""" +import sys +sys.path.insert(0, '/app') + +from app.core.database import execute_query, execute_update + +# Test query +print("\n=== Checking tmodule_times schema ===") +result = execute_query(""" + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'tmodule_times' + AND column_name = 'billed_via_thehub_id' +""") +print(f"Column exists: {len(result) > 0}") +if result: + print(f" Type: {result[0]['data_type']}") + print(f" Nullable: {result[0]['is_nullable']}") + +# Test foreign key +print("\n=== Checking foreign key constraint ===") +fk_result = execute_query(""" + SELECT constraint_name, table_name, column_name + FROM information_schema.key_column_usage + WHERE table_name = 'tmodule_times' + AND column_name = 'billed_via_thehub_id' +""") +if fk_result: + print(f"Foreign key: {fk_result[0]['constraint_name']}") + +# Check if there are any time entries +print("\n=== Sample time entries ===") +times = execute_query(""" + SELECT id, status, billed_via_thehub_id, original_hours, worked_date + FROM tmodule_times + ORDER BY id DESC + LIMIT 5 +""") +print(f"Found {len(times)} time entries:") +for t in times: + print(f" ID {t['id']}: status={t['status']}, billed_via={t['billed_via_thehub_id']}, hours={t['original_hours']}") + +print("\n✅ Test complete!") diff --git a/test_vtiger_account.py b/test_vtiger_account.py new file mode 100644 index 0000000..84fa2f6 --- /dev/null +++ b/test_vtiger_account.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Test vTiger account retrieval""" +import asyncio +import sys +sys.path.insert(0, '/app') + +from app.services.vtiger_service import VTigerService +from app.core.config import settings +import json + +async def main(): + vtiger = VTigerService() + + account_id = "3x957" + + # Fetch account + print(f"\n=== Fetching account {account_id} ===") + account = await vtiger.get_account_by_id(account_id) + + if account: + print(f"\nAccount fields ({len(account)} total):") + for key in sorted(account.keys()): + value = account[key] + if len(str(value)) > 100: + value = str(value)[:100] + "..." + print(f" {key}: {value}") + else: + print("Account not found!") + +if __name__ == "__main__": + asyncio.run(main())