Release v2.2.2: sync safety hardening

This commit is contained in:
Christian 2026-02-22 03:27:40 +01:00
parent bef5c20c83
commit e772311a86
8 changed files with 176 additions and 31 deletions

View File

@ -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

View File

@ -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

View File

@ -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

38
RELEASE_NOTES_v2.2.2.md Normal file
View File

@ -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

View File

@ -1 +1 @@
2.1.1
2.2.2

View File

@ -636,6 +636,10 @@
<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 class="alert alert-info mt-3 mb-0 py-2 px-3 small">
<i class="bi bi-info-circle me-2"></i>
Sync bruger integration credentials fra <strong>miljøvariabler (.env)</strong> ved runtime.
</div>
</div>
<!-- Sync Status Cards -->
@ -733,16 +737,16 @@
<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</h5>
<p class="card-text small text-muted">Hent kundenumre fra e-conomic. Matcher på CVR nummer eller firma navn.</p>
<h6 class="card-title fw-bold">Sync fra e-conomic</h6>
<p class="card-text small text-muted">Hent kunder fra e-conomic. Matcher kun på entydigt e-conomic kundenummer.</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 class="btn btn-outline-secondary btn-sm" id="btnSyncCvrEconomic" disabled>
<i class="bi bi-pause-circle me-2"></i>CVR→e-conomic midlertidigt deaktiveret
</button>
</div>
<div class="mt-3 small">
@ -750,6 +754,10 @@
<i class="bi bi-info-circle me-2"></i>
<span>Sidst synkroniseret: <span id="lastSyncEconomic">Aldrig</span></span>
</div>
<div class="d-flex align-items-center text-muted mt-1">
<i class="bi bi-exclamation-circle me-2"></i>
<span>CVR-søgning er slået fra midlertidigt for stabil drift.</span>
</div>
</div>
</div>
</div>
@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';

View File

@ -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)
}

View File

@ -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.';