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)
This commit is contained in:
Christian 2025-12-24 10:34:13 +01:00
parent a867a7f128
commit 8ac3a9db2f
8 changed files with 170 additions and 24 deletions

View File

@ -1 +1 @@
1.3.63
1.3.64

View File

@ -101,7 +101,7 @@ async def get_contacts(
{where_sql}
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
ORDER BY c.first_name, c.last_name
ORDER BY company_count DESC, c.last_name, c.first_name
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])

View File

@ -1,6 +1,33 @@
"""
System Sync Router
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
@ -124,6 +151,7 @@ async def sync_from_vtiger() -> Dict[str, Any]:
continue
if current_vtiger_id is None:
# Only set vtiger_id if it's currently NULL
execute_query(
"UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s",
(vtiger_id, existing[0]['id'])
@ -131,13 +159,10 @@ async def sync_from_vtiger() -> Dict[str, Any]:
linked_count += 1
logger.info(f"🔗 Linket: {existing[0]['name']} → vTiger #{vtiger_id} (match: {match_method}, CVR: {cvr or 'ingen'})")
elif current_vtiger_id != vtiger_id:
# Update if different vTiger ID
execute_query(
"UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s",
(vtiger_id, existing[0]['id'])
)
updated_count += 1
logger.info(f"✏️ Opdateret vTiger ID: {existing[0]['name']}{vtiger_id} (var: {current_vtiger_id})")
# SKIP if different vTiger ID - do NOT overwrite existing vtiger_id
logger.warning(f"⚠️ Springer over: {existing[0]['name']} har allerede vTiger #{current_vtiger_id}, vil ikke overskrive med #{vtiger_id}")
not_found_count += 1
continue
else:
# Already linked, just update timestamp
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_name = customer_result[0]['name']
# Check if link already exists
existing_link = execute_query(
"SELECT id FROM contact_companies WHERE contact_id = %s AND customer_id = %s",
(contact_id, customer_id)
# DELETE existing links for this contact (we replace, not append)
# This ensures re-sync updates links to match current vTiger state
execute_query(
"DELETE FROM contact_companies WHERE contact_id = %s",
(contact_id,)
)
if existing_link:
# Already linked
if debug_count <= 20:
logger.warning(f" ↳ Already linked to '{customer_name}'")
continue
# CREATE LINK
# CREATE new link from vTiger
execute_query(
"INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES (%s, %s, false)",
(contact_id, customer_id)
@ -502,12 +522,11 @@ async def sync_from_economic() -> Dict[str, Any]:
)
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 customers SET
name = %s,
economic_customer_number = %s,
cvr_number = %s,
email_domain = %s,
address = %s,
city = %s,
@ -518,7 +537,7 @@ async def sync_from_economic() -> Dict[str, Any]:
WHERE id = %s
"""
execute_query(update_query, (
name, customer_number, cvr, email_domain, address, city, zip_code, country, website, existing[0]['id']
customer_number, email_domain, address, city, zip_code, country, website, existing[0]['id']
))
updated_count += 1
logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})")

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
- ./app:/app/app:ro
- ./main.py:/app/main.py:ro
- ./VERSION:/app/VERSION:ro
env_file:
- .env
environment:

View File

@ -4,6 +4,7 @@ Main application entry point
"""
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
@ -13,6 +14,14 @@ from contextlib import asynccontextmanager
from app.core.config import settings
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
from app.customers.backend import router as customers_api
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())