diff --git a/.env.prod.example b/.env.prod.example
index 2e23242..5f8a61f 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -38,6 +38,8 @@ GITHUB_TOKEN=your_gitea_token_here
# =====================================================
# API CONFIGURATION - Production
# =====================================================
+# Stack name used by deployment scripts to name containers
+STACK_NAME=prod
API_HOST=0.0.0.0
API_PORT=8000
API_RELOAD=false
diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md
index dc16ff7..948bb09 100644
--- a/DEPLOYMENT_CHECKLIST.md
+++ b/DEPLOYMENT_CHECKLIST.md
@@ -114,6 +114,9 @@ SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# 5. CORS Origins (production domain)
CORS_ORIGINS=https://hub.bmcnetworks.dk
+# 5b. Stack name (used by deployment scripts for container names)
+STACK_NAME=prod
+
# 6. e-conomic Credentials (hvis relevant)
ECONOMIC_APP_SECRET_TOKEN=xxxxx
ECONOMIC_AGREEMENT_GRANT_TOKEN=xxxxx
diff --git a/PRODUCTION_QUICK_START.md b/PRODUCTION_QUICK_START.md
index 1f8ff1e..2e2e7e1 100644
--- a/PRODUCTION_QUICK_START.md
+++ b/PRODUCTION_QUICK_START.md
@@ -50,6 +50,7 @@ DATABASE_URL=postgresql://bmc_hub_prod:din_stærke_password_her@postgres:5432/bm
SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# API
+STACK_NAME=prod
API_PORT=8000
CORS_ORIGINS=http://172.16.31.183:8001
diff --git a/RELEASE_NOTES_v2.2.2.md b/RELEASE_NOTES_v2.2.2.md
new file mode 100644
index 0000000..3e3aa8b
--- /dev/null
+++ b/RELEASE_NOTES_v2.2.2.md
@@ -0,0 +1,38 @@
+# BMC Hub v2.2.2 - Sync Safety Release
+
+**Release Date:** 22. februar 2026
+
+## 🛡️ Critical Fixes
+
+### e-conomic Customer Sync Mapping
+- **Fixed ambiguous matching**: e-conomic sync now matches customers only by `economic_customer_number`
+- **Removed unsafe fallback in this flow**: CVR/name fallback is no longer used in `/api/v1/system/sync/economic`
+- **Added conflict-safe behavior**: if multiple local rows share the same `economic_customer_number`, the record is skipped and logged as conflict (no overwrite)
+- **Improved traceability**: sync logs now include the actual local customer id that was updated/created
+
+### Settings Sync UX
+- **Aligned frontend with backend response fields** for vTiger/e-conomic sync summaries
+- **Improved 2FA error feedback** in Settings sync UI when API returns `403: 2FA required`
+- **Fixed sync stats request limit** to avoid API validation issues
+- **Temporarily disabled CVR→e-conomic action** in Settings UI to prevent misleading behavior
+- **Clarified runtime config source**: sync uses environment variables (`.env`) at runtime
+
+## 🗄️ Database Safety
+
+### New Migration
+- Added migration: `migrations/138_customers_economic_unique_constraint.sql`
+- Normalizes empty/whitespace `economic_customer_number` values
+- Adds a partial unique index on non-null `economic_customer_number`
+- Migration aborts with clear error if duplicates already exist (manual dedupe required before rerun)
+
+## ⚠️ Deployment Notes
+
+- Run migration `138_customers_economic_unique_constraint.sql` before enabling broad sync operations in production
+- If migration fails due to duplicates, deduplicate `customers.economic_customer_number` first, then rerun migration
+- Existing 2FA API protection remains enabled
+
+## ✅ Expected Outcome
+
+- Sync payload and DB target row are now consistent in the e-conomic flow
+- Incorrect overwrites caused by weak matching strategy are prevented
+- Future duplicate `economic_customer_number` values are blocked at database level
diff --git a/VERSION b/VERSION
index 3e3c2f1..b1b25a5 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.1.1
+2.2.2
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 09cf42b..46e1922 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -636,6 +636,10 @@
Data Synkronisering
Synkroniser firmaer og kontakter fra vTiger og e-conomic
+
+
+ Sync bruger integration credentials fra miljøvariabler (.env) ved runtime.
+
@@ -733,16 +737,16 @@
-
Sync fra e-conomic
-
Hent kundenumre fra e-conomic. Matcher på CVR nummer eller firma navn.
+
Sync fra e-conomic
+
Hent kunder fra e-conomic. Matcher kun på entydigt e-conomic kundenummer.
-
@@ -750,6 +754,10 @@
Sidst synkroniseret: Aldrig
+
+
+ CVR-søgning er slået fra midlertidigt for stabil drift.
+
@@ -3256,7 +3264,7 @@ let syncLog = [];
async function loadSyncStats() {
try {
- const response = await fetch('/api/v1/customers?limit=10000');
+ const response = await fetch('/api/v1/customers?limit=1000');
if (!response.ok) throw new Error('Failed to load customers');
const data = await response.json();
const customers = data.customers || [];
@@ -3321,6 +3329,25 @@ function addSyncLogEntry(title, message, status = 'info') {
loadSyncLog();
}
+async function parseApiError(response, fallbackMessage) {
+ let detailMessage = fallbackMessage;
+
+ try {
+ const errorPayload = await response.json();
+ if (errorPayload && errorPayload.detail) {
+ detailMessage = errorPayload.detail;
+ }
+ } catch (parseError) {
+ // Keep fallback message
+ }
+
+ if (response.status === 403 && detailMessage === '2FA required') {
+ return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.';
+ }
+
+ return detailMessage;
+}
+
async function syncFromVtiger() {
const btn = document.getElementById('btnSyncVtiger');
btn.disabled = true;
@@ -3334,16 +3361,16 @@ async function syncFromVtiger() {
});
if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Sync fejlede');
+ const errorMessage = await parseApiError(response, 'Sync fejlede');
+ throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Behandlet: ${result.total_processed || 0}`,
- `Oprettet: ${result.created || 0}`,
+ `Linket: ${result.linked || 0}`,
`Opdateret: ${result.updated || 0}`,
- `Sprunget over: ${result.skipped || 0}`
+ `Ikke fundet/sprunget over: ${result.not_found || 0}`
].join(' | ');
addSyncLogEntry(
'vTiger Sync Fuldført',
@@ -3377,8 +3404,8 @@ async function syncVtigerContacts() {
});
if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Sync fejlede');
+ const errorMessage = await parseApiError(response, 'Sync fejlede');
+ throw new Error(errorMessage);
}
const result = await response.json();
@@ -3418,16 +3445,17 @@ async function syncFromEconomic() {
});
if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Sync fejlede');
+ const errorMessage = await parseApiError(response, 'Sync fejlede');
+ throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Behandlet: ${result.total_processed || 0}`,
- `Nye matchet: ${result.matched || 0}`,
- `Verificeret: ${result.verified || 0}`,
- `Ikke matchet: ${result.not_matched || 0}`
+ `Oprettet: ${result.created || 0}`,
+ `Opdateret: ${result.updated || 0}`,
+ `Konflikter: ${result.conflicts || 0}`,
+ `Sprunget over: ${result.skipped || 0}`
].join(' | ');
addSyncLogEntry(
'e-conomic Sync Fuldført',
@@ -3449,6 +3477,9 @@ async function syncFromEconomic() {
}
async function syncCvrToEconomic() {
+ showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
+ return;
+
const btn = document.getElementById('btnSyncCvrEconomic');
btn.disabled = true;
btn.innerHTML = 'Søger...';
diff --git a/app/system/backend/sync_router.py b/app/system/backend/sync_router.py
index 9fd1863..b04d532 100644
--- a/app/system/backend/sync_router.py
+++ b/app/system/backend/sync_router.py
@@ -24,7 +24,7 @@ HUB owns (manual or first-sync only):
SYNC RULES:
===========
1. NEVER overwrite source ID if already set (vtiger_id, economic_customer_number)
-2. Match order: CVR → Source ID → Name (normalized)
+2. Matching is source-specific (e-conomic: strict economic_customer_number only)
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
@@ -475,9 +475,11 @@ async def sync_from_economic() -> Dict[str, Any]:
created_count = 0
updated_count = 0
skipped_count = 0
+ conflict_count = 0
for eco_customer in economic_customers:
- customer_number = eco_customer.get('customerNumber')
+ customer_number_raw = eco_customer.get('customerNumber')
+ customer_number = str(customer_number_raw).strip() if customer_number_raw is not None else None
cvr = eco_customer.get('corporateIdentificationNumber')
name = eco_customer.get('name', '').strip()
address = eco_customer.get('address', '')
@@ -508,20 +510,27 @@ async def sync_from_economic() -> Dict[str, Any]:
# Extract email domain
email_domain = email.split('@')[-1] if '@' in email else None
- # Check if customer exists by economic_customer_number OR CVR
+ # Strict matching: ONLY match by economic_customer_number
existing = execute_query(
- "SELECT id FROM customers WHERE economic_customer_number = %s",
+ "SELECT id, name FROM customers WHERE economic_customer_number = %s ORDER BY id",
(customer_number,)
)
-
- # If not found by customer number, try CVR (to avoid duplicates)
- if not existing and cvr:
- existing = execute_query(
- "SELECT id FROM customers WHERE cvr_number = %s",
- (cvr,)
+
+ # Conflict handling: duplicate local rows for same e-conomic number
+ if len(existing) > 1:
+ conflict_count += 1
+ skipped_count += 1
+ duplicate_ids = ", ".join(str(row['id']) for row in existing)
+ logger.error(
+ "❌ Konflikt: e-conomic #%s matcher %s lokale kunder (ids: %s) - springer over",
+ customer_number,
+ len(existing),
+ duplicate_ids
)
+ continue
if existing:
+ target_customer_id = existing[0]['id']
# Update existing customer - ONLY update fields e-conomic owns
# E-conomic does NOT overwrite: name, cvr_number (set once only)
update_query = """
@@ -537,10 +546,16 @@ async def sync_from_economic() -> Dict[str, Any]:
WHERE id = %s
"""
execute_query(update_query, (
- customer_number, email_domain, address, city, zip_code, country, website, existing[0]['id']
+ customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
))
updated_count += 1
- logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})")
+ logger.info(
+ "✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
+ target_customer_id,
+ name,
+ customer_number,
+ cvr or 'ingen'
+ )
else:
# Create new customer from e-conomic
insert_query = """
@@ -555,17 +570,32 @@ async def sync_from_economic() -> Dict[str, Any]:
))
if result:
+ new_customer_id = result[0]['id']
created_count += 1
- logger.info(f"✨ Oprettet: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})")
+ logger.info(
+ "✨ Oprettet lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
+ new_customer_id,
+ name,
+ customer_number,
+ cvr or 'ingen'
+ )
else:
skipped_count += 1
- logger.info(f"✅ e-conomic sync fuldført: {created_count} oprettet, {updated_count} opdateret, {skipped_count} sprunget over af {len(economic_customers)} totalt")
+ logger.info(
+ "✅ e-conomic sync fuldført: %s oprettet, %s opdateret, %s konflikter, %s sprunget over af %s totalt",
+ created_count,
+ updated_count,
+ conflict_count,
+ skipped_count,
+ len(economic_customers)
+ )
return {
"status": "success",
"created": created_count,
"updated": updated_count,
+ "conflicts": conflict_count,
"skipped": skipped_count,
"total_processed": len(economic_customers)
}
diff --git a/migrations/138_customers_economic_unique_constraint.sql b/migrations/138_customers_economic_unique_constraint.sql
new file mode 100644
index 0000000..71642c3
--- /dev/null
+++ b/migrations/138_customers_economic_unique_constraint.sql
@@ -0,0 +1,40 @@
+-- Migration: Enforce unique economic customer number on customers
+-- Prevents ambiguous mapping during e-conomic sync
+
+-- Normalize values before uniqueness check
+UPDATE customers
+SET economic_customer_number = NULL
+WHERE economic_customer_number IS NOT NULL
+ AND btrim(economic_customer_number) = '';
+
+UPDATE customers
+SET economic_customer_number = btrim(economic_customer_number)
+WHERE economic_customer_number IS NOT NULL;
+
+-- Abort migration if duplicates exist (must be manually resolved first)
+DO $$
+DECLARE
+ duplicate_value TEXT;
+ duplicate_count INTEGER;
+BEGIN
+ SELECT economic_customer_number, COUNT(*)::INTEGER
+ INTO duplicate_value, duplicate_count
+ FROM customers
+ WHERE economic_customer_number IS NOT NULL
+ GROUP BY economic_customer_number
+ HAVING COUNT(*) > 1
+ ORDER BY COUNT(*) DESC, economic_customer_number
+ LIMIT 1;
+
+ IF duplicate_value IS NOT NULL THEN
+ RAISE EXCEPTION 'Cannot create unique index on customers.economic_customer_number. Duplicate value: % (count=%). Resolve duplicates and rerun migration.', duplicate_value, duplicate_count;
+ END IF;
+END $$;
+
+-- Enforce uniqueness for non-null values
+CREATE UNIQUE INDEX IF NOT EXISTS customers_economic_customer_number_unique_idx
+ON customers (economic_customer_number)
+WHERE economic_customer_number IS NOT NULL;
+
+COMMENT ON INDEX customers_economic_customer_number_unique_idx IS
+'Ensures e-conomic customer numbers are unique to prevent sync mismatch.';