feat: Implement tracking of billed Hub order ID for time entries and update related services

This commit is contained in:
Christian 2026-01-08 18:57:04 +01:00
parent cbcd0fe4e7
commit ccb7714779
11 changed files with 355 additions and 36 deletions

View File

@ -9,7 +9,7 @@ from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
import logging 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.cvr_service import get_cvr_service
from app.services.customer_activity_logger import CustomerActivityLogger from app.services.customer_activity_logger import CustomerActivityLogger
from app.services.customer_consistency import CustomerConsistencyService from app.services.customer_consistency import CustomerConsistencyService

View File

@ -5,7 +5,7 @@ Compares customer data across BMC Hub, vTiger Cloud, and e-conomic
import logging import logging
import asyncio import asyncio
from typing import Dict, List, Optional, Tuple, Any 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.vtiger_service import VTigerService
from app.services.economic_service import EconomicService from app.services.economic_service import EconomicService
from app.core.config import settings from app.core.config import settings
@ -82,12 +82,12 @@ class CustomerConsistencyService:
vtiger_task = None vtiger_task = None
economic_task = None economic_task = None
# Fetch vTiger data if we have an ID # Fetch vTiger data if we have an ID and vTiger is configured
if hub_data.get('vtiger_id') and settings.VTIGER_ENABLED: if hub_data.get('vtiger_id') and settings.VTIGER_URL:
vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id']) vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id'])
# Fetch e-conomic data if we have a customer number # 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_ENABLED: if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN:
economic_task = self.economic.get_customer(hub_data['economic_customer_number']) economic_task = self.economic.get_customer(hub_data['economic_customer_number'])
# Parallel fetch with error handling # Parallel fetch with error handling
@ -153,8 +153,17 @@ class CustomerConsistencyService:
economic_norm = cls.normalize_value(economic_value) economic_norm = cls.normalize_value(economic_value)
# Check if all values are the same # Check if all values are the same
values = [v for v in [hub_norm, vtiger_norm, economic_norm] if v is not None] # Only compare systems that are available
has_discrepancy = len(set(values)) > 1 if values else False 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] = { discrepancies[hub_field] = {
'hub': hub_value, 'hub': hub_value,
@ -203,7 +212,6 @@ class CustomerConsistencyService:
# Update Hub if not the source # Update Hub if not the source
if source_system != 'hub': if source_system != 'hub':
try: try:
from app.core.database import execute_update
update_query = f"UPDATE customers SET {field_name} = %s WHERE id = %s" update_query = f"UPDATE customers SET {field_name} = %s WHERE id = %s"
await asyncio.to_thread(execute_update, update_query, (source_value, customer_id)) await asyncio.to_thread(execute_update, update_query, (source_value, customer_id))
results['hub'] = True results['hub'] = True
@ -218,9 +226,13 @@ class CustomerConsistencyService:
if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'): if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'):
try: try:
update_data = {vtiger_field: source_value} update_data = {vtiger_field: source_value}
await self.vtiger.update_account(hub_data['vtiger_id'], update_data) success = await self.vtiger.update_account(hub_data['vtiger_id'], update_data)
results['vtiger'] = True if success:
logger.info(f"✅ vTiger {vtiger_field} updated") 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: except Exception as e:
logger.error(f"❌ Failed to update vTiger: {e}") logger.error(f"❌ Failed to update vTiger: {e}")
results['vtiger'] = False results['vtiger'] = False

View File

@ -126,14 +126,26 @@ class VTigerService:
raise ValueError("VTIGER_URL not configured") raise ValueError("VTIGER_URL not configured")
try: 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() 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 = { payload = {
'id': account_id, 'id': account_id,
'accountname': current_account.get('accountname'),
'assigned_user_id': current_account.get('assigned_user_id'),
'modifiedtime': current_account.get('modifiedtime'),
**update_data **update_data
} }
logger.info(f"🔄 vTiger update payload: {payload}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
f"{self.rest_endpoint}/update", f"{self.rest_endpoint}/update",
@ -151,12 +163,14 @@ class VTigerService:
return True return True
else: else:
logger.error(f"❌ vTiger update failed: {data.get('error')}") logger.error(f"❌ vTiger update failed: {data.get('error')}")
logger.error(f"Full response: {text}")
return False return False
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error(f"❌ Invalid JSON in update response: {text[:200]}") logger.error(f"❌ Invalid JSON in update response: {text[:200]}")
return False return False
else: 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 return False
except Exception as e: except Exception as e:

View File

@ -412,16 +412,17 @@ class EconomicExportService:
(economic_draft_id, economic_order_number, user_id, request.order_id) (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( execute_update(
"""UPDATE tmodule_times """UPDATE tmodule_times
SET status = 'billed' SET status = 'billed',
billed_via_thehub_id = %s
WHERE id IN ( WHERE id IN (
SELECT UNNEST(time_entry_ids) SELECT UNNEST(time_entry_ids)
FROM tmodule_order_lines FROM tmodule_order_lines
WHERE order_id = %s WHERE order_id = %s
)""", )""",
(request.order_id,) (request.order_id, request.order_id)
) )
# Hent vTiger IDs for tidsregistreringerne # Hent vTiger IDs for tidsregistreringerne

View File

@ -138,8 +138,10 @@ class TModuleTime(TModuleTimeBase):
rounded_to: Optional[Decimal] = None rounded_to: Optional[Decimal] = None
approval_note: Optional[str] = None approval_note: Optional[str] = None
billable: bool = True billable: bool = True
is_travel: bool = False
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
approved_by: Optional[int] = 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 sync_hash: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

View File

@ -279,22 +279,8 @@ class OrderService:
logger.info(f"✅ Created {len(created_lines)} order lines") logger.info(f"✅ Created {len(created_lines)} order lines")
# Update time entries to 'billed' status # NOTE: Time entries remain 'approved' status until exported to e-conomic
time_entry_ids = [ # They will be updated to 'billed' with billed_via_thehub_id in economic_export.py
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")
# Log order creation # Log order creation
audit.log_order_created( audit.log_order_created(

View File

@ -795,13 +795,15 @@ class TimeTrackingVTigerService:
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
# Update only if NOT yet approved # Update only if NOT yet approved AND NOT yet billed
result = execute_update( result = execute_update(
"""UPDATE tmodule_times """UPDATE tmodule_times
SET description = %s, original_hours = %s, worked_date = %s, SET description = %s, original_hours = %s, worked_date = %s,
user_name = %s, billable = %s, vtiger_data = %s::jsonb, user_name = %s, billable = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP 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', ''), timelog.get('name', ''),
hours, hours,
@ -817,7 +819,7 @@ class TimeTrackingVTigerService:
if result > 0: if result > 0:
stats["updated"] += 1 stats["updated"] += 1
else: else:
logger.debug(f"⏭️ Time entry {vtiger_id} already approved") logger.debug(f"⏭️ Time entry {vtiger_id} already approved or billed")
stats["skipped"] += 1 stats["skipped"] += 1
else: else:
# Insert new # Insert new

View File

@ -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`

View File

@ -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.';

44
test_billed_field.py Normal file
View File

@ -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!")

31
test_vtiger_account.py Normal file
View File

@ -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())