feat: Implement tracking of billed Hub order ID for time entries and update related services
This commit is contained in:
parent
cbcd0fe4e7
commit
ccb7714779
@ -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
|
||||
|
||||
@ -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)
|
||||
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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
179
docs/TIMETRACKING_BILLED_VIA_HUB.md
Normal file
179
docs/TIMETRACKING_BILLED_VIA_HUB.md
Normal 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`
|
||||
48
migrations/060_add_billed_via_thehub_id.sql
Normal file
48
migrations/060_add_billed_via_thehub_id.sql
Normal 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
44
test_billed_field.py
Normal 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
31
test_vtiger_account.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user