Compare commits

...

5 Commits

Author SHA1 Message Date
Christian
bbb9ce8487 Add debug endpoint for timetracking invoice field diagnostics 2026-01-02 00:01:12 +01:00
Christian
8ac3a9db2f v1.3.64 - Redesigned sync architecture with clear field ownership
BREAKING CHANGES:
- vTiger sync: Never overwrites existing vtiger_id
- Contact sync: REPLACES links instead of appending (idempotent)
- E-conomic sync: Only updates fields it owns (address, city, postal, email_domain, website)
- E-conomic sync: Does NOT overwrite name or cvr_number anymore

ARCHITECTURE:
- Each data source owns specific fields
- Sync operations are now idempotent (can run multiple times)
- Clear documentation of field ownership in sync_router.py
- Contact links deleted and recreated on sync to match vTiger state

FIXED:
- Contact relationships now correct after re-sync
- No more mixed customer data from different sources
- Sorting contacts by company_count DESC (companies first)
2025-12-24 10:34:13 +01:00
Christian
a867a7f128 fix: sync address field from e-conomic (v1.3.63)
- Added address field to UPDATE query in economic sync
- Added address field to INSERT query for new customers
- Fixes issue where address from e-conomic was not synced
- Prevents mixed data (København address with Lundby city/postal)
- Address is now synced along with city, postal_code, country
2025-12-24 09:41:51 +01:00
Christian
0dd24c6420 fix: better error handling for order generation (v1.3.62)
- Added more specific error message when customer not found
- Added debug logging to check customer object type
- Changed error from 'Customer not found' to include customer_id
- Helps diagnose 'string indices must be integers' error
2025-12-24 09:39:31 +01:00
Christian
d228362617 fix: parse customer_rate as float in wizard (v1.3.61)
- Fixed customer_rate being returned as string from DB (NUMERIC type)
- Added parseFloat() when using customer_rate in calculations
- Fixes customer stats showing '-' instead of actual hourly rate
- Applied to loadCustomerContext(), displayCaseEntries(), and approveEntry()
2025-12-24 09:35:46 +01:00
11 changed files with 249 additions and 32 deletions

View File

@ -1 +1 @@
1.3.60 1.3.64

View File

@ -101,7 +101,7 @@ async def get_contacts(
{where_sql} {where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.created_at, c.updated_at c.title, c.department, c.is_active, c.created_at, c.updated_at
ORDER BY c.first_name, c.last_name ORDER BY company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
params.extend([limit, offset]) params.extend([limit, offset])

View File

@ -1,6 +1,33 @@
""" """
System Sync Router System Sync Router
API endpoints for syncing data between vTiger, e-conomic and Hub API endpoints for syncing data between vTiger, e-conomic and Hub
SYNC ARCHITECTURE - Field Ownership:
=====================================
E-CONOMIC owns and syncs:
- economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address)
- email_domain, website (contact information)
- cvr_number (used for matching only, not overwritten if already set)
vTIGER owns and syncs:
- vtiger_id (primary key from vTiger)
- vtiger_account_no
- Contact records and contact-company relationships
HUB owns (manual or first-sync only):
- name (can be synced initially but not overwritten)
- cvr_number (used for matching, set once)
- Tags, notes, custom fields
SYNC RULES:
===========
1. NEVER overwrite source ID if already set (vtiger_id, economic_customer_number)
2. Match order: CVR Source ID Name (normalized)
3. Re-sync is idempotent - can run multiple times safely
4. Contact relationships are REPLACED on sync (not added)
5. Each sync only updates fields it owns
""" """
import logging import logging
@ -124,6 +151,7 @@ async def sync_from_vtiger() -> Dict[str, Any]:
continue continue
if current_vtiger_id is None: if current_vtiger_id is None:
# Only set vtiger_id if it's currently NULL
execute_query( execute_query(
"UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s", "UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s",
(vtiger_id, existing[0]['id']) (vtiger_id, existing[0]['id'])
@ -131,13 +159,10 @@ async def sync_from_vtiger() -> Dict[str, Any]:
linked_count += 1 linked_count += 1
logger.info(f"🔗 Linket: {existing[0]['name']} → vTiger #{vtiger_id} (match: {match_method}, CVR: {cvr or 'ingen'})") logger.info(f"🔗 Linket: {existing[0]['name']} → vTiger #{vtiger_id} (match: {match_method}, CVR: {cvr or 'ingen'})")
elif current_vtiger_id != vtiger_id: elif current_vtiger_id != vtiger_id:
# Update if different vTiger ID # SKIP if different vTiger ID - do NOT overwrite existing vtiger_id
execute_query( logger.warning(f"⚠️ Springer over: {existing[0]['name']} har allerede vTiger #{current_vtiger_id}, vil ikke overskrive med #{vtiger_id}")
"UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s", not_found_count += 1
(vtiger_id, existing[0]['id']) continue
)
updated_count += 1
logger.info(f"✏️ Opdateret vTiger ID: {existing[0]['name']}{vtiger_id} (var: {current_vtiger_id})")
else: else:
# Already linked, just update timestamp # Already linked, just update timestamp
execute_query("UPDATE customers SET last_synced_at = NOW() WHERE id = %s", (existing[0]['id'],)) execute_query("UPDATE customers SET last_synced_at = NOW() WHERE id = %s", (existing[0]['id'],))
@ -386,19 +411,14 @@ async def sync_vtiger_contacts() -> Dict[str, Any]:
customer_id = customer_result[0]['id'] customer_id = customer_result[0]['id']
customer_name = customer_result[0]['name'] customer_name = customer_result[0]['name']
# Check if link already exists # DELETE existing links for this contact (we replace, not append)
existing_link = execute_query( # This ensures re-sync updates links to match current vTiger state
"SELECT id FROM contact_companies WHERE contact_id = %s AND customer_id = %s", execute_query(
(contact_id, customer_id) "DELETE FROM contact_companies WHERE contact_id = %s",
(contact_id,)
) )
if existing_link: # CREATE new link from vTiger
# Already linked
if debug_count <= 20:
logger.warning(f" ↳ Already linked to '{customer_name}'")
continue
# CREATE LINK
execute_query( execute_query(
"INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES (%s, %s, false)", "INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES (%s, %s, false)",
(contact_id, customer_id) (contact_id, customer_id)
@ -502,13 +522,13 @@ async def sync_from_economic() -> Dict[str, Any]:
) )
if existing: if existing:
# Update existing customer (always sync economic_customer_number from e-conomic) # Update existing customer - ONLY update fields e-conomic owns
# E-conomic does NOT overwrite: name, cvr_number (set once only)
update_query = """ update_query = """
UPDATE customers SET UPDATE customers SET
name = %s,
economic_customer_number = %s, economic_customer_number = %s,
cvr_number = %s,
email_domain = %s, email_domain = %s,
address = %s,
city = %s, city = %s,
postal_code = %s, postal_code = %s,
country = %s, country = %s,
@ -517,7 +537,7 @@ async def sync_from_economic() -> Dict[str, Any]:
WHERE id = %s WHERE id = %s
""" """
execute_query(update_query, ( execute_query(update_query, (
name, customer_number, cvr, email_domain, city, zip_code, country, website, existing[0]['id'] customer_number, email_domain, address, city, zip_code, country, website, existing[0]['id']
)) ))
updated_count += 1 updated_count += 1
logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})") logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})")
@ -526,12 +546,12 @@ async def sync_from_economic() -> Dict[str, Any]:
insert_query = """ insert_query = """
INSERT INTO customers INSERT INTO customers
(name, economic_customer_number, cvr_number, email_domain, (name, economic_customer_number, cvr_number, email_domain,
city, postal_code, country, website, last_synced_at) address, city, postal_code, country, website, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id RETURNING id
""" """
result = execute_query(insert_query, ( result = execute_query(insert_query, (
name, customer_number, cvr, email_domain, city, zip_code, country, website name, customer_number, cvr, email_domain, address, city, zip_code, country, website
)) ))
if result: if result:

View File

@ -84,7 +84,10 @@ class OrderService:
(customer_id,)) (customer_id,))
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Debug log
logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}")
# Hent godkendte tider for kunden med case og contact detaljer # Hent godkendte tider for kunden med case og contact detaljer
query = """ query = """

View File

@ -118,6 +118,69 @@ async def test_vtiger_connection():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/debug/raw-stats", tags=["Debug"])
async def get_debug_raw_stats():
"""
🔍 DEBUG: Vis statistik fra databasen uden filtering.
Bruges til at diagnosticere hvorfor timelogs ikke vises.
"""
try:
# Total counts without any filtering
total_query = """
SELECT
(SELECT COUNT(*) FROM tmodule_customers) as total_customers,
(SELECT COUNT(*) FROM tmodule_cases) as total_cases,
(SELECT COUNT(*) FROM tmodule_times) as total_times,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true) as billable_times,
(SELECT COUNT(*) FROM tmodule_times WHERE status = 'pending') as pending_times,
(SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' = '0') as not_invoiced_times,
(SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' IS NULL) as null_invoiced_times,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending') as billable_pending,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending' AND vtiger_data->>'cf_timelog_invoiced' = '0') as filtered_pending
"""
totals = execute_query_single(total_query)
# Sample timelogs to see actual data
sample_query = """
SELECT
id, vtiger_id, description, worked_date, original_hours,
status, billable,
vtiger_data->>'cf_timelog_invoiced' as cf_timelog_invoiced,
vtiger_data->>'isbillable' as is_billable_field,
customer_id, case_id
FROM tmodule_times
ORDER BY worked_date DESC
LIMIT 10
"""
samples = execute_query(sample_query)
# Check what invoice statuses actually exist
invoice_status_query = """
SELECT
vtiger_data->>'cf_timelog_invoiced' as invoice_status,
COUNT(*) as count
FROM tmodule_times
GROUP BY vtiger_data->>'cf_timelog_invoiced'
ORDER BY count DESC
"""
invoice_statuses = execute_query(invoice_status_query)
return {
"totals": totals,
"sample_timelogs": samples,
"invoice_statuses": invoice_statuses,
"explanation": {
"issue": "If not_invoiced_times is 0 but total_times > 0, then the cf_timelog_invoiced field is not '0' in vTiger",
"solution": "The SQL views filter on cf_timelog_invoiced = '0' but vTiger might use different values",
"check": "Look at invoice_statuses to see what values actually exist"
}
}
except Exception as e:
logger.error(f"❌ Debug query failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================ # ============================================================================
# WIZARD / APPROVAL ENDPOINTS # WIZARD / APPROVAL ENDPOINTS
# ============================================================================ # ============================================================================

View File

@ -567,8 +567,9 @@
caseLink = 'Ingen case'; caseLink = 'Ingen case';
} }
// Get hourly rate (customer rate or default) // Get hourly rate (customer rate or default) - parse as float if string
const hourlyRate = e.customer_rate || currentEntry.customer_rate || defaultHourlyRate; const rateValue = e.customer_rate || currentEntry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
return ` return `
<div class="card time-entry-card mb-3" id="entry-card-${e.id}"> <div class="card time-entry-card mb-3" id="entry-card-${e.id}">
@ -804,7 +805,9 @@
const response = await fetch(`/api/v1/timetracking/wizard/progress/${currentEntry.customer_id}`); const response = await fetch(`/api/v1/timetracking/wizard/progress/${currentEntry.customer_id}`);
const progress = await response.json(); const progress = await response.json();
const hourlyRate = currentEntry.customer_rate || defaultHourlyRate; // Parse hourly rate as number (may be string from DB)
const rateValue = currentEntry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
document.getElementById('context-hourly-rate').textContent = hourlyRate.toFixed(2) + ' DKK'; document.getElementById('context-hourly-rate').textContent = hourlyRate.toFixed(2) + ' DKK';
// Vis antal registreringer (vi har ikke timer-totaler i progress endpointet) // Vis antal registreringer (vi har ikke timer-totaler i progress endpointet)
@ -1081,7 +1084,8 @@
// Get billable hours and hourly rate from calculation // Get billable hours and hourly rate from calculation
const billableHours = window.entryBillableHours?.[entryId] || entry.original_hours; const billableHours = window.entryBillableHours?.[entryId] || entry.original_hours;
const hourlyRate = window.entryHourlyRates?.[entryId] || entry.customer_rate || defaultHourlyRate; const rateValue = window.entryHourlyRates?.[entryId] || entry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
// Get travel checkbox state // Get travel checkbox state
const travelCheckbox = document.getElementById(`travel-${entryId}`); const travelCheckbox = document.getElementById(`travel-${entryId}`);

53
deploy_to_prod.sh Normal file
View File

@ -0,0 +1,53 @@
#!/bin/bash
# Deploy BMC Hub to production server
# Usage: ./deploy_to_prod.sh v1.3.56
set -e
VERSION=$1
PROD_SERVER="bmcadmin@172.16.31.183"
PROD_DIR="/srv/podman/bmc_hub_v1.0"
if [ -z "$VERSION" ]; then
echo "❌ Usage: ./deploy_to_prod.sh v1.3.56"
exit 1
fi
echo "🚀 Deploying $VERSION to production..."
echo "======================================="
# Update .env file
echo "📝 Updating RELEASE_VERSION to $VERSION..."
ssh $PROD_SERVER "sed -i 's/^RELEASE_VERSION=.*/RELEASE_VERSION=$VERSION/' $PROD_DIR/.env"
# Stop containers
echo "🛑 Stopping containers..."
ssh $PROD_SERVER "cd $PROD_DIR && podman-compose down"
# Rebuild with no cache (to pull latest version from Gitea)
echo "🔨 Building new image from version $VERSION..."
ssh $PROD_SERVER "cd $PROD_DIR && podman-compose build --no-cache"
# Start containers
echo "▶️ Starting containers..."
ssh $PROD_SERVER "cd $PROD_DIR && podman-compose up -d"
# Wait for service to be ready
echo "⏳ Waiting for service to start..."
sleep 15
# Check health
echo "🏥 Checking service health..."
HEALTH=$(curl -s http://172.16.31.183:8000/health || echo "FAILED")
if echo "$HEALTH" | grep -q "ok"; then
echo "✅ Deployment successful!"
echo "$HEALTH"
else
echo "❌ Health check failed:"
echo "$HEALTH"
exit 1
fi
echo ""
echo "🎉 Production is now running version $VERSION"
echo " http://172.16.31.183:8000"

View File

@ -42,6 +42,7 @@ services:
# Mount for local development - live code reload # Mount for local development - live code reload
- ./app:/app/app:ro - ./app:/app/app:ro
- ./main.py:/app/main.py:ro - ./main.py:/app/main.py:ro
- ./VERSION:/app/VERSION:ro
env_file: env_file:
- .env - .env
environment: environment:

View File

@ -4,6 +4,7 @@ Main application entry point
""" """
import logging import logging
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -13,6 +14,14 @@ from contextlib import asynccontextmanager
from app.core.config import settings from app.core.config import settings
from app.core.database import init_db from app.core.database import init_db
def get_version():
"""Read version from VERSION file"""
try:
version_file = Path(__file__).parent / "VERSION"
return version_file.read_text().strip()
except Exception:
return "unknown"
# Import Feature Routers # Import Feature Routers
from app.customers.backend import router as customers_api from app.customers.backend import router as customers_api
from app.customers.backend import views as customers_views from app.customers.backend import views as customers_views

20
test_vtiger_accounts.py Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
import asyncio
import sys
sys.path.insert(0, '/Users/christianthomas/DEV/bmc_hub_dev')
from app.services.vtiger_service import VTigerService
async def test_accounts():
vtiger = VTigerService()
# Check what 3x760 and 3x811 are in vTiger
for vtiger_id in ['3x760', '3x811']:
query = f"SELECT id, accountname FROM Accounts WHERE id = '{vtiger_id}';"
result = await vtiger.query(query)
if result:
print(f"{vtiger_id} = {result[0].get('accountname')}")
else:
print(f"{vtiger_id} = NOT FOUND")
asyncio.run(test_accounts())

44
test_vtiger_contact.py Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Test script to check vTiger contact data
"""
import asyncio
import sys
sys.path.insert(0, '/Users/christianthomas/DEV/bmc_hub_dev')
from app.services.vtiger_service import VTigerService
async def test_contact():
vtiger = VTigerService()
# Fetch contact by email
query = "SELECT id, firstname, lastname, email, account_id FROM Contacts WHERE email = 'accounting@arbodania.com';"
print(f"Query: {query}")
result = await vtiger.query(query)
if result:
contact = result[0]
print(f"\n✅ Contact found:")
print(f" ID: {contact.get('id')}")
print(f" Name: {contact.get('firstname')} {contact.get('lastname')}")
print(f" Email: {contact.get('email')}")
print(f" Account ID: {contact.get('account_id')}")
# Now fetch the account details
account_id = contact.get('account_id')
if account_id:
account_query = f"SELECT id, accountname FROM Accounts WHERE id = '{account_id}';"
print(f"\nFetching account: {account_query}")
account_result = await vtiger.query(account_query)
if account_result:
account = account_result[0]
print(f"\n✅ Account found:")
print(f" ID: {account.get('id')}")
print(f" Name: {account.get('accountname')}")
else:
print("❌ Contact not found")
if __name__ == "__main__":
asyncio.run(test_contact())