diff --git a/VERSION b/VERSION
index a4c8060..951d17f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.3.22
+2.3.23
diff --git a/app/contacts/backend/router.py b/app/contacts/backend/router.py
index 14909a9..93527c8 100644
--- a/app/contacts/backend/router.py
+++ b/app/contacts/backend/router.py
@@ -5,7 +5,7 @@ Handles contact CRUD operations with multi-company support
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
-from app.core.database import execute_query, execute_insert, execute_update
+from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
@@ -119,36 +119,55 @@ async def get_contacts(
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
- # Count total
+ # Count total matching contacts before pagination.
count_query = f"""
- SELECT COUNT(DISTINCT c.id)
+ SELECT COUNT(DISTINCT c.id) AS count
FROM contacts c
{where_sql}
"""
count_result = execute_query_single(count_query, tuple(params))
- total = count_result['count'] if count_result else 0
-
- # Get contacts with company count
- query = f"""
- SELECT
+ total = int((count_result or {}).get('count') or 0)
+
+ # Fetch the page of contacts first, then enrich with aggregated company data.
+ # This avoids grouped-pagination mismatches across PostgreSQL plans/versions.
+ page_query = f"""
+ SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
- c.created_at, c.updated_at,
- COUNT(DISTINCT cc.customer_id) as company_count,
- ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
+ c.created_at, c.updated_at
FROM contacts c
- LEFT JOIN contact_companies cc ON c.id = cc.contact_id
- LEFT JOIN customers cu ON cc.customer_id = cu.id
{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.vtiger_id, c.created_at, c.updated_at
- ORDER BY c.last_name, c.first_name
+ ORDER BY c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
- params.extend([limit, offset])
-
- contacts = execute_query(query, tuple(params)) # Returns all rows
-
+ page_params = list(params)
+ page_params.extend([limit, offset])
+ contacts = execute_query(page_query, tuple(page_params)) or []
+
+ if contacts:
+ contact_ids = [row["id"] for row in contacts if row.get("id") is not None]
+ placeholders = ",".join(["%s"] * len(contact_ids))
+ company_rows = execute_query(
+ f"""
+ SELECT
+ cc.contact_id,
+ COUNT(DISTINCT cc.customer_id) AS company_count,
+ ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name)
+ FILTER (WHERE cu.name IS NOT NULL) AS company_names
+ FROM contact_companies cc
+ LEFT JOIN customers cu ON cc.customer_id = cu.id
+ WHERE cc.contact_id IN ({placeholders})
+ GROUP BY cc.contact_id
+ """,
+ tuple(contact_ids),
+ ) or []
+ company_map = {row["contact_id"]: row for row in company_rows}
+
+ for contact in contacts:
+ company_info = company_map.get(contact["id"]) or {}
+ contact["company_count"] = int(company_info.get("company_count") or 0)
+ contact["company_names"] = company_info.get("company_names") or []
+
return {
"contacts": contacts or [],
"total": total,
diff --git a/app/core/config.py b/app/core/config.py
index cd5c27b..d718306 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -199,6 +199,10 @@ class Settings(BaseSettings):
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
+ ARCHIVED_VTIGER_SYNC_ENABLED: bool = True
+ ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
+ ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
+ ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
# Backup System Configuration
BACKUP_ENABLED: bool = True
diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py
index 67f2018..7435435 100644
--- a/app/customers/backend/router.py
+++ b/app/customers/backend/router.py
@@ -59,6 +59,42 @@ def _ensure_customer_supplier_tag(customer_id: int) -> None:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
+def _ensure_customer_tag(customer_id: int, tag_name: str, description: str = "") -> None:
+ try:
+ tag = execute_query_single(
+ "SELECT id FROM tags WHERE LOWER(name) = LOWER(%s) AND type = 'category' LIMIT 1",
+ (tag_name,),
+ )
+ if tag and tag.get("id") is not None:
+ tag_id = int(tag["id"])
+ else:
+ created = execute_query_single(
+ """
+ INSERT INTO tags (name, type, description, color, is_active)
+ VALUES (%s, %s, %s, %s, %s)
+ ON CONFLICT (name, type)
+ DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
+ RETURNING id
+ """,
+ (tag_name, "category", description or tag_name, "#198754", True),
+ )
+ tag_id = int(created["id"]) if created and created.get("id") is not None else None
+
+ if not tag_id:
+ return
+
+ execute_query(
+ """
+ INSERT INTO entity_tags (entity_type, entity_id, tag_id)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
+ """,
+ ("customer", customer_id, tag_id),
+ )
+ except Exception as tag_error:
+ logger.warning("⚠️ Could not ensure tag %s for customer %s: %s", tag_name, customer_id, tag_error)
+
+
# Pydantic Models
class CustomerBase(BaseModel):
name: str
@@ -111,6 +147,11 @@ class ContactCreate(BaseModel):
role: Optional[str] = None
+class ContactSyncRequest(BaseModel):
+ selected_match_keys: List[str]
+ add_sync_ok_tag: Optional[bool] = False
+
+
@router.get("/customers")
async def list_customers(
limit: int = Query(default=50, ge=1, le=1000),
@@ -747,12 +788,21 @@ async def check_customer_data_consistency(customer_id: int):
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
+ contact_discrepancies = consistency_service.compare_contacts(all_data)
+ actionable_contact_discrepancies = [
+ item for item in contact_discrepancies
+ if item.get("action") != "matched"
+ ]
return {
"enabled": True,
"customer_id": customer_id,
- "discrepancy_count": discrepancy_count,
+ "discrepancy_count": discrepancy_count + len(actionable_contact_discrepancies),
+ "field_discrepancy_count": discrepancy_count,
+ "contact_discrepancy_count": len(actionable_contact_discrepancies),
+ "contact_total_count": len(contact_discrepancies),
"discrepancies": discrepancies,
+ "contact_discrepancies": contact_discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
@@ -813,6 +863,70 @@ async def sync_customer_field(
raise HTTPException(status_code=500, detail=str(e))
+@router.post("/customers/{customer_id}/sync-contacts")
+async def sync_customer_contacts(customer_id: int, request: ContactSyncRequest):
+ """Sync selected vTiger contacts into Hub and optionally tag the customer."""
+ try:
+ consistency_service = CustomerConsistencyService()
+ stats = await consistency_service.sync_vtiger_contacts_to_hub(
+ customer_id=customer_id,
+ selected_match_keys=request.selected_match_keys or [],
+ )
+
+ if request.add_sync_ok_tag and stats.get("selected", 0) > 0 and stats.get("skipped", 0) == 0:
+ _ensure_customer_tag(customer_id, "Sync OK", "Kundedata og kontakter er manuelt verificeret")
+
+ return {
+ "success": True,
+ "customer_id": customer_id,
+ "stats": stats,
+ "sync_ok_tag_added": bool(request.add_sync_ok_tag and stats.get("selected", 0) > 0 and stats.get("skipped", 0) == 0),
+ }
+ except Exception as e:
+ logger.error(f"❌ Failed to sync contacts for customer {customer_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/customers/{customer_id}/mark-sync-ok")
+async def mark_customer_sync_ok(customer_id: int):
+ """Ensure the customer has the Sync OK tag."""
+ try:
+ _ensure_customer_tag(customer_id, "Sync OK", "Kundedata og kontakter er manuelt verificeret")
+ return {"success": True, "customer_id": customer_id, "tag": "Sync OK"}
+ except Exception as e:
+ logger.error(f"❌ Failed to add Sync OK tag for customer {customer_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/customers/{customer_id}/data-consistency-debug")
+async def debug_customer_data_consistency(customer_id: int):
+ """Debug payload for customer consistency, including contact sync source data."""
+ try:
+ consistency_service = CustomerConsistencyService()
+ all_data = await consistency_service.fetch_all_data(customer_id)
+ contact_discrepancies = consistency_service.compare_contacts(all_data)
+
+ return {
+ "customer_id": customer_id,
+ "hub_customer": {
+ "id": all_data.get("hub", {}).get("id"),
+ "name": all_data.get("hub", {}).get("name"),
+ "vtiger_id": all_data.get("hub", {}).get("vtiger_id"),
+ },
+ "counts": {
+ "hub_contacts": len(all_data.get("hub_contacts") or []),
+ "vtiger_contacts": len(all_data.get("vtiger_contacts") or []),
+ "contact_rows": len(contact_discrepancies),
+ },
+ "hub_contacts": all_data.get("hub_contacts") or [],
+ "vtiger_contacts": all_data.get("vtiger_contacts") or [],
+ "contact_discrepancies": contact_discrepancies,
+ }
+ except Exception as e:
+ logger.error(f"❌ Failed to debug consistency for customer {customer_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.post("/customers/sync-economic-from-simplycrm")
async def sync_economic_numbers_from_simplycrm():
"""
@@ -1850,26 +1964,108 @@ async def get_customer_acmp_overview(
products = execute_query(
"""
+ WITH base AS (
+ SELECT
+ COALESCE(material_number, 'N/A') AS material_number,
+ COALESCE(vendor, 'N/A') AS vendor,
+ COALESCE(product_name, 'Ukendt produkt') AS product_name,
+ COALESCE(billable_parameters, 1) AS qty,
+ COALESCE(total_price, sales_price, 0) AS revenue,
+ COALESCE(cost_amount, 0) AS cost,
+ billing_start,
+ created_at
+ FROM also_import_lines
+ WHERE matched_customer_id = %s
+ )
SELECT
- COALESCE(material_number, 'N/A') AS material_number,
- COALESCE(vendor, 'N/A') AS vendor,
- COALESCE(product_name, 'Ukendt produkt') AS product_name,
+ material_number,
+ vendor,
+ product_name,
COUNT(*)::INTEGER AS line_count,
- COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
- COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
- COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
- COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
+ COALESCE(SUM(qty), 0) AS quantity_total,
+ COALESCE(SUM(revenue), 0) AS revenue_total,
+ COALESCE(SUM(cost), 0) AS cost_total,
+ COALESCE(SUM(revenue - cost), 0) AS margin_total,
+ COALESCE(SUM(qty) FILTER (
+ WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
+ ), 0) AS current_month_qty,
+ COALESCE(SUM(qty) FILTER (
+ WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
+ ), 0) AS previous_month_qty,
MAX(billing_start) AS last_billing_start,
MAX(created_at) AS last_seen_at
- FROM also_import_lines
- WHERE matched_customer_id = %s
- GROUP BY COALESCE(material_number, 'N/A'), COALESCE(vendor, 'N/A'), COALESCE(product_name, 'Ukendt produkt')
+ FROM base
+ GROUP BY material_number, vendor, product_name
ORDER BY revenue_total DESC, quantity_total DESC
LIMIT 200
""",
(customer_id,),
) or []
+ changes = execute_query(
+ """
+ WITH current_month AS (
+ SELECT
+ COALESCE(material_number, 'N/A') AS material_number,
+ COALESCE(vendor, 'N/A') AS vendor,
+ COALESCE(product_name, 'Ukendt produkt') AS product_name,
+ SUM(COALESCE(billable_parameters, 1)) AS qty
+ FROM also_import_lines
+ WHERE matched_customer_id = %s
+ AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
+ GROUP BY 1, 2, 3
+ ),
+ previous_month AS (
+ SELECT
+ COALESCE(material_number, 'N/A') AS material_number,
+ COALESCE(vendor, 'N/A') AS vendor,
+ COALESCE(product_name, 'Ukendt produkt') AS product_name,
+ SUM(COALESCE(billable_parameters, 1)) AS qty
+ FROM also_import_lines
+ WHERE matched_customer_id = %s
+ AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
+ GROUP BY 1, 2, 3
+ )
+ SELECT
+ COALESCE(c.material_number, p.material_number) AS material_number,
+ COALESCE(c.vendor, p.vendor) AS vendor,
+ COALESCE(c.product_name, p.product_name) AS product_name,
+ COALESCE(p.qty, 0) AS previous_month_qty,
+ COALESCE(c.qty, 0) AS current_month_qty,
+ COALESCE(c.qty, 0) - COALESCE(p.qty, 0) AS quantity_delta
+ FROM current_month c
+ FULL OUTER JOIN previous_month p
+ ON c.material_number = p.material_number
+ AND c.vendor = p.vendor
+ AND c.product_name = p.product_name
+ WHERE COALESCE(c.qty, 0) <> COALESCE(p.qty, 0)
+ ORDER BY ABS(COALESCE(c.qty, 0) - COALESCE(p.qty, 0)) DESC, COALESCE(c.qty, 0) DESC
+ LIMIT 100
+ """,
+ (customer_id, customer_id),
+ ) or []
+
+ recent_lines = execute_query(
+ """
+ SELECT
+ COALESCE(product_name, 'Ukendt produkt') AS product_name,
+ COALESCE(material_number, 'N/A') AS material_number,
+ COALESCE(vendor, 'N/A') AS vendor,
+ COALESCE(billable_parameters, 1) AS quantity,
+ COALESCE(total_price, sales_price, 0) AS revenue,
+ COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0) AS margin,
+ queue_status,
+ order_draft_id,
+ billing_start,
+ created_at
+ FROM also_import_lines
+ WHERE matched_customer_id = %s
+ ORDER BY COALESCE(billing_start::timestamp, created_at) DESC, id DESC
+ LIMIT 50
+ """,
+ (customer_id,),
+ ) or []
+
monthly = execute_query(
"""
SELECT
@@ -1907,6 +2103,8 @@ async def get_customer_acmp_overview(
"status_breakdown": status_breakdown,
"products": products,
"monthly": monthly,
+ "changes": changes,
+ "recent_lines": recent_lines,
}
return response
@@ -1915,4 +2113,3 @@ async def get_customer_acmp_overview(
except Exception as e:
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
raise HTTPException(status_code=500, detail=str(e))
-
diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index b8c5182..a740816 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -345,6 +345,80 @@
font-weight: 700;
}
+ .consistency-contact-comparison {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+ }
+
+ .consistency-contact-box {
+ border: 1px solid rgba(15, 76, 117, 0.14);
+ border-radius: 10px;
+ padding: 0.75rem;
+ background: rgba(15, 76, 117, 0.03);
+ }
+
+ .consistency-contact-box--new {
+ border-color: rgba(25, 135, 84, 0.24);
+ background: rgba(25, 135, 84, 0.05);
+ }
+
+ .consistency-contact-box-title {
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ margin-bottom: 0.45rem;
+ }
+
+ .consistency-contact-line {
+ font-size: 0.9rem;
+ margin-bottom: 0.3rem;
+ }
+
+ .consistency-contact-line:last-child {
+ margin-bottom: 0;
+ }
+
+ .consistency-contact-line-label {
+ color: var(--text-secondary);
+ font-weight: 600;
+ margin-right: 0.35rem;
+ }
+
+ .consistency-diff-list {
+ margin-top: 0.6rem;
+ padding-top: 0.6rem;
+ border-top: 1px dashed rgba(15, 76, 117, 0.14);
+ }
+
+ .consistency-diff-row {
+ display: grid;
+ grid-template-columns: 140px 1fr 1fr;
+ gap: 0.6rem;
+ font-size: 0.86rem;
+ margin-bottom: 0.35rem;
+ }
+
+ .consistency-diff-row:last-child {
+ margin-bottom: 0;
+ }
+
+ .consistency-diff-field {
+ font-weight: 600;
+ color: var(--text-secondary);
+ }
+
+ .consistency-diff-old {
+ color: #8b1e3f;
+ }
+
+ .consistency-diff-new {
+ color: #146c43;
+ }
+
@media (max-width: 992px) {
.contacts-toolbar {
padding: 0.7rem;
@@ -359,6 +433,11 @@
#contactsContainer {
min-width: 920px;
}
+
+ .consistency-contact-comparison,
+ .consistency-diff-row {
+ grid-template-columns: 1fr;
+ }
}
{% endblock %}
@@ -1029,6 +1108,9 @@
Produkt
Materiale
Vendor
+ Nu
+ Sidste md.
+ Ændring
Antal
Omsætning
DB
@@ -1038,6 +1120,52 @@
+
+
+
+
+
+
Ændringer siden sidste måned
+ Pr. produkt
+
+
+
+
+
+ Produkt
+ Sidste md.
+ Nu
+ Delta
+
+
+
+
+
+
+
+
+
+
+
Seneste ACMP-linjer
+ Seneste 50 linjer
+
+
+
+
+
+ Dato
+ Produkt
+ Antal
+ Oms.
+ Status
+
+
+
+
+
+
+
+
@@ -2934,6 +3062,20 @@ function getContactMobileValue(contact) {
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
}
+function renderContactActionButtons(number, displayName, contactId) {
+ const safeNumber = escapeHtml(number);
+ const safeDisplayName = escapeHtml(displayName || '');
+ const safeContactId = contactId || 'null';
+ return `
+
+
+ VOIP
+
+ SMS
+
+ `;
+}
+
function buildContactsRows(contacts) {
return contacts.map(contact => {
const displayName = getContactDisplayName(contact);
@@ -2947,10 +3089,10 @@ function buildContactsRows(contacts) {
const email = contact.email ? `${escapeHtml(contact.email)} ` : '—';
const phone = phoneValue
- ? ``
+ ? ``
: '—';
const mobile = mobileValue
- ? ``
+ ? ``
: '—';
const title = titleValue ? escapeHtml(titleValue) : '—';
const primaryBadge = contact.is_primary ? 'Primær ' : '—';
@@ -4570,6 +4712,96 @@ async function saveCustomerEdit() {
// Data Consistency Functions
let consistencyData = null;
+function escapeAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function renderConsistencyValue(value) {
+ if (value === null || value === undefined || String(value).trim() === '') {
+ return 'Tom ';
+ }
+ return escapeHtml(String(value));
+}
+
+function getContactFieldLabel(field) {
+ const labels = {
+ first_name: 'Fornavn',
+ last_name: 'Efternavn',
+ email: 'Email',
+ phone: 'Telefon',
+ mobile: 'Mobil',
+ title: 'Titel',
+ department: 'Afdeling'
+ };
+ return labels[field] || field;
+}
+
+function buildContactComparisonLines(contact) {
+ return [
+ ['Navn', contact?.display_name],
+ ['Email', contact?.email],
+ ['Telefon', contact?.phone],
+ ['Mobil', contact?.mobile],
+ ['Titel', contact?.title],
+ ['Afdeling', contact?.department]
+ ].map(([label, value]) => `
+
+ ${escapeHtml(label)}:
+ ${renderConsistencyValue(value)}
+
+ `).join('');
+}
+
+function buildChangedFieldsDetails(hub, vtiger, changedFields) {
+ if (!Array.isArray(changedFields) || !changedFields.length) {
+ return '';
+ }
+
+ return `
+
+ ${changedFields.map((field) => `
+
+
${escapeHtml(getContactFieldLabel(field))}
+
Hub nu: ${renderConsistencyValue(hub?.[field])}
+
Ny fra vTiger: ${renderConsistencyValue(vtiger?.[field])}
+
+ `).join('')}
+
+ `;
+}
+
+function buildFieldDifferenceSummary(fieldData) {
+ const comparisons = [];
+ const hubValue = String(fieldData?.hub ?? '').trim();
+ const vtigerValue = String(fieldData?.vtiger ?? '').trim();
+ const economicValue = String(fieldData?.economic ?? '').trim();
+
+ if (fieldData && fieldData.vtiger !== undefined && hubValue !== vtigerValue) {
+ comparisons.push('Hub/vTiger');
+ }
+ if (fieldData && fieldData.economic !== undefined && hubValue !== economicValue) {
+ comparisons.push('Hub/e-conomic');
+ }
+ if (
+ fieldData &&
+ fieldData.vtiger !== undefined &&
+ fieldData.economic !== undefined &&
+ vtigerValue !== economicValue
+ ) {
+ comparisons.push('vTiger/e-conomic');
+ }
+
+ if (!comparisons.length) {
+ return 'Ingen ';
+ }
+
+ return comparisons.map(item => `${escapeHtml(item)} `).join('');
+}
+
async function checkDataConsistency() {
try {
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
@@ -4602,7 +4834,16 @@ function showConsistencyModal() {
}
const tbody = document.getElementById('consistencyTableBody');
+ const contactsContainer = document.getElementById('consistencyContactsContainer');
+ const contactsSection = document.getElementById('consistencyContactsSection');
+ const syncOkCheckbox = document.getElementById('consistencyAddSyncOkTag');
tbody.innerHTML = '';
+ if (contactsContainer) {
+ contactsContainer.innerHTML = '';
+ }
+ if (syncOkCheckbox) {
+ syncOkCheckbox.checked = false;
+ }
// Field labels in Danish
const fieldLabels = {
@@ -4628,7 +4869,10 @@ function showConsistencyModal() {
// Field name
const fieldCell = document.createElement('td');
- fieldCell.innerHTML = `${fieldLabels[fieldName] || fieldName} `;
+ fieldCell.innerHTML = `
+ ${fieldLabels[fieldName] || fieldName}
+ Forskellig værdi fundet på dette felt
+ `;
row.appendChild(fieldCell);
// Hub value
@@ -4636,9 +4880,9 @@ function showConsistencyModal() {
hubCell.innerHTML = `
+ id="hub_${fieldName}" value="hub" data-value="${escapeAttribute(fieldData.hub || '')}">
- ${fieldData.hub || 'Tom '}
+ ${fieldData.hub ? escapeHtml(fieldData.hub) : 'Tom '}
`;
@@ -4650,9 +4894,9 @@ function showConsistencyModal() {
vtigerCell.innerHTML = `
+ id="vtiger_${fieldName}" value="vtiger" data-value="${escapeAttribute(fieldData.vtiger || '')}">
- ${fieldData.vtiger || 'Tom '}
+ ${fieldData.vtiger ? escapeHtml(fieldData.vtiger) : 'Tom '}
`;
@@ -4667,9 +4911,9 @@ function showConsistencyModal() {
economicCell.innerHTML = `
+ id="economic_${fieldName}" value="economic" data-value="${escapeAttribute(fieldData.economic || '')}">
- ${fieldData.economic || 'Tom '}
+ ${fieldData.economic ? escapeHtml(fieldData.economic) : 'Tom '}
`;
@@ -4680,11 +4924,69 @@ function showConsistencyModal() {
// Action cell (which system to use)
const actionCell = document.createElement('td');
- actionCell.innerHTML = '← Vælg ';
+ actionCell.innerHTML = `
+ Forskelle: ${buildFieldDifferenceSummary(fieldData)}
+ Vælg hvilken værdi der skal bruges som ny korrekt værdi
+ `;
row.appendChild(actionCell);
tbody.appendChild(row);
}
+
+ const contactDiscrepancies = Array.isArray(consistencyData.contact_discrepancies)
+ ? consistencyData.contact_discrepancies
+ : [];
+
+ if (contactsSection) {
+ contactsSection.classList.toggle('d-none', contactDiscrepancies.length === 0);
+ }
+
+ if (contactsContainer && contactDiscrepancies.length > 0) {
+ contactsContainer.innerHTML = contactDiscrepancies.map((item, index) => {
+ const vtiger = item.vtiger || {};
+ const hub = item.hub || null;
+ const selectable = item.selectable !== false;
+ const checkedAttr = selectable ? '' : 'disabled';
+ const statusClass = item.action === 'matched' ? 'bg-success-subtle text-success border-success-subtle' : 'bg-light text-dark border';
+ const changedFields = buildChangedFieldsDetails(hub, vtiger, item.changed_fields || []);
+ const hubSummary = hub
+ ? `
+
+ `
+ : `
+
+ `;
+ const vtigerSummary = `
+
+ `;
+
+ return `
+
+
+
+ ${escapeHtml(vtiger.display_name || 'Ukendt kontakt')}
+ ${escapeHtml(item.action || 'sync')}
+
+ ${escapeHtml(item.reason || '')}
+
+ ${hubSummary}
+ ${vtigerSummary}
+
+ ${changedFields}
+
+ `;
+ }).join('');
+ }
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
modal.show();
@@ -4692,12 +4994,15 @@ function showConsistencyModal() {
async function syncSelectedFields() {
const selections = [];
+ const selectedContactKeys = [];
// Gather all selected values
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
+ const contactCheckboxes = document.querySelectorAll('.consistency-contact-check:checked');
+ const addSyncOkTag = Boolean(document.getElementById('consistencyAddSyncOkTag')?.checked);
- if (radioButtons.length === 0) {
- alert('Vælg venligst mindst ét felt at synkronisere');
+ if (radioButtons.length === 0 && contactCheckboxes.length === 0) {
+ alert('Vælg venligst mindst ét felt eller én kontakt at synkronisere');
return;
}
@@ -4712,15 +5017,21 @@ async function syncSelectedFields() {
source_value: sourceValue
});
});
+
+ contactCheckboxes.forEach((checkbox) => {
+ if (checkbox.value) {
+ selectedContactKeys.push(checkbox.value);
+ }
+ });
// Confirm action
- if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
+ if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) og ${selectedContactKeys.length} kontakt(er). Fortsæt?`)) {
return;
}
- // Sync each field
- let successCount = 0;
+ let fieldSuccessCount = 0;
let failCount = 0;
+ let contactSummary = null;
for (const selection of selections) {
try {
@@ -4731,7 +5042,7 @@ async function syncSelectedFields() {
);
if (response.ok) {
- successCount++;
+ fieldSuccessCount++;
} else {
failCount++;
console.error(`Failed to sync ${selection.field_name}`);
@@ -4741,6 +5052,39 @@ async function syncSelectedFields() {
console.error(`Error syncing ${selection.field_name}:`, error);
}
}
+
+ if (selectedContactKeys.length > 0) {
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/sync-contacts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ selected_match_keys: selectedContactKeys,
+ add_sync_ok_tag: false
+ })
+ });
+
+ if (response.ok) {
+ const payload = await response.json();
+ contactSummary = payload.stats || null;
+ } else {
+ failCount++;
+ console.error('Failed to sync selected contacts');
+ }
+ } catch (error) {
+ failCount++;
+ console.error('Error syncing selected contacts:', error);
+ }
+ }
+
+ if (addSyncOkTag && failCount === 0 && (fieldSuccessCount > 0 || (contactSummary && contactSummary.selected > 0))) {
+ try {
+ await addSyncOkTagToCustomer();
+ } catch (error) {
+ failCount++;
+ console.error('Failed to add Sync OK tag:', error);
+ }
+ }
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
@@ -4748,9 +5092,19 @@ async function syncSelectedFields() {
// Show result
if (failCount === 0) {
- alert(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`);
+ const parts = [];
+ if (fieldSuccessCount > 0) {
+ parts.push(`${fieldSuccessCount} felt`);
+ }
+ if (contactSummary && contactSummary.selected > 0) {
+ parts.push(`${contactSummary.selected} kontakt(er)`);
+ }
+ if (addSyncOkTag) {
+ parts.push('tag "Sync OK"');
+ }
+ alert(`✓ Synkroniseret: ${parts.join(', ')}`);
} else {
- alert(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`);
+ alert(`⚠️ ${fieldSuccessCount} felt synkroniseret, ${selectedContactKeys.length} kontakt(er) forsøgt, ${failCount} fejl`);
}
// Reload customer data and recheck consistency
@@ -4758,6 +5112,17 @@ async function syncSelectedFields() {
await checkDataConsistency();
}
+async function addSyncOkTagToCustomer() {
+ const response = await fetch(`/api/v1/customers/${customerId}/mark-sync-ok`, {
+ method: 'POST'
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || 'Kunne ikke tilføje Sync OK tag');
+ }
+}
+
function showAddContactModal() {
// TODO: Open add contact modal
console.log('Add contact for customer:', customerId);
@@ -5220,12 +5585,61 @@ function renderAcmpOverview(payload) {
${escapeHtml(row.product_name || '-')}
${escapeHtml(row.material_number || '-')}
${escapeHtml(row.vendor || '-')}
+ ${Number(row.current_month_qty || 0).toLocaleString('da-DK')}
+ ${Number(row.previous_month_qty || 0).toLocaleString('da-DK')}
+ ${formatSignedNumber(Number(row.current_month_qty || 0) - Number(row.previous_month_qty || 0))}
${Number(row.quantity_total || 0).toLocaleString('da-DK')}
${formatDKK(Number(row.revenue_total || 0))}
${formatDKK(Number(row.margin_total || 0))}
`).join('')
- : 'Ingen produkter fundet ';
+ : 'Ingen produkter fundet ';
+
+ const changesRows = document.getElementById('acmpChangesRows');
+ const changes = payload.changes || [];
+ changesRows.innerHTML = changes.length > 0
+ ? changes.map(row => `
+
+
+ ${escapeHtml(row.product_name || '-')}
+ ${escapeHtml(row.material_number || '-')}
+
+ ${Number(row.previous_month_qty || 0).toLocaleString('da-DK')}
+ ${Number(row.current_month_qty || 0).toLocaleString('da-DK')}
+ ${formatSignedNumber(Number(row.quantity_delta || 0))}
+
+ `).join('')
+ : 'Ingen ændringer fundet ';
+
+ const recentRows = document.getElementById('acmpRecentRows');
+ const recent = payload.recent_lines || [];
+ recentRows.innerHTML = recent.length > 0
+ ? recent.map(row => `
+
+ ${formatShortDate(row.billing_start || row.created_at)}
+
+ ${escapeHtml(row.product_name || '-')}
+ ${escapeHtml(row.material_number || '-')}
+
+ ${Number(row.quantity || 0).toLocaleString('da-DK')}
+ ${formatDKK(Number(row.revenue || 0))}
+ ${escapeHtml(row.queue_status || '-')}
+
+ `).join('')
+ : 'Ingen linjer fundet ';
+}
+
+function formatSignedNumber(value) {
+ const number = Number(value || 0);
+ const prefix = number > 0 ? '+' : '';
+ return `${prefix}${number.toLocaleString('da-DK')}`;
+}
+
+function formatShortDate(value) {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return escapeHtml(String(value));
+ return date.toLocaleDateString('da-DK');
}
function editInternalComment() {
@@ -5470,7 +5884,7 @@ document.addEventListener('DOMContentLoaded', () => {
Vejledning: Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
- Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer.
+ vTiger bruges kun som læsekilde. Synkronisering skriver kun til BMC Hub og evt. e-conomic.
@@ -5489,6 +5903,23 @@ document.addEventListener('DOMContentLoaded', () => {
+
+
+
+
+
+
+ Tilføj tag "Sync OK" hvis alt lykkes
+
+
@@ -157,6 +160,7 @@
Monitor
+
Vælg kunde
@@ -353,7 +357,7 @@ async function loadDriftEvents() {
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
syncBottomDriftCountFromVisibleEvents(visibleEvents);
if (!visibleEvents.length) {
- body.innerHTML = 'Ingen hændelser fundet. ';
+ body.innerHTML = 'Ingen hændelser fundet. ';
return;
}
body.innerHTML = visibleEvents.map(event => `
@@ -369,7 +373,7 @@ async function loadDriftEvents() {
${event.customer || '-'}
- ${event.monitor_key ? `Knyt ` : ''}
+ ${event.monitor_key ? `Knyt ` : ''}
@@ -378,6 +382,8 @@ async function loadDriftEvents() {
${(event.device_link || event.source_link) ? ` ` : ''}
+ ${event.ip || '-'}
+ ${event.site_client_name || event.site || event.customer || '-'}
${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}
${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}
${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}
@@ -395,7 +401,7 @@ async function loadDriftEvents() {
} catch (e) {
console.error(e);
syncBottomDriftCountFromVisibleEvents([]);
- body.innerHTML = 'Kunne ikke hente drift-data. ';
+ body.innerHTML = 'Kunne ikke hente drift-data. ';
}
}
@@ -422,6 +428,11 @@ async function acknowledgeDriftEvent(eventId) {
return;
}
+ const statusFilter = document.getElementById('drift-status-filter');
+ if (statusFilter && (statusFilter.value === '' || statusFilter.value === 'active')) {
+ statusFilter.value = 'acknowledged';
+ }
+
await loadDriftSummary();
await loadDriftEvents();
}
@@ -451,6 +462,7 @@ async function blacklistDriftEvent(eventId, deviceName = '') {
async function mapDriftCustomer(monitorKey, monitorName) {
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
+ const source = document.getElementById('driftMapSource')?.value || 'uptime-kuma';
if (!Number.isInteger(customerId) || customerId <= 0) {
alert('Vælg en kunde');
return false;
@@ -460,7 +472,7 @@ async function mapDriftCustomer(monitorKey, monitorName) {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId })
+ body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -473,10 +485,11 @@ async function mapDriftCustomer(monitorKey, monitorName) {
return true;
}
-async function openMapCustomerModal(monitorKey, monitorName) {
+async function openMapCustomerModal(monitorKey, monitorName, source = 'uptime-kuma') {
await loadDriftCustomers();
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
document.getElementById('driftMapMonitorName').value = monitorName || '';
+ document.getElementById('driftMapSource').value = source || 'uptime-kuma';
document.getElementById('driftMapModalMonitorText').textContent = `Monitor: ${monitorName || monitorKey}`;
renderCustomerSelectOptions(document.getElementById('driftMapCustomerSelect'));
const modalEl = document.getElementById('driftMapModal');
@@ -555,7 +568,7 @@ async function saveBulkMapping(monitorKey, monitorName, selectId) {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId })
+ body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source: 'uptime-kuma' })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
diff --git a/app/modules/search/backend/router.py b/app/modules/search/backend/router.py
index 9f07392..e898d7e 100644
--- a/app/modules/search/backend/router.py
+++ b/app/modules/search/backend/router.py
@@ -110,3 +110,32 @@ async def search_locations(q: str = Query(..., min_length=2)):
term = f"%{q}%"
results = execute_query(sql, (term, term, term))
return results
+
+
+@router.get("/search/products")
+async def search_products(q: str = Query(..., min_length=2)):
+ """
+ Autocomplete search for products.
+ Returns list of {id, name, sku_internal, supplier_sku, manufacturer}
+ """
+ sql = """
+ SELECT
+ id,
+ name,
+ sku_internal,
+ supplier_sku,
+ manufacturer
+ FROM products
+ WHERE (
+ name ILIKE %s
+ OR COALESCE(sku_internal, '') ILIKE %s
+ OR COALESCE(supplier_sku, '') ILIKE %s
+ OR COALESCE(manufacturer, '') ILIKE %s
+ )
+ AND deleted_at IS NULL
+ ORDER BY name ASC
+ LIMIT 20
+ """
+ term = f"%{q}%"
+ results = execute_query(sql, (term, term, term, term))
+ return results
diff --git a/app/services/customer_consistency.py b/app/services/customer_consistency.py
index b3640f0..16fe712 100644
--- a/app/services/customer_consistency.py
+++ b/app/services/customer_consistency.py
@@ -5,7 +5,7 @@ Compares customer data across BMC Hub, vTiger Cloud, and e-conomic
import logging
import asyncio
from typing import Dict, List, Optional, Tuple, Any
-from app.core.database import execute_query_single, execute_update
+from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
from app.services.vtiger_service import VTigerService
from app.services.economic_service import EconomicService
from app.core.config import settings
@@ -34,6 +34,111 @@ class CustomerConsistencyService:
def __init__(self):
self.vtiger = VTigerService()
self.economic = EconomicService()
+
+ @staticmethod
+ def _clean_contact_value(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ text = str(value).strip()
+ return text or None
+
+ @classmethod
+ def _normalize_contact(cls, source: str, row: Dict[str, Any]) -> Dict[str, Any]:
+ first_name = cls._clean_contact_value(
+ row.get('first_name') if source == 'hub' else row.get('firstname')
+ )
+ last_name = cls._clean_contact_value(
+ row.get('last_name') if source == 'hub' else row.get('lastname')
+ )
+ email = cls._clean_contact_value(row.get('email'))
+ phone = cls._clean_contact_value(row.get('phone'))
+ mobile = cls._clean_contact_value(row.get('mobile'))
+ title = cls._clean_contact_value(row.get('title'))
+ department = cls._clean_contact_value(row.get('department'))
+ vtiger_id = cls._clean_contact_value(
+ row.get('vtiger_id') if source == 'hub' else row.get('id')
+ )
+ display_name = " ".join(part for part in [first_name, last_name] if part).strip()
+ if not display_name:
+ display_name = email or phone or mobile or vtiger_id or 'Ukendt kontakt'
+
+ return {
+ 'id': row.get('id'),
+ 'vtiger_id': vtiger_id,
+ 'first_name': first_name,
+ 'last_name': last_name,
+ 'email': email,
+ 'phone': phone,
+ 'mobile': mobile,
+ 'title': title,
+ 'department': department,
+ 'display_name': display_name,
+ 'is_primary': bool(row.get('is_primary')) if source == 'hub' else False,
+ 'role': cls._clean_contact_value(row.get('role')) if source == 'hub' else None,
+ 'customer_count': row.get('customer_count', 0) or 0,
+ }
+
+ @staticmethod
+ def _contact_match_key(contact: Dict[str, Any]) -> str:
+ vtiger_id = str(contact.get('vtiger_id') or '').strip().lower()
+ if vtiger_id:
+ return f"vtiger:{vtiger_id}"
+
+ email = str(contact.get('email') or '').strip().lower()
+ if email:
+ return f"email:{email}"
+
+ first_name = str(contact.get('first_name') or '').strip().lower()
+ last_name = str(contact.get('last_name') or '').strip().lower()
+ phone = str(contact.get('phone') or '').strip().lower()
+ mobile = str(contact.get('mobile') or '').strip().lower()
+ return f"name:{first_name}|{last_name}|{phone}|{mobile}"
+
+ async def fetch_hub_contacts(self, customer_id: int) -> List[Dict[str, Any]]:
+ query = """
+ SELECT
+ c.*,
+ cc.is_primary,
+ cc.role,
+ (
+ SELECT COUNT(*)
+ FROM contact_companies cc2
+ WHERE cc2.contact_id = c.id
+ ) AS customer_count
+ FROM contacts c
+ JOIN contact_companies cc ON cc.contact_id = c.id
+ WHERE cc.customer_id = %s
+ AND c.is_active = TRUE
+ ORDER BY cc.is_primary DESC, c.first_name, c.last_name, c.id
+ """
+ rows = await asyncio.to_thread(execute_query, query, (customer_id,))
+ return [self._normalize_contact('hub', row) for row in (rows or [])]
+
+ async def fetch_vtiger_contacts(self, vtiger_customer_id: Optional[str]) -> List[Dict[str, Any]]:
+ if not vtiger_customer_id or not settings.VTIGER_URL:
+ return []
+
+ safe_customer_id = self.vtiger._sanitize_vtiger_id(vtiger_customer_id)
+ if not safe_customer_id:
+ return []
+
+ rows: List[Dict[str, Any]] = []
+ seen_ids = set()
+
+ # vTiger installations are inconsistent about which relation field is exposed on Contacts.
+ relation_fields = ("account_id", "accountid", "parent_id", "account")
+ for field_name in relation_fields:
+ query = f"SELECT * FROM Contacts WHERE {field_name}='{safe_customer_id}';"
+ result = await self.vtiger.query(query)
+ for row in (result or []):
+ row_id = str(row.get("id") or "").strip()
+ dedupe_key = row_id or f"{row.get('email')}|{row.get('firstname')}|{row.get('lastname')}"
+ if dedupe_key in seen_ids:
+ continue
+ seen_ids.add(dedupe_key)
+ rows.append(row)
+
+ return [self._normalize_contact('vtiger', row) for row in rows]
@staticmethod
def normalize_value(value: Any) -> Optional[str]:
@@ -57,7 +162,7 @@ class CustomerConsistencyService:
# Lowercase for case-insensitive comparison
return str_value.lower()
- async def fetch_all_data(self, customer_id: int) -> Dict[str, Optional[Dict[str, Any]]]:
+ async def fetch_all_data(self, customer_id: int) -> Dict[str, Any]:
"""
Fetch customer data from all three systems in parallel
@@ -81,10 +186,13 @@ class CustomerConsistencyService:
# Prepare async tasks for vTiger and e-conomic
vtiger_task = None
economic_task = None
+ hub_contacts_task = self.fetch_hub_contacts(customer_id)
+ vtiger_contacts_task = None
# Fetch vTiger data if we have an ID and vTiger is configured
if hub_data.get('vtiger_id') and settings.VTIGER_URL:
vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id'])
+ vtiger_contacts_task = self.fetch_vtiger_contacts(hub_data['vtiger_id'])
# Fetch e-conomic data if we have a customer number and e-conomic is configured
if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN:
@@ -96,6 +204,9 @@ class CustomerConsistencyService:
tasks['vtiger'] = vtiger_task
if economic_task:
tasks['economic'] = economic_task
+ tasks['hub_contacts'] = hub_contacts_task
+ if vtiger_contacts_task:
+ tasks['vtiger_contacts'] = vtiger_contacts_task
results = {}
if tasks:
@@ -115,7 +226,9 @@ class CustomerConsistencyService:
return {
'hub': hub_data,
'vtiger': results.get('vtiger'),
- 'economic': results.get('economic')
+ 'economic': results.get('economic'),
+ 'hub_contacts': results.get('hub_contacts') or [],
+ 'vtiger_contacts': results.get('vtiger_contacts') or [],
}
@classmethod
@@ -173,6 +286,56 @@ class CustomerConsistencyService:
}
return discrepancies
+
+ @classmethod
+ def compare_contacts(cls, all_data: Dict[str, Any]) -> List[Dict[str, Any]]:
+ hub_contacts = all_data.get('hub_contacts') or []
+ vtiger_contacts = all_data.get('vtiger_contacts') or []
+
+ hub_by_key = {cls._contact_match_key(contact): contact for contact in hub_contacts}
+ discrepancies: List[Dict[str, Any]] = []
+
+ for vtiger_contact in vtiger_contacts:
+ match_key = cls._contact_match_key(vtiger_contact)
+ hub_contact = hub_by_key.get(match_key)
+ if not hub_contact:
+ discrepancies.append({
+ 'match_key': match_key,
+ 'action': 'create_or_link',
+ 'reason': 'Kontakten findes i vTiger men ikke på denne kunde i Hub',
+ 'hub': None,
+ 'vtiger': vtiger_contact,
+ 'selectable': True,
+ })
+ continue
+
+ changed_fields = []
+ for field in ('first_name', 'last_name', 'email', 'phone', 'mobile', 'title', 'department'):
+ if cls.normalize_value(hub_contact.get(field)) != cls.normalize_value(vtiger_contact.get(field)):
+ changed_fields.append(field)
+
+ if changed_fields:
+ discrepancies.append({
+ 'match_key': match_key,
+ 'action': 'update_hub',
+ 'reason': 'Kontakt findes begge steder men har feltforskelle',
+ 'hub': hub_contact,
+ 'vtiger': vtiger_contact,
+ 'changed_fields': changed_fields,
+ 'selectable': True,
+ })
+ else:
+ discrepancies.append({
+ 'match_key': match_key,
+ 'action': 'matched',
+ 'reason': 'Kontakt findes allerede i Hub og matcher vTiger',
+ 'hub': hub_contact,
+ 'vtiger': vtiger_contact,
+ 'changed_fields': [],
+ 'selectable': False,
+ })
+
+ return discrepancies
async def sync_field(
self,
@@ -198,7 +361,7 @@ class CustomerConsistencyService:
if field_name not in self.FIELD_MAP:
raise ValueError(f"Unknown field: {field_name}")
- vtiger_field, economic_field = self.FIELD_MAP[field_name]
+ _, economic_field = self.FIELD_MAP[field_name]
# Fetch Hub data to get mapping IDs
hub_query = "SELECT * FROM customers WHERE id = %s"
@@ -222,22 +385,8 @@ class CustomerConsistencyService:
else:
results['hub'] = True # Already correct
- # Update vTiger if enabled and not the source
- if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'):
- try:
- update_data = {vtiger_field: source_value}
- success = await self.vtiger.update_account(hub_data['vtiger_id'], update_data)
- if success:
- results['vtiger'] = True
- logger.info(f"✅ vTiger {vtiger_field} updated")
- else:
- results['vtiger'] = False
- logger.error(f"❌ vTiger update failed - API returned False")
- except Exception as e:
- logger.error(f"❌ Failed to update vTiger: {e}")
- results['vtiger'] = False
- else:
- results['vtiger'] = True # Not applicable or already correct
+ # vTiger is read-only for this workflow.
+ results['vtiger'] = True
# Update e-conomic if enabled and not the source
if settings.ECONOMIC_SYNC_ENABLED and source_system != 'economic' and hub_data.get('economic_customer_number'):
@@ -258,5 +407,130 @@ class CustomerConsistencyService:
results['economic'] = False
else:
results['economic'] = True # Not applicable or already correct
-
+
return results
+
+ async def sync_vtiger_contacts_to_hub(
+ self,
+ customer_id: int,
+ selected_match_keys: List[str],
+ ) -> Dict[str, Any]:
+ if not selected_match_keys:
+ return {"selected": 0, "created": 0, "updated": 0, "linked": 0, "skipped": 0}
+
+ all_data = await self.fetch_all_data(customer_id)
+ discrepancies = self.compare_contacts(all_data)
+ selected = {str(item or '').strip() for item in selected_match_keys if str(item or '').strip()}
+ selected_rows = [row for row in discrepancies if row.get('match_key') in selected]
+
+ stats = {"selected": len(selected_rows), "created": 0, "updated": 0, "linked": 0, "skipped": 0}
+
+ for row in selected_rows:
+ vtiger_contact = row.get('vtiger') or {}
+ hub_contact = row.get('hub')
+ if not vtiger_contact:
+ stats["skipped"] += 1
+ continue
+
+ contact_id = hub_contact.get('id') if hub_contact else None
+ if not contact_id:
+ vtiger_id = vtiger_contact.get("vtiger_id")
+ email = vtiger_contact.get("email")
+ if vtiger_id:
+ existing_global = await asyncio.to_thread(
+ execute_query_single,
+ "SELECT id FROM contacts WHERE vtiger_id = %s LIMIT 1",
+ (vtiger_id,),
+ )
+ contact_id = existing_global.get("id") if existing_global else None
+ if not contact_id and email:
+ existing_global = await asyncio.to_thread(
+ execute_query_single,
+ "SELECT id FROM contacts WHERE LOWER(COALESCE(email, '')) = %s LIMIT 1",
+ (str(email).strip().lower(),),
+ )
+ contact_id = existing_global.get("id") if existing_global else None
+
+ if contact_id:
+ update_fields = {
+ "first_name": vtiger_contact.get("first_name"),
+ "last_name": vtiger_contact.get("last_name"),
+ "email": vtiger_contact.get("email"),
+ "phone": vtiger_contact.get("phone"),
+ "mobile": vtiger_contact.get("mobile"),
+ "title": vtiger_contact.get("title"),
+ "department": vtiger_contact.get("department"),
+ "vtiger_id": vtiger_contact.get("vtiger_id"),
+ }
+ await asyncio.to_thread(
+ execute_update,
+ """
+ UPDATE contacts
+ SET first_name = %s,
+ last_name = %s,
+ email = %s,
+ phone = %s,
+ mobile = %s,
+ title = %s,
+ department = %s,
+ vtiger_id = COALESCE(%s, vtiger_id)
+ WHERE id = %s
+ """,
+ (
+ update_fields["first_name"],
+ update_fields["last_name"],
+ update_fields["email"],
+ update_fields["phone"],
+ update_fields["mobile"],
+ update_fields["title"],
+ update_fields["department"],
+ update_fields["vtiger_id"],
+ contact_id,
+ ),
+ )
+ stats["updated"] += 1
+ else:
+ contact_id = await asyncio.to_thread(
+ execute_insert,
+ """
+ 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
+ """,
+ (
+ vtiger_contact.get("first_name"),
+ vtiger_contact.get("last_name"),
+ vtiger_contact.get("email"),
+ vtiger_contact.get("phone"),
+ vtiger_contact.get("mobile"),
+ vtiger_contact.get("title"),
+ vtiger_contact.get("department"),
+ vtiger_contact.get("vtiger_id"),
+ ),
+ )
+ stats["created"] += 1
+
+ link_exists = await asyncio.to_thread(
+ execute_query_single,
+ """
+ SELECT id
+ FROM contact_companies
+ WHERE contact_id = %s AND customer_id = %s
+ """,
+ (contact_id, customer_id),
+ )
+ if not link_exists:
+ await asyncio.to_thread(
+ execute_update,
+ """
+ INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (contact_id, customer_id) DO NOTHING
+ """,
+ (contact_id, customer_id, False, None),
+ )
+ stats["linked"] += 1
+
+ return stats
diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py
index 1a0fcfb..b115afb 100644
--- a/app/services/email_workflow_service.py
+++ b/app/services/email_workflow_service.py
@@ -19,6 +19,7 @@ from uuid import uuid4
from app.core.database import execute_query, execute_insert, execute_update, table_has_column
from app.core.config import settings
+from app.modules.also.backend.service import also_service
from app.services.email_activity_logger import email_activity_logger
logger = logging.getLogger(__name__)
@@ -1236,7 +1237,7 @@ class EmailWorkflowService:
confidence_threshold, workflow_steps, priority, stop_on_match
FROM email_workflows
WHERE enabled = true
- AND classification_trigger = %s
+ AND LOWER(classification_trigger) IN (%s, 'any')
AND confidence_threshold <= %s
ORDER BY priority ASC
"""
@@ -1412,6 +1413,7 @@ class EmailWorkflowService:
'extract_invoice_data': self._action_extract_invoice_data,
'extract_tracking_number': self._action_extract_tracking_number,
'regex_extract_and_link': self._action_regex_extract_and_link,
+ 'process_also_cloud_billing': self._action_process_also_cloud_billing,
'send_slack_notification': self._action_send_slack_notification,
'send_email_notification': self._action_send_email_notification,
'mark_as_processed': self._action_mark_as_processed,
@@ -1439,6 +1441,52 @@ class EmailWorkflowService:
'status': 'failed',
'error': str(e)
}
+
+ def _extract_also_billing_download_link(self, email_data: Dict) -> Optional[str]:
+ candidates = [
+ email_data.get('body_html') or '',
+ email_data.get('body_text') or '',
+ email_data.get('subject') or '',
+ ]
+ pattern = re.compile(r'https://marketplace\.also\.[^"\s<]+?/download/[^"\s<]+', re.IGNORECASE)
+ for value in candidates:
+ match = pattern.search(str(value))
+ if match:
+ return html.unescape(match.group(0))
+ return None
+
+ async def _action_process_also_cloud_billing(self, params: Dict, email_data: Dict) -> Dict:
+ """Download ALSO Cloud Marketplace billing ZIP, import lines, and create ordre drafts."""
+ download_url = params.get('download_url') or self._extract_also_billing_download_link(email_data)
+ if not download_url:
+ return {
+ 'action': 'process_also_cloud_billing',
+ 'success': False,
+ 'reason': 'download_link_not_found',
+ }
+
+ sender_email = str(email_data.get('sender_email') or '')
+ if params.get('require_sender_match', True):
+ sender_pattern = params.get('sender_pattern') or r'also\.[a-z]{2,}$'
+ if sender_email and not re.search(sender_pattern, sender_email, re.IGNORECASE):
+ return {
+ 'action': 'process_also_cloud_billing',
+ 'success': False,
+ 'reason': 'sender_pattern_no_match',
+ 'sender_email': sender_email,
+ }
+
+ result = await also_service.import_billing_zip_from_url(
+ download_url=download_url,
+ imported_by_user_id=params.get('imported_by_user_id'),
+ email_id=email_data.get('id'),
+ source_label=params.get('source_label') or 'ALSO Cloud Marketplace email',
+ )
+ return {
+ 'action': 'process_also_cloud_billing',
+ 'success': True,
+ **result,
+ }
# Action Handlers
diff --git a/app/services/simplycrm_service.py b/app/services/simplycrm_service.py
index 6733790..5afd8fb 100644
--- a/app/services/simplycrm_service.py
+++ b/app/services/simplycrm_service.py
@@ -202,7 +202,7 @@ class SimplyCRMService:
Returns:
List of ticket records
"""
- module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
+ module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
all_records: List[Dict] = []
offset = 0
batch_size = 200
diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py
index 1d3d36a..9b348f5 100644
--- a/app/settings/backend/router.py
+++ b/app/settings/backend/router.py
@@ -5,6 +5,7 @@ Settings and User Management API Router
from fastapi import APIRouter, HTTPException, Request
from typing import List, Optional, Dict
from pydantic import BaseModel
+from datetime import datetime
from app.core.database import execute_query
from app.core.config import settings
import httpx
@@ -57,8 +58,8 @@ class User(BaseModel):
email: Optional[str]
full_name: Optional[str]
is_active: bool
- last_login: Optional[str]
- created_at: str
+ last_login: Optional[datetime]
+ created_at: datetime
class UserCreate(BaseModel):
@@ -992,4 +993,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
-
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 6bf9281..c9d4fa3 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -1047,7 +1047,7 @@
Beskeder lokalt: -
- Sync vTiger Archived
+ Sync resten fra vTiger
@@ -5145,9 +5145,9 @@ async function syncArchivedVtiger() {
btn.innerHTML = ' Synkroniserer...';
try {
- addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info');
+ addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer nye eller ændrede archived tickets fra vTiger Cases...', 'info');
- const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', {
+ const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false&incremental=true', {
method: 'POST'
});
@@ -5158,6 +5158,8 @@ async function syncArchivedVtiger() {
const result = await response.json();
const details = [
+ `Mode: ${result.mode || 'ukendt'}`,
+ `Hentet remote: ${result.fetched_remote || 0}`,
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
@@ -5173,7 +5175,7 @@ async function syncArchivedVtiger() {
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
- btn.innerHTML = ' Sync vTiger Archived';
+ btn.innerHTML = ' Sync resten fra vTiger';
}
}
@@ -5891,6 +5893,7 @@ const MENU_VISIBILITY_GROUPS = [
{ key: 'menu-salg-products', label: 'Produkter' },
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Time Queue' },
+ { key: 'menu-okonomi-also-cloud', label: 'ALSO Cloud Marketplace' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 6eac3c6..898f339 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -391,10 +391,14 @@
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
+ height: min(52vh, 420px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
margin-top: 0.5rem;
}
+ .global-bottom-bar .bb-sheet-inner > * {
+ min-height: 0;
+ }
.global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
@@ -403,6 +407,8 @@
display: grid;
gap: 0.4rem;
align-content: start;
+ min-height: 0;
+ overflow-y: auto;
}
.global-bottom-bar .bb-tab-btn {
@@ -424,6 +430,23 @@
font-size: 1rem;
opacity: 0.7;
}
+ .global-bottom-bar .bb-tab-btn .bb-tab-badge {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.2rem;
+ height: 1.2rem;
+ border-radius: 999px;
+ padding: 0 0.35rem;
+ margin-left: auto;
+ font-size: 0.7rem;
+ font-weight: 700;
+ background: rgba(220, 53, 69, 0.14);
+ color: #b02a37;
+ }
+ .global-bottom-bar .bb-tab-btn.has-unread .bb-tab-badge {
+ display: inline-flex;
+ }
.global-bottom-bar .bb-tab-btn:hover {
background: rgba(var(--text-primary-rgb), 0.05);
color: var(--text-primary);
@@ -439,20 +462,29 @@
opacity: 1;
color: var(--accent);
}
+ [data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge {
+ background: rgba(255, 138, 148, 0.18);
+ color: #ffb3ba;
+ }
.global-bottom-bar .bb-tab-content {
- padding: 1.2rem;
- overflow: auto;
+ padding: 0.75rem 0.9rem 0.9rem;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
}
.global-bottom-bar .bb-tab-title {
- font-size: 1.1rem;
+ font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
+ line-height: 1.15;
}
.global-bottom-bar .bb-tab-list {
@@ -467,7 +499,7 @@
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
- padding: 0.75rem 1rem;
+ padding: 0.65rem 0.85rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
@@ -478,6 +510,105 @@
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
+ .global-bottom-bar .bb-messages-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ min-height: 0;
+ }
+ .global-bottom-bar .bb-message-threads {
+ display: flex;
+ gap: 0.5rem;
+ overflow-x: auto;
+ padding-bottom: 0.1rem;
+ scrollbar-width: none;
+ }
+ .global-bottom-bar .bb-message-threads::-webkit-scrollbar {
+ display: none;
+ }
+ .global-bottom-bar .bb-message-thread {
+ border: 1px solid rgba(var(--text-primary-rgb), 0.1);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border-radius: 999px;
+ padding: 0.42rem 0.75rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.78rem;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+ .global-bottom-bar .bb-message-thread.is-active {
+ background: var(--accent);
+ color: #fff;
+ border-color: transparent;
+ box-shadow: 0 8px 20px rgba(15, 76, 117, 0.18);
+ }
+ .global-bottom-bar .bb-message-thread-count {
+ min-width: 1.2rem;
+ height: 1.2rem;
+ border-radius: 999px;
+ background: rgba(220, 53, 69, 0.14);
+ color: #b42318;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ padding: 0 0.35rem;
+ }
+ .global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count {
+ background: rgba(255, 255, 255, 0.18);
+ color: #fff;
+ }
+ .global-bottom-bar .bb-messages-list {
+ max-height: min(26vh, 240px);
+ overflow-y: auto;
+ padding-right: 0.2rem;
+ }
+ .global-bottom-bar #bbTabInnerContent {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .global-bottom-bar .bb-notes-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .global-bottom-bar .bb-notes-editor {
+ flex: 0 0 auto;
+ }
+ .global-bottom-bar .bb-notes-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 0.2rem;
+ }
+ .global-bottom-bar .bb-messages-composer {
+ border-top: 1px solid rgba(var(--text-primary-rgb), 0.08);
+ padding-top: 0.55rem;
+ margin-top: 0.15rem;
+ }
+ .global-bottom-bar .bb-messages-layout {
+ height: 100%;
+ overflow: hidden;
+ }
+ .global-bottom-bar .bb-detail-line {
+ min-height: 28px;
+ padding: 0.2rem 0.2rem 0;
+ font-size: 0.8rem;
+ line-height: 1.2;
+ }
+ .global-bottom-bar.is-expanded .bb-detail-line {
+ padding-bottom: 0.1rem;
+ }
#bbSwitchCaseModal .modal-content {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
@@ -777,7 +908,7 @@
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
{% set _client_ip = (request.headers.get('cf-connecting-ip') if request and request.headers else '') or (request.headers.get('true-client-ip') if request and request.headers else '') or _xff_first or (request.headers.get('x-real-ip') if request and request.headers else '') or (request.client.host if request and request.client else '') %}
-{% set _can_click_to_call = _client_ip.startswith('172.16.31.') %}
+{% set _can_click_to_call = true %}
@@ -862,6 +993,7 @@