feat: Add BMC Office subscriptions Excel upload interface with auto customer mapping

This commit is contained in:
Christian 2026-01-06 08:21:24 +01:00
parent da5ec19188
commit bd746b7f9c
8 changed files with 684 additions and 1 deletions

View File

@ -1 +1 @@
1.3.118 1.3.119

View File

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

View File

@ -22,3 +22,12 @@ async def customer_detail_page(request: Request, customer_id: int):
"request": request, "request": request,
"customer_id": customer_id "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}
)

View File

@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BMC Office Abonnementer - Upload</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
:root {
--primary: #0f4c75;
--accent: #3282b8;
}
body {
background: #f8f9fa;
}
.upload-zone {
border: 3px dashed var(--accent);
border-radius: 15px;
padding: 60px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: white;
}
.upload-zone:hover {
border-color: var(--primary);
background: #f8f9fa;
}
.upload-zone.dragover {
background: #e3f2fd;
border-color: var(--primary);
transform: scale(1.02);
}
.stats-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.stat-item {
text-align: center;
padding: 15px;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
margin-top: 5px;
}
.progress-container {
display: none;
margin-top: 20px;
}
.error-list {
max-height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="container py-5">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold">
<i class="bi bi-cloud-upload text-primary me-2"></i>
BMC Office Abonnementer
</h2>
<a href="/customers" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Tilbage
</a>
</div>
<!-- Info Card -->
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="bi bi-info-circle me-2"></i>Om Upload
</h5>
<p class="mb-0">Upload en Excel fil (.xlsx eller .xls) med BMC Office abonnementsdata.
Systemet vil automatisk matche firma navne med kunder i databasen.</p>
<hr>
<small class="text-muted">
<strong>Forventede kolonner:</strong> FirmaID, Firma, Startdate, Text, Antal, Pris, Rabat, Beskrivelse, FakturaFirmaID, FakturaFirma
</small>
</div>
<!-- Upload Zone -->
<div class="upload-zone" id="uploadZone">
<i class="bi bi-cloud-arrow-up" style="font-size: 4rem; color: var(--accent);"></i>
<h4 class="mt-3">Træk Excel fil hertil</h4>
<p class="text-muted">eller klik for at vælge fil</p>
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display: none;">
<button class="btn btn-primary mt-3" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-folder2-open me-2"></i>Vælg Fil
</button>
</div>
<!-- Progress -->
<div class="progress-container" id="progressContainer">
<div class="progress" style="height: 30px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
id="progressBar"
style="width: 0%">
<span id="progressText">Uploader...</span>
</div>
</div>
</div>
<!-- Results -->
<div id="resultsContainer" class="mt-4" style="display: none;">
<!-- Statistics -->
<div class="stats-card">
<h5 class="fw-bold mb-4">
<i class="bi bi-bar-chart text-success me-2"></i>Import Resultat
</h5>
<div class="row text-center">
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-primary" id="statImported">0</div>
<div class="stat-label">Importeret</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-warning" id="statSkipped">0</div>
<div class="stat-label">Sprunget Over</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-success" id="statActive">0</div>
<div class="stat-label">Aktive</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-info" id="statValue">0</div>
<div class="stat-label">DKK (Total)</div>
</div>
</div>
</div>
</div>
<!-- Errors (if any) -->
<div id="errorsCard" class="stats-card" style="display: none;">
<h5 class="fw-bold text-danger mb-3">
<i class="bi bi-exclamation-triangle me-2"></i>Advarsler
</h5>
<div class="error-list" id="errorList"></div>
</div>
<!-- Actions -->
<div class="text-center mt-4">
<button class="btn btn-primary btn-lg" onclick="location.reload()">
<i class="bi bi-arrow-clockwise me-2"></i>Upload Ny Fil
</button>
<a href="/customers" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-people me-2"></i>Gå til Kunder
</a>
</div>
</div>
</div>
</div>
</div>
<script>
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultsContainer = document.getElementById('resultsContainer');
// Drag & Drop
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.remove('dragover');
});
});
uploadZone.addEventListener('drop', handleDrop);
fileInput.addEventListener('change', handleFileSelect);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
uploadFile(files[0]);
}
}
function handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
uploadFile(files[0]);
}
}
async function uploadFile(file) {
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
alert('Kun Excel filer (.xlsx, .xls) er tilladt');
return;
}
uploadZone.style.display = 'none';
progressContainer.style.display = 'block';
progressBar.style.width = '50%';
progressText.textContent = 'Uploader og importerer...';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/v1/admin/bmc-office-subscriptions/upload', {
method: 'POST',
body: formData
});
progressBar.style.width = '100%';
progressText.textContent = 'Færdig!';
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Upload fejlede');
}
displayResults(result);
} catch (error) {
console.error('Upload error:', error);
alert('Fejl ved upload: ' + error.message);
location.reload();
}
}
function displayResults(result) {
progressContainer.style.display = 'none';
resultsContainer.style.display = 'block';
document.getElementById('statImported').textContent = result.imported;
document.getElementById('statSkipped').textContent = result.skipped;
document.getElementById('statActive').textContent = result.statistics.active_records;
document.getElementById('statValue').textContent = result.statistics.total_value_dkk.toFixed(2);
if (result.errors && result.errors.length > 0) {
document.getElementById('errorsCard').style.display = 'block';
const errorList = document.getElementById('errorList');
errorList.innerHTML = result.errors.map(err =>
`<div class="alert alert-warning py-2 mb-2"><small>${err}</small></div>`
).join('');
}
}
</script>
</body>
</html>

View File

@ -25,6 +25,7 @@ def get_version():
# Import Feature Routers # Import Feature Routers
from app.customers.backend import router as customers_api from app.customers.backend import router as customers_api
from app.customers.backend import views as customers_views 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.hardware.backend import router as hardware_api
from app.billing.backend import router as billing_api from app.billing.backend import router as billing_api
from app.billing.frontend import views as billing_views from app.billing.frontend import views as billing_views
@ -107,6 +108,7 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"]) 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(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"])

36
scripts/debug_bmc_subs.sh Executable file
View File

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

View File

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

118
scripts/sync_bmc_subs_to_prod.sh Executable file
View File

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