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} {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,12 +522,11 @@ 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, address = %s,
city = %s, city = %s,
@ -518,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, 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 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'})")

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