diff --git a/VERSION b/VERSION
index c5a6820..0431208 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.3.118
\ No newline at end of file
+1.3.119
\ No newline at end of file
diff --git a/app/customers/backend/bmc_office_router.py b/app/customers/backend/bmc_office_router.py
new file mode 100644
index 0000000..93b9362
--- /dev/null
+++ b/app/customers/backend/bmc_office_router.py
@@ -0,0 +1,169 @@
+"""
+BMC Office Subscriptions Upload Router
+Allows admins to upload Excel files with subscription data
+"""
+
+from fastapi import APIRouter, UploadFile, File, HTTPException
+from fastapi.responses import JSONResponse
+import pandas as pd
+from datetime import datetime
+import logging
+from io import BytesIO
+
+from app.core.database import execute_query, execute_update
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+def parse_danish_number(value):
+ """Convert Danish number format to float (2.995,00 -> 2995.00)"""
+ if pd.isna(value) or value == '':
+ return 0.0
+ if isinstance(value, (int, float)):
+ return float(value)
+
+ value_str = str(value).strip()
+ value_str = value_str.replace('.', '').replace(',', '.')
+ try:
+ return float(value_str)
+ except:
+ return 0.0
+
+
+def parse_danish_date(value):
+ """Convert DD.MM.YYYY to YYYY-MM-DD"""
+ if pd.isna(value) or value == '':
+ return None
+
+ if isinstance(value, datetime):
+ return value.strftime('%Y-%m-%d')
+
+ value_str = str(value).strip()
+ try:
+ dt = datetime.strptime(value_str, '%d.%m.%Y')
+ return dt.strftime('%Y-%m-%d')
+ except:
+ try:
+ dt = pd.to_datetime(value_str)
+ return dt.strftime('%Y-%m-%d')
+ except:
+ return None
+
+
+@router.post("/admin/bmc-office-subscriptions/upload")
+async def upload_bmc_office_subscriptions(file: UploadFile = File(...)):
+ """
+ Upload Excel file with BMC Office subscriptions
+ Expected columns: FirmaID, Firma, Startdate, Text, Antal, Pris, Rabat, Beskrivelse, FakturaFirmaID, FakturaFirma
+ """
+
+ if not file.filename.endswith(('.xlsx', '.xls')):
+ raise HTTPException(status_code=400, detail="Kun Excel filer (.xlsx, .xls) er tilladt")
+
+ try:
+ # Read Excel file
+ contents = await file.read()
+ df = pd.read_excel(BytesIO(contents))
+
+ logger.info(f"📂 Læst Excel fil: {file.filename} med {len(df)} rækker")
+ logger.info(f"📋 Kolonner: {', '.join(df.columns)}")
+
+ # Get all customers from database
+ customers = execute_query("SELECT id, name FROM customers ORDER BY name")
+ customer_map = {c['name'].lower().strip(): c['id'] for c in customers}
+
+ logger.info(f"✅ Fundet {len(customers)} kunder i databasen")
+
+ # Clear existing subscriptions
+ execute_update("DELETE FROM bmc_office_subscriptions", ())
+ logger.info("🗑️ Ryddet eksisterende abonnementer")
+
+ # Process rows
+ imported = 0
+ skipped = 0
+ errors = []
+
+ for idx, row in df.iterrows():
+ try:
+ firma_name = str(row.get('Firma', '')).strip()
+ if not firma_name:
+ skipped += 1
+ continue
+
+ # Find customer by name (case-insensitive)
+ customer_id = None
+ firma_lower = firma_name.lower()
+
+ # Exact match first
+ if firma_lower in customer_map:
+ customer_id = customer_map[firma_lower]
+ else:
+ # Partial match
+ for cust_name, cust_id in customer_map.items():
+ if firma_lower in cust_name or cust_name in firma_lower:
+ customer_id = cust_id
+ break
+
+ if not customer_id:
+ skipped += 1
+ errors.append(f"Række {idx+2}: Kunde '{firma_name}' ikke fundet")
+ continue
+
+ # Parse data
+ firma_id = str(row.get('FirmaID', '')).strip()
+ start_date = parse_danish_date(row.get('Startdate'))
+ text = str(row.get('Text', '')).strip()
+ antal = parse_danish_number(row.get('Antal', 1))
+ pris = parse_danish_number(row.get('Pris', 0))
+ rabat = parse_danish_number(row.get('Rabat', 0))
+ beskrivelse = str(row.get('Beskrivelse', '')).strip()
+ faktura_firma_id = str(row.get('FakturaFirmaID', '')).strip()
+ faktura_firma_name = str(row.get('FakturaFirma', '')).strip()
+
+ # Insert subscription
+ execute_update(
+ """INSERT INTO bmc_office_subscriptions
+ (customer_id, firma_id, firma_name, start_date, text, antal, pris, rabat,
+ beskrivelse, faktura_firma_id, faktura_firma_name, active)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
+ (customer_id, firma_id, firma_name, start_date, text, antal, pris, rabat,
+ beskrivelse, faktura_firma_id, faktura_firma_name, True)
+ )
+
+ imported += 1
+
+ except Exception as e:
+ skipped += 1
+ errors.append(f"Række {idx+2}: {str(e)}")
+ logger.error(f"❌ Fejl ved import af række {idx+2}: {e}")
+
+ # Get statistics
+ stats = execute_query("""
+ SELECT
+ COUNT(*) as total_records,
+ COUNT(CASE WHEN active THEN 1 END) as active_records,
+ ROUND(SUM(CASE WHEN active THEN total_inkl_moms ELSE 0 END)::numeric, 2) as total_value_dkk
+ FROM bmc_office_subscription_totals
+ """)[0]
+
+ logger.info(f"✅ Import færdig: {imported} importeret, {skipped} sprunget over")
+
+ return {
+ "success": True,
+ "filename": file.filename,
+ "total_rows": len(df),
+ "imported": imported,
+ "skipped": skipped,
+ "errors": errors[:10], # Max 10 error messages
+ "statistics": {
+ "total_records": stats['total_records'],
+ "active_records": stats['active_records'],
+ "total_value_dkk": float(stats['total_value_dkk'] or 0)
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Fejl ved upload: {e}")
+ raise HTTPException(status_code=500, detail=f"Fejl ved upload: {str(e)}")
diff --git a/app/customers/backend/views.py b/app/customers/backend/views.py
index 1e8bd41..ff88d7c 100644
--- a/app/customers/backend/views.py
+++ b/app/customers/backend/views.py
@@ -22,3 +22,12 @@ async def customer_detail_page(request: Request, customer_id: int):
"request": request,
"customer_id": customer_id
})
+
+
+@router.get("/admin/bmc-office-upload", response_class=HTMLResponse)
+async def bmc_office_upload_page(request: Request):
+ """BMC Office subscriptions upload page"""
+ return templates.TemplateResponse(
+ "customers/frontend/bmc_office_upload.html",
+ {"request": request}
+ )
diff --git a/app/customers/frontend/bmc_office_upload.html b/app/customers/frontend/bmc_office_upload.html
new file mode 100644
index 0000000..4cb62b2
--- /dev/null
+++ b/app/customers/frontend/bmc_office_upload.html
@@ -0,0 +1,288 @@
+
+
+
+
+
+ BMC Office Abonnementer - Upload
+
+
+
+
+
+
+
+
+
+
+
+ BMC Office Abonnementer
+
+
+ Tilbage
+
+
+
+
+
+
+ Om Upload
+
+
Upload en Excel fil (.xlsx eller .xls) med BMC Office abonnementsdata.
+ Systemet vil automatisk matche firma navne med kunder i databasen.
+
+
+ Forventede kolonner: FirmaID, Firma, Startdate, Text, Antal, Pris, Rabat, Beskrivelse, FakturaFirmaID, FakturaFirma
+
+
+
+
+
+
+
Træk Excel fil hertil
+
eller klik for at vælge fil
+
+
+
+
+
+
+
+
+
+
+
+
+ Import Resultat
+
+
+
+
+
+
+
+
+
+
+
+ Gå til Kunder
+
+
+
+
+
+
+
+
+
+
diff --git a/main.py b/main.py
index 90d2836..e7ae29a 100644
--- a/main.py
+++ b/main.py
@@ -25,6 +25,7 @@ def get_version():
# Import Feature Routers
from app.customers.backend import router as customers_api
from app.customers.backend import views as customers_views
+from app.customers.backend import bmc_office_router
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
@@ -107,6 +108,7 @@ app.add_middleware(
# Include routers
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
+app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
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"])
diff --git a/scripts/debug_bmc_subs.sh b/scripts/debug_bmc_subs.sh
new file mode 100755
index 0000000..2b426ff
--- /dev/null
+++ b/scripts/debug_bmc_subs.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Debug: Check BMC Office subscriptions on prod
+
+echo "🔍 Tjekker BMC Office abonnementer på prod..."
+echo ""
+
+ssh bmcadmin@172.16.31.183 << 'ENDSSH'
+ echo "📊 Tabel statistik:"
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "
+ SELECT COUNT(*) as total_rows FROM bmc_office_subscriptions;
+ "
+
+ echo ""
+ echo "📊 Hvis der er data - vis sample:"
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "
+ SELECT id, customer_id, firma_name, text, active
+ FROM bmc_office_subscriptions
+ LIMIT 5;
+ "
+
+ echo ""
+ echo "📊 Customer name matching check:"
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "
+ SELECT
+ c.id,
+ c.name,
+ COUNT(bos.id) as subscription_count
+ FROM customers c
+ LEFT JOIN bmc_office_subscriptions bos ON bos.customer_id = c.id
+ WHERE c.name LIKE '%Mate%'
+ GROUP BY c.id, c.name;
+ "
+ENDSSH
+
+echo ""
+echo "✅ Færdig"
diff --git a/scripts/export_bmc_subs_to_prod.sh b/scripts/export_bmc_subs_to_prod.sh
new file mode 100644
index 0000000..3ce3763
--- /dev/null
+++ b/scripts/export_bmc_subs_to_prod.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Export BMC Office subscriptions from local dev and import to production
+
+set -e
+
+echo "🔍 Eksporterer BMC Office abonnementer fra lokal dev..."
+
+# Export to SQL file with proper escaping
+docker exec bmc-hub-postgres psql -U bmc_hub -d bmc_hub -t -A -F',' -c "
+SELECT
+ id,
+ customer_id,
+ firma_id,
+ firma_name,
+ start_date,
+ text,
+ antal,
+ pris,
+ rabat,
+ beskrivelse,
+ faktura_firma_id,
+ faktura_firma_name,
+ active
+FROM bmc_office_subscriptions
+ORDER BY id;" > /tmp/bmc_subs_data.csv
+
+echo "✅ Eksporteret $(wc -l < /tmp/bmc_subs_data.csv) records"
+
+echo ""
+echo "📤 Uploader til prod server..."
+
+# Copy file to prod
+scp /tmp/bmc_subs_data.csv root@172.16.31.183:/tmp/
+
+echo ""
+echo "📥 Importerer på prod server..."
+
+# Import on prod
+ssh root@172.16.31.183 << 'ENDSSH'
+ echo "🗑️ Rydder eksisterende data..."
+ sudo podman exec bmc-hub-postgres-prod psql -U bmchub -d bmchub -c "TRUNCATE TABLE bmc_office_subscriptions RESTART IDENTITY CASCADE;"
+
+ echo "📥 Importerer ny data..."
+ sudo podman exec -i bmc-hub-postgres-prod psql -U bmchub -d bmchub -c "
+ COPY bmc_office_subscriptions (id, customer_id, firma_id, firma_name, start_date, text, antal, pris, rabat, beskrivelse, faktura_firma_id, faktura_firma_name, active)
+ FROM '/tmp/bmc_subs_data.csv'
+ WITH (FORMAT csv, DELIMITER ',');
+ " < /tmp/bmc_subs_data.csv
+
+ echo "✅ Import færdig!"
+
+ sudo podman exec bmc-hub-postgres-prod psql -U bmchub -d bmchub -c "
+ SELECT COUNT(*) as imported_total,
+ ROUND(SUM(total_inkl_moms)::numeric, 2) as total_value
+ FROM bmc_office_subscription_totals
+ WHERE active = true;
+ "
+ENDSSH
+
+echo ""
+echo "🎉 Færdig! BMC Office abonnementer overført til prod."
diff --git a/scripts/sync_bmc_subs_to_prod.sh b/scripts/sync_bmc_subs_to_prod.sh
new file mode 100755
index 0000000..4957bdb
--- /dev/null
+++ b/scripts/sync_bmc_subs_to_prod.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+# Synkroniser BMC Office abonnementer med customer ID mapping
+
+set -e
+
+PROD_SERVER="bmcadmin@172.16.31.183"
+
+echo "🔍 Eksporterer BMC Office abonnementer fra lokal dev..."
+
+# Export with firma_name for mapping (not customer_id)
+docker exec bmc-hub-postgres psql -U bmc_hub -d bmc_hub -t -A -F'|' -c "
+SELECT
+ firma_id,
+ firma_name,
+ start_date,
+ text,
+ antal,
+ pris,
+ rabat,
+ beskrivelse,
+ faktura_firma_id,
+ faktura_firma_name,
+ active
+FROM bmc_office_subscriptions
+ORDER BY firma_name, id;" > /tmp/bmc_subs_export.csv
+
+LINES=$(wc -l < /tmp/bmc_subs_export.csv | tr -d ' ')
+echo "✅ Eksporteret $LINES records"
+
+echo ""
+echo "📤 Uploader og importerer til prod med customer mapping..."
+
+# SSH to prod and run import with customer mapping
+ssh $PROD_SERVER << 'ENDSSH'
+ echo "📥 Modtager data..."
+ cat > /tmp/bmc_subs_data.csv
+
+ echo "🗑️ Rydder eksisterende abonnementer..."
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "TRUNCATE TABLE bmc_office_subscriptions RESTART IDENTITY CASCADE;" > /dev/null
+
+ echo "📥 Importerer med customer ID mapping..."
+ podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub << 'EOSQL'
+ CREATE TEMP TABLE bmc_import (
+ firma_id VARCHAR(50),
+ firma_name VARCHAR(255),
+ start_date DATE,
+ text VARCHAR(500),
+ antal DECIMAL(10,2),
+ pris DECIMAL(10,2),
+ rabat DECIMAL(10,2),
+ beskrivelse TEXT,
+ faktura_firma_id VARCHAR(50),
+ faktura_firma_name VARCHAR(255),
+ active BOOLEAN
+ );
+
+ \COPY bmc_import FROM '/tmp/bmc_subs_data.csv' WITH (FORMAT csv, DELIMITER '|');
+
+ INSERT INTO bmc_office_subscriptions (
+ customer_id, firma_id, firma_name, start_date, text, antal, pris, rabat,
+ beskrivelse, faktura_firma_id, faktura_firma_name, active
+ )
+ SELECT
+ c.id as customer_id,
+ bi.firma_id,
+ bi.firma_name,
+ bi.start_date,
+ bi.text,
+ bi.antal,
+ bi.pris,
+ bi.rabat,
+ bi.beskrivelse,
+ bi.faktura_firma_id,
+ bi.faktura_firma_name,
+ bi.active
+ FROM bmc_import bi
+ LEFT JOIN customers c ON LOWER(TRIM(c.name)) = LOWER(TRIM(bi.firma_name))
+ WHERE c.id IS NOT NULL;
+
+ SELECT
+ COUNT(*) as total_imported,
+ COUNT(DISTINCT customer_id) as unique_customers
+ FROM bmc_office_subscriptions;
+EOSQL
+
+ echo ""
+ echo "✅ Import færdig! Verificerer..."
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "
+ SELECT
+ COUNT(*) as total_records,
+ COUNT(CASE WHEN active THEN 1 END) as active_records,
+ ROUND(SUM(CASE WHEN active THEN total_inkl_moms ELSE 0 END)::numeric, 2) as total_value_dkk
+ FROM bmc_office_subscription_totals;
+ "
+
+ echo ""
+ echo "📊 Sample kunde check (Mate.Bike):"
+ podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "
+ SELECT
+ c.id as customer_id,
+ c.name,
+ COUNT(bos.id) as bmc_subs_count,
+ ROUND(SUM(bos.total_inkl_moms)::numeric, 2) as total_value
+ FROM customers c
+ JOIN bmc_office_subscription_totals bos ON bos.customer_id = c.id
+ WHERE c.name LIKE '%Mate%'
+ GROUP BY c.id, c.name;
+ "
+
+ echo ""
+ echo "🧹 Rydder op..."
+ rm /tmp/bmc_subs_data.csv
+ENDSSH < /tmp/bmc_subs_export.csv
+
+echo ""
+echo "🎉 Færdig! BMC Office abonnementer er synkroniseret til produktion."
+echo ""
+echo "🔗 Test på: http://172.16.31.183:8000/customers/183 -> Abonnnents tjek tab (Mate.Bike)"