Compare commits

..

No commits in common. "bbb9ce8487f0cc41981d91fff15fc7263ff55e2d" and "097f0633f5edc002fa14675a88a6f0d72149a384" have entirely different histories.

11 changed files with 32 additions and 249 deletions

View File

@ -1 +1 @@
1.3.64 1.3.60

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 company_count DESC, c.last_name, c.first_name ORDER BY c.first_name, c.last_name
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
params.extend([limit, offset]) params.extend([limit, offset])

View File

@ -1,33 +1,6 @@
""" """
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
@ -151,7 +124,6 @@ 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'])
@ -159,10 +131,13 @@ 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:
# SKIP if different vTiger ID - do NOT overwrite existing vtiger_id # Update if different vTiger ID
logger.warning(f"⚠️ Springer over: {existing[0]['name']} har allerede vTiger #{current_vtiger_id}, vil ikke overskrive med #{vtiger_id}") execute_query(
not_found_count += 1 "UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s",
continue (vtiger_id, existing[0]['id'])
)
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'],))
@ -411,14 +386,19 @@ 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']
# DELETE existing links for this contact (we replace, not append) # Check if link already exists
# This ensures re-sync updates links to match current vTiger state existing_link = execute_query(
execute_query( "SELECT id FROM contact_companies WHERE contact_id = %s AND customer_id = %s",
"DELETE FROM contact_companies WHERE contact_id = %s", (contact_id, customer_id)
(contact_id,)
) )
# CREATE new link from vTiger if existing_link:
# 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)
@ -522,13 +502,13 @@ async def sync_from_economic() -> Dict[str, Any]:
) )
if existing: if existing:
# Update existing customer - ONLY update fields e-conomic owns # Update existing customer (always sync economic_customer_number from e-conomic)
# 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,
@ -537,7 +517,7 @@ async def sync_from_economic() -> Dict[str, Any]:
WHERE id = %s WHERE id = %s
""" """
execute_query(update_query, ( execute_query(update_query, (
customer_number, email_domain, address, city, zip_code, country, website, existing[0]['id'] name, customer_number, cvr, email_domain, 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'})")
@ -546,12 +526,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,
address, city, postal_code, country, website, last_synced_at) city, postal_code, country, website, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) VALUES (%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, address, city, zip_code, country, website name, customer_number, cvr, email_domain, city, zip_code, country, website
)) ))
if result: if result:

View File

@ -84,10 +84,7 @@ class OrderService:
(customer_id,)) (customer_id,))
if not customer: if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers") raise HTTPException(status_code=404, detail="Customer not found")
# 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,69 +118,6 @@ 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,9 +567,8 @@
caseLink = 'Ingen case'; caseLink = 'Ingen case';
} }
// Get hourly rate (customer rate or default) - parse as float if string // Get hourly rate (customer rate or default)
const rateValue = e.customer_rate || currentEntry.customer_rate || defaultHourlyRate; const hourlyRate = 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}">
@ -805,9 +804,7 @@
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();
// Parse hourly rate as number (may be string from DB) const hourlyRate = currentEntry.customer_rate || defaultHourlyRate;
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)
@ -1084,8 +1081,7 @@
// 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 rateValue = window.entryHourlyRates?.[entryId] || entry.customer_rate || defaultHourlyRate; const hourlyRate = 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}`);

View File

@ -1,53 +0,0 @@
#!/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,7 +42,6 @@ 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,7 +4,6 @@ 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
@ -14,14 +13,6 @@ 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

View File

@ -1,20 +0,0 @@
#!/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())

View File

@ -1,44 +0,0 @@
#!/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())