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 phone: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
anydesk_id: 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") @router.get("/me/profile")
@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
) )
if not rows: if not rows:
raise HTTPException(status_code=404, detail="User not found") 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") @router.patch("/me/profile")
@ -253,12 +280,29 @@ async def update_my_profile(
fields.append("anydesk_id = %s") fields.append("anydesk_id = %s")
values.append(payload.anydesk_id.strip() or None) 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") raise HTTPException(status_code=400, detail="No fields to update")
if fields:
fields.append("updated_at = NOW()") fields.append("updated_at = NOW()")
values.append(current_user["id"]) values.append(current_user["id"])
execute_query( execute_query(
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
tuple(values) tuple(values)

View File

@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
<td>${escapeHtml(stage)}</td> <td>${escapeHtml(stage)}</td>
<td>${probability}</td> <td>${probability}</td>
<td class="text-end"> <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> <i class="bi bi-eye"></i>
</a> </a>
</td> </td>

View File

@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int):
return rows or [] 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") @router.get("/customers/{customer_id}/kontakt")
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)): 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.""" """Get unified contact communication history (calls + SMS) for all company contacts."""

View File

@ -260,6 +260,253 @@
min-height: 200px; 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 { .column-header {
position: sticky; position: sticky;
top: 0; top: 0;
@ -990,9 +1237,27 @@
<!-- Invoices Tab --> <!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices"> <div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="text-muted text-center py-5"> <div>
Fakturamodul kommer snart... <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>
</div> </div>
@ -1088,9 +1353,9 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0"> <h5 class="fw-bold mb-0">
<i class="bi bi-table me-2"></i>Abonnements-matrix <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> </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 <i class="bi bi-arrow-repeat me-1"></i>Opdater
</button> </button>
</div> </div>
@ -2013,6 +2278,23 @@
</div> </div>
</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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -2032,6 +2314,9 @@ let pipelineStages = [];
let allTagsCache = []; let allTagsCache = [];
let customerKontaktItems = []; let customerKontaktItems = [];
let customerKontaktFilter = 'all'; let customerKontaktFilter = 'all';
let customerInvoicesLoaded = false;
let customerInvoicesData = [];
let customerInvoiceSearchTerm = '';
let eventListenersAdded = false; let eventListenersAdded = false;
@ -2157,6 +2442,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
const invoicesTab = document.querySelector('a[href="#invoices"]');
if (invoicesTab) {
invoicesTab.addEventListener('shown.bs.tab', () => {
loadCustomerInvoices();
}, { once: false });
}
if (window.location.hash) { if (window.location.hash) {
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`); const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
if (hashTab && window.bootstrap?.Tab) { if (hashTab && window.bootstrap?.Tab) {
@ -3953,7 +4245,7 @@ function renderCustomerPipeline(opportunities) {
</td> </td>
<td>${o.probability || 0}%</td> <td>${o.probability || 0}%</td>
<td class="text-end"> <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> <i class="bi bi-arrow-right"></i>
</button> </button>
</td> </td>
@ -4472,6 +4764,349 @@ function formatCurrency(value, currency) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num); 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() { async function loadActivity() {
const container = document.getElementById('activityContainer'); const container = document.getElementById('activityContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; 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 amount = cell.amount || 0;
const statusBadge = getStatusBadge(cell.status); const statusBadge = getStatusBadge(cell.status);
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : ''; 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}> return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<div class="fw-500">${formatDKK(amount)}</div> <div class="fw-500">${formatDKK(amount)}</div>
<div>${statusBadge}</div> <div>${statusBadge}</div>
${detailButton}
</div> </div>
</td>`; </td>`;
}).join(''); }).join('');

View File

@ -370,7 +370,7 @@ async def create_hardware(data: dict):
try: try:
query = """ query = """
INSERT INTO hardware_assets ( 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, internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life, status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link, anydesk_id, anydesk_link,
@ -378,7 +378,7 @@ async def create_hardware(data: dict):
rental_default_start_price, rental_default_freight_price, rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_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 * RETURNING *
""" """
@ -392,6 +392,7 @@ async def create_hardware(data: dict):
data.get("model"), data.get("model"),
data.get("serial_number"), data.get("serial_number"),
data.get("customer_asset_id"), data.get("customer_asset_id"),
data.get("current_location_id"),
data.get("internal_asset_id"), data.get("internal_asset_id"),
data.get("notes"), data.get("notes"),
data.get("current_owner_type", "bmc"), data.get("current_owner_type", "bmc"),
@ -1172,4 +1173,3 @@ async def list_eset_incidents(
""" """
result = execute_query(query, (severity_list, limit)) result = execute_query(query, (severity_list, limit))
return result or [] 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"]) title_hit = _count_term_hits(title, terms["query_terms"])
if has_query: 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 continue
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0: elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
continue continue
@ -3106,7 +3113,9 @@ async def get_migration_wizard_v2_context(
if summary_source_parts: if summary_source_parts:
summary_input = ( summary_input = (
f"Kunde: {customer_name}\n" 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) + "\n\n".join(summary_source_parts)
) )
ai_summary = await ollama_service.generate_summary(summary_input) ai_summary = await ollama_service.generate_summary(summary_input)
@ -3120,3 +3129,35 @@ async def get_migration_wizard_v2_context(
"invoice_hits": invoice_hits, "invoice_hits": invoice_hits,
"ai_summary": ai_summary, "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; 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) { @media (max-width: 991px) {
.wiz-grid { .wiz-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -256,11 +278,27 @@
</div> </div>
</div> </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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
let customerSearchTimer = null; let customerSearchTimer = null;
let customerSearchRequest = 0;
let customerOptions = []; let customerOptions = [];
let localUploadedDocuments = []; let localUploadedDocuments = [];
@ -346,6 +384,7 @@
async function onCustomerInputChanged() { async function onCustomerInputChanged() {
const input = document.getElementById('customerSearchInput'); const input = document.getElementById('customerSearchInput');
const query = input.value.trim(); const query = input.value.trim();
const requestId = ++customerSearchRequest;
document.getElementById('selectedCustomerId').value = ''; document.getElementById('selectedCustomerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.'; document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
@ -358,6 +397,8 @@
if (query.length < 2) return; if (query.length < 2) return;
const items = await searchCustomers(query); 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); renderCustomerOptions(items);
} }
@ -457,11 +498,12 @@
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`; segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
} else { } else {
segmentList.innerHTML = segmentHits.map(item => ` segmentList.innerHTML = segmentHits.map(item => `
<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="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3"> <div class="d-flex justify-content-between align-items-start gap-3">
<div> <div>
<h3>${escapeHtml(item.title || 'Blok')}</h3> <h3>${escapeHtml(item.title || 'Blok')}</h3>
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score}</div> <div class="wiz-meta">Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken</div>
</div> </div>
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span> <span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
</div> </div>
@ -473,6 +515,7 @@
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''} ${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
</div> </div>
</div> </div>
</button>
`).join(''); `).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() { async function uploadCustomerFile() {
const fileInput = document.getElementById('customerFileInput'); const fileInput = document.getElementById('customerFileInput');
const notes = document.getElementById('customerFileNotes').value.trim(); const notes = document.getElementById('customerFileNotes').value.trim();

View File

@ -3,6 +3,7 @@ Invoice Error Finder API router.
""" """
import json import json
import logging import logging
import re
from datetime import date from datetime import date
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -26,6 +27,7 @@ ALLOWED_ISSUE_STATUSES = {
"ready_to_invoice", "ready_to_invoice",
"invoiced", "invoiced",
"ignored", "ignored",
"resolved",
} }
ISSUE_STATUS_LABELS = { ISSUE_STATUS_LABELS = {
@ -33,9 +35,10 @@ ISSUE_STATUS_LABELS = {
"investigating": "Under undersøgelse", "investigating": "Under undersøgelse",
"approved_change": "Godkendt ændring", "approved_change": "Godkendt ændring",
"error_found": "Fejl fundet", "error_found": "Fejl fundet",
"ready_to_invoice": "Klar til fakturering", "ready_to_invoice": "Opret ordrekladde",
"invoiced": "Faktureret", "invoiced": "Faktureret",
"ignored": "Ignoreret", "ignored": "Ignoreret",
"resolved": "Løst",
} }
ISSUE_TYPE_LABELS = { ISSUE_TYPE_LABELS = {
@ -67,6 +70,95 @@ class CreateOrdreDraftRequest(BaseModel):
description: Optional[str] = None 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]: def _get_user_id(request: Request) -> Optional[int]:
value = getattr(request.state, "user_id", None) value = getattr(request.state, "user_id", None)
if value is not None: if value is not None:
@ -255,9 +347,22 @@ async def list_issues(
f""" f"""
SELECT SELECT
i.*, i.*,
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
COALESCE(u.full_name, u.username) AS assigned_user_name, COALESCE(u.full_name, u.username) AS assigned_user_name,
sg.titel AS sag_title sg.titel AS sag_title
FROM invoice_error_finder_issues i 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 users u ON u.user_id = i.assigned_user_id
LEFT JOIN sag_sager sg ON sg.id = i.sag_id LEFT JOIN sag_sager sg ON sg.id = i.sag_id
WHERE {where_clause} WHERE {where_clause}
@ -289,9 +394,22 @@ async def get_issue(
""" """
SELECT SELECT
i.*, i.*,
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
COALESCE(u.full_name, u.username) AS assigned_user_name, COALESCE(u.full_name, u.username) AS assigned_user_name,
sg.titel AS sag_title sg.titel AS sag_title
FROM invoice_error_finder_issues i 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 users u ON u.user_id = i.assigned_user_id
LEFT JOIN sag_sager sg ON sg.id = i.sag_id LEFT JOIN sag_sager sg ON sg.id = i.sag_id
WHERE i.id = %s WHERE i.id = %s
@ -308,6 +426,479 @@ async def get_issue(
raise HTTPException(status_code=500, detail=str(exc)) 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") @router.patch("/issues/{issue_id}/status")
async def update_issue_status( async def update_issue_status(
issue_id: int, issue_id: int,
@ -320,7 +911,7 @@ async def update_issue_status(
raise HTTPException(status_code=400, detail="Invalid status") raise HTTPException(status_code=400, detail="Invalid status")
resolved_at = None resolved_at = None
if payload.status in {"invoiced", "ignored"}: if payload.status in {"invoiced", "ignored", "resolved"}:
resolved_at = "CURRENT_TIMESTAMP" resolved_at = "CURRENT_TIMESTAMP"
extra_fields = [] extra_fields = []
@ -334,7 +925,11 @@ async def update_issue_status(
extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s") extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s")
extra_values.append(payload.notes) 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( execute_query(
f""" f"""

View File

@ -116,7 +116,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
'error_found', 'error_found',
'ready_to_invoice', 'ready_to_invoice',
'invoiced', 'invoiced',
'ignored' 'ignored',
'resolved'
)), )),
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
customer_name VARCHAR(255), 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. writes issues to invoice_error_finder_issues.
""" """
import logging import logging
import json
import re
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@ -12,6 +14,38 @@ from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__) 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: class DetectionService:
"""Detect invoice anomalies and write issues.""" """Detect invoice anomalies and write issues."""
@ -23,16 +57,40 @@ class DetectionService:
): ):
self.quantity_drop_threshold = quantity_drop_threshold self.quantity_drop_threshold = quantity_drop_threshold
self.open_order_days_threshold = open_order_days_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]: def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
""" """
Run all detection rules for the given reference month (defaults to current month). Run detection rules for a specific month or sweep historical invoice months.
Returns counts per issue_type. Returns aggregated counts per issue_type.
""" """
if reference_month is None: self._ignore_existing_issues()
reference_month = date.today().replace(day=1)
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) previous_month = reference_month - relativedelta(months=1)
self._seen_issue_ids = set()
logger.info("🔍 Running invoice error detection for %s", reference_month) 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), "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 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: 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. 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, COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
SUM(line.quantity) AS quantity, 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 FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id ON line.invoice_id = inv.id
@ -97,6 +188,8 @@ class DetectionService:
prev.product_key, prev.product_key,
prev.quantity AS expected_quantity, prev.quantity AS expected_quantity,
prev.last_invoice_date, prev.last_invoice_date,
prev.product_name,
prev.description,
c.name AS customer_name, c.name AS customer_name,
m2.hub_customer_id m2.hub_customer_id
FROM previous_lines prev FROM previous_lines prev
@ -118,12 +211,18 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
issue_id = self._upsert_issue( issue_id = self._upsert_issue(
issue_type="missing_line", issue_type="missing_line",
customer_id=hub_customer_id, customer_id=hub_customer_id,
customer_name=customer_name, customer_name=customer_name,
product_number=row["product_key"], product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start, reference_period_start=current_start,
reference_period_end=current_end, reference_period_end=current_end,
expected_quantity=row.get("expected_quantity"), expected_quantity=row.get("expected_quantity"),
@ -186,6 +285,11 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month): if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
continue 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 # Check if there is any e-conomic invoice line for this customer + product recently
has_invoice = self._has_recent_invoice_for_product( 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, COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
DATE_TRUNC('month', inv.invoice_date)::date AS period, 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 FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id ON line.invoice_id = inv.id
@ -249,7 +355,7 @@ class DetectionService:
GROUP BY customer_key, product_key, period GROUP BY customer_key, product_key, period
), ),
prev AS ( 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 ( cur AS (
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
@ -259,6 +365,8 @@ class DetectionService:
prev.product_key, prev.product_key,
prev.quantity AS expected_quantity, prev.quantity AS expected_quantity,
cur.quantity AS actual_quantity, cur.quantity AS actual_quantity,
prev.product_name,
prev.description,
c.name AS customer_name, c.name AS customer_name,
m.hub_customer_id m.hub_customer_id
FROM prev FROM prev
@ -286,12 +394,18 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
issue_id = self._upsert_issue( issue_id = self._upsert_issue(
issue_type="quantity_drop", issue_type="quantity_drop",
customer_id=hub_customer_id, customer_id=hub_customer_id,
customer_name=customer_name, customer_name=customer_name,
product_number=row["product_key"], product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start, reference_period_start=current_start,
reference_period_end=current_end, reference_period_end=current_end,
expected_quantity=row["expected_quantity"], expected_quantity=row["expected_quantity"],
@ -324,7 +438,9 @@ class DetectionService:
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key, COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key, LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
DATE_TRUNC('month', inv.invoice_date)::date AS period, 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 FROM invoice_error_finder_economic_invoices inv
JOIN invoice_error_finder_economic_invoice_lines line JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = inv.id ON line.invoice_id = inv.id
@ -336,7 +452,7 @@ class DetectionService:
GROUP BY customer_key, product_key, period GROUP BY customer_key, product_key, period
), ),
prev AS ( 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 ( cur AS (
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
@ -346,6 +462,8 @@ class DetectionService:
prev.product_key, prev.product_key,
prev.avg_price AS expected_price, prev.avg_price AS expected_price,
cur.avg_price AS actual_price, cur.avg_price AS actual_price,
prev.product_name,
prev.description,
c.name AS customer_name, c.name AS customer_name,
m.hub_customer_id m.hub_customer_id
FROM prev FROM prev
@ -371,6 +489,11 @@ class DetectionService:
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month): if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
continue continue
if self._is_ignored_product_text(
row.get("product_name"),
row.get("description"),
):
continue
expected_price = row["expected_price"] expected_price = row["expected_price"]
actual_price = row["actual_price"] actual_price = row["actual_price"]
@ -383,6 +506,7 @@ class DetectionService:
customer_id=hub_customer_id, customer_id=hub_customer_id,
customer_name=customer_name, customer_name=customer_name,
product_number=row["product_key"], product_number=row["product_key"],
product_name=row.get("product_name") or row.get("description"),
reference_period_start=current_start, reference_period_start=current_start,
reference_period_end=current_end, reference_period_end=current_end,
expected_price=expected_price, expected_price=expected_price,
@ -473,10 +597,130 @@ class DetectionService:
return False 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]: def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
"""Insert a new issue or update an existing open one.""" """Insert a new issue or update an existing open one."""
issue_type = kwargs["issue_type"] 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") product_number = kwargs.get("product_number")
reference_period_start = kwargs.get("reference_period_start") reference_period_start = kwargs.get("reference_period_start")
reference_period_end = kwargs.get("reference_period_end") 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"}: if existing and existing.get("status") not in {"ignored", "invoiced"}:
execute_query( execute_query(
""" """
@ -525,6 +804,7 @@ class DetectionService:
last_invoice_number = COALESCE(%s, last_invoice_number), last_invoice_number = COALESCE(%s, last_invoice_number),
last_invoice_date = COALESCE(%s, last_invoice_date), last_invoice_date = COALESCE(%s, last_invoice_date),
sales_order_number = COALESCE(%s, sales_order_number), sales_order_number = COALESCE(%s, sales_order_number),
product_name = COALESCE(%s, product_name),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", """,
@ -537,9 +817,11 @@ class DetectionService:
kwargs.get("last_invoice_number"), kwargs.get("last_invoice_number"),
kwargs.get("last_invoice_date"), kwargs.get("last_invoice_date"),
kwargs.get("sales_order_number"), kwargs.get("sales_order_number"),
kwargs.get("product_name"),
existing["id"], existing["id"],
), ),
) )
self._seen_issue_ids.add(int(existing["id"]))
return existing["id"] return existing["id"]
if existing and existing.get("status") == "invoiced": if existing and existing.get("status") == "invoiced":
@ -568,6 +850,7 @@ class DetectionService:
last_invoice_number = COALESCE(%s, last_invoice_number), last_invoice_number = COALESCE(%s, last_invoice_number),
last_invoice_date = COALESCE(%s, last_invoice_date), last_invoice_date = COALESCE(%s, last_invoice_date),
sales_order_number = COALESCE(%s, sales_order_number), sales_order_number = COALESCE(%s, sales_order_number),
product_name = COALESCE(%s, product_name),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", """,
@ -581,9 +864,11 @@ class DetectionService:
kwargs.get("last_invoice_number"), kwargs.get("last_invoice_number"),
kwargs.get("last_invoice_date"), kwargs.get("last_invoice_date"),
kwargs.get("sales_order_number"), kwargs.get("sales_order_number"),
kwargs.get("product_name"),
existing["id"], existing["id"],
), ),
) )
self._seen_issue_ids.add(int(existing["id"]))
return existing["id"] return existing["id"]
row = execute_query_single( row = execute_query_single(
@ -624,6 +909,8 @@ class DetectionService:
kwargs.get("notes"), 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 return row["id"] if row else None
@staticmethod @staticmethod
@ -638,6 +925,17 @@ class DetectionService:
) )
return value if customer else None 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 @staticmethod
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]: def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
if hub_customer_id: if hub_customer_id:

View File

@ -100,7 +100,7 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <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> <h2 class="mb-0" id="readyToInvoiceCount">-</h2>
</div> </div>
<div class="bg-success bg-opacity-10 p-2 rounded"> <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="investigating">Under undersøgelse</option>
<option value="approved_change">Godkendt ændring</option> <option value="approved_change">Godkendt ændring</option>
<option value="error_found">Fejl fundet</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="invoiced">Faktureret</option>
<option value="ignored">Ignoreret</option> <option value="ignored">Ignoreret</option>
<option value="resolved">Løst</option>
</select> </select>
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
@ -100,11 +101,60 @@
<span class="text-muted small" id="paginationInfo"></span> <span class="text-muted small" id="paginationInfo"></span>
<div class="btn-group" id="paginationControls"></div> <div class="btn-group" id="paginationControls"></div>
</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> </div>
<script> <script>
let currentOffset = 0; let currentOffset = 0;
const pageSize = 100; const pageSize = 100;
let currentHistoryIssueId = null;
const issueTypeLabels = { const issueTypeLabels = {
missing_line: 'Manglende varelinje', missing_line: 'Manglende varelinje',
@ -119,9 +169,10 @@ const statusLabels = {
investigating: 'Under undersøgelse', investigating: 'Under undersøgelse',
approved_change: 'Godkendt ændring', approved_change: 'Godkendt ændring',
error_found: 'Fejl fundet', error_found: 'Fejl fundet',
ready_to_invoice: 'Klar til fakturering', ready_to_invoice: 'Opret ordrekladde',
invoiced: 'Faktureret', invoiced: 'Faktureret',
ignored: 'Ignoreret' ignored: 'Ignoreret',
resolved: 'Løst'
}; };
function escapeHtml(text) { function escapeHtml(text) {
@ -143,6 +194,11 @@ function formatNumber(value) {
return new Intl.NumberFormat('da-DK').format(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) { function statusBadge(status) {
const map = { const map = {
open: 'bg-danger', open: 'bg-danger',
@ -151,7 +207,8 @@ function statusBadge(status) {
error_found: 'bg-danger', error_found: 'bg-danger',
ready_to_invoice: 'bg-success', ready_to_invoice: 'bg-success',
invoiced: 'bg-secondary', 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'; const cls = map[status] || 'bg-light text-dark';
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`; return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
@ -198,7 +255,7 @@ async function loadIssues() {
<tr> <tr>
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td> <td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</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.expected_quantity ?? issue.expected_price)}</td>
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td> <td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
<td>${issue.reference_period_start || '-'}</td> <td>${issue.reference_period_start || '-'}</td>
@ -222,13 +279,16 @@ async function loadIssues() {
} }
function renderActionButtons(issue) { function renderActionButtons(issue) {
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</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 === 'invoiced') return '<span class="text-muted small">Faktureret</span>'; 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 ` 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-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-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-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> <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(); loadIssues();
} }
async function updateStatus(issueId, status) { async function updateStatus(issueId, status, options = {}) {
const { target = 'page', successMessage = 'Status opdateret' } = options;
try { try {
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, { const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
method: 'PATCH', method: 'PATCH',
@ -265,14 +326,23 @@ async function updateStatus(issueId, status) {
body: JSON.stringify({ status }) body: JSON.stringify({ status })
}); });
if (!res.ok) throw new Error('Opdatering fejlede'); if (!res.ok) throw new Error('Opdatering fejlede');
showStatus('Status opdateret', 'success'); if (target === 'history') {
showHistoryStatus(successMessage, 'success');
} else {
showStatus(successMessage, 'success');
}
loadIssues(); loadIssues();
} catch (err) { } catch (err) {
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
showStatus('Fejl: ' + err.message, 'danger'); showStatus('Fejl: ' + err.message, 'danger');
} }
} }
}
async function createSag(issueId) { async function createSag(issueId, options = {}) {
const { target = 'page' } = options;
const titel = prompt('Titel på sag:'); const titel = prompt('Titel på sag:');
if (!titel) return; if (!titel) return;
try { try {
@ -283,12 +353,20 @@ async function createSag(issueId) {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede'); if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
if (target === 'history') {
showHistoryStatus(`Sag #${data.sag_id} oprettet`, 'success');
} else {
showStatus(`Sag #${data.sag_id} oprettet`, 'success'); showStatus(`Sag #${data.sag_id} oprettet`, 'success');
}
loadIssues(); loadIssues();
} catch (err) { } catch (err) {
if (target === 'history') {
showHistoryStatus('Fejl: ' + err.message, 'danger');
} else {
showStatus('Fejl: ' + err.message, 'danger'); showStatus('Fejl: ' + err.message, 'danger');
} }
} }
}
async function createOrdreDraft(issueId) { async function createOrdreDraft(issueId) {
try { try {
@ -328,6 +406,162 @@ function showStatus(message, type) {
setTimeout(() => el.classList.add('d-none'), 4000); 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(); loadIssues();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import (
Service, ServiceCreate, ServiceUpdate, Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate, Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats, BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse LocationWizardCreateRequest, LocationWizardCreateResponse,
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
) )
router = APIRouter() router = APIRouter()
@ -164,15 +165,21 @@ async def create_location(request: Request):
logger.warning("⚠️ Invalid location payload") logger.warning("⚠️ Invalid location payload")
raise HTTPException(status_code=422, detail=e.errors()) raise HTTPException(status_code=422, detail=e.errors())
# Check for duplicate name # Names only need to be unique within the same customer and hierarchy.
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (data.name,)) 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: if existing:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name: {data.name}")
raise HTTPException( raise HTTPException(
status_code=400, 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: if data.customer_id is not None:
@ -201,9 +208,9 @@ async def create_location(request: Request):
INSERT INTO locations_locations ( INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city, name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude, 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 * RETURNING *
""" """
@ -221,7 +228,8 @@ async def create_location(request: Request):
data.phone, data.phone,
data.email, data.email,
data.notes, 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) result = execute_query(insert_query, params)
@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
def _normalize_name(value: str) -> str: def _normalize_name(value: str) -> str:
return (value or "").strip().lower() 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) 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 return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (value,)) 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) 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) normalized = _normalize_name(value)
if normalized: 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: if not auto_suffix:
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
base_name = base_name.strip() base_name = base_name.strip()
if not _name_exists(base_name): if not _name_exists(base_name, parent_location_id, customer_id):
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
suffix = 2 suffix = 2
while True: while True:
candidate = f"{base_name} ({suffix})" candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate): if not _name_exists(candidate, parent_location_id, customer_id):
_reserve_name(candidate) _reserve_name(candidate, parent_location_id, customer_id)
return candidate return candidate
suffix += 1 suffix += 1
@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
raise HTTPException(status_code=500, detail="Failed to create location") raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0]) 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( root_location = insert_location_record(
name=resolved_root_name, name=resolved_root_name,
location_type=root.location_type, location_type=root.location_type,
@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
room_ids: List[int] = [] room_ids: List[int] = []
for floor in data.floors: 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( floor_location = insert_location_record(
name=resolved_floor_name, name=resolved_floor_name,
location_type=floor.location_type, location_type=floor.location_type,
@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
)) ))
for room in floor.rooms: 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( room_location = insert_location_record(
name=resolved_room_name, name=resolved_room_name,
location_type=room.location_type, 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 # 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) @router.get("/locations/{id}", response_model=LocationDetail)
async def get_location(id: int): async def get_location(id: int):
""" """
@ -645,6 +841,12 @@ async def get_location(id: int):
capacity_result = execute_query(capacity_query, (id,)) capacity_result = execute_query(capacity_query, (id,))
capacity = [dict(row) for row in capacity_result] if capacity_result else [] 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) # Build hierarchy breadcrumb (ancestors from root to parent)
hierarchy_query = """ hierarchy_query = """
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
@ -693,7 +895,8 @@ async def get_location(id: int):
contacts=contacts, contacts=contacts,
hours=hours, hours=hours,
services=services, services=services,
capacity=capacity capacity=capacity,
wall_outlets=wall_outlets,
) )
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})") 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]) old_location = Location(**existing[0])
# Check for duplicate name if name is being updated # Check the resulting name/customer/parent scope, including when only
if data.name is not None and data.name != old_location.name: # customer or parent is changed.
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL" if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
dup_check = execute_query(dup_query, (data.name, id)) 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: if dup_check:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
raise HTTPException( raise HTTPException(
status_code=400, 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 # Build UPDATE query with only provided fields
@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate):
'phone': 'phone', 'phone': 'phone',
'email': 'email', 'email': 'email',
'notes': 'notes', 'notes': 'notes',
'is_active': 'is_active' 'is_active': 'is_active',
'has_cross_field': 'has_cross_field'
} }
update_data = {} update_data = {}
@ -792,6 +1007,30 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400, status_code=400,
detail="parent_location_id does not exist" 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': if key == 'customer_id':
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL" customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
customer = execute_query(customer_query, (value,)) customer = execute_query(customer_query, (value,))
@ -809,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400, status_code=400,
detail=f"location_type must be one of: {', '.join(allowed_types)}" 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") update_parts.append(f"{db_column} = %s")
params.append(value) params.append(value)
update_data[key] = value update_data[key] = value

View File

@ -57,6 +57,100 @@ LOCATION_TYPES = [
{"value": "vehicle", "label": "Køretøj"}, {"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: def render_template(template_name: str, **context) -> str:
""" """
@ -167,7 +261,7 @@ def list_locations_view(
""" """
query_params.extend([limit, skip]) 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: def build_tree(items: list) -> list:
nodes = {} nodes = {}
@ -247,7 +341,10 @@ def list_locations_view(
# ============================================================================ # ============================================================================
@router.get("/app/locations/create", response_class=HTMLResponse) @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. Render the location creation form.
@ -268,14 +365,11 @@ def create_location_view():
try: try:
logger.info("🆕 Rendering create location form") logger.info("🆕 Rendering create location form")
# Query parent locations parent_locations = get_parent_location_choices() or []
parent_locations = execute_query(""" selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
SELECT id, name, location_type
FROM locations_locations if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
WHERE deleted_at IS NULL AND is_active = true customer_id = selected_parent.get("customer_id")
ORDER BY name
LIMIT 1000
""")
# Query customers # Query customers
customers = execute_query(""" customers = execute_query("""
@ -295,7 +389,10 @@ def create_location_view():
cancel_url="/app/locations", cancel_url="/app/locations",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, 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 location=None, # No location data for create form
) )
@ -321,13 +418,7 @@ def location_wizard_view():
try: try:
logger.info("🧭 Rendering location wizard") logger.info("🧭 Rendering location wizard")
parent_locations = execute_query(""" parent_locations = get_parent_location_choices()
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
customers = execute_query(""" customers = execute_query("""
SELECT id, name, email, phone 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) @router.get("/app/locations/{id}", response_class=HTMLResponse)
@ -473,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,) (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( audit_log = execute_query(
""" """
SELECT id, location_id, event_type, user_id, changes, created_at 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["services"] = services or []
location["capacity"] = capacity or [] location["capacity"] = capacity or []
location["hardware"] = hardware or [] location["hardware"] = hardware or []
location["wall_outlets"] = wall_outlets or []
location["cross_fields"] = cross_fields or []
location["audit_log"] = audit_log or [] location["audit_log"] = audit_log or []
# Query customers # 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") # contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours") # hours = call_api("GET", f"/api/v1/locations/{id}/hours")
customers = customers or []
# Render template with context # Render template with context
html = render_template( html = render_template(
"modules/locations/templates/detail.html", "modules/locations/templates/detail.html",
@ -555,14 +719,11 @@ def edit_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result location = location[0] # Get first result
# Query parent locations (exclude self) parent_locations = get_parent_location_choices(exclude_id=id) or []
parent_locations = execute_query(""" selected_parent = next(
SELECT id, name, location_type (row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
FROM locations_locations None,
WHERE is_active = true AND id != %s )
ORDER BY name
LIMIT 1000
""", (id,))
# Query customers # Query customers
customers = execute_query(""" customers = execute_query("""
@ -584,7 +745,8 @@ def edit_location_view(id: int = Path(..., gt=0)):
cancel_url=f"/app/locations/{id}", cancel_url=f"/app/locations/{id}",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, 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 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, latitude = %s,
longitude = %s, longitude = %s,
notes = %s, notes = %s,
has_cross_field = %s,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s 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("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None, float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"), form.get("notes"),
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
id id
)) ))

View File

@ -17,7 +17,7 @@ from decimal import Decimal
class LocationBase(BaseModel): class LocationBase(BaseModel):
"""Shared fields for location models""" """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( location_type: str = Field(
..., ...,
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = Field(True, description="Whether location is active") 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') @field_validator('location_type')
@classmethod @classmethod
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
has_cross_field: Optional[bool] = None
@field_validator('location_type') @field_validator('location_type')
@classmethod @classmethod
@ -101,6 +103,99 @@ class Location(LocationBase):
from_attributes = True 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 # 2. CONTACT MODELS
# ============================================================================ # ============================================================================
@ -360,6 +455,7 @@ class LocationDetail(Location):
hours: List[OperatingHours] = Field(default_factory=list) hours: List[OperatingHours] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list) services: List[Service] = Field(default_factory=list)
capacity: List[Capacity] = Field(default_factory=list) capacity: List[Capacity] = Field(default_factory=list)
wall_outlets: List[WallOutlet] = Field(default_factory=list)
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):

View File

@ -2,6 +2,40 @@
{% block title %}Opret lokation - BMC Hub{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@ -57,19 +91,52 @@
</select> </select>
</div> </div>
<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"> <div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id"> <select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option> <option value="">Ingen (øverste niveau)</option>
{% if parent_locations %} {% if parent_locations %}
{% for parent in parent_locations %} {% for parent in parent_locations %}
<option value="{{ parent.id }}"> <option
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %} 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> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </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>
<div class="mb-3"> <div class="mb-3">
@ -78,11 +145,11 @@
<option value="">Ingen</option> <option value="">Ingen</option>
{% if customers %} {% if customers %}
{% for customer in customers %} {% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option> <option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
<div class="form-text">Valgfri kan knyttes til alle typer.</div> <div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -91,6 +158,14 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</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> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -185,12 +260,79 @@ document.addEventListener('DOMContentLoaded', function() {
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const notesField = document.getElementById('notes'); const notesField = document.getElementById('notes');
const charCount = document.getElementById('charCount'); 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 // Character counter for notes
notesField.addEventListener('input', function() { notesField.addEventListener('input', function() {
charCount.textContent = this.value.length; 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 submission
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@ -202,7 +344,10 @@ document.addEventListener('DOMContentLoaded', function() {
const data = { const data = {
name: formData.get('name'), name: formData.get('name'),
location_type: formData.get('location_type'), 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', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), 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'; 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> </script>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,17 @@
{% block extra_css %} {% block extra_css %}
<style> <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 { .locations-detail-page {
--loc-accent: var(--accent, #0f4c75); --loc-accent: var(--accent, #0f4c75);
} }
@ -247,6 +258,9 @@
<span class="case-type-chip" style="--tcolor: {{ type_color }};"> <span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }} {{ type_label }}
</span> </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 %} {% if location.is_active %}
<span class="case-status-chip open"> <span class="case-status-chip open">
<span class="case-status-dot"></span>Aktiv <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> <span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button> </button>
</li> </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"> <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"> <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 <i class="bi bi-diagram-3 me-2"></i>Relationer
@ -715,9 +736,15 @@
</div> </div>
<div class="card border-0"> <div class="card border-0">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h5 class="card-title mb-0">Tilføj underlokation</h5> <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>
<div class="card-body"> <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"> <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="parent_location_id" value="{{ location.id }}">
<input type="hidden" name="redirect_to" value="/app/locations/{id}"> <input type="hidden" name="redirect_to" value="/app/locations/{id}">
@ -747,7 +774,7 @@
<option value="">Ingen</option> <option value="">Ingen</option>
{% if customers %} {% if customers %}
{% for customer in 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 %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
@ -763,6 +790,32 @@
</div> </div>
</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 border-0 mt-4">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5> <h5 class="card-title mb-0">Hierarki (træ)</h5>
@ -803,6 +856,23 @@
</div> </div>
</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 --> <!-- Tab 7: Hardware -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab"> <div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0"> <div class="card border-0">
@ -976,6 +1046,37 @@
</div> </div>
</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 --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <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> </script>
{% endblock %} {% endblock %}

View File

@ -2,6 +2,40 @@
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %} {% 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 %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@ -58,19 +92,52 @@
</select> </select>
</div> </div>
<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 %}
</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"> <div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id"> <select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option> <option value="">Ingen (øverste niveau)</option>
{% if parent_locations %} {% if parent_locations %}
{% for parent in parent_locations %} {% for parent in parent_locations %}
<option value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}> <option
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %} 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> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div> <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>
<div class="mb-3"> <div class="mb-3">
@ -92,6 +159,13 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</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> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -130,7 +204,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email</label> <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> </div>
</fieldset> </fieldset>
@ -160,7 +234,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="notes" class="form-label">Noter og kommentarer</label> <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> <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> </div>
</fieldset> </fieldset>
@ -216,12 +290,64 @@ document.addEventListener('DOMContentLoaded', function() {
const deleteModalElement = document.getElementById('deleteModal'); const deleteModalElement = document.getElementById('deleteModal');
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null; const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const parentLocationSelect = document.getElementById('parentLocation');
const parentLocationSearch = document.getElementById('parentLocationSearch');
const parentSummary = document.getElementById('parentSummary');
// Character counter for notes // Character counter for notes
notesField.addEventListener('input', function() { notesField.addEventListener('input', function() {
charCount.textContent = this.value.length; 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 submission
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
if (form.dataset.noIntercept === 'true') { 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, 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, customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), 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 // Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() { document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
fetch(`/api/v1/locations/${locationId}`, { fetch(`/api/v1/locations/${locationId}`, {

View File

@ -6,81 +6,271 @@
<style> <style>
.locations-list-page { .locations-list-page {
--loc-accent: #0f4c75; --loc-accent: #0f4c75;
--loc-accent-soft: rgba(15, 76, 117, 0.08); --loc-accent-soft: rgba(15, 76, 117, 0.06);
--loc-border: rgba(15, 76, 117, 0.16); --loc-border: rgba(15, 76, 117, 0.12);
--loc-surface: #f7f9fc;
} }
.locations-list-page .locations-hero { .locations-list-page .locations-hero {
border: 1px solid var(--loc-border); border: 1px solid rgba(0, 0, 0, 0.08);
background: background: var(--bg-card);
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%), border-radius: 0.9rem;
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%), box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
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));
} }
.locations-list-page .stat-tile { .locations-list-page .stat-tile {
background: var(--loc-accent-soft); background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
border: 1px solid var(--loc-border); border: 1px solid rgba(15, 76, 117, 0.08);
border-radius: 0.9rem; border-radius: 0.8rem;
padding: 0.8rem 0.95rem; 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 { .locations-list-page .stat-tile .stat-value {
font-size: 1.15rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--loc-accent); color: var(--loc-accent);
line-height: 1.1; line-height: 1.1;
} }
[data-theme="dark"] .locations-list-page .stat-tile .stat-value { .locations-list-page .stat-tile .stat-note {
color: #8fd0ff; font-size: 0.76rem;
color: var(--text-secondary);
} }
.locations-list-page .table thead th { .locations-list-page .table thead th {
font-size: 0.8rem; font-size: 0.76rem;
letter-spacing: 0.02em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; 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 { .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 { .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); 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 { .locations-list-page .shortcut-hint {
border: 1px dashed var(--loc-border); border: 1px dashed rgba(0, 0, 0, 0.12);
border-radius: 0.55rem; border-radius: 0.55rem;
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-secondary); 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) { @media (max-width: 767.98px) {
.locations-list-page { .locations-list-page {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.locations-list-page .stat-tile { .locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
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> </style>
{% endblock %} {% endblock %}
@ -96,59 +286,83 @@
</nav> </nav>
<!-- Header Section --> <!-- Header Section -->
<div class="row mb-4"> <div class="locations-hero p-3 p-lg-4 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 class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div> <div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1> <div class="section-label mb-2">
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p> <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 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>
<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>
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
</div> </div>
<div class="row g-2 mt-2"> <div class="row g-2 mt-2">
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Total</div> <div class="small text-muted">Total</div>
<div class="stat-value" id="statTotal">{{ total or 0 }}</div> <div class="stat-value" id="statTotal">{{ total or 0 }}</div>
<div class="stat-note">Alle registrerede lokationer</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Aktive</div> <div class="small text-muted">Aktive</div>
<div class="stat-value" id="statActive">0</div> <div class="stat-value" id="statActive">0</div>
<div class="stat-note">Klar til daglig brug</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Inaktive</div> <div class="small text-muted">Inaktive</div>
<div class="stat-value" id="statInactive">0</div> <div class="stat-value" id="statInactive">0</div>
<div class="stat-note">Skjulte eller lukkede</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Synlige nu</div> <div class="small text-muted">Synlige nu</div>
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div> <div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
</div> <div class="stat-note">Efter søgning og foldning</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Filter Card --> <!-- Filter Card -->
<div class="card mb-4 border-0"> <div class="filter-shell mb-4">
<div class="card-body"> <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"> <form id="filterForm" method="get" class="row g-3 align-items-end">
<div class="col-md-4"> <div class="col-lg-5">
<label for="locationSearch" class="form-label small text-muted">Søg</label> <label for="locationSearch" class="form-label small text-muted">Søg</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span> <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..."> <input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4 col-lg-3">
<label for="locationTypeFilter" class="form-label small text-muted">Type</label> <label for="locationTypeFilter" class="form-label small text-muted">Type</label>
<select class="form-select" id="locationTypeFilter" name="location_type"> <select class="form-select" id="locationTypeFilter" name="location_type">
<option value="">Alle typer</option> <option value="">Alle typer</option>
@ -164,7 +378,7 @@
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3 col-lg-2">
<label for="statusFilter" class="form-label small text-muted">Status</label> <label for="statusFilter" class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" name="is_active"> <select class="form-select" id="statusFilter" name="is_active">
<option value="">Alle</option> <option value="">Alle</option>
@ -173,44 +387,43 @@
</select> </select>
</div> </div>
<div class="col-md-2 d-flex gap-2"> <div class="col-md-5 col-lg-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100"> <button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel me-2"></i>Anvend filtre <i class="bi bi-funnel me-2"></i>Filtrer
</button> </button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm"> <a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-lg"></i> <i class="bi bi-arrow-counterclockwise"></i>
</a> </a>
</div> </div>
</form> </form>
</div> </div>
</div>
<!-- Toolbar Section --> <!-- Main Content Section -->
<div class="row mb-3"> <div class="content-shell">
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="content-shell-header">
<div class="d-flex gap-2"> <div class="content-shell-toolbar w-100">
<a href="/app/locations/create" class="btn btn-primary btn-sm"> <div>
<i class="bi bi-plus-lg me-2"></i>Opret lokation <div class="content-shell-title">Lokationsliste</div>
</a> <div class="content-shell-subtitle">
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm"> {% if total %}
<i class="bi bi-diagram-3 me-2"></i>Wizard Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
</a> <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> <button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte <i class="bi bi-trash me-2"></i>Slet valgte
</button> </button>
</div> </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>
</div> </div>
</div>
<!-- Main Content Section -->
<div class="card border-0">
<div class="table-responsive"> <div class="table-responsive">
{% if location_tree %} {% if location_tree %}
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@ -250,26 +463,44 @@
'vehicle': '#8e44ad' 'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %} }.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' }}"> <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> <td>
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}"> <input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
</td> </td>
<td> <td>
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;"> <div class="location-summary">
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
{% if node.children %} {% 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"> <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> <i class="bi bi-caret-right-fill"></i>
</button> </button>
{% else %} {% else %}
<span class="text-muted me-2"></span> <span class="location-tree-spacer text-muted"></span>
{% endif %} {% endif %}
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500"> </div>
<div class="location-name-block">
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
{{ node.name }} {{ node.name }}
</a> </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> </div>
</td> </td>
<td> <td>
<span class="badge" style="background-color: {{ type_color }}; color: white;"> <span class="type-pill" style="background-color: {{ type_color }};">
{{ type_label }} {{ type_label }}
</span> </span>
</td> </td>
@ -278,20 +509,20 @@
</td> </td>
<td> <td>
{% if node.is_active %} {% if node.is_active %}
<span class="badge bg-success">Aktiv</span> <span class="status-pill active"><span></span>Aktiv</span>
{% else %} {% else %}
<span class="badge bg-secondary">Inaktiv</span> <span class="status-pill inactive"><span></span>Inaktiv</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
<div class="btn-group btn-group-sm" role="group"> <div class="actions-inline" role="group" aria-label="Handlinger">
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis"> <a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </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> <i class="bi bi-pencil"></i>
</a> </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-id="{{ node.id }}"
data-location-name="{{ node.name }}" data-location-name="{{ node.name }}"
title="Slet"> title="Slet">
@ -315,6 +546,7 @@
{% else %} {% else %}
<!-- Empty State --> <!-- Empty State -->
<div class="text-center py-5"> <div class="text-center py-5">
<div class="empty-state-soft">
<div class="mb-3"> <div class="mb-3">
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i> <i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
</div> </div>
@ -324,6 +556,7 @@
<i class="bi bi-plus-lg me-2"></i>Opret lokation <i class="bi bi-plus-lg me-2"></i>Opret lokation
</a> </a>
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -608,12 +841,14 @@ document.addEventListener('DOMContentLoaded', function() {
let currentDeleteId = null; let currentDeleteId = null;
// Select all functionality // Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() { selectAllCheckbox.addEventListener('change', function() {
locationCheckboxes.forEach(checkbox => { locationCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked; checkbox.checked = this.checked;
}); });
updateBulkDeleteButton(); updateBulkDeleteButton();
}); });
}
// Individual checkbox functionality // Individual checkbox functionality
locationCheckboxes.forEach(checkbox => { locationCheckboxes.forEach(checkbox => {
@ -632,6 +867,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
function updateSelectAllCheckbox() { function updateSelectAllCheckbox() {
if (!selectAllCheckbox) return;
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked); const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked); const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked; selectAllCheckbox.checked = allChecked;
@ -678,6 +914,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// Bulk delete // Bulk delete
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener('click', function() { bulkDeleteBtn.addEventListener('click', function() {
const selectedIds = Array.from(locationCheckboxes) const selectedIds = Array.from(locationCheckboxes)
.filter(cb => cb.checked) .filter(cb => cb.checked)
@ -696,18 +933,8 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
}); });
// 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;
} }
const link = this.querySelector('a');
if (link) link.click();
});
});
}); });
</script> </script>
{% endblock %} {% 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 import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field 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.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings from app.core.config import settings
from app.services.email_service import EmailService from app.services.email_service import EmailService
@ -903,7 +904,7 @@ async def list_all_sale_items(
@router.post("/sag") @router.post("/sag")
async def create_sag(data: dict): async def create_sag(data: dict):
"""Create a new case.""" """Create a case and its optional pipeline/order data atomically."""
try: try:
if not data.get("titel"): if not data.get("titel"):
raise HTTPException(status_code=400, detail="titel is required") 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_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id) _validate_group_id(assigned_group_id)
query = """ case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
INSERT INTO sag_sager pipeline = data.get("pipeline") if case_type == "pipeline" else None
(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) order_items = data.get("order_items") if case_type == "ordre" else []
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) if pipeline is not None and not isinstance(pipeline, dict):
RETURNING * raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
""" if not isinstance(order_items, list):
params = ( raise HTTPException(status_code=400, detail="order_items skal være en liste")
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) conn = get_db_connection()
if result: try:
logger.info("✅ Case created: %s", result[0]["id"]) with conn.cursor(cursor_factory=RealDictCursor) as cursor:
return result[0] 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") 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -162,6 +162,12 @@
</div> </div>
<form id="createForm" novalidate> <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 --> <!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
@ -215,6 +221,7 @@
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk --> <!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-12"> <div class="col-12">
@ -246,23 +253,38 @@
</div> </div>
</div> </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"> <hr class="my-4 opacity-25">
<!-- Section: Metadata --> <!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-3"> <div class="col-md-4">
<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">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label> <label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required> <select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option> <option value="åben" selected>🟢 Åben</option>
@ -270,7 +292,7 @@
<option value="lukket">🔴 Lukket</option> <option value="lukket">🔴 Lukket</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label> <label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id"> <select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option> <option value="">Ingen</option>
@ -280,7 +302,7 @@
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label> <label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id"> <select class="form-select" id="assigned_group_id">
<option value="">Ingen</option> <option value="">Ingen</option>
@ -324,6 +346,7 @@
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
let successAlertTimeout; let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0; 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() { async function loadCaseTypesSelect() {
const select = document.getElementById('type'); const select = document.getElementById('type');
if (!select) return; if (!select) return;
try { try {
const res = await fetch('/api/v1/settings/case_types'); const [typesRes, profileRes] = await Promise.all([
if (!res.ok) return; fetch('/api/v1/settings/case_types', { credentials: 'include' }),
const setting = await res.json(); fetch('/api/v1/auth/me/profile', { credentials: 'include' })
const types = JSON.parse(setting.value || '[]'); ]);
if (!Array.isArray(types) || types.length === 0) return; const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
select.innerHTML = types const configured = JSON.parse(setting.value || '[]');
.map((type) => `<option value="${type}">${type}</option>`) const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
.join(''); 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) { } catch (err) {
console.error('Failed to load case types', 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 --- // --- Initialization ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeSearch(); initializeSearch();
loadCaseTypesSelect(); loadCaseTypesSelect();
loadPipelineStages();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill(); applyTelefoniPrefill();
}); });
@ -993,6 +1101,18 @@
deadline: document.getElementById('deadline').value || null 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 { try {
const response = await fetch('/api/v1/sag', { const response = await fetch('/api/v1/sag', {
method: 'POST', method: 'POST',

View File

@ -16,7 +16,9 @@ router = APIRouter()
async def list_opportunities( async def list_opportunities(
q: Optional[str] = None, q: Optional[str] = None,
stage: 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. List all 'pipeline' cases.
@ -71,6 +73,33 @@ async def list_opportunities(
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)" query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
params.extend([f"%{q}%", f"%{q}%"]) 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 and status != 'all':
if status == 'open': if status == 'open':
query += " AND s.status = 'åben'" query += " AND s.status = 'åben'"

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app")
@router.get("/opportunities", response_class=HTMLResponse) @router.get("/opportunities", response_class=HTMLResponse)
async def opportunities_page(request: Request): async def opportunities_page(request: Request):
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"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 datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from collections import defaultdict from collections import defaultdict
from app.services.economic_service import get_economic_service
from app.core.database import execute_query from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,9 +22,6 @@ logger = logging.getLogger(__name__)
class SubscriptionMatrixService: class SubscriptionMatrixService:
"""Generate billing matrix for customer subscriptions""" """Generate billing matrix for customer subscriptions"""
def __init__(self):
self.economic_service = get_economic_service()
async def generate_billing_matrix( async def generate_billing_matrix(
self, self,
customer_id: int, customer_id: int,
@ -87,13 +83,9 @@ class SubscriptionMatrixService:
economic_customer_number = customer[0]['economic_customer_number'] economic_customer_number = customer[0]['economic_customer_number']
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}") logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
# Fetch invoices from e-conomic # Fetch imported invoice snapshot from local invoice_error_finder tables
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}") invoices = self._load_imported_invoices(str(economic_customer_number))
invoices = await self.economic_service.get_customer_invoices( logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
economic_customer_number,
include_lines=True
)
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
if not invoices: if not invoices:
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}") logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
@ -131,6 +123,125 @@ class SubscriptionMatrixService:
"products": [] "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]: def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
""" """
Group invoice lines by product number and aggregate by month Group invoice lines by product number and aggregate by month
@ -378,6 +489,18 @@ class SubscriptionMatrixService:
return products 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 @staticmethod
def _generate_month_range(num_months: int) -> List[str]: def _generate_month_range(num_months: int) -> List[str]:
""" """

View File

@ -168,7 +168,7 @@ async def get_setting(key: str):
seed_query, seed_query,
( (
"case_types", "case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]', '["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
"system", "system",
"Sags-typer", "Sags-typer",
"json", "json",
@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
return execute_migration(model) 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"]) @router.post("/settings/sync-from-env", tags=["Settings"])
async def sync_settings_from_env(): async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)""" """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)}") logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__ err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") 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 from datetime import datetime
import time
from pathlib import Path from pathlib import Path
import re import re
from fastapi import APIRouter, Request, HTTPException, Depends from fastapi import APIRouter, Request, HTTPException, Depends
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
release_db_connection(conn) release_db_connection(conn)
return {"message": "Migration executed successfully"} 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()"> <button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
<i class="bi bi-check2-circle me-1"></i>Tjek status <i class="bi bi-check2-circle me-1"></i>Tjek status
</button> </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> </div>
<div class="card-body"> <div class="card-body">
@ -356,5 +359,35 @@
button.disabled = false; 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> </script>
{% endblock %} {% endblock %}

View File

@ -184,6 +184,16 @@
</div> </div>
</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="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
@ -2374,7 +2384,8 @@ async function testTelefoniCall() {
function renderDriftConnectors() { function renderDriftConnectors() {
const container = document.getElementById('driftConnectorCards'); const container = document.getElementById('driftConnectorCards');
if (!container) return; const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
if (!container && !invoiceErrorFinderContainer) return;
const connectors = [ const connectors = [
{ {
@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
<span id="uispSaveStatus" class="small text-muted"></span> <span id="uispSaveStatus" class="small text-muted"></span>
</div> </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="card border-0 bg-light p-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-3"> <div class="d-flex align-items-center justify-content-between gap-2 mb-3">
<div> <div>
@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
</div> </div>
${connector.body} ${connector.body}
</div> </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() { async function loadSettings() {
@ -2477,6 +2527,7 @@ async function loadSettings() {
renderDriftConnectors(); renderDriftConnectors();
await loadUptimeKumaSettings(); await loadUptimeKumaSettings();
await loadUISPSettings(); await loadUISPSettings();
await loadInvoiceErrorFinderSettings();
await loadLabelPrinterSettings(); await loadLabelPrinterSettings();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@ -2805,6 +2856,30 @@ async function loadUISPSettings() {
} }
let driftBlacklistItems = []; 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) { function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim(); const raw = String(rawValue || '').trim();
@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) {
renderDriftBlacklistList(); 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() { async function saveUISPSettings() {
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim(); const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
const apiToken = (document.getElementById('uispApiToken').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 (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
} }
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate // Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
} }
// ESC to close // ESC to close
@ -1719,22 +1719,14 @@ if (bmcOriginalFetch) {
} }
}); });
// QuickCreate modal opener function function openNewCasePage() {
function openQuickCreateModal() { window.location.href = '/sag/new';
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
quickCreateModal.show();
setTimeout(() => {
const textInput = document.getElementById('quickCreateText');
if (textInput) {
textInput.focus();
}
}, 300);
} }
// QuickCreate button click handler // QuickCreate button click handler
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => { document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
}); });
// Reset search when modal is closed // Reset search when modal is closed
@ -2257,9 +2249,6 @@ if (bmcOriginalFetch) {
}); });
</script> </script>
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Manual Help Modal --> <!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %} {% 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> <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"> <input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div> </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"> <div class="col-12">
<label class="form-label fw-semibold"> <label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs <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_full_name').value = p.full_name || '';
document.getElementById('prof_title').value = p.title || ''; document.getElementById('prof_title').value = p.title || '';
document.getElementById('prof_phone').value = p.phone || ''; document.getElementById('prof_phone').value = p.phone || '';
await loadProfileCaseTypes(p.default_case_type || 'ticket');
} catch (e) { console.error('Failed to load profile', e); } } catch (e) { console.error('Failed to load profile', e); }
loadAnyDeskChips(); 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) { function buildInitials(name) {
const clean = String(name || '').trim(); const clean = String(name || '').trim();
if (!clean) return 'BR'; if (!clean) return 'BR';
@ -2763,6 +2774,7 @@ if (bmcOriginalFetch) {
full_name: document.getElementById('prof_full_name').value || null, full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null, title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null, phone: document.getElementById('prof_phone').value || null,
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
}; };
try { try {
const res = await fetch('/api/v1/auth/me/profile', { 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) - economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address) - address, city, postal_code, country (physical address)
- email_domain, website (contact information) - 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 owns and syncs:
- vtiger_id (primary key from vTiger) - vtiger_id (primary key from vTiger)
@ -18,7 +18,7 @@ vTIGER owns and syncs:
HUB owns (manual or first-sync only): HUB owns (manual or first-sync only):
- name (can be synced initially but not overwritten) - 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 - Tags, notes, custom fields
SYNC RULES: 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 # Strict matching: ONLY match by economic_customer_number
existing = execute_query( 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 FROM customers
WHERE economic_customer_number = %s WHERE economic_customer_number = %s
ORDER BY id ORDER BY id
@ -221,6 +221,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing: if existing:
target_customer_id = existing[0]['id'] target_customer_id = existing[0]['id']
current_values = { current_values = {
"cvr_number": existing[0].get("cvr_number"),
"email_domain": existing[0].get("email_domain"), "email_domain": existing[0].get("email_domain"),
"address": existing[0].get("address"), "address": existing[0].get("address"),
"city": existing[0].get("city"), "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"), "website": existing[0].get("website"),
} }
proposed_values = { proposed_values = {
"cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
"address": address, "address": address,
"city": city, "city": city,
@ -252,6 +254,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
update_query = """ update_query = """
UPDATE customers SET UPDATE customers SET
economic_customer_number = %s, economic_customer_number = %s,
cvr_number = %s,
email_domain = %s, email_domain = %s,
address = %s, address = %s,
city = %s, city = %s,
@ -262,7 +265,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
WHERE id = %s WHERE id = %s
""" """
execute_query(update_query, ( 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( logger.info(
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", "✏️ 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', 'error_found',
'ready_to_invoice', 'ready_to_invoice',
'invoiced', 'invoiced',
'ignored' 'ignored',
'resolved'
)), )),
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
customer_name VARCHAR(255), 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 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): def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router from app.modules.internet_connections.backend import router as internet_router

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import sys import sys
from datetime import date
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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 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): def test_list_issues_supports_unassigned_filter(monkeypatch):
from app.modules.invoice_error_finder.backend.router import list_issues 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 payload["total"] == 0
assert "assigned_user_id IS NULL" in captured["count_query"] assert "assigned_user_id IS NULL" in captured["count_query"]
assert "assigned_user_id IS NULL" in captured["list_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"