Feature: Add sync page to settings
- Add Sync navigation tab in settings - Sync UI with status cards (total, vTiger, e-conomic) - Action cards for vTiger and e-conomic sync - Sync log with real-time updates - JavaScript functions for sync operations - Backend sync router with vTiger account sync - Backend vTiger contacts sync with customer linking - Placeholder for e-conomic sync (needs get_customers method) - Name normalization for company matching - CVR number matching and validation
This commit is contained in:
parent
a011f36385
commit
7c69cb22e7
30
.env.example
30
.env.example
@ -60,3 +60,33 @@ VTIGER_API_KEY=your_vtiger_api_key
|
||||
OLD_VTIGER_URL=http://your-old-vtiger-server.com
|
||||
OLD_VTIGER_USERNAME=your_old_username
|
||||
OLD_VTIGER_API_KEY=your_old_api_key
|
||||
|
||||
# =====================================================
|
||||
# EMAIL SYSTEM CONFIGURATION
|
||||
# =====================================================
|
||||
# IMAP Settings (Standard email)
|
||||
IMAP_SERVER=imap.gmail.com
|
||||
IMAP_PORT=993
|
||||
IMAP_USERNAME=your_email@gmail.com
|
||||
IMAP_PASSWORD=your_app_password
|
||||
IMAP_USE_SSL=true
|
||||
IMAP_FOLDER=INBOX
|
||||
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
|
||||
|
||||
# Microsoft Graph API (Alternative to IMAP - for Office365/Outlook)
|
||||
USE_GRAPH_API=false
|
||||
GRAPH_TENANT_ID=your_tenant_id
|
||||
GRAPH_CLIENT_ID=your_client_id
|
||||
GRAPH_CLIENT_SECRET=your_client_secret
|
||||
GRAPH_USER_EMAIL=your_email@domain.com
|
||||
|
||||
# Email Processing Settings
|
||||
EMAIL_TO_TICKET_ENABLED=false
|
||||
EMAIL_RULES_ENABLED=true
|
||||
EMAIL_RULES_AUTO_PROCESS=false
|
||||
EMAIL_AI_ENABLED=false
|
||||
EMAIL_AUTO_CLASSIFY=false
|
||||
EMAIL_AI_CONFIDENCE_THRESHOLD=0.7
|
||||
EMAIL_MAX_FETCH_PER_RUN=50
|
||||
EMAIL_PROCESS_INTERVAL_MINUTES=5
|
||||
EMAIL_WORKFLOWS_ENABLED=true
|
||||
@ -40,6 +40,34 @@ class Settings(BaseSettings):
|
||||
OLLAMA_ENDPOINT: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2:3b"
|
||||
|
||||
# Email System Configuration
|
||||
# IMAP Settings
|
||||
IMAP_SERVER: str = ""
|
||||
IMAP_PORT: int = 993
|
||||
IMAP_USERNAME: str = ""
|
||||
IMAP_PASSWORD: str = ""
|
||||
IMAP_USE_SSL: bool = True
|
||||
IMAP_FOLDER: str = "INBOX"
|
||||
IMAP_READ_ONLY: bool = True
|
||||
|
||||
# Microsoft Graph API (alternative to IMAP)
|
||||
USE_GRAPH_API: bool = False
|
||||
GRAPH_TENANT_ID: str = ""
|
||||
GRAPH_CLIENT_ID: str = ""
|
||||
GRAPH_CLIENT_SECRET: str = ""
|
||||
GRAPH_USER_EMAIL: str = ""
|
||||
|
||||
# Email Processing Settings
|
||||
EMAIL_TO_TICKET_ENABLED: bool = False
|
||||
EMAIL_RULES_ENABLED: bool = True
|
||||
EMAIL_RULES_AUTO_PROCESS: bool = False
|
||||
EMAIL_AI_ENABLED: bool = False
|
||||
EMAIL_AUTO_CLASSIFY: bool = False
|
||||
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
|
||||
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
||||
EMAIL_WORKFLOWS_ENABLED: bool = True
|
||||
|
||||
# vTiger Cloud Integration
|
||||
VTIGER_URL: str = ""
|
||||
VTIGER_USERNAME: str = ""
|
||||
|
||||
@ -92,6 +92,9 @@
|
||||
<a class="nav-link" href="#tags" data-tab="tags">
|
||||
<i class="bi bi-tags me-2"></i>Tags
|
||||
</a>
|
||||
<a class="nav-link" href="#sync" data-tab="sync">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Sync
|
||||
</a>
|
||||
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
|
||||
<i class="bi bi-robot me-2"></i>AI Prompts
|
||||
</a>
|
||||
@ -295,7 +298,153 @@
|
||||
<div class="col-12 text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
<Sync Integration -->
|
||||
<div class="tab-pane fade" id="sync">
|
||||
<div class="mb-4">
|
||||
<h5 class="fw-bold mb-1">Data Synkronisering</h5>
|
||||
<p class="text-muted mb-0">Synkroniser firmaer og kontakter fra vTiger og e-conomic</p>
|
||||
</div>
|
||||
|
||||
<!-- Sync Status Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="rounded-circle" style="width: 48px; height: 48px; background: #f0f9ff; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-building text-primary" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="small text-muted">Firmaer i Hub</div>
|
||||
<div class="h4 mb-0" id="syncStatsCustomers">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="rounded-circle" style="width: 48px; height: 48px; background: #fff4ed; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-diagram-3 text-warning" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="small text-muted">Med vTiger ID</div>
|
||||
<div class="h4 mb-0" id="syncStatsVtiger">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="rounded-circle" style="width: 48px; height: 48px; background: #f0fdf4; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-currency-dollar text-success" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="small text-muted">Med e-conomic ID</div>
|
||||
<div class="h4 mb-0" id="syncStatsEconomic">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Actions -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-diagram-3 text-warning" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="card-title fw-bold">Sync fra vTiger</h6>
|
||||
<p class="card-text small text-muted">Hent firmaer og kontakter fra vTiger CRM. Matcher på CVR nummer eller firma navn.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-warning" onclick="syncFromVtiger()" id="btnSyncVtiger">
|
||||
<i class="bi bi-download me-2"></i>Sync Firmaer fra vTiger
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="syncVtigerContacts()" id="btnSyncVtigerContacts">
|
||||
<i class="bi bi-people me-2"></i>Sync Kontakter fra vTiger
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 small">
|
||||
<div class="d-flex align-items-center text-muted">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<span>Sidst synkroniseret: <span id="lastSyncVtiger">Aldrig</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-currency-dollar text-success" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="card-title fw-bold">Sync fra e-conomic</h6>
|
||||
<p class="card-text small text-muted">Hent kundenumre fra e-conomic. Matcher på CVR nummer eller firma navn.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-success" onclick="syncFromEconomic()" id="btnSyncEconomic">
|
||||
<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic
|
||||
</button>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="syncCvrToEconomic()" id="btnSyncCvrEconomic">
|
||||
<i class="bi bi-search me-2"></i>Find Manglende CVR i e-conomic
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 small">
|
||||
<div class="d-flex align-items-center text-muted">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<span>Sidst synkroniseret: <span id="lastSyncEconomic">Aldrig</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Log -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Synkroniserings Log</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadSyncLog()">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="syncLogContainer" style="max-height: 400px; overflow-y: auto;">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<p class="text-muted small mt-2 mb-0">Indlæser log...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- /div>
|
||||
</div>
|
||||
|
||||
<!-- AI Prompts -->
|
||||
@ -1215,6 +1364,229 @@ if (tagsNavLink) {
|
||||
});
|
||||
}
|
||||
|
||||
// ====== SYNC MANAGEMENT ======
|
||||
let syncLog = [];
|
||||
|
||||
async function loadSyncStats() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/customers?limit=10000');
|
||||
if (!response.ok) throw new Error('Failed to load customers');
|
||||
const customers = await response.json();
|
||||
|
||||
const stats = {
|
||||
total: customers.length,
|
||||
withVtiger: customers.filter(c => c.vtiger_id).length,
|
||||
withEconomic: customers.filter(c => c.economic_customer_number).length
|
||||
};
|
||||
|
||||
document.getElementById('syncStatsCustomers').textContent = stats.total;
|
||||
document.getElementById('syncStatsVtiger').textContent = stats.withVtiger;
|
||||
document.getElementById('syncStatsEconomic').textContent = stats.withEconomic;
|
||||
} catch (error) {
|
||||
console.error('Error loading sync stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSyncLog() {
|
||||
const container = document.getElementById('syncLogContainer');
|
||||
|
||||
if (syncLog.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2 mb-0">Ingen synkroniseringer endnu</p>
|
||||
<small>Klik på en af sync knapperne ovenfor for at starte</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = syncLog.map(log => `
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<i class="bi ${log.status === 'success' ? 'bi-check-circle text-success' : log.status === 'error' ? 'bi-x-circle text-danger' : 'bi-info-circle text-primary'} me-2"></i>
|
||||
<strong>${log.title}</strong>
|
||||
</div>
|
||||
<small class="text-muted">${log.message}</small>
|
||||
</div>
|
||||
<small class="text-muted">${new Date(log.timestamp).toLocaleString('da-DK')}</small>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function addSyncLogEntry(title, message, status = 'info') {
|
||||
syncLog.unshift({
|
||||
title,
|
||||
message,
|
||||
status,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Keep last 50 entries
|
||||
if (syncLog.length > 50) {
|
||||
syncLog = syncLog.slice(0, 50);
|
||||
}
|
||||
|
||||
loadSyncLog();
|
||||
}
|
||||
|
||||
async function syncFromVtiger() {
|
||||
const btn = document.getElementById('btnSyncVtiger');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('vTiger Sync Startet', 'Henter firmaer fra vTiger...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/system/sync/vtiger', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Sync fejlede');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
addSyncLogEntry(
|
||||
'vTiger Sync Fuldført',
|
||||
`${result.created || 0} nye firmaer, ${result.updated || 0} opdateret`,
|
||||
'success'
|
||||
);
|
||||
|
||||
document.getElementById('lastSyncVtiger').textContent = new Date().toLocaleString('da-DK');
|
||||
await loadSyncStats();
|
||||
showNotification('vTiger sync fuldført!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
addSyncLogEntry('vTiger Sync Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-download me-2"></i>Sync Firmaer fra vTiger';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncVtigerContacts() {
|
||||
const btn = document.getElementById('btnSyncVtigerContacts');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('vTiger Kontakt Sync Startet', 'Henter kontakter fra vTiger...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/system/sync/vtiger-contacts', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Sync fejlede');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
addSyncLogEntry(
|
||||
'vTiger Kontakt Sync Fuldført',
|
||||
`${result.created || 0} nye kontakter, ${result.updated || 0} opdateret`,
|
||||
'success'
|
||||
);
|
||||
|
||||
showNotification('vTiger kontakt sync fuldført!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
addSyncLogEntry('vTiger Kontakt Sync Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-people me-2"></i>Sync Kontakter fra vTiger';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFromEconomic() {
|
||||
const btn = document.getElementById('btnSyncEconomic');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('e-conomic Sync Startet', 'Henter kundenumre fra e-conomic...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/system/sync/economic', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Sync fejlede');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
addSyncLogEntry(
|
||||
'e-conomic Sync Fuldført',
|
||||
`${result.matched || 0} firmaer matchet med e-conomic kundenumre`,
|
||||
'success'
|
||||
);
|
||||
|
||||
document.getElementById('lastSyncEconomic').textContent = new Date().toLocaleString('da-DK');
|
||||
await loadSyncStats();
|
||||
showNotification('e-conomic sync fuldført!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
addSyncLogEntry('e-conomic Sync Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCvrToEconomic() {
|
||||
const btn = document.getElementById('btnSyncCvrEconomic');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('CVR Søgning Startet', 'Tjekker CVR numre i e-conomic...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/system/sync/cvr-to-economic', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Søgning fejlede');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
addSyncLogEntry(
|
||||
'CVR Søgning Fuldført',
|
||||
`Fundet ${result.found || 0} firmaer med CVR match i e-conomic`,
|
||||
'success'
|
||||
);
|
||||
|
||||
await loadSyncStats();
|
||||
showNotification('CVR søgning fuldført!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
addSyncLogEntry('CVR Søgning Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-search me-2"></i>Find Manglende CVR i e-conomic';
|
||||
}
|
||||
}
|
||||
|
||||
// Load sync data when sync tab is activated
|
||||
const syncNavLink = document.querySelector('a[data-tab="sync"]');
|
||||
if (syncNavLink) {
|
||||
syncNavLink.addEventListener('click', () => {
|
||||
loadSyncStats();
|
||||
loadSyncLog();
|
||||
});
|
||||
}
|
||||
|
||||
// Load on page ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSettings();
|
||||
|
||||
365
app/system/backend/sync_router.py
Normal file
365
app/system/backend/sync_router.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""
|
||||
System Sync Router
|
||||
API endpoints for syncing data between vTiger, e-conomic and Hub
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
from app.core.database import execute_query
|
||||
from app.services.vtiger_service import get_vtiger_service
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Normalize company name for matching"""
|
||||
if not name:
|
||||
return ""
|
||||
# Remove common suffixes and punctuation
|
||||
name = re.sub(r'\b(A/S|ApS|I/S|IVS|v/)\b', '', name, flags=re.IGNORECASE)
|
||||
name = re.sub(r'[^\w\s]', '', name) # Remove punctuation
|
||||
return name.lower().strip()
|
||||
|
||||
|
||||
@router.post("/sync/vtiger")
|
||||
async def sync_from_vtiger() -> Dict[str, Any]:
|
||||
"""
|
||||
Sync companies from vTiger Accounts module
|
||||
Matches by CVR number or normalized company name
|
||||
"""
|
||||
try:
|
||||
logger.info("🔄 Starting vTiger accounts sync...")
|
||||
|
||||
vtiger = get_vtiger_service()
|
||||
|
||||
# Query vTiger for all accounts with CVR or name
|
||||
query = """
|
||||
SELECT id, accountname, email1, siccode, cf_accounts_cvr,
|
||||
cf_854, website1, bill_city, bill_code, bill_country
|
||||
FROM Accounts
|
||||
WHERE accountname != ''
|
||||
LIMIT 1000
|
||||
"""
|
||||
|
||||
accounts = await vtiger.query(query)
|
||||
logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger")
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for account in accounts:
|
||||
vtiger_id = account.get('id')
|
||||
name = account.get('accountname', '').strip()
|
||||
cvr = account.get('cf_accounts_cvr') or account.get('siccode')
|
||||
economic_customer_number = account.get('cf_854') # Custom field for e-conomic number
|
||||
|
||||
if not name:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Clean CVR number
|
||||
if cvr:
|
||||
cvr = re.sub(r'\D', '', str(cvr))[:8] # Remove non-digits, max 8 chars
|
||||
if len(cvr) != 8:
|
||||
cvr = None
|
||||
|
||||
# Try to find existing customer by vTiger ID or CVR
|
||||
existing = None
|
||||
if vtiger_id:
|
||||
existing = execute_query(
|
||||
"SELECT id FROM customers WHERE vtiger_id = %s",
|
||||
(vtiger_id,)
|
||||
)
|
||||
|
||||
if not existing and cvr:
|
||||
existing = execute_query(
|
||||
"SELECT id FROM customers WHERE cvr_number = %s",
|
||||
(cvr,)
|
||||
)
|
||||
|
||||
if not existing:
|
||||
# Match by normalized name
|
||||
normalized = normalize_name(name)
|
||||
all_customers = execute_query("SELECT id, name FROM customers")
|
||||
for customer in all_customers:
|
||||
if normalize_name(customer['name']) == normalized:
|
||||
existing = [customer]
|
||||
break
|
||||
|
||||
if existing:
|
||||
# Update existing customer
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if vtiger_id:
|
||||
update_fields.append("vtiger_id = %s")
|
||||
params.append(vtiger_id)
|
||||
|
||||
if cvr:
|
||||
update_fields.append("cvr_number = %s")
|
||||
params.append(cvr)
|
||||
|
||||
if economic_customer_number:
|
||||
update_fields.append("economic_customer_number = %s")
|
||||
params.append(int(economic_customer_number))
|
||||
|
||||
update_fields.append("last_synced_at = NOW()")
|
||||
|
||||
if update_fields:
|
||||
params.append(existing[0]['id'])
|
||||
query = f"UPDATE customers SET {', '.join(update_fields)} WHERE id = %s"
|
||||
execute_query(query, tuple(params))
|
||||
updated_count += 1
|
||||
logger.debug(f"✅ Updated: {name}")
|
||||
else:
|
||||
# Create new customer
|
||||
insert_query = """
|
||||
INSERT INTO customers
|
||||
(name, vtiger_id, cvr_number, economic_customer_number,
|
||||
email_domain, city, postal_code, country, website, last_synced_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
email_domain = account.get('email1', '').split('@')[-1] if '@' in account.get('email1', '') else None
|
||||
city = account.get('bill_city')
|
||||
postal_code = account.get('bill_code')
|
||||
country = account.get('bill_country') or 'DK'
|
||||
website = account.get('website1')
|
||||
|
||||
result = execute_query(insert_query, (
|
||||
name,
|
||||
vtiger_id,
|
||||
cvr,
|
||||
int(economic_customer_number) if economic_customer_number else None,
|
||||
email_domain,
|
||||
city,
|
||||
postal_code,
|
||||
country,
|
||||
website
|
||||
))
|
||||
|
||||
if result:
|
||||
created_count += 1
|
||||
logger.debug(f"✨ Created: {name}")
|
||||
|
||||
logger.info(f"✅ vTiger sync complete: {created_count} created, {updated_count} updated, {skipped_count} skipped")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
"skipped": skipped_count,
|
||||
"total_processed": len(accounts)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ vTiger sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sync/vtiger-contacts")
|
||||
async def sync_vtiger_contacts() -> Dict[str, Any]:
|
||||
"""
|
||||
Sync contacts from vTiger Contacts module
|
||||
Links to existing Hub customers by Account ID
|
||||
"""
|
||||
try:
|
||||
logger.info("🔄 Starting vTiger contacts sync...")
|
||||
|
||||
vtiger = get_vtiger_service()
|
||||
|
||||
# Query vTiger for contacts
|
||||
query = """
|
||||
SELECT id, firstname, lastname, email, phone, mobile, title,
|
||||
department, account_id
|
||||
FROM Contacts
|
||||
WHERE firstname != '' OR lastname != ''
|
||||
LIMIT 1000
|
||||
"""
|
||||
|
||||
contacts = await vtiger.query(query)
|
||||
logger.info(f"📥 Fetched {len(contacts)} contacts from vTiger")
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for contact in contacts:
|
||||
vtiger_contact_id = contact.get('id')
|
||||
first_name = contact.get('firstname', '').strip()
|
||||
last_name = contact.get('lastname', '').strip()
|
||||
|
||||
if not (first_name or last_name):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Find existing contact by vTiger ID
|
||||
existing = None
|
||||
if vtiger_contact_id:
|
||||
existing = execute_query(
|
||||
"SELECT id FROM contacts WHERE vtiger_id = %s",
|
||||
(vtiger_contact_id,)
|
||||
)
|
||||
|
||||
contact_data = {
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'email': contact.get('email'),
|
||||
'phone': contact.get('phone'),
|
||||
'mobile': contact.get('mobile'),
|
||||
'title': contact.get('title'),
|
||||
'department': contact.get('department'),
|
||||
'vtiger_id': vtiger_contact_id
|
||||
}
|
||||
|
||||
if existing:
|
||||
# Update existing contact
|
||||
update_query = """
|
||||
UPDATE contacts
|
||||
SET first_name = %s, last_name = %s, email = %s, phone = %s,
|
||||
mobile = %s, title = %s, department = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_query(update_query, (
|
||||
contact_data['first_name'],
|
||||
contact_data['last_name'],
|
||||
contact_data['email'],
|
||||
contact_data['phone'],
|
||||
contact_data['mobile'],
|
||||
contact_data['title'],
|
||||
contact_data['department'],
|
||||
existing[0]['id']
|
||||
))
|
||||
updated_count += 1
|
||||
contact_id = existing[0]['id']
|
||||
else:
|
||||
# Create new contact
|
||||
insert_query = """
|
||||
INSERT INTO contacts
|
||||
(first_name, last_name, email, phone, mobile, title, department, vtiger_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
"""
|
||||
result = execute_query(insert_query, (
|
||||
contact_data['first_name'],
|
||||
contact_data['last_name'],
|
||||
contact_data['email'],
|
||||
contact_data['phone'],
|
||||
contact_data['mobile'],
|
||||
contact_data['title'],
|
||||
contact_data['department'],
|
||||
contact_data['vtiger_id']
|
||||
))
|
||||
|
||||
if result:
|
||||
created_count += 1
|
||||
contact_id = result[0]['id']
|
||||
else:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Link contact to customer if account_id exists
|
||||
account_id = contact.get('account_id')
|
||||
if account_id and contact_id:
|
||||
# Find customer by vTiger account ID
|
||||
customer = execute_query(
|
||||
"SELECT id FROM customers WHERE vtiger_id = %s",
|
||||
(account_id,)
|
||||
)
|
||||
|
||||
if customer:
|
||||
# Check if relationship exists
|
||||
existing_rel = execute_query(
|
||||
"SELECT id FROM contact_companies WHERE contact_id = %s AND customer_id = %s",
|
||||
(contact_id, customer[0]['id'])
|
||||
)
|
||||
|
||||
if not existing_rel:
|
||||
# Create relationship
|
||||
execute_query(
|
||||
"INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES (%s, %s, false)",
|
||||
(contact_id, customer[0]['id'])
|
||||
)
|
||||
|
||||
logger.info(f"✅ vTiger contacts sync complete: {created_count} created, {updated_count} updated, {skipped_count} skipped")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
"skipped": skipped_count,
|
||||
"total_processed": len(contacts)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ vTiger contacts sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sync/economic")
|
||||
async def sync_from_economic() -> Dict[str, Any]:
|
||||
"""
|
||||
Sync customer numbers from e-conomic
|
||||
Matches Hub customers to e-conomic by CVR number or normalized name
|
||||
"""
|
||||
try:
|
||||
logger.info("🔄 Starting e-conomic sync...")
|
||||
|
||||
# Note: This requires adding get_customers() method to economic_service.py
|
||||
# For now, return a placeholder response
|
||||
|
||||
logger.warning("⚠️ e-conomic sync not fully implemented yet")
|
||||
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "e-conomic customer sync requires get_customers() method in economic_service.py",
|
||||
"matched": 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ e-conomic sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/sync/cvr-to-economic")
|
||||
async def sync_cvr_to_economic() -> Dict[str, Any]:
|
||||
"""
|
||||
Find customers in Hub with CVR but without e-conomic customer number
|
||||
Search e-conomic for matching CVR and update Hub
|
||||
"""
|
||||
try:
|
||||
logger.info("🔄 Starting CVR to e-conomic sync...")
|
||||
|
||||
# Find customers with CVR but no economic_customer_number
|
||||
customers = execute_query("""
|
||||
SELECT id, name, cvr_number
|
||||
FROM customers
|
||||
WHERE cvr_number IS NOT NULL
|
||||
AND cvr_number != ''
|
||||
AND economic_customer_number IS NULL
|
||||
LIMIT 100
|
||||
""")
|
||||
|
||||
logger.info(f"📥 Found {len(customers)} customers with CVR but no e-conomic number")
|
||||
|
||||
# Note: This requires e-conomic API search functionality
|
||||
# For now, return placeholder
|
||||
|
||||
logger.warning("⚠️ CVR to e-conomic sync not fully implemented yet")
|
||||
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Requires e-conomic API search by CVR functionality",
|
||||
"found": 0,
|
||||
"candidates": len(customers)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ CVR to e-conomic sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
2
main.py
2
main.py
@ -20,6 +20,7 @@ from app.hardware.backend import router as hardware_api
|
||||
from app.billing.backend import router as billing_api
|
||||
from app.billing.frontend import views as billing_views
|
||||
from app.system.backend import router as system_api
|
||||
from app.system.backend import sync_router
|
||||
from app.dashboard.backend import views as dashboard_views
|
||||
from app.prepaid.backend import router as prepaid_api
|
||||
from app.prepaid.backend import views as prepaid_views
|
||||
@ -98,6 +99,7 @@ app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
||||
app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
||||
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
|
||||
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
||||
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user