Compare commits

...

2 Commits

Author SHA1 Message Date
Christian
d4d9ac22a5 feat: add locations outlet and cross-field management 2026-07-17 10:23:57 +02:00
Christian
0655b4c4f8 feat: Enhance opportunity listing and invoice error finder functionality
- Added customer_id and contact_id filters to the list_opportunities endpoint for improved querying.
- Implemented a redirect for opportunity detail pages to a new format.
- Refactored SubscriptionMatrixService to load invoices from a local snapshot instead of an external service, improving performance and reliability.
- Updated settings to include 'pipeline' as a case type and added a new section for managing ignored product texts in the invoice error finder.
- Introduced a new user_sag_create_preferences table to store per-user default case types for new cases.
- Enhanced frontend settings page with invoice error finder configuration options and improved handling of ignored product texts.
- Added migrations to support new features, including resolved status for invoice error finder issues and user-specific case type preferences.
2026-07-17 01:58:02 +02:00
43 changed files with 5029 additions and 442 deletions

View File

@ -217,6 +217,22 @@ class UserProfileUpdate(BaseModel):
phone: Optional[str] = None
title: Optional[str] = None
anydesk_id: Optional[str] = None
default_case_type: Optional[str] = None
def _allowed_case_types() -> list[str]:
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
try:
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
if rows:
import json
configured = json.loads(rows[0].get("value") or "[]")
values = [str(value).strip().lower() for value in configured if str(value).strip()]
if values:
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
except Exception:
logger.warning("Could not load configured case types for profile preference", exc_info=True)
return fallback
@router.get("/me/profile")
@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
)
if not rows:
raise HTTPException(status_code=404, detail="User not found")
return dict(rows[0])
profile = dict(rows[0])
profile["default_case_type"] = "ticket"
try:
preference = execute_query(
"SELECT default_case_type FROM user_sag_create_preferences WHERE user_id = %s",
(current_user["id"],),
) or []
if preference:
profile["default_case_type"] = preference[0].get("default_case_type") or "ticket"
except Exception:
logger.warning("Sag create preferences table not available yet")
return profile
@router.patch("/me/profile")
@ -253,16 +280,33 @@ async def update_my_profile(
fields.append("anydesk_id = %s")
values.append(payload.anydesk_id.strip() or None)
if not fields:
if payload.default_case_type is not None:
default_case_type = payload.default_case_type.strip().lower() or "ticket"
if default_case_type not in _allowed_case_types():
raise HTTPException(status_code=400, detail="Ukendt sagstype")
try:
execute_query(
"""
INSERT INTO user_sag_create_preferences (user_id, default_case_type, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (user_id) DO UPDATE
SET default_case_type = EXCLUDED.default_case_type, updated_at = NOW()
""",
(current_user["id"], default_case_type),
)
except Exception as exc:
raise HTTPException(status_code=409, detail="Profilindstillingen er ikke klar. Kør migration 214 først.") from exc
if not fields and payload.default_case_type is None:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = NOW()")
values.append(current_user["id"])
execute_query(
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
tuple(values)
)
if fields:
fields.append("updated_at = NOW()")
values.append(current_user["id"])
execute_query(
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
tuple(values)
)
return {"message": "Profil opdateret"}

View File

@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
<td>${escapeHtml(stage)}</td>
<td>${probability}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/opportunities/${o.id}">
<a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
<i class="bi bi-eye"></i>
</a>
</td>

View File

@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int):
return rows or []
@router.get("/customers/{customer_id}/economic-invoices")
async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)):
"""Get imported e-conomic invoices for a customer from invoice_error_finder staging data."""
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
economic_customer_number = customer.get("economic_customer_number")
if not economic_customer_number:
return {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"economic_customer_number": None,
"items": [],
}
rows = execute_query(
"""
WITH ranked_invoices AS (
SELECT
inv.id,
inv.source_invoice_number,
inv.invoice_date,
inv.due_date,
inv.total_amount,
inv.net_amount,
inv.vat_amount,
inv.currency,
inv.source_type,
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
NULLIF(
CONCAT_WS(
E'\n',
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
),
''
) AS note_text,
CASE inv.source_type
WHEN 'paid' THEN 1
WHEN 'booked' THEN 2
WHEN 'unpaid' THEN 3
WHEN 'draft' THEN 4
ELSE 9
END AS source_rank
FROM invoice_error_finder_economic_invoices inv
WHERE inv.customer_number = %s
),
selected_invoices AS (
SELECT DISTINCT ON (source_invoice_number)
id,
source_invoice_number,
invoice_date,
due_date,
total_amount,
net_amount,
vat_amount,
currency,
source_type,
heading,
note_text
FROM ranked_invoices
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
)
SELECT
si.id AS invoice_id,
si.source_invoice_number,
si.invoice_date,
si.due_date,
si.total_amount,
si.net_amount,
si.vat_amount,
si.currency,
si.source_type,
si.heading,
si.note_text,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount
FROM selected_invoices si
LEFT JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = si.id
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
LIMIT %s
""",
(economic_customer_number, limit * 25),
) or []
invoices: List[Dict[str, Any]] = []
invoices_by_id: Dict[int, Dict[str, Any]] = {}
for row in rows:
invoice_id = row.get("invoice_id")
if invoice_id is None:
continue
if invoice_id not in invoices_by_id:
payload = {
"invoice_id": invoice_id,
"invoice_number": row.get("source_invoice_number"),
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
"due_date": row["due_date"].isoformat() if row.get("due_date") else None,
"total_amount": float(row.get("total_amount") or 0),
"net_amount": float(row.get("net_amount") or 0),
"vat_amount": float(row.get("vat_amount") or 0),
"currency": row.get("currency") or "DKK",
"source_type": row.get("source_type"),
"heading": row.get("heading") or None,
"note_text": row.get("note_text") or None,
"lines": [],
}
invoices_by_id[invoice_id] = payload
invoices.append(payload)
if row.get("line_number") is not None:
invoices_by_id[invoice_id]["lines"].append(
{
"line_number": int(row.get("line_number") or 0),
"product_number": row.get("product_number"),
"product_name": row.get("product_name"),
"description": row.get("description"),
"quantity": float(row.get("quantity") or 0),
"unit_price": float(row.get("unit_price") or 0),
"line_net_amount": float(row.get("line_net_amount") or 0),
}
)
return {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"economic_customer_number": economic_customer_number,
"items": invoices[:limit],
}
@router.get("/customers/{customer_id}/kontakt")
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
"""Get unified contact communication history (calls + SMS) for all company contacts."""

View File

@ -259,6 +259,253 @@
.subscription-column {
min-height: 200px;
}
.customer-invoice-list {
display: block;
}
.customer-invoice-shell {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
overflow: hidden;
}
.customer-invoice-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(15, 76, 117, 0.02);
}
.customer-invoice-toolbar-copy {
min-width: 0;
}
.customer-invoice-toolbar-title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.2rem;
}
.customer-invoice-toolbar-subtitle {
color: var(--text-secondary);
font-size: 0.88rem;
}
.customer-invoice-summary {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
justify-content: flex-end;
}
.customer-invoice-summary-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.04);
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 700;
line-height: 1;
}
.customer-invoice-month-group + .customer-invoice-month-group {
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.customer-invoice-month-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.9rem 1.25rem;
background: rgba(0, 0, 0, 0.015);
}
.customer-invoice-month-label {
color: var(--text-primary);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.customer-invoice-month-total {
color: var(--text-secondary);
font-size: 0.82rem;
font-weight: 700;
}
.customer-invoice-table {
margin-bottom: 0;
}
.customer-invoice-table thead th {
background: rgba(15, 76, 117, 0.04);
color: var(--text-secondary);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
}
.customer-invoice-row td {
vertical-align: middle;
}
.customer-invoice-row:hover {
background: rgba(15, 76, 117, 0.025);
}
.customer-invoice-number {
display: inline-flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
color: var(--text-primary);
}
.customer-invoice-period-cell {
max-width: 340px;
}
.customer-invoice-period-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
font-weight: 500;
}
.customer-invoice-period-sub {
display: block;
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.customer-invoice-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 76px;
padding: 0.38rem 0.7rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.customer-invoice-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.customer-invoice-open,
.customer-invoice-toggle {
border-radius: 999px;
padding-inline: 0.8rem;
font-size: 0.78rem;
white-space: nowrap;
}
.customer-invoice-expanded-row td {
background: rgba(15, 76, 117, 0.025);
padding: 0;
border-top: 0;
}
.customer-invoice-expanded {
padding: 1rem 1.25rem 1.1rem;
}
.customer-invoice-note-panel {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
background: #fff;
padding: 0.8rem 0.95rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.customer-invoice-totals {
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 1rem;
padding-top: 0.85rem;
font-size: 0.92rem;
}
.matrix-cell-button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 0;
background: transparent;
padding: 0;
margin-top: 0.3rem;
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
text-decoration: underline;
}
.matrix-cell-button:hover {
color: #0b3b5a;
}
.invoice-detail-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.85rem;
margin-bottom: 1rem;
}
.invoice-detail-card {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
background: rgba(15, 76, 117, 0.04);
padding: 0.85rem 1rem;
}
.invoice-detail-card .label {
display: block;
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.invoice-detail-card .value {
color: var(--text-primary);
font-weight: 700;
}
@media (max-width: 991.98px) {
.customer-invoice-toolbar {
flex-direction: column;
align-items: flex-start;
}
.customer-invoice-summary {
justify-content: flex-start;
}
}
.column-header {
position: sticky;
@ -990,9 +1237,27 @@
<!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5>
<div class="text-muted text-center py-5">
Fakturamodul kommer snart...
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Fakturaer</h5>
<small class="text-muted">Importerede e-conomic-fakturaer for kunden</small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadCustomerInvoices(true)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
<div class="contacts-toolbar mb-3">
<div class="input-group contacts-search">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="customerInvoiceSearchInput" placeholder="Søg i fakturanr., periode, note eller dato" oninput="filterCustomerInvoices(this.value)">
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearCustomerInvoiceSearch()">Nulstil</button>
<span class="badge text-bg-light border" id="customerInvoiceResultCount">0</span>
</div>
</div>
<div id="customerInvoicesContainer" class="text-muted text-center py-5">
Åbn fanen for at indlæse fakturaer...
</div>
</div>
@ -1088,9 +1353,9 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">
<i class="bi bi-table me-2"></i>Abonnements-matrix
<small class="text-muted fw-normal">(fra e-conomic)</small>
<small class="text-muted fw-normal">(fra importerede e-conomic-fakturaer)</small>
</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent matrix fra importerede e-conomic-fakturaer">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
</div>
@ -2007,12 +2272,29 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="customerWorklogSuggestionBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="customerInvoiceDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fakturadetaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="customerInvoiceDetailBody">
<div class="text-center py-4 text-muted">Indlæser...</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -2032,6 +2314,9 @@ let pipelineStages = [];
let allTagsCache = [];
let customerKontaktItems = [];
let customerKontaktFilter = 'all';
let customerInvoicesLoaded = false;
let customerInvoicesData = [];
let customerInvoiceSearchTerm = '';
let eventListenersAdded = false;
@ -2157,6 +2442,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const invoicesTab = document.querySelector('a[href="#invoices"]');
if (invoicesTab) {
invoicesTab.addEventListener('shown.bs.tab', () => {
loadCustomerInvoices();
}, { once: false });
}
if (window.location.hash) {
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
if (hashTab && window.bootstrap?.Tab) {
@ -3953,7 +4245,7 @@ function renderCustomerPipeline(opportunities) {
</td>
<td>${o.probability || 0}%</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/sag/${o.id}/v3'">
<i class="bi bi-arrow-right"></i>
</button>
</td>
@ -4472,6 +4764,349 @@ function formatCurrency(value, currency) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
function renderCustomerInvoiceLineRows(lines) {
if (!Array.isArray(lines) || lines.length === 0) {
return '<div class="text-muted small">Ingen fakturalinjer fundet</div>';
}
return `
<div class="table-responsive mt-2">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Linje</th>
<th>Varenr</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Beløb</th>
</tr>
</thead>
<tbody>
${lines.map(line => {
const description = line.description || line.product_name || '-';
return `
<tr>
<td>${Number(line.line_number || 0).toLocaleString('da-DK')}</td>
<td>${escapeHtml(line.product_number || '-')}</td>
<td class="text-truncate" style="max-width: 520px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(description)}">${escapeHtml(description)}</td>
<td>${Number(line.quantity || 0).toLocaleString('da-DK')}</td>
<td>${formatCurrency(line.unit_price, 'DKK')}</td>
<td>${formatCurrency(line.line_net_amount, 'DKK')}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
function renderCustomerInvoiceCards(invoices) {
if (!Array.isArray(invoices) || invoices.length === 0) {
return '<div class="text-muted text-center py-5">Ingen importerede e-conomic-fakturaer fundet for denne kunde</div>';
}
const grouped = {};
invoices.forEach((invoice) => {
const key = (invoice.invoice_date || '').slice(0, 7) || 'unknown';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(invoice);
});
const summaryTotal = invoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
const monthKeys = Object.keys(grouped).sort().reverse();
const groupsHtml = monthKeys.map((monthKey) => {
const monthInvoices = grouped[monthKey] || [];
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
const monthLabel = formatInvoiceMonthLabel(monthKey);
const rows = monthInvoices.map((invoice, idx) => {
const itemId = `customer-economic-invoice-${monthKey}-${idx}`;
const status = invoice.source_type || 'booked';
const periodText = invoice.heading || invoice.note_text || 'Ingen periodetekst';
const lineCount = Array.isArray(invoice.lines) ? invoice.lines.length : 0;
const detailLabel = lineCount > 0 ? `Vis linjer (${lineCount})` : 'Vis detaljer';
return `
<tr class="customer-invoice-row">
<td>
<button class="btn btn-link p-0 text-decoration-none customer-invoice-number" type="button" onclick="toggleLineItems('${itemId}')">
<i class="bi bi-chevron-right" id="${itemId}-icon"></i>
<span>${escapeHtml(invoice.invoice_number || 'Ukendt faktura')}</span>
</button>
</td>
<td class="customer-invoice-period-cell">
<span class="customer-invoice-period-text" title="${escapeHtml(periodText)}">${escapeHtml(periodText)}</span>
<span class="customer-invoice-period-sub">${lineCount} linjer</span>
</td>
<td>${invoice.invoice_date ? escapeHtml(formatDate(invoice.invoice_date)) : '-'}</td>
<td>${invoice.due_date ? escapeHtml(formatDate(invoice.due_date)) : '-'}</td>
<td class="text-end fw-semibold">${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</td>
<td><span class="badge bg-${getStatusColor(status)} customer-invoice-status">${escapeHtml(status)}</span></td>
<td>
<div class="customer-invoice-actions">
<button class="btn btn-sm btn-outline-primary customer-invoice-open" type="button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(invoice.invoice_number || '')).replace(/'/g, "\\'")}')">Faktura</button>
<button class="btn btn-sm btn-outline-secondary customer-invoice-toggle" type="button" onclick="toggleLineItems('${itemId}')">${detailLabel}</button>
</div>
</td>
</tr>
<tr class="customer-invoice-expanded-row">
<td colspan="7">
<div id="${itemId}-lines" class="customer-invoice-expanded" style="display: none;">
${invoice.heading || invoice.note_text ? `
<div class="customer-invoice-note-panel">
${invoice.heading ? `<div class="fw-semibold">${escapeHtml(invoice.heading)}</div>` : ''}
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
</div>
` : ''}
${renderCustomerInvoiceLineRows(invoice.lines || [])}
<div class="customer-invoice-totals">
<div class="d-flex justify-content-between">
<span>Netto:</span>
<strong>${formatCurrency(invoice.net_amount, invoice.currency || 'DKK')}</strong>
</div>
<div class="d-flex justify-content-between">
<span>Moms:</span>
<strong>${formatCurrency(invoice.vat_amount, invoice.currency || 'DKK')}</strong>
</div>
<div class="d-flex justify-content-between text-info fw-bold">
<span>Total:</span>
<strong>${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</strong>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('');
return `
<section class="customer-invoice-month-group">
<div class="customer-invoice-month-header">
<div class="customer-invoice-month-label">${escapeHtml(monthLabel)}</div>
<div class="customer-invoice-month-total">${monthInvoices.length} fakturaer · ${formatCurrency(monthTotal, monthInvoices[0]?.currency || 'DKK')}</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle customer-invoice-table">
<thead>
<tr>
<th>Faktura</th>
<th>Periode</th>
<th>Dato</th>
<th>Forfald</th>
<th class="text-end">Beløb</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
</section>
`;
}).join('');
return `
<div class="customer-invoice-shell">
<div class="customer-invoice-toolbar">
<div class="customer-invoice-toolbar-copy">
<div class="customer-invoice-toolbar-title">Fakturaoversigt</div>
<div class="customer-invoice-toolbar-subtitle">Importerede e-conomic-fakturaer vist som en almindelig oversigt med detaljer pr. faktura</div>
</div>
<div class="customer-invoice-summary">
<span class="customer-invoice-summary-chip"><i class="bi bi-receipt"></i>${invoices.length} fakturaer</span>
<span class="customer-invoice-summary-chip"><i class="bi bi-calendar3"></i>${monthKeys.length} måneder</span>
<span class="customer-invoice-summary-chip"><i class="bi bi-cash-stack"></i>${formatCurrency(summaryTotal, invoices[0]?.currency || 'DKK')}</span>
</div>
</div>
<div class="customer-invoice-list">${groupsHtml}</div>
</div>
`;
}
function formatInvoiceMonthLabel(yearMonth) {
if (!yearMonth || yearMonth === 'unknown') return 'Uden dato';
try {
const date = new Date(`${yearMonth}-01`);
return date.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' });
} catch {
return yearMonth;
}
}
function getFilteredCustomerInvoices() {
const term = String(customerInvoiceSearchTerm || '').trim().toLowerCase();
if (!term) return customerInvoicesData;
return customerInvoicesData.filter((invoice) => {
const haystack = [
invoice.invoice_number,
invoice.invoice_date,
invoice.due_date,
invoice.heading,
invoice.note_text,
...(Array.isArray(invoice.lines) ? invoice.lines.flatMap((line) => [
line.product_number,
line.product_name,
line.description
]) : [])
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
}
function renderFilteredCustomerInvoices() {
const container = document.getElementById('customerInvoicesContainer');
const countBadge = document.getElementById('customerInvoiceResultCount');
if (!container) return;
const filteredInvoices = getFilteredCustomerInvoices();
container.innerHTML = renderCustomerInvoiceCards(filteredInvoices);
if (countBadge) {
countBadge.textContent = String(filteredInvoices.length);
}
}
function filterCustomerInvoices(value) {
customerInvoiceSearchTerm = String(value || '');
renderFilteredCustomerInvoices();
}
function clearCustomerInvoiceSearch() {
const input = document.getElementById('customerInvoiceSearchInput');
customerInvoiceSearchTerm = '';
if (input) {
input.value = '';
input.focus();
}
renderFilteredCustomerInvoices();
}
async function loadCustomerInvoices(force = false) {
const container = document.getElementById('customerInvoicesContainer');
const countBadge = document.getElementById('customerInvoiceResultCount');
if (!container) return;
if (customerInvoicesLoaded && !force) return;
container.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
<div class="small text-muted mt-2">Indlæser fakturaer...</div>
</div>
`;
try {
const data = await fetchCustomerInvoices(force);
if (!data.economic_customer_number) {
container.innerHTML = '<div class="alert alert-info mb-0">Kunden har ikke et e-conomic kundenummer i BMC Hub endnu.</div>';
if (countBadge) countBadge.textContent = '0';
customerInvoicesLoaded = true;
return;
}
renderFilteredCustomerInvoices();
customerInvoicesLoaded = true;
} catch (error) {
if (countBadge) countBadge.textContent = '0';
container.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente fakturaer')}</div>`;
}
}
async function fetchCustomerInvoices(force = false) {
if (customerInvoicesData.length > 0 && !force) {
return {
customer_id: customerId,
items: customerInvoicesData,
economic_customer_number: customerData?.economic_customer_number || true
};
}
const response = await fetch(`/api/v1/customers/${customerId}/economic-invoices`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Kunne ikke hente fakturaer');
}
customerInvoicesData = Array.isArray(data.items) ? data.items : [];
return data;
}
function renderInvoiceDetailModal(invoice) {
const linesHtml = renderCustomerInvoiceLineRows(invoice.lines || []);
const notePanel = (invoice.heading || invoice.note_text) ? `
<div class="customer-invoice-note-panel">
${invoice.heading ? `<div class="fw-semibold mb-1">${escapeHtml(invoice.heading)}</div>` : ''}
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
</div>
` : '';
return `
<div class="invoice-detail-summary">
<div class="invoice-detail-card">
<span class="label">Fakturanummer</span>
<span class="value">${escapeHtml(invoice.invoice_number || '-')}</span>
</div>
<div class="invoice-detail-card">
<span class="label">Status</span>
<span class="value">${escapeHtml(invoice.source_type || '-')}</span>
</div>
<div class="invoice-detail-card">
<span class="label">Fakturadato</span>
<span class="value">${escapeHtml(formatDate(invoice.invoice_date) || '-')}</span>
</div>
<div class="invoice-detail-card">
<span class="label">Forfald</span>
<span class="value">${escapeHtml(formatDate(invoice.due_date) || '-')}</span>
</div>
<div class="invoice-detail-card">
<span class="label">Netto</span>
<span class="value">${escapeHtml(formatCurrency(invoice.net_amount, invoice.currency || 'DKK'))}</span>
</div>
<div class="invoice-detail-card">
<span class="label">Total</span>
<span class="value">${escapeHtml(formatCurrency(invoice.total_amount, invoice.currency || 'DKK'))}</span>
</div>
</div>
${notePanel}
${linesHtml}
`;
}
async function openCustomerInvoiceDetail(invoiceNumber) {
const body = document.getElementById('customerInvoiceDetailBody');
const modalElement = document.getElementById('customerInvoiceDetailModal');
if (!body || !modalElement) return;
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary"></div></div>';
const modal = new bootstrap.Modal(modalElement);
modal.show();
try {
await fetchCustomerInvoices(false);
const matches = customerInvoicesData.filter(invoice => String(invoice.invoice_number || '') === String(invoiceNumber || ''));
if (!matches.length) {
body.innerHTML = '<div class="alert alert-warning mb-0">Kunne ikke finde fakturaen i de importerede kundedata.</div>';
return;
}
body.innerHTML = matches.map(renderInvoiceDetailModal).join('<hr class="my-4">');
} catch (error) {
body.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente faktura')}</div>`;
}
}
async function loadActivity() {
const container = document.getElementById('activityContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
@ -6166,11 +6801,15 @@ function renderBillingMatrix(matrix) {
const amount = cell.amount || 0;
const statusBadge = getStatusBadge(cell.status);
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
const detailButton = cell.invoice_number
? `<button type="button" class="matrix-cell-button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(cell.invoice_number)).replace(/'/g, "\\'")}')"><i class="bi bi-receipt-cutoff"></i>Se faktura</button>`
: '';
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
<div class="d-flex flex-column align-items-center">
<div class="fw-500">${formatDKK(amount)}</div>
<div>${statusBadge}</div>
${detailButton}
</div>
</td>`;
}).join('');

View File

@ -370,7 +370,7 @@ async def create_hardware(data: dict):
try:
query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id,
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
@ -378,7 +378,7 @@ async def create_hardware(data: dict):
rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
@ -392,6 +392,7 @@ async def create_hardware(data: dict):
data.get("model"),
data.get("serial_number"),
data.get("customer_asset_id"),
data.get("current_location_id"),
data.get("internal_asset_id"),
data.get("notes"),
data.get("current_owner_type", "bmc"),
@ -1172,4 +1173,3 @@ async def list_eset_incidents(
"""
result = execute_query(query, (severity_list, limit))
return result or []

View File

@ -483,7 +483,14 @@ async def _build_customer_document_hits(customer_id: int, customer_name: str, qu
title_hit = _count_term_hits(title, terms["query_terms"])
if has_query:
if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
# A block is only relevant when it contains every word from the user's
# search. A query such as "sales management" must not return a block
# containing just one of the two words.
all_query_terms_match = all(
term in _normalize_text_for_match(searchable)
for term in terms["query_terms"]
)
if not all_query_terms_match:
continue
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
continue
@ -3106,7 +3113,9 @@ async def get_migration_wizard_v2_context(
if summary_source_parts:
summary_input = (
f"Kunde: {customer_name}\n"
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n"
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n"
"VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, "
"eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\n\n"
+ "\n\n".join(summary_source_parts)
)
ai_summary = await ollama_service.generate_summary(summary_input)
@ -3120,3 +3129,35 @@ async def get_migration_wizard_v2_context(
"invoice_hits": invoice_hits,
"ai_summary": ai_summary,
}
@router.get("/internet-connections/customer-documents/segments/{segment_id}")
async def get_customer_document_segment(segment_id: int):
"""Return the complete, indexed text block for the migration wizard."""
row = execute_query_single(
"""
SELECT
seg.id AS segment_id,
seg.document_id,
seg.block_index,
seg.block_title AS title,
seg.content,
doc.original_filename
FROM internet_connections_customer_document_segments seg
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
WHERE seg.id = %s
AND doc.deleted_at IS NULL
""",
(segment_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Text block not found")
return {
"segment_id": int(row["segment_id"]),
"document_id": int(row["document_id"]),
"block_index": int(row.get("block_index") or 0),
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
"original_filename": row.get("original_filename") or "Tekstfil",
"content": str(row.get("content") or ""),
}

View File

@ -107,6 +107,28 @@
white-space: pre-wrap;
}
.wiz-segment-button {
width: 100%;
border: 0;
padding: 0;
text-align: left;
color: inherit;
background: transparent;
}
.wiz-segment-button:hover .wiz-card,
.wiz-segment-button:focus-visible .wiz-card {
border-color: rgba(15, 76, 117, 0.45);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
}
.wiz-full-block {
white-space: pre-wrap;
max-height: 60vh;
overflow: auto;
margin: 0;
}
@media (max-width: 991px) {
.wiz-grid {
grid-template-columns: 1fr;
@ -256,11 +278,27 @@
</div>
</div>
</div>
<div class="modal fade" id="segmentModal" tabindex="-1" aria-labelledby="segmentModalTitle" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title" id="segmentModalTitle">Tekstblok</h5>
<div class="wiz-meta" id="segmentModalMeta"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body"><pre class="wiz-full-block" id="segmentModalContent">Henter tekstblok…</pre></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let customerSearchTimer = null;
let customerSearchRequest = 0;
let customerOptions = [];
let localUploadedDocuments = [];
@ -346,6 +384,7 @@
async function onCustomerInputChanged() {
const input = document.getElementById('customerSearchInput');
const query = input.value.trim();
const requestId = ++customerSearchRequest;
document.getElementById('selectedCustomerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
@ -358,6 +397,8 @@
if (query.length < 2) return;
const items = await searchCustomers(query);
// Ignore a slower response from an earlier, shorter search phrase.
if (requestId !== customerSearchRequest || input.value.trim() !== query) return;
renderCustomerOptions(items);
}
@ -457,22 +498,24 @@
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
} else {
segmentList.innerHTML = segmentHits.map(item => `
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.title || 'Blok')}</h3>
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score}</div>
<button class="wiz-segment-button" type="button" onclick="openSegment(${Number(item.segment_id)})" aria-label="Vis hele tekstblokken: ${escapeHtml(item.title || 'Blok')}">
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.title || 'Blok')}</h3>
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken</div>
</div>
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
</div>
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
<div class="wiz-meta mt-2">
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
</div>
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
</div>
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
<div class="wiz-meta mt-2">
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
</div>
</div>
</button>
`).join('');
}
@ -496,6 +539,25 @@
}
}
async function openSegment(segmentId) {
const modalElement = document.getElementById('segmentModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
document.getElementById('segmentModalTitle').textContent = 'Tekstblok';
document.getElementById('segmentModalMeta').textContent = '';
document.getElementById('segmentModalContent').textContent = 'Henter tekstblok…';
modal.show();
try {
const response = await fetch(`/api/v1/internet-connections/customer-documents/segments/${segmentId}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Kunne ikke hente tekstblokken');
document.getElementById('segmentModalTitle').textContent = payload.title || 'Tekstblok';
document.getElementById('segmentModalMeta').textContent = `${payload.original_filename || 'Tekstfil'} · blok ${Number(payload.block_index || 0) + 1}`;
document.getElementById('segmentModalContent').textContent = payload.content || 'Blokken er tom.';
} catch (error) {
document.getElementById('segmentModalContent').textContent = error.message || 'Kunne ikke hente tekstblokken.';
}
}
async function uploadCustomerFile() {
const fileInput = document.getElementById('customerFileInput');
const notes = document.getElementById('customerFileNotes').value.trim();

View File

@ -3,6 +3,7 @@ Invoice Error Finder API router.
"""
import json
import logging
import re
from datetime import date
from typing import Any, Dict, List, Optional
@ -26,6 +27,7 @@ ALLOWED_ISSUE_STATUSES = {
"ready_to_invoice",
"invoiced",
"ignored",
"resolved",
}
ISSUE_STATUS_LABELS = {
@ -33,9 +35,10 @@ ISSUE_STATUS_LABELS = {
"investigating": "Under undersøgelse",
"approved_change": "Godkendt ændring",
"error_found": "Fejl fundet",
"ready_to_invoice": "Klar til fakturering",
"ready_to_invoice": "Opret ordrekladde",
"invoiced": "Faktureret",
"ignored": "Ignoreret",
"resolved": "Løst",
}
ISSUE_TYPE_LABELS = {
@ -67,6 +70,95 @@ class CreateOrdreDraftRequest(BaseModel):
description: Optional[str] = None
def _tokenize_product_text(value: Optional[str]) -> List[str]:
if not value:
return []
return re.findall(r"[a-z0-9]+", value.lower())
_PRODUCT_MATCH_STOP_TOKENS = {
"periode", "period", "forbrugsperiode",
"jan", "januar", "january",
"feb", "februar", "february",
"mar", "marts", "march",
"apr", "april",
"maj", "may",
"jun", "juni", "june",
"jul", "juli", "july",
"aug", "august",
"sep", "sept", "september",
"okt", "oct", "october", "oktober",
"nov", "november",
"dec", "december",
"til", "from", "to", "fra",
}
def _normalized_product_tokens(value: Optional[str]) -> List[str]:
tokens = []
for token in _tokenize_product_text(value):
if token in _PRODUCT_MATCH_STOP_TOKENS:
continue
if token.isdigit() and len(token) == 4:
continue
tokens.append(token)
return tokens
def _line_matches_issue_product(
line_product_number: Optional[str],
line_product_name: Optional[str],
line_description: Optional[str],
issue_product_number: Optional[str],
issue_product_name: Optional[str],
extra_text: Optional[str] = None,
) -> bool:
line_number = (line_product_number or "").strip().lower()
issue_number = (issue_product_number or "").strip().lower()
issue_name = (issue_product_name or "").strip().lower()
combined_text = " ".join(
part.strip().lower()
for part in [line_product_name or "", line_description or "", extra_text or ""]
if part and part.strip()
)
if not issue_name:
return bool(line_number and issue_number and line_number == issue_number)
if not combined_text:
return False
issue_tokens = _normalized_product_tokens(issue_name)
line_tokens = _normalized_product_tokens(combined_text)
issue_token_set = set(issue_tokens)
line_token_set = set(line_tokens)
shared_tokens = issue_token_set & line_token_set
alpha_shared = {token for token in shared_tokens if any(ch.isalpha() for ch in token)}
if issue_name in combined_text or combined_text in issue_name:
return True
if line_number and issue_number and line_number == issue_number:
if not issue_token_set:
return True
if len(shared_tokens) >= max(1, min(2, len(issue_token_set))):
return True
if not issue_token_set or not line_token_set:
return False
coverage = len(shared_tokens) / max(1, len(issue_token_set))
if len(issue_token_set) == 1:
return len(shared_tokens) >= 1
if len(issue_token_set) == 2:
return len(shared_tokens) >= 2
if coverage >= 0.75:
return True
if coverage >= 0.5 and len(alpha_shared) >= 1:
return True
return len(shared_tokens) >= 3 and len(alpha_shared) >= 1
def _get_user_id(request: Request) -> Optional[int]:
value = getattr(request.state, "user_id", None)
if value is not None:
@ -255,9 +347,22 @@ async def list_issues(
f"""
SELECT
i.*,
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
COALESCE(u.full_name, u.username) AS assigned_user_name,
sg.titel AS sag_title
FROM invoice_error_finder_issues i
LEFT JOIN customers c ON c.id = i.customer_id
LEFT JOIN LATERAL (
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
WHERE c.economic_customer_number IS NOT NULL
AND inv.customer_number = c.economic_customer_number
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
LIMIT 1
) latest_line ON TRUE
LEFT JOIN users u ON u.user_id = i.assigned_user_id
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
WHERE {where_clause}
@ -289,9 +394,22 @@ async def get_issue(
"""
SELECT
i.*,
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
COALESCE(u.full_name, u.username) AS assigned_user_name,
sg.titel AS sag_title
FROM invoice_error_finder_issues i
LEFT JOIN customers c ON c.id = i.customer_id
LEFT JOIN LATERAL (
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
WHERE c.economic_customer_number IS NOT NULL
AND inv.customer_number = c.economic_customer_number
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
LIMIT 1
) latest_line ON TRUE
LEFT JOIN users u ON u.user_id = i.assigned_user_id
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
WHERE i.id = %s
@ -308,6 +426,479 @@ async def get_issue(
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/issues/{issue_id}/invoice-history")
async def get_issue_invoice_history(
issue_id: int,
current_user: dict = Depends(require_permission("invoice_error_finder.view")),
):
"""Return monthly invoice history around an issue for the same customer/product."""
try:
issue = execute_query_single(
"""
SELECT
i.id,
i.customer_id,
i.customer_name,
i.product_number,
i.product_name,
i.reference_period_start,
i.reference_period_end
FROM invoice_error_finder_issues i
WHERE i.id = %s
""",
(issue_id,),
)
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
customer_id = issue.get("customer_id")
product_number = issue.get("product_number")
reference_period_start = issue.get("reference_period_start")
if not customer_id:
raise HTTPException(status_code=400, detail="Issue has no mapped customer")
if not product_number:
raise HTTPException(status_code=400, detail="Issue has no product number")
if not reference_period_start:
raise HTTPException(status_code=400, detail="Issue has no reference period")
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
)
if not customer or not customer.get("economic_customer_number"):
raise HTTPException(status_code=400, detail="Customer has no e-conomic mapping")
source_rank = {"paid": 1, "booked": 2, "unpaid": 3, "draft": 4}
def dedupe_invoice_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
best_by_number: Dict[str, Dict[str, Any]] = {}
for row in rows:
invoice_number = row.get("source_invoice_number")
if not invoice_number:
continue
current_best = best_by_number.get(invoice_number)
candidate_rank = (
source_rank.get(row.get("source_type"), 9),
-(row.get("invoice_date").toordinal() if row.get("invoice_date") else 0),
-(int(row.get("invoice_id") or 0)),
)
if current_best is None:
best_by_number[invoice_number] = row
continue
current_rank = (
source_rank.get(current_best.get("source_type"), 9),
-(current_best.get("invoice_date").toordinal() if current_best.get("invoice_date") else 0),
-(int(current_best.get("invoice_id") or 0)),
)
if candidate_rank < current_rank:
best_by_number[invoice_number] = row
selected_ids = {row.get("invoice_id") for row in best_by_number.values() if row.get("invoice_id")}
return [row for row in rows if row.get("invoice_id") in selected_ids]
def build_invoice_payloads(rows: List[Dict[str, Any]]) -> tuple[Dict[int, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
invoices_by_id: Dict[int, Dict[str, Any]] = {}
invoices_by_month: Dict[str, List[Dict[str, Any]]] = {}
for row in rows:
invoice_id = row["invoice_id"]
month_key = row["month_start"].isoformat() if row.get("month_start") else None
if invoice_id not in invoices_by_id:
payload = {
"invoice_id": invoice_id,
"invoice_number": row.get("source_invoice_number"),
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
"total_amount": float(row.get("total_amount") or 0),
"net_amount": float(row.get("net_amount") or 0),
"vat_amount": float(row.get("vat_amount") or 0),
"currency": row.get("currency") or "DKK",
"source_type": row.get("source_type"),
"heading": row.get("heading") or None,
"note_text": row.get("note_text") or None,
"lines": [],
}
invoices_by_id[invoice_id] = payload
if month_key:
invoices_by_month.setdefault(month_key, []).append(payload)
invoices_by_id[invoice_id]["lines"].append(
{
"line_number": int(row.get("line_number") or 0),
"product_number": row.get("product_number"),
"product_name": row.get("product_name"),
"description": row.get("description"),
"quantity": float(row.get("quantity") or 0),
"unit_price": float(row.get("unit_price") or 0),
"line_net_amount": float(row.get("line_net_amount") or 0),
}
)
return invoices_by_id, invoices_by_month
def aggregate_months(month_rows: List[Dict[str, Any]], matched_rows: List[Dict[str, Any]], invoices_by_month: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
agg_by_month: Dict[str, Dict[str, Any]] = {}
for row in matched_rows:
month_key = row["month_start"].isoformat() if row.get("month_start") else None
if not month_key:
continue
bucket = agg_by_month.setdefault(
month_key,
{
"line_count": 0,
"total_quantity": 0.0,
"total_amount": 0.0,
"invoice_numbers": [],
"invoice_dates": [],
"descriptions": [],
"_seen_invoice_numbers": set(),
"_seen_descriptions": set(),
},
)
bucket["line_count"] += 1
bucket["total_quantity"] += float(row.get("quantity") or 0)
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
invoice_number = row.get("source_invoice_number")
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
bucket["invoice_numbers"].append(invoice_number)
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
bucket["_seen_invoice_numbers"].add(invoice_number)
description = row.get("description")
if description and description not in bucket["_seen_descriptions"]:
bucket["descriptions"].append(description)
bucket["_seen_descriptions"].add(description)
month_payloads: List[Dict[str, Any]] = []
for month_row in month_rows:
month_key = month_row["month_start"].isoformat() if month_row.get("month_start") else None
bucket = agg_by_month.get(month_key) or {}
month_payloads.append(
{
"month_start": month_key,
"line_count": int(bucket.get("line_count") or 0),
"total_quantity": float(bucket.get("total_quantity") or 0),
"total_amount": float(bucket.get("total_amount") or 0),
"invoice_numbers": bucket.get("invoice_numbers") or [],
"invoice_dates": bucket.get("invoice_dates") or [],
"descriptions": bucket.get("descriptions") or [],
"invoices": invoices_by_month.get(month_key, []),
"is_reference_month": month_key == reference_period_start.replace(day=1).isoformat(),
"is_fallback_history": False,
}
)
return month_payloads
month_rows = execute_query(
"""
SELECT generate_series(
date_trunc('month', %s::date) - interval '13 months',
date_trunc('month', %s::date) + interval '2 months',
interval '1 month'
)::date AS month_start
ORDER BY month_start
""",
(reference_period_start, reference_period_start),
) or []
candidate_window_rows = execute_query(
"""
SELECT
inv.id AS invoice_id,
inv.source_invoice_number,
inv.invoice_date,
inv.total_amount,
inv.net_amount,
inv.vat_amount,
inv.currency,
inv.source_type,
date_trunc('month', inv.invoice_date)::date AS month_start,
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
NULLIF(
CONCAT_WS(
E'\n',
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
),
''
) AS note_text,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
WHERE inv.customer_number = %s
AND inv.invoice_date >= date_trunc('month', %s::date) - interval '13 months'
AND inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
""",
(
customer["economic_customer_number"],
reference_period_start,
reference_period_start,
),
) or []
window_rows = dedupe_invoice_rows(candidate_window_rows)
matched_window_rows = [
row for row in window_rows
if _line_matches_issue_product(
row.get("product_number"),
row.get("product_name"),
row.get("description"),
product_number,
issue.get("product_name"),
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
)
]
matched_window_invoice_ids = {row["invoice_id"] for row in matched_window_rows}
invoice_rows = [row for row in window_rows if row.get("invoice_id") in matched_window_invoice_ids]
_, invoices_by_month = build_invoice_payloads(invoice_rows)
months_payload = aggregate_months(month_rows, matched_window_rows, invoices_by_month)
fallback_month_rows: List[Dict[str, Any]] = []
if not matched_window_invoice_ids:
candidate_older_rows = execute_query(
"""
SELECT
inv.id AS invoice_id,
inv.source_invoice_number,
inv.invoice_date,
inv.total_amount,
inv.net_amount,
inv.vat_amount,
inv.currency,
inv.source_type,
date_trunc('month', inv.invoice_date)::date AS month_start,
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
NULLIF(
CONCAT_WS(
E'\n',
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
),
''
) AS note_text,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
WHERE inv.customer_number = %s
AND inv.invoice_date < date_trunc('month', %s::date) - interval '13 months'
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
""",
(
customer["economic_customer_number"],
reference_period_start,
),
) or []
older_rows = dedupe_invoice_rows(candidate_older_rows)
matched_older_rows = [
row for row in older_rows
if _line_matches_issue_product(
row.get("product_number"),
row.get("product_name"),
row.get("description"),
product_number,
issue.get("product_name"),
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
)
]
top3_invoice_ids: List[int] = []
seen_ids = set()
for row in matched_older_rows:
invoice_id = row.get("invoice_id")
if invoice_id and invoice_id not in seen_ids:
seen_ids.add(invoice_id)
top3_invoice_ids.append(invoice_id)
if len(top3_invoice_ids) == 3:
break
fallback_invoice_rows = [row for row in older_rows if row.get("invoice_id") in set(top3_invoice_ids)]
_, fallback_invoices_by_month = build_invoice_payloads(fallback_invoice_rows)
fallback_month_map: Dict[str, Dict[str, Any]] = {}
for row in matched_older_rows:
if row.get("invoice_id") not in top3_invoice_ids or not row.get("month_start"):
continue
month_key = row["month_start"].isoformat()
bucket = fallback_month_map.setdefault(
month_key,
{
"month_start": month_key,
"line_count": 0,
"total_quantity": 0.0,
"total_amount": 0.0,
"invoice_numbers": [],
"invoice_dates": [],
"descriptions": [],
"invoices": fallback_invoices_by_month.get(month_key, []),
"is_reference_month": False,
"is_fallback_history": True,
"_seen_invoice_numbers": set(),
"_seen_descriptions": set(),
},
)
bucket["line_count"] += 1
bucket["total_quantity"] += float(row.get("quantity") or 0)
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
invoice_number = row.get("source_invoice_number")
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
bucket["invoice_numbers"].append(invoice_number)
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
bucket["_seen_invoice_numbers"].add(invoice_number)
description = row.get("description")
if description and description not in bucket["_seen_descriptions"]:
bucket["descriptions"].append(description)
bucket["_seen_descriptions"].add(description)
fallback_month_rows = sorted(
[
{key: value for key, value in month.items() if not key.startswith("_")}
for month in fallback_month_map.values()
],
key=lambda item: item["month_start"],
)
if not matched_window_invoice_ids and not fallback_month_rows:
candidate_global_rows = execute_query(
"""
SELECT
inv.id AS invoice_id,
inv.source_invoice_number,
inv.invoice_date,
inv.total_amount,
inv.net_amount,
inv.vat_amount,
inv.currency,
inv.source_type,
date_trunc('month', inv.invoice_date)::date AS month_start,
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
NULLIF(
CONCAT_WS(
E'\n',
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
),
''
) AS note_text,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
WHERE inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
""",
(reference_period_start,),
) or []
global_rows = dedupe_invoice_rows(candidate_global_rows)
matched_global_rows = [
row for row in global_rows
if _line_matches_issue_product(
row.get("product_number"),
row.get("product_name"),
row.get("description"),
product_number,
issue.get("product_name"),
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
)
]
top3_global_invoice_ids: List[int] = []
seen_ids = set()
for row in matched_global_rows:
invoice_id = row.get("invoice_id")
if invoice_id and invoice_id not in seen_ids:
seen_ids.add(invoice_id)
top3_global_invoice_ids.append(invoice_id)
if len(top3_global_invoice_ids) == 3:
break
global_fallback_invoice_rows = [
row for row in global_rows if row.get("invoice_id") in set(top3_global_invoice_ids)
]
_, global_fallback_invoices_by_month = build_invoice_payloads(global_fallback_invoice_rows)
global_fallback_month_map: Dict[str, Dict[str, Any]] = {}
for row in matched_global_rows:
if row.get("invoice_id") not in top3_global_invoice_ids:
continue
if not row.get("month_start"):
continue
month_key = row["month_start"].isoformat()
bucket = global_fallback_month_map.setdefault(
month_key,
{
"month_start": month_key,
"line_count": 0,
"total_quantity": 0.0,
"total_amount": 0.0,
"invoice_numbers": [],
"invoice_dates": [],
"descriptions": [],
"invoices": global_fallback_invoices_by_month.get(month_key, []),
"is_reference_month": False,
"is_fallback_history": True,
"fallback_label": "Seneste lignende fakturaer",
"_seen_invoice_numbers": set(),
"_seen_descriptions": set(),
},
)
bucket["line_count"] += 1
bucket["total_quantity"] += float(row.get("quantity") or 0)
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
invoice_number = row.get("source_invoice_number")
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
bucket["invoice_numbers"].append(invoice_number)
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
bucket["_seen_invoice_numbers"].add(invoice_number)
description = row.get("description")
if description and description not in bucket["_seen_descriptions"]:
bucket["descriptions"].append(description)
bucket["_seen_descriptions"].add(description)
fallback_month_rows = sorted(
[
{key: value for key, value in month.items() if not key.startswith("_")}
for month in global_fallback_month_map.values()
],
key=lambda item: item["month_start"],
)
result = {
"issue_id": issue_id,
"customer_id": customer_id,
"customer_name": customer.get("name") or issue.get("customer_name"),
"economic_customer_number": customer.get("economic_customer_number"),
"product_number": product_number,
"product_name": issue.get("product_name"),
"reference_period_start": reference_period_start.isoformat(),
"months": [*fallback_month_rows, *months_payload],
}
return result
except HTTPException:
raise
except Exception as exc:
logger.error("❌ Get issue invoice history failed: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
@router.patch("/issues/{issue_id}/status")
async def update_issue_status(
issue_id: int,
@ -320,7 +911,7 @@ async def update_issue_status(
raise HTTPException(status_code=400, detail="Invalid status")
resolved_at = None
if payload.status in {"invoiced", "ignored"}:
if payload.status in {"invoiced", "ignored", "resolved"}:
resolved_at = "CURRENT_TIMESTAMP"
extra_fields = []
@ -334,7 +925,11 @@ async def update_issue_status(
extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s")
extra_values.append(payload.notes)
resolved_sql = f"resolved_at = COALESCE(resolved_at, {resolved_at})" if resolved_at else "resolved_at = resolved_at"
resolved_sql = (
f"resolved_at = COALESCE(resolved_at, {resolved_at})"
if resolved_at
else "resolved_at = NULL"
)
execute_query(
f"""

View File

@ -116,7 +116,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
'error_found',
'ready_to_invoice',
'invoiced',
'ignored'
'ignored',
'resolved'
)),
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
customer_name VARCHAR(255),

View File

@ -4,6 +4,8 @@ Compares imported e-conomic invoices with subscriptions / Simply orders and
writes issues to invoice_error_finder_issues.
"""
import logging
import json
import re
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional
from dateutil.relativedelta import relativedelta
@ -12,6 +14,38 @@ from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
DEFAULT_IGNORED_PRODUCT_TEXTS = [
"faktureringsgebyr",
"gebyr",
"porto",
"fragt",
"fragtomkostning",
"forsendelse",
"shipping",
"levering",
"engangsydelse",
"engangsarbejde",
"oprettelse",
"opstartsgebyr",
"installation",
"installationsgebyr",
"timeforbrug",
"arbejdstid",
"konsulenttimer",
"supporttid",
"teknikertid",
"montørtimer",
"projektarbejde",
]
DEFAULT_IGNORED_PRODUCT_PATTERNS = [
re.compile(r"\bcase\s*id\s*cc[\w-]+\b", re.IGNORECASE),
re.compile(r"\bcase\s*id\b", re.IGNORECASE),
re.compile(r"\bsag\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
re.compile(r"\bprojekt\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
re.compile(r"\b(?:timeforbrug|arbejdstid|konsulenttimer|supporttid|teknikertid|montørtimer)\b", re.IGNORECASE),
]
class DetectionService:
"""Detect invoice anomalies and write issues."""
@ -23,16 +57,40 @@ class DetectionService:
):
self.quantity_drop_threshold = quantity_drop_threshold
self.open_order_days_threshold = open_order_days_threshold
self._ignored_product_texts_cache: Optional[List[str]] = None
self._seen_issue_ids: set[int] = set()
def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
"""
Run all detection rules for the given reference month (defaults to current month).
Returns counts per issue_type.
Run detection rules for a specific month or sweep historical invoice months.
Returns aggregated counts per issue_type.
"""
if reference_month is None:
reference_month = date.today().replace(day=1)
self._ignore_existing_issues()
if reference_month is not None:
return self._analyze_single_month(reference_month)
totals = {
"missing_line": 0,
"open_order_not_invoiced": 0,
"quantity_drop": 0,
"price_change": 0,
}
months = self._get_analysis_months()
logger.info("🔍 Running historical invoice error detection for %s month(s)", len(months))
for month in months:
month_counts = self._analyze_single_month(month)
for key, value in month_counts.items():
totals[key] = totals.get(key, 0) + int(value or 0)
logger.info("✅ Historical detection complete: %s", totals)
return totals
def _analyze_single_month(self, reference_month: date) -> Dict[str, int]:
previous_month = reference_month - relativedelta(months=1)
self._seen_issue_ids = set()
logger.info("🔍 Running invoice error detection for %s", reference_month)
@ -43,9 +101,40 @@ class DetectionService:
"price_change": self._detect_price_changes(reference_month, previous_month),
}
logger.info("✅ Detection complete: %s", counts)
self._resolve_stale_issues(reference_month)
logger.info("✅ Detection complete for %s: %s", reference_month, counts)
return counts
@staticmethod
def _get_analysis_months() -> List[date]:
current_month = date.today().replace(day=1)
bounds = execute_query_single(
"""
SELECT
DATE_TRUNC('month', MIN(invoice_date))::date AS first_month,
GREATEST(
DATE_TRUNC('month', MAX(invoice_date))::date,
DATE_TRUNC('month', CURRENT_DATE)::date
) AS last_month
FROM invoice_error_finder_economic_invoices
"""
) or {}
first_month = bounds.get("first_month")
last_month = bounds.get("last_month") or current_month
if not first_month:
return [current_month]
month = first_month + relativedelta(months=1)
months: List[date] = []
while month <= last_month:
months.append(month)
month += relativedelta(months=1)
return months or [last_month]
def _detect_missing_lines(self, current_month: date, previous_month: date) -> int:
"""
Products invoiced in previous month but missing in current month for same customer.
@ -69,7 +158,9 @@ class DetectionService:
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
SUM(line.quantity) AS quantity,
MAX(inv.invoice_date) AS last_invoice_date
MAX(inv.invoice_date) AS last_invoice_date,
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
@ -97,6 +188,8 @@ class DetectionService:
prev.product_key,
prev.quantity AS expected_quantity,
prev.last_invoice_date,
prev.product_name,
prev.description,
c.name AS customer_name,
m2.hub_customer_id
FROM previous_lines prev
@ -118,12 +211,18 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
issue_id = self._upsert_issue(
issue_type="missing_line",
customer_id=hub_customer_id,
customer_name=customer_name,
product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start,
reference_period_end=current_end,
expected_quantity=row.get("expected_quantity"),
@ -186,6 +285,11 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("subject"),
):
continue
# Check if there is any e-conomic invoice line for this customer + product recently
has_invoice = self._has_recent_invoice_for_product(
@ -238,7 +342,9 @@ class DetectionService:
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
DATE_TRUNC('month', inv.invoice_date)::date AS period,
SUM(line.quantity) AS quantity
SUM(line.quantity) AS quantity,
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
@ -249,7 +355,7 @@ class DetectionService:
GROUP BY customer_key, product_key, period
),
prev AS (
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
SELECT customer_key, product_key, quantity, product_name, description FROM monthly_qty WHERE period = %s
),
cur AS (
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
@ -259,6 +365,8 @@ class DetectionService:
prev.product_key,
prev.quantity AS expected_quantity,
cur.quantity AS actual_quantity,
prev.product_name,
prev.description,
c.name AS customer_name,
m.hub_customer_id
FROM prev
@ -286,12 +394,18 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
issue_id = self._upsert_issue(
issue_type="quantity_drop",
customer_id=hub_customer_id,
customer_name=customer_name,
product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start,
reference_period_end=current_end,
expected_quantity=row["expected_quantity"],
@ -324,7 +438,9 @@ class DetectionService:
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
DATE_TRUNC('month', inv.invoice_date)::date AS period,
AVG(line.unit_price) AS avg_price
AVG(line.unit_price) AS avg_price,
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id
@ -336,7 +452,7 @@ class DetectionService:
GROUP BY customer_key, product_key, period
),
prev AS (
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
SELECT customer_key, product_key, avg_price, product_name, description FROM monthly_price WHERE period = %s
),
cur AS (
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
@ -346,6 +462,8 @@ class DetectionService:
prev.product_key,
prev.avg_price AS expected_price,
cur.avg_price AS actual_price,
prev.product_name,
prev.description,
c.name AS customer_name,
m.hub_customer_id
FROM prev
@ -371,6 +489,11 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
expected_price = row["expected_price"]
actual_price = row["actual_price"]
@ -383,6 +506,7 @@ class DetectionService:
customer_id=hub_customer_id,
customer_name=customer_name,
product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start,
reference_period_end=current_end,
expected_price=expected_price,
@ -473,10 +597,130 @@ class DetectionService:
return False
def _ignore_existing_issues(self) -> None:
ignored_terms = self._load_ignored_product_texts()
if not ignored_terms:
return
rows = execute_query(
"""
SELECT id, product_name
FROM invoice_error_finder_issues
WHERE status IN ('open', 'investigating', 'approved_change', 'error_found', 'ready_to_invoice')
""",
(),
) or []
for row in rows:
if not self._is_ignored_product_text(row.get("product_name")):
continue
execute_query(
"""
UPDATE invoice_error_finder_issues
SET status = 'ignored',
ignored_until = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(row["id"],),
)
def _load_ignored_product_texts(self) -> List[str]:
if self._ignored_product_texts_cache is not None:
return self._ignored_product_texts_cache
setting = execute_query_single(
"SELECT value FROM settings WHERE key = %s",
("invoice_error_finder_ignored_product_texts",),
) or {}
raw_value = setting.get("value")
values: List[str] = []
if raw_value:
try:
parsed = json.loads(str(raw_value))
if isinstance(parsed, list):
values = [str(item) for item in parsed]
elif isinstance(parsed, str):
values = [parsed]
except (TypeError, ValueError, json.JSONDecodeError):
values = str(raw_value).replace(";", "\n").replace(",", "\n").splitlines()
combined = DEFAULT_IGNORED_PRODUCT_TEXTS + values
normalized: List[str] = []
seen = set()
for value in combined:
item = self._normalize_text(value)
if not item or item in seen:
continue
seen.add(item)
normalized.append(item)
self._ignored_product_texts_cache = normalized
return normalized
@staticmethod
def _normalize_text(value: Any) -> str:
return " ".join(str(value or "").strip().lower().split())
def _is_ignored_product_text(self, *values: Any) -> bool:
ignore_terms = self._load_ignored_product_texts()
if not ignore_terms:
ignore_terms = []
normalized_values = [
self._normalize_text(value)
for value in values
if self._normalize_text(value)
]
if not normalized_values:
return False
for candidate in normalized_values:
for term in ignore_terms:
if term and term in candidate:
return True
for pattern in DEFAULT_IGNORED_PRODUCT_PATTERNS:
if pattern.search(candidate):
return True
return False
def _resolve_stale_issues(self, reference_month: date) -> None:
period_start, period_end = self._month_bounds(reference_month)
active_statuses = ("open", "investigating", "approved_change", "error_found", "ready_to_invoice")
issue_types = ("missing_line", "open_order_not_invoiced", "quantity_drop", "price_change")
rows = execute_query(
"""
SELECT id
FROM invoice_error_finder_issues
WHERE reference_period_start = %s
AND reference_period_end = %s
AND issue_type = ANY(%s::text[])
AND status = ANY(%s::text[])
""",
(period_start, period_end, list(issue_types), list(active_statuses)),
) or []
stale_ids = [row["id"] for row in rows if int(row["id"]) not in self._seen_issue_ids]
if not stale_ids:
return
execute_query(
"""
UPDATE invoice_error_finder_issues
SET status = 'resolved',
resolved_at = COALESCE(resolved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY(%s::int[])
""",
(stale_ids,),
)
def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
"""Insert a new issue or update an existing open one."""
issue_type = kwargs["issue_type"]
customer_id = kwargs.get("customer_id")
customer_id = self._resolve_existing_customer_id(kwargs.get("customer_id"))
product_number = kwargs.get("product_number")
reference_period_start = kwargs.get("reference_period_start")
reference_period_end = kwargs.get("reference_period_end")
@ -513,6 +757,41 @@ class DetectionService:
),
)
if existing and existing.get("status") == "resolved":
execute_query(
"""
UPDATE invoice_error_finder_issues
SET status = %s,
resolved_at = NULL,
expected_quantity = COALESCE(%s, expected_quantity),
actual_quantity = COALESCE(%s, actual_quantity),
expected_price = COALESCE(%s, expected_price),
actual_price = COALESCE(%s, actual_price),
amount_impact = COALESCE(%s, amount_impact),
last_invoice_number = COALESCE(%s, last_invoice_number),
last_invoice_date = COALESCE(%s, last_invoice_date),
sales_order_number = COALESCE(%s, sales_order_number),
product_name = COALESCE(%s, product_name),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
kwargs.get("status", "open"),
kwargs.get("expected_quantity"),
kwargs.get("actual_quantity"),
kwargs.get("expected_price"),
kwargs.get("actual_price"),
kwargs.get("amount_impact"),
kwargs.get("last_invoice_number"),
kwargs.get("last_invoice_date"),
kwargs.get("sales_order_number"),
kwargs.get("product_name"),
existing["id"],
),
)
self._seen_issue_ids.add(int(existing["id"]))
return existing["id"]
if existing and existing.get("status") not in {"ignored", "invoiced"}:
execute_query(
"""
@ -525,6 +804,7 @@ class DetectionService:
last_invoice_number = COALESCE(%s, last_invoice_number),
last_invoice_date = COALESCE(%s, last_invoice_date),
sales_order_number = COALESCE(%s, sales_order_number),
product_name = COALESCE(%s, product_name),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
@ -537,9 +817,11 @@ class DetectionService:
kwargs.get("last_invoice_number"),
kwargs.get("last_invoice_date"),
kwargs.get("sales_order_number"),
kwargs.get("product_name"),
existing["id"],
),
)
self._seen_issue_ids.add(int(existing["id"]))
return existing["id"]
if existing and existing.get("status") == "invoiced":
@ -568,6 +850,7 @@ class DetectionService:
last_invoice_number = COALESCE(%s, last_invoice_number),
last_invoice_date = COALESCE(%s, last_invoice_date),
sales_order_number = COALESCE(%s, sales_order_number),
product_name = COALESCE(%s, product_name),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
@ -581,9 +864,11 @@ class DetectionService:
kwargs.get("last_invoice_number"),
kwargs.get("last_invoice_date"),
kwargs.get("sales_order_number"),
kwargs.get("product_name"),
existing["id"],
),
)
self._seen_issue_ids.add(int(existing["id"]))
return existing["id"]
row = execute_query_single(
@ -624,6 +909,8 @@ class DetectionService:
kwargs.get("notes"),
),
)
if row and row.get("id") is not None:
self._seen_issue_ids.add(int(row["id"]))
return row["id"] if row else None
@staticmethod
@ -638,6 +925,17 @@ class DetectionService:
)
return value if customer else None
@staticmethod
def _resolve_existing_customer_id(value: Any) -> Optional[int]:
if not isinstance(value, int):
return None
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s",
(value,),
)
return value if customer else None
@staticmethod
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
if hub_customer_id:

View File

@ -100,7 +100,7 @@
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="text-muted text-uppercase small mb-2">Klar til fakturering</h6>
<h6 class="text-muted text-uppercase small mb-2">Ordrekladder klar</h6>
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
</div>
<div class="bg-success bg-opacity-10 p-2 rounded">

View File

@ -30,9 +30,10 @@
<option value="investigating">Under undersøgelse</option>
<option value="approved_change">Godkendt ændring</option>
<option value="error_found">Fejl fundet</option>
<option value="ready_to_invoice">Klar til fakturering</option>
<option value="ready_to_invoice">Opret ordrekladde</option>
<option value="invoiced">Faktureret</option>
<option value="ignored">Ignoreret</option>
<option value="resolved">Løst</option>
</select>
</div>
<div class="col-12 col-md-3">
@ -99,12 +100,61 @@
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="text-muted small" id="paginationInfo"></span>
<div class="btn-group" id="paginationControls"></div>
</div>
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable" style="max-width: 90vw; width: 90vw;">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Fakturahistorik</h5>
<div class="text-muted small" id="historyModalMeta"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="historyModalStatus" class="alert d-none mb-3"></div>
<div class="d-flex flex-wrap gap-2 mb-3" id="historyModalActions">
<button type="button" class="btn btn-outline-success btn-sm" id="historyApproveBtn" disabled>
<i class="bi bi-check2 me-1"></i>Godkend
</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="historyInvestigatingBtn" disabled>
<i class="bi bi-search me-1"></i>Undersøgelse
</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="historyReadyBtn" disabled>
<i class="bi bi-receipt me-1"></i>Opret ordrekladde
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="historyCreateSagBtn" disabled>
<i class="bi bi-folder-plus me-1"></i>Opret sag
</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Måned</th>
<th>Status</th>
<th>Antal linjer</th>
<th>Samlet mængde</th>
<th>Samlet beløb</th>
<th>Fakturaer</th>
</tr>
</thead>
<tbody id="historyModalBody">
<tr><td colspan="6" class="text-muted text-center py-4">Vælg en fejl for at se historik</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let currentOffset = 0;
const pageSize = 100;
let currentHistoryIssueId = null;
const issueTypeLabels = {
missing_line: 'Manglende varelinje',
@ -119,9 +169,10 @@ const statusLabels = {
investigating: 'Under undersøgelse',
approved_change: 'Godkendt ændring',
error_found: 'Fejl fundet',
ready_to_invoice: 'Klar til fakturering',
ready_to_invoice: 'Opret ordrekladde',
invoiced: 'Faktureret',
ignored: 'Ignoreret'
ignored: 'Ignoreret',
resolved: 'Løst'
};
function escapeHtml(text) {
@ -143,6 +194,11 @@ function formatNumber(value) {
return new Intl.NumberFormat('da-DK').format(value);
}
function formatMonth(value) {
if (!value) return '-';
return new Intl.DateTimeFormat('da-DK', { year: 'numeric', month: 'short' }).format(new Date(value));
}
function statusBadge(status) {
const map = {
open: 'bg-danger',
@ -151,7 +207,8 @@ function statusBadge(status) {
error_found: 'bg-danger',
ready_to_invoice: 'bg-success',
invoiced: 'bg-secondary',
ignored: 'bg-light text-dark'
ignored: 'bg-light text-dark',
resolved: 'bg-secondary-subtle text-dark'
};
const cls = map[status] || 'bg-light text-dark';
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
@ -198,7 +255,7 @@ async function loadIssues() {
<tr>
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
<td>${escapeHtml(issue.product_name || issue.product_number || '-')}</td>
<td>${escapeHtml(issue.resolved_product_name || issue.product_name || issue.product_number || '-')}</td>
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
<td>${issue.reference_period_start || '-'}</td>
@ -222,13 +279,16 @@ async function loadIssues() {
}
function renderActionButtons(issue) {
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</span>';
if (issue.status === 'invoiced') return '<span class="text-muted small">Faktureret</span>';
const historyButton = `<button class="btn btn-outline-dark" title="Se 13 måneder tilbage og 2 frem" onclick="showInvoiceHistory(${issue.id})"><i class="bi bi-clock-history"></i></button>`;
if (issue.status === 'ignored') return `${historyButton}<span class="text-muted small ms-2">Ignoreret</span>`;
if (issue.status === 'invoiced') return `${historyButton}<span class="text-muted small ms-2">Faktureret</span>`;
if (issue.status === 'resolved') return `${historyButton}<span class="text-muted small ms-2">Løst</span>`;
return `
${historyButton}
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
<button class="btn btn-outline-primary" title="Klar til fakturering" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
<button class="btn btn-outline-primary" title="Opret ordrekladde" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
`;
@ -257,7 +317,8 @@ function goToPage(page) {
loadIssues();
}
async function updateStatus(issueId, status) {
async function updateStatus(issueId, status, options = {}) {
const { target = 'page', successMessage = 'Status opdateret' } = options;
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
method: 'PATCH',
@ -265,14 +326,23 @@ async function updateStatus(issueId, status) {
body: JSON.stringify({ status })
});
if (!res.ok) throw new Error('Opdatering fejlede');
showStatus('Status opdateret', 'success');
if (target === 'history') {
showHistoryStatus(successMessage, 'success');
} else {
showStatus(successMessage, 'success');
}
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
showStatus('Fejl: ' + err.message, 'danger');
}
}
}
async function createSag(issueId) {
async function createSag(issueId, options = {}) {
const { target = 'page' } = options;
const titel = prompt('Titel på sag:');
if (!titel) return;
try {
@ -283,10 +353,18 @@ async function createSag(issueId) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
if (target === 'history') {
showHistoryStatus(`Sag #${data.sag_id} oprettet`, 'success');
} else {
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
}
loadIssues();
} catch (err) {
showStatus('Fejl: ' + err.message, 'danger');
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
showStatus('Fejl: ' + err.message, 'danger');
}
}
}
@ -328,6 +406,162 @@ function showStatus(message, type) {
setTimeout(() => el.classList.add('d-none'), 4000);
}
function showHistoryStatus(message, type) {
const el = document.getElementById('historyModalStatus');
el.className = `alert alert-${type} mb-3`;
el.textContent = message;
el.classList.remove('d-none');
}
function setHistoryActionState(enabled) {
['historyApproveBtn', 'historyInvestigatingBtn', 'historyReadyBtn', 'historyCreateSagBtn']
.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function renderInvoiceLines(lines) {
if (!lines || lines.length === 0) {
return '<div class="text-muted small">Ingen linjer fundet</div>';
}
return `
<div class="table-responsive mt-2">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Linje</th>
<th>Varenr</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Beløb</th>
</tr>
</thead>
<tbody>
${lines.map(line => `
<tr>
<td>${formatNumber(line.line_number)}</td>
<td>${escapeHtml(line.product_number || '-')}</td>
<td class="text-truncate" style="max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(line.description || line.product_name || '-')}">${escapeHtml(line.description || line.product_name || '-')}</td>
<td>${formatNumber(line.quantity)}</td>
<td>${formatCurrency(line.unit_price)}</td>
<td>${formatCurrency(line.line_net_amount)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function renderInvoiceNotes(invoice) {
const parts = [invoice.heading, invoice.note_text].filter(Boolean);
if (parts.length === 0) {
return '';
}
return `
<div class="border rounded bg-light p-2 mb-2 small">
${parts.map(part => `<div>${escapeHtml(part)}</div>`).join('')}
</div>
`;
}
function renderInvoiceCard(invoice, monthKey, index) {
const collapseId = `invoice-lines-${monthKey}-${index}`;
return `
<div class="border rounded p-2 mb-2 bg-white">
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="text-truncate small" style="min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(`${invoice.invoice_number || '-'} (${invoice.invoice_date || '-'}) · ${invoice.heading || invoice.source_type || ''} · Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}`)}">
<strong>${escapeHtml(invoice.invoice_number || '-')}</strong>
<span class="text-muted">(${escapeHtml(invoice.invoice_date || '-')})</span>
<span class="text-muted">· ${escapeHtml(invoice.heading || invoice.source_type || '')}</span>
<span>· Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}</span>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false">
Vis linjer
</button>
</div>
<div class="collapse" id="${collapseId}">
${renderInvoiceNotes(invoice)}
${renderInvoiceLines(invoice.lines)}
</div>
</div>
`;
}
async function showInvoiceHistory(issueId) {
const body = document.getElementById('historyModalBody');
const meta = document.getElementById('historyModalMeta');
const status = document.getElementById('historyModalStatus');
currentHistoryIssueId = issueId;
setHistoryActionState(true);
status.classList.add('d-none');
meta.textContent = '';
body.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Indlæser historik...</td></tr>';
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
modal.show();
try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/invoice-history`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente historik');
meta.textContent = `${data.customer_name || 'Ukendt kunde'} · ${data.product_name || data.product_number || '-'}`;
body.innerHTML = data.months.map(row => {
const stateBadge = row.line_count > 0
? '<span class="badge bg-success">Faktureret</span>'
: '<span class="badge bg-danger">Manglende</span>';
const monthBadge = row.is_reference_month
? ' <span class="badge bg-warning text-dark">Reference</span>'
: row.is_fallback_history
? ` <span class="badge bg-info text-dark">${escapeHtml(row.fallback_label || 'Seneste tidligere faktura')}</span>`
: '';
const invoiceList = row.invoices && row.invoices.length
? row.invoices.map((invoice, index) => renderInvoiceCard(invoice, row.month_start || 'month', index)).join('')
: '<span class="text-muted">Ingen faktura</span>';
return `
<tr class="${row.is_reference_month ? 'table-warning' : row.is_fallback_history ? 'table-info' : ''}">
<td>${formatMonth(row.month_start)}${monthBadge}</td>
<td>${stateBadge}</td>
<td>${formatNumber(row.line_count)}</td>
<td>${formatNumber(row.total_quantity)}</td>
<td>${formatCurrency(row.total_amount)}</td>
<td>${invoiceList}</td>
</tr>
`;
}).join('');
} catch (err) {
body.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Kunne ikke hente historik</td></tr>';
showHistoryStatus(`Fejl: ${err.message}`, 'danger');
}
}
document.getElementById('historyApproveBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'approved_change', { target: 'history', successMessage: 'Fejlen er godkendt' });
});
document.getElementById('historyInvestigatingBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'investigating', { target: 'history', successMessage: 'Fejlen er sat til undersøgelse' });
});
document.getElementById('historyReadyBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
updateStatus(currentHistoryIssueId, 'ready_to_invoice', { target: 'history', successMessage: 'Ordrekladde markeret som klar' });
});
document.getElementById('historyCreateSagBtn')?.addEventListener('click', function() {
if (!currentHistoryIssueId) return;
createSag(currentHistoryIssueId, { target: 'history' });
});
loadIssues();
</script>
{% endblock %}

View File

@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import (
Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse
LocationWizardCreateRequest, LocationWizardCreateResponse,
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
)
router = APIRouter()
@ -164,15 +165,21 @@ async def create_location(request: Request):
logger.warning("⚠️ Invalid location payload")
raise HTTPException(status_code=422, detail=e.errors())
# Check for duplicate name
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (data.name,))
# Names only need to be unique within the same customer and hierarchy.
check_query = """
SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
if existing:
logger.warning(f"⚠️ Duplicate location name: {data.name}")
raise HTTPException(
status_code=400,
detail=f"Location with name '{data.name}' already exists"
detail=f"Location with name '{data.name}' already exists under the same customer/location"
)
if data.customer_id is not None:
@ -201,9 +208,9 @@ async def create_location(request: Request):
INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude,
phone, email, notes, is_active, created_at, updated_at
phone, email, notes, is_active, has_cross_field, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING *
"""
@ -221,7 +228,8 @@ async def create_location(request: Request):
data.phone,
data.email,
data.notes,
data.is_active
data.is_active,
data.has_cross_field if data.location_type == 'rum' else False
)
result = execute_query(insert_query, params)
@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
def _normalize_name(value: str) -> str:
return (value or "").strip().lower()
def _name_exists(value: str) -> bool:
def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
normalized = _normalize_name(value)
if normalized in reserved_names:
scope_key = (normalized, parent_location_id, customer_id)
if scope_key in reserved_names:
return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (value,))
check_query = """
SELECT 1 FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (value, parent_location_id, customer_id))
return bool(existing)
def _reserve_name(value: str) -> None:
def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
normalized = _normalize_name(value)
if normalized:
reserved_names.add(normalized)
reserved_names.add((normalized, parent_location_id, customer_id))
def _resolve_unique_name(base_name: str) -> str:
def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
if not auto_suffix:
_reserve_name(base_name)
_reserve_name(base_name, parent_location_id, customer_id)
return base_name
base_name = base_name.strip()
if not _name_exists(base_name):
_reserve_name(base_name)
if not _name_exists(base_name, parent_location_id, customer_id):
_reserve_name(base_name, parent_location_id, customer_id)
return base_name
suffix = 2
while True:
candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate):
_reserve_name(candidate)
if not _name_exists(candidate, parent_location_id, customer_id):
_reserve_name(candidate, parent_location_id, customer_id)
return candidate
suffix += 1
@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0])
resolved_root_name = _resolve_unique_name(root.name)
resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
root_location = insert_location_record(
name=resolved_root_name,
location_type=root.location_type,
@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
room_ids: List[int] = []
for floor in data.floors:
resolved_floor_name = _resolve_unique_name(floor.name)
resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
floor_location = insert_location_record(
name=resolved_floor_name,
location_type=floor.location_type,
@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
))
for room in floor.rooms:
resolved_room_name = _resolve_unique_name(room.name)
resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
room_location = insert_location_record(
name=resolved_room_name,
location_type=room.location_type,
@ -594,6 +609,187 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
@router.get('/locations/cross-fields', response_model=List[CrossField])
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE'
params: tuple = ()
if location_id:
where += ' AND cf.location_id = %s'
params = (location_id,)
fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.name''', params) or []
for field in fields:
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
return [CrossField(**field) for field in fields]
@router.get('/locations/cross-field-ports')
async def list_cross_field_ports():
return execute_query('''
SELECT p.id, p.port_number, cf.name AS cross_field_name,
l.name AS location_name, l.id AS location_id
FROM locations_cross_field_ports p
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
JOIN locations_locations l ON l.id = cf.location_id
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL
ORDER BY l.name, cf.name, p.port_number
''') or []
@router.post('/locations/cross-fields', response_model=CrossField, status_code=201)
async def create_cross_field(data: CrossFieldCreate):
location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or []
if not location:
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt')
try:
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, notes) VALUES (%s, %s, %s, %s) RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.notes)) or []
if not created:
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
field = created[0]
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(1, %s)''', (field['id'], data.port_count))
except HTTPException:
raise
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
return CrossField(**field)
@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField)
async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate):
field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or []
if not field_rows:
raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet')
field = field_rows[0]
changes = data.model_dump(exclude_unset=True)
requested_ports = changes.pop('port_count', None)
if requested_ports is not None and requested_ports < field['port_count']:
raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt')
if changes:
assignments = ', '.join(f'{column} = %s' for column in changes)
try:
updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or []
field = updated[0]
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
if requested_ports and requested_ports > field['port_count']:
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(%s, %s)''', (cross_field_id, field['port_count'] + 1, requested_ports))
field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0]
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (cross_field_id,)) or []
return CrossField(**field)
def _outlet_location(location_id: int) -> dict:
rows = execute_query(
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
(location_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
location = dict(rows[0])
if location.get('location_type') not in _OUTLET_LOCATION_TYPES:
raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum")
return location
_OUTLET_SELECT = """
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE id = l.id
UNION ALL
SELECT parent.id, parent.name, parent.parent_location_id,
parent.name || ' > ' || ancestors.hierarchy_path
FROM locations_locations parent
JOIN ancestors ON ancestors.parent_location_id = parent.id
)
SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1
) path ON TRUE
"""
@router.get('/locations/outlets', response_model=List[WallOutlet])
async def list_wall_outlets(
q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1),
status: Optional[str] = Query(None), include_inactive: bool = Query(False),
):
where = ['o.deleted_at IS NULL']
params: List[Any] = []
if not include_inactive:
where.append('o.is_active = TRUE')
if location_id:
where.append('o.location_id = %s')
params.append(location_id)
if status:
where.append('o.status = %s')
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f'%{q.strip()}%'] * 7)
rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or []
return [WallOutlet(**row) for row in rows]
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id)
try:
rows = execute_query(
"""INSERT INTO locations_wall_outlets
(location_id, outlet_number, category, patch_panel, patch_port, cross_field_port_id, switch_name, switch_port, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, data.outlet_number.strip(), data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
outlet_id = rows[0]['id']
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
changes = data.model_dump(exclude_unset=True)
if not changes:
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
if 'outlet_number' in changes:
changes['outlet_number'] = changes['outlet_number'].strip()
fields = ', '.join(f'{field} = %s' for field in changes)
try:
rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.delete('/locations/outlets/{outlet_id}')
async def delete_wall_outlet(outlet_id: int):
rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
return {'status': 'deleted', 'id': outlet_id}
@router.get("/locations/{id}", response_model=LocationDetail)
async def get_location(id: int):
"""
@ -645,6 +841,12 @@ async def get_location(id: int):
capacity_result = execute_query(capacity_query, (id,))
capacity = [dict(row) for row in capacity_result] if capacity_result else []
outlet_result = execute_query(
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
(id,),
)
wall_outlets = [dict(row) for row in (outlet_result or [])]
# Build hierarchy breadcrumb (ancestors from root to parent)
hierarchy_query = """
WITH RECURSIVE ancestors AS (
@ -693,7 +895,8 @@ async def get_location(id: int):
contacts=contacts,
hours=hours,
services=services,
capacity=capacity
capacity=capacity,
wall_outlets=wall_outlets,
)
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
@ -740,15 +943,26 @@ async def update_location(id: int, data: LocationUpdate):
old_location = Location(**existing[0])
# Check for duplicate name if name is being updated
if data.name is not None and data.name != old_location.name:
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL"
dup_check = execute_query(dup_query, (data.name, id))
# Check the resulting name/customer/parent scope, including when only
# customer or parent is changed.
if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
candidate_name = data.name if data.name is not None else old_location.name
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
dup_query = """
SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND id != %s
AND deleted_at IS NULL
"""
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
if dup_check:
logger.warning(f"⚠️ Duplicate location name: {data.name}")
logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
raise HTTPException(
status_code=400,
detail=f"Location with name '{data.name}' already exists"
detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
)
# Build UPDATE query with only provided fields
@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate):
'phone': 'phone',
'email': 'email',
'notes': 'notes',
'is_active': 'is_active'
'is_active': 'is_active',
'has_cross_field': 'has_cross_field'
}
update_data = {}
@ -792,6 +1007,30 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400,
detail="parent_location_id does not exist"
)
descendant_check = execute_query(
"""
WITH RECURSIVE descendants AS (
SELECT id
FROM locations_locations
WHERE parent_location_id = %s AND deleted_at IS NULL
UNION ALL
SELECT l.id
FROM locations_locations l
JOIN descendants d ON l.parent_location_id = d.id
WHERE l.deleted_at IS NULL
)
SELECT id FROM descendants WHERE id = %s LIMIT 1
""",
(id, value),
)
if descendant_check:
logger.warning("⚠️ parent_location_id cannot reference a descendant")
raise HTTPException(
status_code=400,
detail="parent_location_id cannot reference a descendant"
)
if key == 'customer_id':
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
customer = execute_query(customer_query, (value,))
@ -809,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400,
detail=f"location_type must be one of: {', '.join(allowed_types)}"
)
if key == 'has_cross_field' and value:
resulting_type = data.location_type or old_location.location_type
if resulting_type != 'rum':
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
update_parts.append(f"{db_column} = %s")
params.append(value)
update_data[key] = value

View File

@ -57,6 +57,100 @@ LOCATION_TYPES = [
{"value": "vehicle", "label": "Køretøj"},
]
LOCATION_TYPE_LABELS = {
"kompleks": "Kompleks",
"bygning": "Bygning",
"etage": "Etage",
"customer_site": "Kundesite",
"rum": "Rum",
"kantine": "Kantine",
"moedelokale": "Mødelokale",
"vehicle": "Køretøj",
}
def get_location_type_label(location_type: Optional[str]) -> str:
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
exclude_ids = []
if exclude_id is not None:
exclude_tree = execute_query(
"""
WITH RECURSIVE descendants AS (
SELECT id
FROM locations_locations
WHERE id = %s
UNION ALL
SELECT l.id
FROM locations_locations l
JOIN descendants d ON l.parent_location_id = d.id
WHERE l.deleted_at IS NULL
)
SELECT id FROM descendants
""",
(exclude_id,),
)
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
parent_locations = execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT
id,
name,
location_type,
parent_location_id,
customer_id,
is_active,
name::text AS hierarchy_path,
0 AS depth
FROM locations_locations
WHERE deleted_at IS NULL AND parent_location_id IS NULL
UNION ALL
SELECT
l.id,
l.name,
l.location_type,
l.parent_location_id,
l.customer_id,
l.is_active,
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
lt.depth + 1 AS depth
FROM locations_locations l
JOIN location_tree lt ON l.parent_location_id = lt.id
WHERE l.deleted_at IS NULL
)
SELECT
id,
name,
location_type,
parent_location_id,
customer_id,
is_active,
hierarchy_path,
depth
FROM location_tree
WHERE is_active = true
ORDER BY hierarchy_path
LIMIT 2000
"""
)
choices = []
for row in parent_locations or []:
if row.get("id") in exclude_ids:
continue
row["type_label"] = get_location_type_label(row.get("location_type"))
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
choices.append(row)
return choices
def render_template(template_name: str, **context) -> str:
"""
@ -167,7 +261,7 @@ def list_locations_view(
"""
query_params.extend([limit, skip])
locations = execute_query(query, tuple(query_params))
locations = execute_query(query, tuple(query_params)) or []
def build_tree(items: list) -> list:
nodes = {}
@ -247,7 +341,10 @@ def list_locations_view(
# ============================================================================
@router.get("/app/locations/create", response_class=HTMLResponse)
def create_location_view():
def create_location_view(
parent_location_id: Optional[int] = Query(None, gt=0),
customer_id: Optional[int] = Query(None, gt=0),
):
"""
Render the location creation form.
@ -268,14 +365,11 @@ def create_location_view():
try:
logger.info("🆕 Rendering create location form")
# Query parent locations
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
parent_locations = get_parent_location_choices() or []
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
customer_id = selected_parent.get("customer_id")
# Query customers
customers = execute_query("""
@ -295,7 +389,10 @@ def create_location_view():
cancel_url="/app/locations",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
customers=customers or [],
selected_parent_id=parent_location_id,
selected_customer_id=customer_id,
selected_parent=selected_parent,
location=None, # No location data for create form
)
@ -321,13 +418,7 @@ def location_wizard_view():
try:
logger.info("🧭 Rendering location wizard")
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
parent_locations = get_parent_location_choices()
customers = execute_query("""
SELECT id, name, email, phone
@ -356,7 +447,47 @@ def location_wizard_view():
# ============================================================================
# 3. GET /app/locations/{id} - Detail view (HTML)
# 3. GET /app/locations/outlets - Wall outlet overview
# ============================================================================
@router.get("/app/locations/outlets", response_class=HTMLResponse)
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
try:
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
params = []
if status:
where.append("o.status = %s")
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f"%{q.strip()}%"] * 7)
outlets = execute_query(f"""
WITH RECURSIVE tree AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
UNION ALL
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
WHERE l.deleted_at IS NULL
)
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN tree ON tree.id = l.id
WHERE {' AND '.join(where)}
ORDER BY tree.hierarchy_path, o.outlet_number
""", tuple(params)) or []
return HTMLResponse(render_template(
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
))
except Exception as exc:
logger.error("Error rendering wall outlets overview: %s", exc)
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
# ============================================================================
# 4. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================
@router.get("/app/locations/{id}", response_class=HTMLResponse)
@ -473,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,)
)
wall_outlets = execute_query(
"""
SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active
FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY outlet_number
""",
(id,),
)
cross_fields = execute_query(
"""SELECT id, name, port_count, notes, is_active
FROM locations_cross_fields
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
ORDER BY name""",
(id,),
)
for cross_field in cross_fields or []:
cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.is_active,
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
l.name AS outlet_location_name
FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
LEFT JOIN locations_locations l ON l.id = o.location_id
WHERE p.cross_field_id = %s ORDER BY p.port_number""",
(cross_field["id"],),
) or []
audit_log = execute_query(
"""
SELECT id, location_id, event_type, user_id, changes, created_at
@ -490,6 +650,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["services"] = services or []
location["capacity"] = capacity or []
location["hardware"] = hardware or []
location["wall_outlets"] = wall_outlets or []
location["cross_fields"] = cross_fields or []
location["audit_log"] = audit_log or []
# Query customers
@ -505,6 +667,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
customers = customers or []
# Render template with context
html = render_template(
"modules/locations/templates/detail.html",
@ -555,14 +719,11 @@ def edit_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result
# Query parent locations (exclude self)
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE is_active = true AND id != %s
ORDER BY name
LIMIT 1000
""", (id,))
parent_locations = get_parent_location_choices(exclude_id=id) or []
selected_parent = next(
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
None,
)
# Query customers
customers = execute_query("""
@ -584,7 +745,8 @@ def edit_location_view(id: int = Path(..., gt=0)):
cancel_url=f"/app/locations/{id}",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
customers=customers or [],
selected_parent=selected_parent,
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
)
@ -625,6 +787,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
latitude = %s,
longitude = %s,
notes = %s,
has_cross_field = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (
@ -642,6 +805,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
float(form.get("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"),
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
id
))

View File

@ -17,7 +17,7 @@ from decimal import Decimal
class LocationBase(BaseModel):
"""Shared fields for location models"""
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
location_type: str = Field(
...,
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
email: Optional[str] = None
notes: Optional[str] = None
is_active: bool = Field(True, description="Whether location is active")
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
@field_validator('location_type')
@classmethod
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
email: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
has_cross_field: Optional[bool] = None
@field_validator('location_type')
@classmethod
@ -101,6 +103,99 @@ class Location(LocationBase):
from_attributes = True
# ============================================================================
# NETWORK WALL OUTLET MODELS
# ============================================================================
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
class WallOutletBase(BaseModel):
location_id: int = Field(..., ge=1)
outlet_number: str = Field(..., min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: str = Field('unknown')
notes: Optional[str] = None
is_active: bool = True
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutletCreate(WallOutletBase):
pass
class WallOutletUpdate(BaseModel):
outlet_number: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value is not None and value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutlet(WallOutletBase):
id: int
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
location_name: Optional[str] = None
location_type: Optional[str] = None
customer_name: Optional[str] = None
hierarchy_path: Optional[str] = None
class CrossFieldCreate(BaseModel):
location_id: int = Field(..., ge=1)
name: str = Field(..., min_length=1, max_length=100)
port_count: int = Field(..., ge=1, le=999)
notes: Optional[str] = None
class CrossFieldUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
port_count: Optional[int] = Field(None, ge=1, le=999)
notes: Optional[str] = None
class CrossFieldPort(BaseModel):
id: int
port_number: int
is_active: bool
class CrossField(BaseModel):
id: int
location_id: int
name: str
port_count: int
notes: Optional[str] = None
is_active: bool
created_at: datetime
ports: List[CrossFieldPort] = []
# ============================================================================
# 2. CONTACT MODELS
# ============================================================================
@ -360,6 +455,7 @@ class LocationDetail(Location):
hours: List[OperatingHours] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list)
capacity: List[Capacity] = Field(default_factory=list)
wall_outlets: List[WallOutlet] = Field(default_factory=list)
class AuditLogEntry(BaseModel):

View File

@ -2,6 +2,40 @@
{% block title %}Opret lokation - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.relation-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 1rem;
}
.relation-summary {
border: 1px dashed rgba(15, 76, 117, 0.22);
border-radius: 0.85rem;
background: rgba(15, 76, 117, 0.05);
padding: 0.85rem 1rem;
}
.relation-summary.empty {
background: #f8fafc;
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}
.relation-path {
font-weight: 600;
color: #1f3b53;
}
.relation-meta {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<!-- Breadcrumb -->
@ -57,33 +91,66 @@
</select>
</div>
<div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<div class="relation-card mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
<div class="text-muted small">Vælg hurtigt, hvor lokationen skal ligge, og søg i hele træet.</div>
</div>
{% if selected_parent %}
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye me-2"></i>Åbn valgt parent
</a>
{% endif %}
</div>
<div class="mb-3">
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, bygningsdel eller sti...">
</div>
<div class="mb-3">
<select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option>
{% if parent_locations %}
{% for parent in parent_locations %}
<option value="{{ parent.id }}">
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
<option
value="{{ parent.id }}"
data-path="{{ parent.hierarchy_path }}"
data-type="{{ parent.type_label }}"
data-customer-id="{{ parent.customer_id | default('') }}"
{% if selected_parent_id == parent.id %}selected{% endif %}>
{{ parent.display_name }}
</option>
{% endfor %}
{% endif %}
</select>
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
<div class="form-text">Bruges til hierarki, fx Kompleks → Bygning → Etage → Rum.</div>
</div>
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
{% if selected_parent %}
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
<div class="relation-meta">{{ selected_parent.type_label }}</div>
{% else %}
<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="customerId" class="form-label">Kunde (valgfri)</label>
<select class="form-select" id="customerId" name="customer_id">
<option value="">Ingen</option>
{% if customers %}
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
{% endfor %}
{% endif %}
</select>
<div class="form-text">Valgfri kan knyttes til alle typer.</div>
</div>
<div class="mb-3">
<label for="customerId" class="form-label">Kunde (valgfri)</label>
<select class="form-select" id="customerId" name="customer_id">
<option value="">Ingen</option>
{% if customers %}
{% for customer in customers %}
<option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
{% endfor %}
{% endif %}
</select>
<div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
</div>
<div class="mb-3">
<div class="form-check">
@ -91,6 +158,14 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div>
</div>
<div class="mb-3" id="crossFieldOption" hidden>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
</div>
</div>
</fieldset>
<!-- Section 2: Address -->
@ -185,12 +260,79 @@ document.addEventListener('DOMContentLoaded', function() {
const submitBtn = document.getElementById('submitBtn');
const notesField = document.getElementById('notes');
const charCount = document.getElementById('charCount');
const parentLocationSelect = document.getElementById('parentLocation');
const parentLocationSearch = document.getElementById('parentLocationSearch');
const customerSelect = document.getElementById('customerId');
const parentSummary = document.getElementById('parentSummary');
// Character counter for notes
notesField.addEventListener('input', function() {
charCount.textContent = this.value.length;
});
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function updateParentSummary() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const path = selectedOption?.dataset?.path || '';
const type = selectedOption?.dataset?.type || '';
if (!selectedOption || !selectedOption.value) {
parentSummary.classList.add('empty');
parentSummary.innerHTML = '<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>';
return;
}
parentSummary.classList.remove('empty');
parentSummary.innerHTML = `
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">${escapeHtml(path)}</div>
<div class="relation-meta">${escapeHtml(type)}</div>
`;
}
function filterParentLocations() {
const query = (parentLocationSearch.value || '').trim().toLowerCase();
Array.from(parentLocationSelect.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
option.hidden = query ? !haystack.includes(query) : false;
});
}
if (parentLocationSearch) {
parentLocationSearch.addEventListener('input', filterParentLocations);
}
if (parentLocationSelect) {
parentLocationSelect.addEventListener('change', function() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const parentCustomerId = selectedOption?.dataset?.customerId;
if ((!customerSelect.value || customerSelect.dataset.autofilled === 'true') && parentCustomerId) {
customerSelect.value = parentCustomerId;
customerSelect.dataset.autofilled = 'true';
}
updateParentSummary();
});
updateParentSummary();
}
if (customerSelect) {
customerSelect.addEventListener('change', function() {
customerSelect.dataset.autofilled = 'false';
});
}
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
@ -202,7 +344,10 @@ document.addEventListener('DOMContentLoaded', function() {
const data = {
name: formData.get('name'),
location_type: formData.get('location_type'),
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'),
address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'),
@ -239,6 +384,17 @@ document.addEventListener('DOMContentLoaded', function() {
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
}
});
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
});
</script>
{% endblock %}

View File

@ -4,6 +4,17 @@
{% block extra_css %}
<style>
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
.locations-detail-page {
--loc-accent: var(--accent, #0f4c75);
}
@ -247,6 +258,9 @@
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }}
</span>
{% if location.has_cross_field %}
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
{% endif %}
{% if location.is_active %}
<span class="case-status-chip open">
<span class="case-status-dot"></span>Aktiv
@ -328,6 +342,13 @@
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button>
</li>
{% if location.location_type == 'rum' and location.has_cross_field %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Relationer
@ -715,9 +736,15 @@
</div>
<div class="card border-0">
<div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Tilføj underlokation</h5>
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h5 class="card-title mb-0">Tilføj underlokation</h5>
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
<form action="/api/v1/locations" method="post" class="row g-2">
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
@ -747,7 +774,7 @@
<option value="">Ingen</option>
{% if customers %}
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
{% endfor %}
{% endif %}
</select>
@ -763,6 +790,32 @@
</div>
</div>
<div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
<div>
<h5 class="card-title mb-0">Vægstik</h5>
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
{% endif %}
</div>
</div>
<div class="card-body">
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
{% elif location.wall_outlets %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %}
</tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
</div>
</div>
<div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5>
@ -803,6 +856,23 @@
</div>
</div>
{% if location.location_type == 'rum' and location.has_cross_field %}
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
<div class="card border-0">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
</div>
<div class="card-body">
{% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">{{ field.port_count }} porte</span></div><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div>
<div class="patch-panel"><div class="patch-panel-grid">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tab 7: Hardware -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0">
@ -976,6 +1046,37 @@
</div>
</div>
<!-- Wall outlet modal -->
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
<input type="hidden" id="crossFieldId">
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 24"></div>
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
</div>
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer *</label><input class="form-control" id="outletNumber" required placeholder="Fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" placeholder="Fx Switch 3"></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><input class="form-control" id="outletSwitchPort" placeholder="Fx Gi1/0/12"></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div>
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
</div></div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@ -1292,6 +1393,103 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
const crossFieldButton = document.getElementById('addCrossFieldBtn');
const crossFieldModalElement = document.getElementById('crossFieldModal');
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const crossFieldId = document.getElementById('crossFieldId').value;
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), notes: document.getElementById('crossFieldNotes').value || null})});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
const outletModalElement = document.getElementById('outletModal');
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
const outletForm = document.getElementById('outletForm');
const outletValue = (id) => document.getElementById(id).value.trim() || null;
async function loadCrossFieldPorts() {
const select = document.getElementById('outletCrossFieldPort');
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
}
async function loadOutletLocations() {
const select = document.getElementById('outletLocationId');
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
select.value = String(locationId);
}
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations()]);
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || '';
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
document.getElementById('outletSwitch').value = outlet?.switchName || '';
document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletNotes').value = outlet?.notes || '';
if (selectedPort) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(selectedPort.id);
if (portSelect.value !== String(selectedPort.id)) {
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
}
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
}
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
outletModal.show();
}
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
id: btn.dataset.id, number: btn.dataset.number, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
status: btn.dataset.status, notes: btn.dataset.notes
})));
outletForm?.addEventListener('submit', async (event) => {
event.preventDefault();
const outletId = document.getElementById('outletId').value;
const payload = {
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), category: outletValue('outletCategory'),
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme vægstik');
return;
}
location.reload();
});
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
const outletId = document.getElementById('outletId').value;
if (!outletId || !confirm('Slet dette vægstik?')) return;
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
if (response.ok) location.reload();
else alert('Kunne ikke slette vægstik');
});
});
</script>
{% endblock %}

View File

@ -2,6 +2,40 @@
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.relation-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 1rem;
}
.relation-summary {
border: 1px dashed rgba(15, 76, 117, 0.22);
border-radius: 0.85rem;
background: rgba(15, 76, 117, 0.05);
padding: 0.85rem 1rem;
}
.relation-summary.empty {
background: #f8fafc;
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}
.relation-path {
font-weight: 600;
color: #1f3b53;
}
.relation-meta {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<!-- Breadcrumb -->
@ -58,19 +92,52 @@
</select>
</div>
<div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option>
{% if parent_locations %}
{% for parent in parent_locations %}
<option value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}>
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
</option>
{% endfor %}
<div class="relation-card mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
<div class="text-muted small">Flyt lokationen ved at vælge en ny overordnet lokation.</div>
</div>
{% if selected_parent %}
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye me-2"></i>Åbn valgt parent
</a>
{% endif %}
</select>
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
</div>
<div class="mb-3">
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, sti eller type...">
</div>
<div class="mb-3">
<select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option>
{% if parent_locations %}
{% for parent in parent_locations %}
<option
value="{{ parent.id }}"
data-path="{{ parent.hierarchy_path }}"
data-type="{{ parent.type_label }}"
data-customer-id="{{ parent.customer_id | default('') }}"
{% if location.parent_location_id == parent.id %}selected{% endif %}>
{{ parent.display_name }}
</option>
{% endfor %}
{% endif %}
</select>
<div class="form-text">Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.</div>
</div>
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
{% if selected_parent %}
<div class="small text-muted mb-1">Nuværende overordnet lokation</div>
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
<div class="relation-meta">{{ selected_parent.type_label }}</div>
{% else %}
<div class="small text-muted">Lokationen ligger i topniveau.</div>
{% endif %}
</div>
</div>
<div class="mb-3">
@ -92,6 +159,13 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div>
</div>
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
</div>
</div>
</fieldset>
<!-- Section 2: Address -->
@ -130,7 +204,7 @@
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk">
<input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
</div>
</fieldset>
@ -160,7 +234,7 @@
<div class="mb-3">
<label for="notes" class="form-label">Noter og kommentarer</label>
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small>
<small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
</div>
</fieldset>
@ -216,12 +290,64 @@ document.addEventListener('DOMContentLoaded', function() {
const deleteModalElement = document.getElementById('deleteModal');
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
const locationId = '{{ location.id }}';
const parentLocationSelect = document.getElementById('parentLocation');
const parentLocationSearch = document.getElementById('parentLocationSearch');
const parentSummary = document.getElementById('parentSummary');
// Character counter for notes
notesField.addEventListener('input', function() {
charCount.textContent = this.value.length;
});
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function updateParentSummary() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const path = selectedOption?.dataset?.path || '';
const type = selectedOption?.dataset?.type || '';
if (!selectedOption || !selectedOption.value) {
parentSummary.classList.add('empty');
parentSummary.innerHTML = '<div class="small text-muted">Lokationen ligger i topniveau.</div>';
return;
}
parentSummary.classList.remove('empty');
parentSummary.innerHTML = `
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">${escapeHtml(path)}</div>
<div class="relation-meta">${escapeHtml(type)}</div>
`;
}
function filterParentLocations() {
const query = (parentLocationSearch.value || '').trim().toLowerCase();
Array.from(parentLocationSelect.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
option.hidden = query ? !haystack.includes(query) : false;
});
}
if (parentLocationSearch) {
parentLocationSearch.addEventListener('input', filterParentLocations);
}
if (parentLocationSelect) {
parentLocationSelect.addEventListener('change', updateParentSummary);
updateParentSummary();
}
// Form submission
form.addEventListener('submit', async function(e) {
if (form.dataset.noIntercept === 'true') {
@ -239,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'),
address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'),
@ -275,6 +402,17 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
// Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
fetch(`/api/v1/locations/${locationId}`, {

View File

@ -6,81 +6,271 @@
<style>
.locations-list-page {
--loc-accent: #0f4c75;
--loc-accent-soft: rgba(15, 76, 117, 0.08);
--loc-border: rgba(15, 76, 117, 0.16);
--loc-accent-soft: rgba(15, 76, 117, 0.06);
--loc-border: rgba(15, 76, 117, 0.12);
--loc-surface: #f7f9fc;
}
.locations-list-page .locations-hero {
border: 1px solid var(--loc-border);
background:
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
border-radius: 1rem;
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
}
[data-theme="dark"] .locations-list-page .locations-hero {
background:
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
border: 1px solid rgba(0, 0, 0, 0.08);
background: var(--bg-card);
border-radius: 0.9rem;
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
}
.locations-list-page .stat-tile {
background: var(--loc-accent-soft);
border: 1px solid var(--loc-border);
border-radius: 0.9rem;
padding: 0.8rem 0.95rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
border: 1px solid rgba(15, 76, 117, 0.08);
border-radius: 0.8rem;
padding: 0.75rem 0.9rem;
}
.locations-list-page .section-label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--loc-accent);
}
.locations-list-page .stat-tile .stat-value {
font-size: 1.15rem;
font-size: 1.2rem;
font-weight: 700;
color: var(--loc-accent);
line-height: 1.1;
}
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
color: #8fd0ff;
.locations-list-page .stat-tile .stat-note {
font-size: 0.76rem;
color: var(--text-secondary);
}
.locations-list-page .table thead th {
font-size: 0.8rem;
letter-spacing: 0.02em;
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
background: #f8fafc;
}
.locations-list-page .location-row {
transition: background-color 0.18s ease, transform 0.16s ease;
transition: background-color 0.16s ease;
}
.locations-list-page .location-row:hover {
background-color: rgba(52, 152, 219, 0.08);
background-color: rgba(15, 76, 117, 0.03);
}
.locations-list-page .toggle-row {
.locations-list-page .table > :not(caption) > * > * {
padding-top: 0.95rem;
padding-bottom: 0.95rem;
vertical-align: middle;
}
.locations-list-page .table tbody tr:last-child td {
border-bottom: 0;
}
.locations-list-page .toggle-row,
.locations-list-page .location-tree-spacer {
color: var(--loc-accent);
width: 1.2rem;
display: inline-flex;
justify-content: center;
flex: 0 0 1.2rem;
}
.locations-list-page .filter-shell,
.locations-list-page .content-shell {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.9rem;
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
}
.locations-list-page .filter-shell {
padding: 1.05rem 1.1rem;
}
.locations-list-page .content-shell-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.locations-list-page .content-shell-toolbar,
.locations-list-page .filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.locations-list-page .content-shell-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 700;
color: var(--text-secondary);
}
.locations-list-page .content-shell-subtitle {
font-size: 0.92rem;
color: var(--text-primary);
font-weight: 600;
}
.locations-list-page .content-shell-subtitle span {
color: var(--text-secondary);
font-weight: 500;
}
.locations-list-page .location-name-link {
color: var(--text-primary);
font-weight: 600;
font-size: 0.98rem;
}
.locations-list-page .location-name-link:hover {
color: var(--loc-accent);
}
.locations-list-page .location-primary {
min-width: 0;
}
.locations-list-page .location-summary {
display: flex;
align-items: flex-start;
gap: 0.7rem;
min-width: 0;
}
.locations-list-page .location-indent {
display: inline-flex;
align-items: stretch;
flex: 0 0 auto;
}
.locations-list-page .location-name-block {
min-width: 0;
}
.locations-list-page .location-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.25rem;
}
.locations-list-page .location-meta-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
color: #2d5670;
font-size: 0.73rem;
font-weight: 600;
}
.locations-list-page .type-pill {
display: inline-flex;
align-items: center;
padding: 0.32rem 0.62rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
line-height: 1;
color: #fff;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
}
.locations-list-page .status-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.locations-list-page .status-pill.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.locations-list-page .status-pill.inactive {
background: rgba(108, 117, 125, 0.12);
color: #495057;
}
.locations-list-page .shortcut-hint {
border: 1px dashed var(--loc-border);
border: 1px dashed rgba(0, 0, 0, 0.12);
border-radius: 0.55rem;
padding: 0.3rem 0.45rem;
font-size: 0.72rem;
color: var(--text-secondary);
}
.locations-list-page .hero-actions {
align-items: center;
}
.locations-list-page .list-status-note {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.4rem 0.65rem;
border-radius: 999px;
background: var(--loc-surface);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
}
.locations-list-page .actions-inline {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: nowrap;
}
.locations-list-page .actions-inline .btn {
border-radius: 0.7rem;
}
.locations-list-page .empty-state-soft {
max-width: 420px;
margin: 0 auto;
}
@media (max-width: 767.98px) {
.locations-list-page {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.locations-list-page .stat-tile {
padding: 0.65rem 0.75rem;
}
.locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
.locations-list-page .content-shell-header { align-items: flex-start; flex-direction: column; }
.locations-list-page .content-shell-toolbar,
.locations-list-page .filter-toolbar { align-items: flex-start; flex-direction: column; }
.locations-list-page .hero-actions { width: 100%; }
.locations-list-page .hero-actions .btn { flex: 1 1 auto; }
.locations-list-page .location-summary { gap: 0.55rem; }
.locations-list-page .location-name-link { font-size: 0.94rem; }
}
</style>
{% endblock %}
@ -96,121 +286,144 @@
</nav>
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="locations-hero p-3 p-lg-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
</div>
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
</div>
<div class="row g-2 mt-2">
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Total</div>
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Aktive</div>
<div class="stat-value" id="statActive">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Inaktive</div>
<div class="stat-value" id="statInactive">0</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Synlige nu</div>
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
</div>
</div>
<div class="locations-hero p-3 p-lg-4 mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="section-label mb-2">
<i class="bi bi-diagram-3"></i>
Lokationsstruktur
</div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
</div>
</div>
</div>
<!-- Filter Card -->
<div class="card mb-4 border-0">
<div class="card-body">
<form id="filterForm" method="get" class="row g-3 align-items-end">
<div class="col-md-4">
<label for="locationSearch" class="form-label small text-muted">Søg</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter navn, hierarki eller by...">
</div>
</div>
<div class="col-md-4">
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
<select class="form-select" id="locationTypeFilter" name="location_type">
<option value="">Alle typer</option>
{% if location_types %}
{% for type_option in location_types %}
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-md-2">
<label for="statusFilter" class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" name="is_active">
<option value="">Alle</option>
<option value="true" {% if is_active == True %}selected{% endif %}>Aktive</option>
<option value="false" {% if is_active == False %}selected{% endif %}>Inaktive</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel me-2"></i>Anvend filtre
</button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Toolbar Section -->
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap hero-actions">
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard
</a>
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte
</button>
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-ethernet me-2"></i>Vægstik
</a>
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
</div>
<div class="text-muted small">
{% if total %}
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
{% else %}
Ingen lokationer
{% endif %}
</div>
<div class="row g-2 mt-2">
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Total</div>
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
<div class="stat-note">Alle registrerede lokationer</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Aktive</div>
<div class="stat-value" id="statActive">0</div>
<div class="stat-note">Klar til daglig brug</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Inaktive</div>
<div class="stat-value" id="statInactive">0</div>
<div class="stat-note">Skjulte eller lukkede</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="small text-muted">Synlige nu</div>
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
<div class="stat-note">Efter søgning og foldning</div>
</div>
</div>
</div>
</div>
<!-- Filter Card -->
<div class="filter-shell mb-4">
<div class="filter-toolbar mb-3">
<div>
<div class="content-shell-title">Filtre</div>
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
</div>
<div class="list-status-note">
<i class="bi bi-lightning-charge"></i>
Hierarkiet kan foldes direkte i listen
</div>
</div>
<form id="filterForm" method="get" class="row g-3 align-items-end">
<div class="col-lg-5">
<label for="locationSearch" class="form-label small text-muted">Søg</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
</div>
</div>
<div class="col-md-4 col-lg-3">
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
<select class="form-select" id="locationTypeFilter" name="location_type">
<option value="">Alle typer</option>
{% if location_types %}
{% for type_option in location_types %}
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-md-3 col-lg-2">
<label for="statusFilter" class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" name="is_active">
<option value="">Alle</option>
<option value="true" {% if is_active == True %}selected{% endif %}>Aktive</option>
<option value="false" {% if is_active == False %}selected{% endif %}>Inaktive</option>
</select>
</div>
<div class="col-md-5 col-lg-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel me-2"></i>Filtrer
</button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-counterclockwise"></i>
</a>
</div>
</form>
</div>
<!-- Main Content Section -->
<div class="card border-0">
<div class="content-shell">
<div class="content-shell-header">
<div class="content-shell-toolbar w-100">
<div>
<div class="content-shell-title">Lokationsliste</div>
<div class="content-shell-subtitle">
{% if total %}
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
<span>med tydeligt hierarki og status</span>
{% else %}
Ingen lokationer endnu
{% endif %}
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<span class="list-status-note">
<i class="bi bi-check2-circle"></i>
Klik kun på navn eller handlinger for at åbne
</span>
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte
</button>
</div>
</div>
</div>
<div class="table-responsive">
{% if location_tree %}
<table class="table table-hover mb-0">
@ -250,26 +463,44 @@
'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %}
{% set child_count = node.children|length if node.children else 0 %}
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
<td>
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
</td>
<td>
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;">
{% if node.children %}
<button type="button" class="btn btn-link btn-sm p-0 me-2 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
<i class="bi bi-caret-right-fill"></i>
</button>
{% else %}
<span class="text-muted me-2"></span>
{% endif %}
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500">
{{ node.name }}
</a>
<div class="location-summary">
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
{% if node.children %}
<button type="button" class="btn btn-link btn-sm p-0 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
<i class="bi bi-caret-right-fill"></i>
</button>
{% else %}
<span class="location-tree-spacer text-muted"></span>
{% endif %}
</div>
<div class="location-name-block">
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
{{ node.name }}
</a>
<div class="location-meta">
{% if depth > 0 %}
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
{% endif %}
{% if child_count %}
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
{% endif %}
{% if node.address_city %}
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
{% endif %}
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
</div>
</div>
</div>
</td>
<td>
<span class="badge" style="background-color: {{ type_color }}; color: white;">
<span class="type-pill" style="background-color: {{ type_color }};">
{{ type_label }}
</span>
</td>
@ -278,20 +509,20 @@
</td>
<td>
{% if node.is_active %}
<span class="badge bg-success">Aktiv</span>
<span class="status-pill active"><span></span>Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
<span class="status-pill inactive"><span></span>Inaktiv</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis">
<div class="actions-inline" role="group" aria-label="Handlinger">
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
<i class="bi bi-eye"></i>
</a>
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger">
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-outline-danger delete-location-btn"
<button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
data-location-id="{{ node.id }}"
data-location-name="{{ node.name }}"
title="Slet">
@ -315,14 +546,16 @@
{% else %}
<!-- Empty State -->
<div class="text-center py-5">
<div class="mb-3">
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
<div class="empty-state-soft">
<div class="mb-3">
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
</div>
<h5 class="text-muted">Ingen lokationer endnu</h5>
<p class="text-muted small mb-3">Opret din første lokation ved at klikke på knappen nedenfor</p>
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
</div>
<h5 class="text-muted">Ingen lokationer endnu</h5>
<p class="text-muted small mb-3">Opret din første lokation ved at klikke på knappen nedenfor</p>
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
</div>
{% endif %}
</div>
@ -608,12 +841,14 @@ document.addEventListener('DOMContentLoaded', function() {
let currentDeleteId = null;
// Select all functionality
selectAllCheckbox.addEventListener('change', function() {
locationCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
locationCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkDeleteButton();
});
updateBulkDeleteButton();
});
}
// Individual checkbox functionality
locationCheckboxes.forEach(checkbox => {
@ -632,6 +867,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
function updateSelectAllCheckbox() {
if (!selectAllCheckbox) return;
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
@ -678,36 +914,27 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Bulk delete
bulkDeleteBtn.addEventListener('click', function() {
const selectedIds = Array.from(locationCheckboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selectedIds.length === 0) return;
if (confirm(`Slet ${selectedIds.length} lokation(er)? Dette kan ikke fortrydes.`)) {
Promise.all(selectedIds.map(id =>
fetch(`/api/v1/locations/${id}`, { method: 'DELETE' })
))
.then(() => location.reload())
.catch(error => {
console.error('Error:', error);
alert('Fejl ved sletning af lokationer');
});
}
});
// Clickable rows
document.querySelectorAll('.location-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking checkbox or action buttons
if (e.target.tagName === 'INPUT' || e.target.closest('.btn-group')) {
return;
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener('click', function() {
const selectedIds = Array.from(locationCheckboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selectedIds.length === 0) return;
if (confirm(`Slet ${selectedIds.length} lokation(er)? Dette kan ikke fortrydes.`)) {
Promise.all(selectedIds.map(id =>
fetch(`/api/v1/locations/${id}`, { method: 'DELETE' })
))
.then(() => location.reload())
.catch(error => {
console.error('Error:', error);
alert('Fejl ved sletning af lokationer');
});
}
const link = this.querySelector('a');
if (link) link.click();
});
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vægstik - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div><div class="small text-uppercase text-muted fw-semibold mb-1">Lokaliteter</div><h1 class="h3 mb-1">Vægstik</h1><p class="text-muted mb-0">Søg og find netværksstik på tværs af bygninger, etager og rum.</p></div>
<a href="/app/locations" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Lokaliteter</a>
</div>
<div class="card border-0 shadow-sm mb-4"><div class="card-body">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-7"><label class="form-label">Søg</label><input class="form-control" name="q" value="{{ query }}" placeholder="Stiknummer, lokation, patchpanel eller switch-port"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="">Alle</option>{% for value, label in [('available', 'Ledig'), ('active', 'Aktiv'), ('reserved', 'Reserveret'), ('faulty', 'Defekt'), ('unknown', 'Ukendt')] %}<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-2 d-grid"><button class="btn btn-primary"><i class="bi bi-search me-1"></i>Søg</button></div>
</form>
</div></div>
<div class="card border-0 shadow-sm"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Stik</th><th>Lokation</th><th>Patchpanel</th><th>Switch</th><th>Status</th></tr></thead><tbody>
{% for outlet in outlets %}<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><a href="/app/locations/{{ outlet.location_id }}" class="text-decoration-none">{{ outlet.hierarchy_path or outlet.location_name }}</a>{% if outlet.customer_name %}<div class="small text-muted">{{ outlet.customer_name }}</div>{% endif %}</td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td></tr>{% else %}<tr><td colspan="5" class="text-center text-muted py-5">Ingen vægstik matcher søgningen.</td></tr>{% endfor %}
</tbody></table></div></div>
</div>
{% endblock %}

View File

@ -14,7 +14,8 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
from app.services.email_service import EmailService
@ -903,7 +904,7 @@ async def list_all_sale_items(
@router.post("/sag")
async def create_sag(data: dict):
"""Create a new case."""
"""Create a case and its optional pipeline/order data atomically."""
try:
if not data.get("titel"):
raise HTTPException(status_code=400, detail="titel is required")
@ -919,32 +920,118 @@ async def create_sag(data: dict):
_validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id)
query = """
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
data.get("titel"),
data.get("beskrivelse", ""),
data.get("template_key") or data.get("type", "ticket"),
status,
data.get("customer_id"),
ansvarlig_bruger_id,
assigned_group_id,
data.get("created_by_user_id", 1),
deadline,
deferred_until,
data.get("deferred_until_case_id"),
data.get("deferred_until_status"),
)
result = execute_query(query, params)
if result:
logger.info("✅ Case created: %s", result[0]["id"])
return result[0]
raise HTTPException(status_code=500, detail="Failed to create case")
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items") if case_type == "ordre" else []
if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list):
raise HTTPException(status_code=400, detail="order_items skal være en liste")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
pipeline_values = {"amount": None, "probability": None, "stage_id": None, "description": None}
if pipeline:
pipeline_values.update({key: pipeline.get(key) for key in pipeline_values})
if pipeline_values["amount"] not in (None, ""):
try:
pipeline_values["amount"] = float(pipeline_values["amount"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else:
pipeline_values["amount"] = None
if pipeline_values["probability"] not in (None, ""):
try:
pipeline_values["probability"] = int(pipeline_values["probability"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være et helt tal") from exc
if not 0 <= pipeline_values["probability"] <= 100:
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være mellem 0 og 100")
else:
pipeline_values["probability"] = None
if pipeline_values["stage_id"] not in (None, ""):
try:
pipeline_values["stage_id"] = int(pipeline_values["stage_id"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage") from exc
cursor.execute("SELECT id FROM pipeline_stages WHERE id = %s", (pipeline_values["stage_id"],))
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage")
else:
pipeline_values["stage_id"] = None
normalized_items = []
for item in order_items:
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinje")
description = str(item.get("description") or "").strip()
item_type = str(item.get("type") or "sale").lower()
if not description:
raise HTTPException(status_code=400, detail="Ordrelinjens beskrivelse er påkrævet")
if item_type not in ("sale", "purchase"):
raise HTTPException(status_code=400, detail="Ordrelinjetype skal være køb eller salg")
if item.get("amount") in (None, ""):
raise HTTPException(status_code=400, detail="Ordrelinjens beløb er påkrævet")
try:
normalized_items.append({
"type": item_type,
"description": description,
"quantity": float(item["quantity"]) if item.get("quantity") not in (None, "") else None,
"unit": item.get("unit") or None,
"unit_price": float(item["unit_price"]) if item.get("unit_price") not in (None, "") else None,
"amount": float(item["amount"]),
"currency": str(item.get("currency") or "DKK").upper(),
"status": str(item.get("status") or "draft").lower(),
"line_date": item.get("line_date") or None,
"external_ref": item.get("external_ref") or None,
"purchase_purpose": _normalize_purchase_purpose(item.get("purchase_purpose"), item_type),
})
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
cursor.execute(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
)
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items:
if has_purchase_columns:
cursor.execute(
"""INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, purchase_purpose)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"], item["purchase_purpose"]),
)
else:
cursor.execute(
"""INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
)
conn.commit()
logger.info("✅ Case created: %s", result["id"])
return dict(result)
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:

View File

@ -162,6 +162,12 @@
</div>
<form id="createForm" novalidate>
<div class="mb-4 p-3 rounded-3 border bg-light">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
</div>
<!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
@ -215,6 +221,7 @@
<hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">
<div class="col-12">
@ -246,23 +253,38 @@
</div>
</div>
</div>
</section>
<section id="pipelineSection" class="d-none">
<hr class="my-4 opacity-25">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
<div class="row g-4 mb-4">
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
</div>
</section>
<section id="orderSection" class="d-none">
<hr class="my-4 opacity-25">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
</div>
</div>
<div id="orderLines" class="vstack gap-3 mb-4"></div>
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
</section>
<hr class="my-4 opacity-25">
<!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4">
<div class="col-md-3">
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="type" required>
<option value="ticket" selected>🎫 Ticket</option>
<option value="opgave">🧩 Opgave</option>
<option value="ordre">🧾 Ordre</option>
<option value="projekt">📁 Projekt</option>
<option value="service">🛠️ Service</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-4">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option>
@ -270,7 +292,7 @@
<option value="lukket">🔴 Lukket</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option>
@ -280,7 +302,7 @@
</select>
</div>
<div class="col-md-3">
<div class="col-md-4">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
@ -324,6 +346,7 @@
let customerSearchTimeout;
let contactSearchTimeout;
let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0;
@ -889,28 +912,113 @@
}
}
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
};
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Denne sagstype bruger kun de fælles sagsfelter.';
}
function renderOrderLinesEmptyState() {
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
}
function addOrderLine(type = 'sale') {
const id = ++orderLineCounter;
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
const purpose = type === 'purchase' ? `
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
const container = document.getElementById('orderLines');
container.insertAdjacentHTML('beforeend', `
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
<input type="hidden" class="order-type" value="${type}">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
${purpose}
</div>
</div>`);
renderOrderLinesEmptyState();
}
function removeOrderLine(id) {
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
renderOrderLinesEmptyState();
}
function collectOrderItems() {
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
type: line.querySelector('.order-type').value,
description: line.querySelector('.order-description').value.trim(),
quantity: line.querySelector('.order-quantity').value || null,
unit: line.querySelector('.order-unit').value.trim() || null,
unit_price: line.querySelector('.order-unit-price').value || null,
amount: line.querySelector('.order-amount').value || null,
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
status: line.querySelector('.order-status').value,
external_ref: line.querySelector('.order-reference').value.trim() || null,
purchase_purpose: line.querySelector('.order-purpose')?.value || null
}));
}
async function loadPipelineStages() {
const select = document.getElementById('pipeline_stage_id');
if (!select) return;
try {
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
if (!response.ok) return;
const stages = await response.json();
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
} catch (err) { console.error('Failed to load pipeline stages', err); }
}
async function loadCaseTypesSelect() {
const select = document.getElementById('type');
if (!select) return;
try {
const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return;
const setting = await res.json();
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
select.innerHTML = types
.map((type) => `<option value="${type}">${type}</option>`)
.join('');
const [typesRes, profileRes] = await Promise.all([
fetch('/api/v1/settings/case_types', { credentials: 'include' }),
fetch('/api/v1/auth/me/profile', { credentials: 'include' })
]);
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (err) {
console.error('Failed to load case types', err);
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
}
updateCaseTypeSections();
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initializeSearch();
loadCaseTypesSelect();
loadPipelineStages();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill();
});
@ -993,6 +1101,18 @@
deadline: document.getElementById('deadline').value || null
};
if (data.type === 'pipeline') {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
probability: document.getElementById('pipeline_probability').value || null,
description: document.getElementById('pipeline_description').value || null
};
}
if (data.type === 'ordre') {
data.order_items = collectOrderItems();
}
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',

View File

@ -16,7 +16,9 @@ router = APIRouter()
async def list_opportunities(
q: Optional[str] = None,
stage: Optional[str] = None,
status: Optional[str] = None
status: Optional[str] = None,
customer_id: Optional[int] = Query(default=None),
contact_id: Optional[int] = Query(default=None),
):
"""
List all 'pipeline' cases.
@ -70,6 +72,33 @@ async def list_opportunities(
if q:
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
params.extend([f"%{q}%", f"%{q}%"])
if customer_id is not None:
query += """
AND (
s.customer_id = %s
OR EXISTS (
SELECT 1
FROM sag_kunder sk
WHERE sk.sag_id = s.id
AND sk.customer_id = %s
AND sk.deleted_at IS NULL
)
)
"""
params.extend([customer_id, customer_id])
if contact_id is not None:
query += """
AND EXISTS (
SELECT 1
FROM sag_kontakter sk
WHERE sk.sag_id = s.id
AND sk.contact_id = %s
AND sk.deleted_at IS NULL
)
"""
params.append(contact_id)
if status and status != 'all':
if status == 'open':

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
router = APIRouter()
templates = Jinja2Templates(directory="app")
@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app")
@router.get("/opportunities", response_class=HTMLResponse)
async def opportunities_page(request: Request):
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
@router.get("/opportunities/{opportunity_id}", include_in_schema=False)
async def opportunity_detail_redirect(opportunity_id: int):
return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307)

View File

@ -14,7 +14,6 @@ import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from collections import defaultdict
from app.services.economic_service import get_economic_service
from app.core.database import execute_query
logger = logging.getLogger(__name__)
@ -23,9 +22,6 @@ logger = logging.getLogger(__name__)
class SubscriptionMatrixService:
"""Generate billing matrix for customer subscriptions"""
def __init__(self):
self.economic_service = get_economic_service()
async def generate_billing_matrix(
self,
customer_id: int,
@ -87,13 +83,9 @@ class SubscriptionMatrixService:
economic_customer_number = customer[0]['economic_customer_number']
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
# Fetch invoices from e-conomic
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
invoices = await self.economic_service.get_customer_invoices(
economic_customer_number,
include_lines=True
)
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
# Fetch imported invoice snapshot from local invoice_error_finder tables
invoices = self._load_imported_invoices(str(economic_customer_number))
logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
if not invoices:
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
@ -130,6 +122,125 @@ class SubscriptionMatrixService:
"error": str(e),
"products": []
}
def _load_imported_invoices(self, economic_customer_number: str) -> List[Dict]:
rows = execute_query(
"""
WITH ranked_invoices AS (
SELECT
inv.id,
inv.source_invoice_number,
inv.source_type,
inv.invoice_date,
inv.due_date,
inv.net_amount,
inv.vat_amount,
inv.total_amount,
inv.currency,
inv.source_raw,
CASE inv.source_type
WHEN 'paid' THEN 1
WHEN 'booked' THEN 2
WHEN 'unpaid' THEN 3
WHEN 'draft' THEN 4
ELSE 9
END AS source_rank
FROM invoice_error_finder_economic_invoices inv
WHERE inv.customer_number = %s
),
selected_invoices AS (
SELECT DISTINCT ON (source_invoice_number)
id,
source_invoice_number,
source_type,
invoice_date,
due_date,
net_amount,
vat_amount,
total_amount,
currency,
source_raw
FROM ranked_invoices
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
)
SELECT
si.id AS invoice_id,
si.source_invoice_number,
si.source_type,
si.invoice_date,
si.due_date,
si.net_amount,
si.vat_amount,
si.total_amount,
si.currency,
si.source_raw AS invoice_source_raw,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount,
line.source_raw AS line_source_raw
FROM selected_invoices si
LEFT JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = si.id
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
""",
(economic_customer_number,),
) or []
invoices: List[Dict] = []
invoices_by_id: Dict[int, Dict] = {}
for row in rows:
invoice_id = row.get("invoice_id")
if invoice_id is None:
continue
invoice_source_raw = self._ensure_dict(row.get("invoice_source_raw"))
line_source_raw = self._ensure_dict(row.get("line_source_raw"))
if invoice_id not in invoices_by_id:
invoice_payload = {
"id": invoice_id,
"status": row.get("source_type"),
"bookedInvoiceNumber": row.get("source_invoice_number"),
"date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
"dueDate": row.get("due_date").isoformat() if row.get("due_date") else None,
"netAmount": float(row.get("net_amount") or 0),
"vatAmount": float(row.get("vat_amount") or 0),
"grossAmount": float(row.get("total_amount") or 0),
"currency": row.get("currency") or "DKK",
"notes": invoice_source_raw.get("notes"),
"heading": invoice_source_raw.get("heading"),
"description": invoice_source_raw.get("description"),
"text": invoice_source_raw.get("text"),
"subject": invoice_source_raw.get("subject"),
"otherReference": invoice_source_raw.get("otherReference"),
"orderNumberDb": invoice_source_raw.get("orderNumberDb"),
"lines": [],
}
invoices_by_id[invoice_id] = invoice_payload
invoices.append(invoice_payload)
if row.get("line_number") is None:
continue
invoices_by_id[invoice_id]["lines"].append({
"lineNumber": row.get("line_number"),
"description": row.get("description"),
"quantity": float(row.get("quantity") or 0),
"unitNetPrice": float(row.get("unit_price") or 0),
"totalNetAmount": float(row.get("line_net_amount") or 0),
"period": (line_source_raw.get("period") if isinstance(line_source_raw, dict) else None) or {},
"product": {
"productNumber": row.get("product_number"),
"name": row.get("product_name"),
},
})
return invoices
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
"""
@ -377,6 +488,18 @@ class SubscriptionMatrixService:
})
return products
@staticmethod
def _ensure_dict(value) -> Dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
@staticmethod
def _generate_month_range(num_months: int) -> List[str]:

View File

@ -168,7 +168,7 @@ async def get_setting(key: str):
seed_query,
(
"case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]',
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
"system",
"Sags-typer",
"json",
@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
return execute_migration(model)
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
async def execute_missing_migrations_api():
"""Run schema-detected missing migrations via the API namespace."""
from app.settings.backend.views import execute_missing_migrations
return execute_missing_migrations()
@router.post("/settings/sync-from-env", tags=["Settings"])
async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)"""
@ -992,4 +1000,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")

View File

@ -3,6 +3,7 @@ Settings Frontend Views
"""
from datetime import datetime
import time
from pathlib import Path
import re
from fastapi import APIRouter, Request, HTTPException, Depends
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
release_db_connection(conn)
return {"message": "Migration executed successfully"}
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
def execute_missing_migrations():
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
conn = get_db_connection()
logs = []
try:
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
candidates = []
for migration_file in files:
sql = migration_file.read_text(encoding="utf-8")
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
if status["status"] == "red":
candidates.append((migration_file, sql, status))
for migration_file, sql, status in candidates:
started = time.monotonic()
try:
with conn.cursor() as cursor:
cursor.execute(sql)
conn.commit()
logs.append({
"file_name": migration_file.name,
"status": "success",
"summary": status["summary"],
"duration_ms": round((time.monotonic() - started) * 1000),
})
except Exception as exc:
conn.rollback()
logs.append({
"file_name": migration_file.name,
"status": "failed",
"summary": str(exc).splitlines()[0],
"duration_ms": round((time.monotonic() - started) * 1000),
})
return {
"message": "Manglende migrationer behandlet",
"checked": len(files),
"candidates": len(candidates),
"executed": sum(item["status"] == "success" for item in logs),
"failed": sum(item["status"] == "failed" for item in logs),
"logs": logs,
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
finally:
release_db_connection(conn)

View File

@ -69,6 +69,9 @@
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
<i class="bi bi-check2-circle me-1"></i>Tjek status
</button>
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
<i class="bi bi-play-fill me-1"></i>Kør manglende
</button>
</div>
</div>
<div class="card-body">
@ -356,5 +359,35 @@
button.disabled = false;
}
}
async function runMissingMigrations() {
const button = document.getElementById('runMissingMigrationsBtn');
const feedback = document.getElementById('migrationFeedback');
button.disabled = true;
feedback.className = 'alert alert-info mt-3';
feedback.textContent = 'Tjekker og kører manglende migrationer...';
feedback.classList.remove('d-none');
try {
const urls = buildMigrationActionUrls('execute-missing');
let data = null;
let lastError = null;
for (const url of urls) {
const response = await fetch(url, {method: 'POST', credentials: 'include'});
const payload = await response.json().catch(() => ({}));
if (response.ok) { data = payload; break; }
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
lastError = payload.detail || `HTTP ${response.status}`;
}
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
} catch (error) {
feedback.className = 'alert alert-danger mt-3';
feedback.textContent = `Fejl: ${error.message}`;
} finally {
button.disabled = false;
}
}
</script>
{% endblock %}

View File

@ -184,6 +184,16 @@
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
<div>
<h5 class="mb-1 fw-bold">Faktura-fejl-finder</h5>
<p class="text-muted mb-0">Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.</p>
</div>
</div>
<div id="invoiceErrorFinderSettingsCard"></div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
@ -2374,7 +2384,8 @@ async function testTelefoniCall() {
function renderDriftConnectors() {
const container = document.getElementById('driftConnectorCards');
if (!container) return;
const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
if (!container && !invoiceErrorFinderContainer) return;
const connectors = [
{
@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
<span id="uispSaveStatus" class="small text-muted"></span>
</div>
`
},
{
key: 'invoice-error-finder',
title: 'Faktura-fejl-finder',
description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.',
badge: 'Okonomi',
body: `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Ignorer varetekster</label>
<div class="input-group">
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
</div>
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
<div class="d-flex align-items-center gap-3 mt-4">
<button class="btn btn-primary" onclick="saveInvoiceErrorFinderSettings()">
<i class="bi bi-save me-2"></i>Gem faktura-fejl-finder
</button>
<span id="invoiceErrorFinderSaveStatus" class="small text-muted"></span>
</div>
`
}
];
container.innerHTML = connectors.map(connector => `
const renderConnectorCard = (connector) => `
<div class="card border-0 bg-light p-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
<div>
@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
</div>
${connector.body}
</div>
`).join('');
`;
if (container) {
container.innerHTML = connectors
.filter(connector => connector.key !== 'invoice-error-finder')
.map(renderConnectorCard)
.join('');
}
if (invoiceErrorFinderContainer) {
const invoiceErrorFinderConnector = connectors.find(connector => connector.key === 'invoice-error-finder');
invoiceErrorFinderContainer.innerHTML = invoiceErrorFinderConnector
? renderConnectorCard(invoiceErrorFinderConnector)
: '';
}
}
async function loadSettings() {
@ -2477,6 +2527,7 @@ async function loadSettings() {
renderDriftConnectors();
await loadUptimeKumaSettings();
await loadUISPSettings();
await loadInvoiceErrorFinderSettings();
await loadLabelPrinterSettings();
} catch (error) {
console.error('Error loading settings:', error);
@ -2805,6 +2856,30 @@ async function loadUISPSettings() {
}
let driftBlacklistItems = [];
const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [
'faktureringsgebyr',
'gebyr',
'porto',
'fragt',
'fragtomkostning',
'forsendelse',
'shipping',
'levering',
'engangsydelse',
'engangsarbejde',
'oprettelse',
'opstartsgebyr',
'installation',
'installationsgebyr',
'timeforbrug',
'arbejdstid',
'konsulenttimer',
'supporttid',
'teknikertid',
'montørtimer',
'projektarbejde'
];
let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim();
@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) {
renderDriftBlacklistList();
}
function parseInvoiceErrorFinderIgnoreValue(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
let values = [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
values = parsed;
} else if (typeof parsed === 'string') {
values = [parsed];
}
} catch (e) {
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
}
const seen = new Set();
const cleaned = [];
[...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => {
const normalized = String(item || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
cleaned.push(normalized);
});
return cleaned;
}
function renderInvoiceErrorFinderIgnoreList() {
const list = document.getElementById('invoiceErrorFinderIgnoreList');
if (!list) return;
if (!invoiceErrorFinderIgnoreItems.length) {
list.innerHTML = '<span class="text-muted small">Ingen varetekster ignoreres endnu.</span>';
return;
}
list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => {
const safe = String(item).replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<span class="badge text-bg-dark">${safe} <button type="button" class="btn btn-sm btn-link text-white p-0 ms-1" onclick="removeInvoiceErrorFinderIgnoreItem(${JSON.stringify(item)})" title="Fjern">&times;</button></span>`;
}).join('');
}
function addInvoiceErrorFinderIgnoreItem() {
const input = document.getElementById('invoiceErrorFinderIgnoreInput');
if (!input) return;
const value = String(input.value || '').trim().toLowerCase();
if (!value) return;
if (!invoiceErrorFinderIgnoreItems.includes(value)) {
invoiceErrorFinderIgnoreItems.push(value);
}
input.value = '';
renderInvoiceErrorFinderIgnoreList();
}
function removeInvoiceErrorFinderIgnoreItem(item) {
invoiceErrorFinderIgnoreItems = invoiceErrorFinderIgnoreItems.filter(v => v !== item);
renderInvoiceErrorFinderIgnoreList();
}
async function loadInvoiceErrorFinderSettings() {
try {
const response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { credentials: 'include' });
if (!response.ok) {
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
renderInvoiceErrorFinderIgnoreList();
return;
}
const setting = await response.json();
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(setting?.value || '[]');
renderInvoiceErrorFinderIgnoreList();
} catch (e) {
console.warn('Invoice Error Finder settings load failed:', e);
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
renderInvoiceErrorFinderIgnoreList();
}
}
async function saveInvoiceErrorFinderSettings() {
const statusEl = document.getElementById('invoiceErrorFinderSaveStatus');
statusEl.textContent = 'Gemmer...';
statusEl.className = 'small text-muted';
const value = JSON.stringify(parseInvoiceErrorFinderIgnoreValue(invoiceErrorFinderIgnoreItems));
const payload = {
key: 'invoice_error_finder_ignored_product_texts',
value,
category: 'finance',
description: 'JSON array of invoice product texts/descriptions ignored by Invoice Error Finder',
value_type: 'string',
is_public: false
};
try {
let response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
});
if (response.status === 404 || response.status === 405) {
response = await fetch('/api/v1/settings', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme ignore-listen'));
}
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(value);
renderInvoiceErrorFinderIgnoreList();
statusEl.textContent = '✅ Gemt';
statusEl.className = 'small text-success';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
showNotification('Ignore-liste gemt', 'success');
} catch (error) {
statusEl.textContent = '❌ Kunne ikke gemme';
statusEl.className = 'small text-danger';
showNotification(error.message || 'Kunne ikke gemme ignore-listen', 'error');
}
}
async function saveUISPSettings() {
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
const apiToken = (document.getElementById('uispApiToken').value || '').trim();

View File

@ -1704,13 +1704,13 @@ if (bmcOriginalFetch) {
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault();
openQuickCreateModal();
openNewCasePage();
}
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault();
openQuickCreateModal();
openNewCasePage();
}
// ESC to close
@ -1719,22 +1719,14 @@ if (bmcOriginalFetch) {
}
});
// QuickCreate modal opener function
function openQuickCreateModal() {
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
quickCreateModal.show();
setTimeout(() => {
const textInput = document.getElementById('quickCreateText');
if (textInput) {
textInput.focus();
}
}, 300);
function openNewCasePage() {
window.location.href = '/sag/new';
}
// QuickCreate button click handler
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
e.preventDefault();
openQuickCreateModal();
openNewCasePage();
});
// Reset search when modal is closed
@ -2257,9 +2249,6 @@ if (bmcOriginalFetch) {
});
</script>
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
@ -2305,6 +2294,11 @@ if (bmcOriginalFetch) {
<label class="form-label fw-semibold">Mobilnummer</label>
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Standard sagstype</label>
<select class="form-select" id="prof_default_case_type"></select>
<div class="form-text">Bruges som udgangspunkt på “Ny sag”.</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
@ -2673,10 +2667,27 @@ if (bmcOriginalFetch) {
document.getElementById('prof_full_name').value = p.full_name || '';
document.getElementById('prof_title').value = p.title || '';
document.getElementById('prof_phone').value = p.phone || '';
await loadProfileCaseTypes(p.default_case_type || 'ticket');
} catch (e) { console.error('Failed to load profile', e); }
loadAnyDeskChips();
}
async function loadProfileCaseTypes(selectedType = 'ticket') {
const select = document.getElementById('prof_default_case_type');
if (!select) return;
const labels = { ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service' };
try {
const res = await fetch('/api/v1/settings/case_types', { credentials: 'include' });
const setting = res.ok ? await res.json() : { value: '[]' };
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(value => String(value).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(labels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${labels[type] || type}</option>`).join('');
select.value = finalTypes.includes(selectedType) ? selectedType : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (e) { console.error('Failed to load profile case types', e); }
}
function buildInitials(name) {
const clean = String(name || '').trim();
if (!clean) return 'BR';
@ -2763,6 +2774,7 @@ if (bmcOriginalFetch) {
full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null,
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
};
try {
const res = await fetch('/api/v1/auth/me/profile', {

View File

@ -9,7 +9,7 @@ E-CONOMIC owns and syncs:
- economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address)
- email_domain, website (contact information)
- cvr_number (used for matching only, not overwritten if already set)
- cvr_number (company metadata; may be shared by several customers)
vTIGER owns and syncs:
- vtiger_id (primary key from vTiger)
@ -18,7 +18,7 @@ vTIGER owns and syncs:
HUB owns (manual or first-sync only):
- name (can be synced initially but not overwritten)
- cvr_number (used for matching, set once)
- cvr_number (informational; can be refreshed from e-conomic)
- Tags, notes, custom fields
SYNC RULES:
@ -192,7 +192,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
# Strict matching: ONLY match by economic_customer_number
existing = execute_query(
"""
SELECT id, name, email_domain, address, city, postal_code, country, website
SELECT id, name, cvr_number, email_domain, address, city, postal_code, country, website
FROM customers
WHERE economic_customer_number = %s
ORDER BY id
@ -221,6 +221,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing:
target_customer_id = existing[0]['id']
current_values = {
"cvr_number": existing[0].get("cvr_number"),
"email_domain": existing[0].get("email_domain"),
"address": existing[0].get("address"),
"city": existing[0].get("city"),
@ -229,6 +230,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
"website": existing[0].get("website"),
}
proposed_values = {
"cvr_number": cvr,
"email_domain": email_domain,
"address": address,
"city": city,
@ -252,6 +254,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
update_query = """
UPDATE customers SET
economic_customer_number = %s,
cvr_number = %s,
email_domain = %s,
address = %s,
city = %s,
@ -262,7 +265,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
WHERE id = %s
"""
execute_query(update_query, (
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
customer_number, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
))
logger.info(
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",

View File

@ -0,0 +1,13 @@
-- CVR is company metadata, not an external customer identity. Multiple
-- e-conomic customer records may legitimately share the same CVR number.
ALTER TABLE customers
DROP CONSTRAINT IF EXISTS customers_cvr_number_key;
DROP INDEX IF EXISTS customers_cvr_number_unique_idx;
CREATE INDEX IF NOT EXISTS idx_customers_cvr
ON customers(cvr_number)
WHERE cvr_number IS NOT NULL AND cvr_number <> '';
COMMENT ON COLUMN customers.cvr_number IS
'Danish CVR number. Informational/searchable; duplicates are allowed.';

View File

@ -108,7 +108,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
'error_found',
'ready_to_invoice',
'invoiced',
'ignored'
'ignored',
'resolved'
)),
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
customer_name VARCHAR(255),

View File

@ -0,0 +1,26 @@
-- Migration 213: allow smart-sync to mark invoice error finder issues as resolved
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'invoice_error_finder_issues_status_check'
) THEN
ALTER TABLE invoice_error_finder_issues
DROP CONSTRAINT invoice_error_finder_issues_status_check;
END IF;
END $$;
ALTER TABLE invoice_error_finder_issues
ADD CONSTRAINT invoice_error_finder_issues_status_check
CHECK (status IN (
'open',
'investigating',
'approved_change',
'error_found',
'ready_to_invoice',
'invoiced',
'ignored',
'resolved'
));

View File

@ -0,0 +1,15 @@
-- Per-user default for the type selected on the new-case page.
CREATE TABLE IF NOT EXISTS user_sag_create_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
default_case_type VARCHAR(50) NOT NULL DEFAULT 'ticket',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_sag_create_preferences_updated_at
ON user_sag_create_preferences(updated_at DESC);
-- Make pipeline available in installations that already have the configured list.
UPDATE settings
SET value = ((value::jsonb || '["pipeline"]'::jsonb)::text)
WHERE key = 'case_types'
AND NOT (value::jsonb ? 'pipeline');

View File

@ -0,0 +1,38 @@
-- Network wall outlets attached to buildings, floors, or rooms.
CREATE TABLE IF NOT EXISTS locations_wall_outlets (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
outlet_number VARCHAR(100) NOT NULL,
category VARCHAR(50),
patch_panel VARCHAR(255),
patch_port VARCHAR(100),
switch_name VARCHAR(255),
switch_port VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'unknown'
CHECK (status IN ('available', 'active', 'reserved', 'faulty', 'unknown')),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_wall_outlets_unique_location_number
ON locations_wall_outlets(location_id, lower(outlet_number))
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_location ON locations_wall_outlets(location_id);
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_status ON locations_wall_outlets(status) WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_locations_wall_outlets_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_locations_wall_outlets_updated_at ON locations_wall_outlets;
CREATE TRIGGER trg_locations_wall_outlets_updated_at
BEFORE UPDATE ON locations_wall_outlets
FOR EACH ROW EXECUTE FUNCTION update_locations_wall_outlets_updated_at();

View File

@ -0,0 +1,17 @@
-- Location names are meaningful within a customer and hierarchy, not globally.
-- Example: every building may have an "1 Sal".
BEGIN;
ALTER TABLE locations_locations
DROP CONSTRAINT IF EXISTS locations_locations_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_name_scope_unique
ON locations_locations (
COALESCE(parent_location_id, 0),
COALESCE(customer_id, 0),
lower(name)
)
WHERE deleted_at IS NULL;
COMMIT;

View File

@ -0,0 +1,8 @@
-- A technical room can contain a network cross-connect / patch field without
-- becoming a separate location type.
ALTER TABLE locations_locations
ADD COLUMN IF NOT EXISTS has_cross_field BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_locations_has_cross_field
ON locations_locations(has_cross_field)
WHERE has_cross_field = TRUE AND deleted_at IS NULL;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS locations_cross_fields (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
port_count INTEGER NOT NULL CHECK (port_count BETWEEN 1 AND 999),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_fields_location_name_active
ON locations_cross_fields(location_id, lower(name)) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS locations_cross_field_ports (
id SERIAL PRIMARY KEY,
cross_field_id INTEGER NOT NULL REFERENCES locations_cross_fields(id) ON DELETE CASCADE,
port_number INTEGER NOT NULL CHECK (port_number > 0),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(cross_field_id, port_number)
);
CREATE INDEX IF NOT EXISTS idx_cross_field_ports_field ON locations_cross_field_ports(cross_field_id);

View File

@ -0,0 +1,7 @@
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS cross_field_port_id INTEGER
REFERENCES locations_cross_field_ports(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wall_outlet_cross_field_port_active
ON locations_wall_outlets(cross_field_port_id)
WHERE cross_field_port_id IS NOT NULL AND deleted_at IS NULL;

View File

@ -182,6 +182,52 @@ def test_migration_wizard_v2_query_keeps_precise_segment_hits(monkeypatch):
assert payload["documents"][0]["snippet_count"] == 1
def test_migration_wizard_v2_returns_full_text_for_selected_segment(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, "execute_query_single", lambda query, params: {
"segment_id": 202,
"document_id": 102,
"block_index": 3,
"title": "StageOne uplink",
"original_filename": "karise.txt",
"content": "Hele den valgte tekstblok\nmed alle linjer.",
})
payload = asyncio.run(internet_router.get_customer_document_segment(202))
assert payload["title"] == "StageOne uplink"
assert payload["content"] == "Hele den valgte tekstblok\nmed alle linjer."
def test_migration_wizard_block_search_requires_all_words(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if "FROM internet_connections_customer_documents" in query:
return [{
"id": 103, "customer_id": 77, "connection_id": None,
"original_filename": "sales.txt", "filename": "sales.txt",
"file_size": 1, "mime_type": "text/plain", "notes": None,
"created_at": None, "extracted_text": "Management network notes",
}]
if "FROM internet_connections_customer_document_segments" in query:
return [{
"id": 203, "document_id": 103, "block_index": 0,
"block_title": "Management", "content": "Management network notes",
"ip_addresses": [], "cidr_blocks": [], "references_json": [], "socket_numbers": [],
}]
return []
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
monkeypatch.setattr(internet_router, "_ensure_document_segments", lambda document_id, extracted_text: 1)
payload = asyncio.run(internet_router._build_customer_document_hits(77, "Karise", "sales management"))
assert payload["segments"] == []
assert payload["documents"] == []
def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router

View File

@ -1,6 +1,7 @@
import asyncio
import json
import sys
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
@ -203,6 +204,85 @@ def test_detection_service_ignores_non_hub_customer_keys(monkeypatch):
assert customer_id is None
def test_detection_service_upsert_nulls_unknown_customer_id(monkeypatch):
from app.modules.invoice_error_finder.services.detection_service import DetectionService
inserted = {}
def fake_execute_query_single(query, params=None):
if "SELECT id, status" in query and "FROM invoice_error_finder_issues" in query:
return None
if "SELECT id FROM customers" in query:
return None
if "INSERT INTO invoice_error_finder_issues" in query:
inserted["params"] = params
return {"id": 21}
return None
monkeypatch.setattr(
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
fake_execute_query_single,
)
service = DetectionService()
issue_id = service._upsert_issue(
issue_type="missing_line",
customer_id=702222153,
customer_name="Unknown Mapping",
product_number="INET-1000",
reference_period_start=date(2026, 7, 1),
reference_period_end=date(2026, 7, 31),
)
assert issue_id == 21
assert inserted["params"][2] is None
def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
from app.modules.invoice_error_finder.services.detection_service import DetectionService
scanned_months = []
current_month = date.today().replace(day=1)
first_month = current_month - __import__("dateutil.relativedelta").relativedelta.relativedelta(months=3)
def fake_execute_query_single(query, params=None):
if "MIN(invoice_date)" in query and "MAX(invoice_date)" in query:
return {
"first_month": first_month,
"last_month": current_month,
}
return None
def fake_analyze_single_month(self, month):
scanned_months.append(month)
return {
"missing_line": 1,
"open_order_not_invoiced": 0,
"quantity_drop": 0,
"price_change": 0,
}
monkeypatch.setattr(
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
fake_execute_query_single,
)
monkeypatch.setattr(
DetectionService,
"_analyze_single_month",
fake_analyze_single_month,
)
service = DetectionService()
counts = service.analyze()
assert scanned_months == [
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=1),
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=2),
current_month,
]
assert counts["missing_line"] == 3
def test_list_issues_supports_unassigned_filter(monkeypatch):
from app.modules.invoice_error_finder.backend.router import list_issues
@ -235,3 +315,266 @@ def test_list_issues_supports_unassigned_filter(monkeypatch):
assert payload["total"] == 0
assert "assigned_user_id IS NULL" in captured["count_query"]
assert "assigned_user_id IS NULL" in captured["list_query"]
def test_list_issues_returns_resolved_product_name(monkeypatch):
from app.modules.invoice_error_finder.backend.router import list_issues
def fake_execute_query_single(query, params=None):
if "COUNT(*) AS c" in query:
return {"c": 1}
return None
def fake_execute_query(query, params=None):
return [
{
"id": 44,
"customer_name": "Karise Anlæg & Byg A/S",
"product_number": "PRO563",
"product_name": None,
"resolved_product_name": "Fiberforbindelse 1/1 Gbit.",
"status": "open",
"issue_type": "missing_line",
}
]
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query_single",
fake_execute_query_single,
)
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query",
fake_execute_query,
)
payload = asyncio.run(
list_issues(
status=None,
issue_type=None,
customer_id=None,
assigned_user_id=None,
limit=100,
offset=0,
current_user={},
)
)
assert payload["items"][0]["resolved_product_name"] == "Fiberforbindelse 1/1 Gbit."
def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
def fake_execute_query_single(query, params=None):
if "FROM invoice_error_finder_issues" in query:
return {
"id": 14,
"customer_id": 77,
"customer_name": "ACME",
"product_number": "INET-1000",
"product_name": "1/1 Gbit Internet",
"reference_period_start": date(2026, 7, 1),
"reference_period_end": date(2026, 7, 31),
}
if "FROM customers WHERE id = %s" in query:
return {
"id": 77,
"name": "ACME",
"economic_customer_number": 56283338,
}
return None
def fake_execute_query(query, params=None):
if "FROM month_window mw" in query:
return [
{
"month_start": date(2026, 6, 1),
"line_count": 1,
"total_quantity": 1,
"total_amount": 999.0,
"invoice_numbers": ["18912"],
"invoice_dates": ["2026-06-03"],
"descriptions": ["1/1 Gbit Internet"],
"is_reference_month": False,
},
{
"month_start": date(2026, 7, 1),
"line_count": 0,
"total_quantity": 0,
"total_amount": 0,
"invoice_numbers": [],
"invoice_dates": [],
"descriptions": [],
"is_reference_month": True,
},
{
"month_start": date(2026, 8, 1),
"line_count": 1,
"total_quantity": 1,
"total_amount": 999.0,
"invoice_numbers": ["19001"],
"invoice_dates": ["2026-08-04"],
"descriptions": ["1/1 Gbit Internet"],
"is_reference_month": False,
},
]
if "WITH ranked_invoices AS" in query:
return [
{
"month_start": date(2026, 6, 1),
"invoice_id": 101,
"source_invoice_number": "18912",
"invoice_date": date(2026, 6, 3),
"total_amount": 1248.75,
"net_amount": 999.0,
"vat_amount": 249.75,
"currency": "DKK",
"source_type": "booked",
"heading": "Periode June 2026",
"note_text": "Kundeperiode juni\nEkstra note",
"line_number": 1,
"product_number": "INET-1000",
"product_name": None,
"description": "1/1 Gbit Internet",
"quantity": 1,
"unit_price": 999.0,
"line_net_amount": 999.0,
},
{
"month_start": date(2026, 6, 1),
"invoice_id": 101,
"source_invoice_number": "18912",
"invoice_date": date(2026, 6, 3),
"total_amount": 1248.75,
"net_amount": 999.0,
"vat_amount": 249.75,
"currency": "DKK",
"source_type": "booked",
"heading": "Periode June 2026",
"note_text": "Kundeperiode juni\nEkstra note",
"line_number": 2,
"product_number": "RTR-1",
"product_name": None,
"description": "Leje af router",
"quantity": 1,
"unit_price": 249.0,
"line_net_amount": 249.0,
},
{
"month_start": date(2026, 8, 1),
"invoice_id": 102,
"source_invoice_number": "19001",
"invoice_date": date(2026, 8, 4),
"total_amount": 1248.75,
"net_amount": 999.0,
"vat_amount": 249.75,
"currency": "DKK",
"source_type": "booked",
"heading": "Periode August 2026",
"note_text": None,
"line_number": 1,
"product_number": "INET-1000",
"product_name": None,
"description": "1/1 Gbit Internet",
"quantity": 1,
"unit_price": 999.0,
"line_net_amount": 999.0,
},
]
return []
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query_single",
fake_execute_query_single,
)
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query",
fake_execute_query,
)
payload = asyncio.run(get_issue_invoice_history(14, current_user={}))
assert payload["customer_name"] == "ACME"
assert payload["product_number"] == "INET-1000"
assert len(payload["months"]) == 3
assert payload["months"][1]["is_reference_month"] is True
assert payload["months"][1]["line_count"] == 0
assert payload["months"][0]["invoices"][0]["invoice_number"] == "18912"
assert payload["months"][0]["invoices"][0]["note_text"] == "Kundeperiode juni\nEkstra note"
assert len(payload["months"][0]["invoices"][0]["lines"]) == 2
def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch):
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
def fake_execute_query_single(query, params=None):
if "FROM invoice_error_finder_issues" in query:
return {
"id": 15,
"customer_id": 77,
"customer_name": "ACME",
"product_number": "INET-1000",
"product_name": "1/1 Gbit Internet",
"reference_period_start": date(2026, 7, 1),
"reference_period_end": date(2026, 7, 31),
}
if "FROM customers WHERE id = %s" in query:
return {
"id": 77,
"name": "ACME",
"economic_customer_number": 56283338,
}
return None
def fake_execute_query(query, params=None):
if "FROM month_window mw" in query:
return [
{
"month_start": date(2026, 7, 1),
"line_count": 2,
"total_quantity": 2,
"total_amount": 1444.0,
"invoice_numbers": ["20098", "20098"],
"invoice_dates": ["2026-03-13", "2026-03-13"],
"descriptions": ["Fiberforbindelse", "Fiberforbindelse"],
"is_reference_month": True,
},
]
if "WITH ranked_invoices AS" in query:
return [
{
"month_start": date(2026, 7, 1),
"invoice_id": 201,
"source_invoice_number": "20098",
"invoice_date": date(2026, 3, 13),
"total_amount": 902.5,
"net_amount": 722.0,
"vat_amount": 180.5,
"currency": "DKK",
"source_type": "paid",
"heading": None,
"note_text": None,
"line_number": 1,
"product_number": "INET-1000",
"product_name": None,
"description": "Fiberforbindelse",
"quantity": 1,
"unit_price": 722.0,
"line_net_amount": 722.0,
}
]
return []
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query_single",
fake_execute_query_single,
)
monkeypatch.setattr(
"app.modules.invoice_error_finder.backend.router.execute_query",
fake_execute_query,
)
payload = asyncio.run(get_issue_invoice_history(15, current_user={}))
assert len(payload["months"][0]["invoices"]) == 1
assert payload["months"][0]["invoices"][0]["source_type"] == "paid"

View File

@ -0,0 +1,78 @@
import asyncio
import importlib
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).parent.parent))
from main import app # noqa: F401 - initializes the project import path used by module tests
locations_router = importlib.import_module("app.modules.locations.backend.router")
from app.modules.locations.models.schemas import WallOutletCreate
def test_wall_outlet_requires_supported_location_type(monkeypatch):
monkeypatch.setattr(
locations_router,
"execute_query",
lambda query, params=None: [{"id": 1, "name": "HQ", "location_type": "kompleks"}],
)
with pytest.raises(HTTPException) as exc:
asyncio.run(locations_router.create_wall_outlet(WallOutletCreate(location_id=1, outlet_number="A-01")))
assert exc.value.status_code == 400
def test_wall_outlet_create_returns_location_context(monkeypatch):
calls = []
def fake_execute_query(query, params=None):
calls.append((query, params))
if "SELECT id, name, location_type FROM locations_locations" in query:
return [{"id": 2, "name": "1. sal", "location_type": "etage"}]
if "INSERT INTO locations_wall_outlets" in query:
return [{"id": 33}]
return [{
"id": 33, "location_id": 2, "outlet_number": "A-12", "category": "Cat6a",
"patch_panel": "PP-A", "patch_port": "12", "switch_name": "SW-1",
"switch_port": "Gi1/0/12", "status": "active", "notes": None,
"is_active": True, "created_at": "2026-07-17T12:00:00",
"updated_at": "2026-07-17T12:00:00", "deleted_at": None,
"location_name": "1. sal", "location_type": "etage", "customer_name": "BMC",
"hierarchy_path": "HQ > 1. sal",
}]
monkeypatch.setattr(locations_router, "execute_query", fake_execute_query)
result = asyncio.run(locations_router.create_wall_outlet(
WallOutletCreate(location_id=2, outlet_number="A-12", category="Cat6a", status="active")
))
assert result.id == 33
assert result.hierarchy_path == "HQ > 1. sal"
assert any("INSERT INTO locations_wall_outlets" in query for query, _ in calls)
def test_wall_outlet_allows_customer_site(monkeypatch):
def fake_execute_query(query, params=None):
if "SELECT id, name, location_type FROM locations_locations" in query:
return [{"id": 2, "name": "Kundesite", "location_type": "customer_site"}]
if "INSERT INTO locations_wall_outlets" in query:
return [{"id": 34}]
return [{
"id": 34, "location_id": 2, "outlet_number": "A-01", "category": None,
"patch_panel": None, "patch_port": None, "switch_name": None, "switch_port": None,
"status": "unknown", "notes": None, "is_active": True,
"created_at": "2026-07-17T12:00:00", "updated_at": "2026-07-17T12:00:00",
"deleted_at": None, "location_name": "Kundesite", "location_type": "customer_site",
"customer_name": "BMC", "hierarchy_path": "Kundesite",
}]
monkeypatch.setattr(locations_router, "execute_query", fake_execute_query)
result = asyncio.run(locations_router.create_wall_outlet(
WallOutletCreate(location_id=2, outlet_number="A-01")
))
assert result.location_type == "customer_site"