Compare commits

..

4 Commits

Author SHA1 Message Date
Christian
be68148448 feat(email): add RETURNING id to email activity log insert
feat(ollama): implement case creation rewrite with strict rules

fix(sync): include phone number in economic customer sync

fix(migrations): clean up orphan links before enforcing foreign keys in email threading schema

feat(migrations): add support for physical patch-panel layouts and update related constraints

feat(migrations): add start port number for physical patch panels

feat(migrations): introduce display order for physical panels

feat(migrations): link wall outlets to switch hardware

feat(migrations): allow optional label and customer for wall outlets

feat(migrations): create hardware network links table

feat(migrations): add location display order for hardware assets

feat(migrations): create UISP devices and link to hardware assets
2026-07-18 10:10:31 +02:00
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
Christian
db2e8c3157 chore(release): bump version to 2.3.30 2026-07-11 11:48:42 +02:00
59 changed files with 6566 additions and 17088 deletions

View File

@ -1 +1 @@
2.3.29 2.3.30

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

@ -7,6 +7,7 @@ from urllib.parse import urlparse
import httpx import httpx
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
@ -738,6 +739,18 @@ def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str]
return [] return []
def _fetch_uisp_device_detail(base_url: str, token: str, external_id: str) -> Any:
"""Fetch interface telemetry for one linked UISP device."""
return _fetch_json_from_candidates(
base_url,
token,
[
f"nms/api/v2.1/devices/{external_id}/detail",
f"nms/api/v2/devices/{external_id}/detail",
],
)
def _parse_prometheus_labels(raw: str) -> Dict[str, str]: def _parse_prometheus_labels(raw: str) -> Dict[str, str]:
labels: Dict[str, str] = {} labels: Dict[str, str] = {}
if not raw.strip(): if not raw.strip():
@ -1068,6 +1081,112 @@ def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]:
return [] return []
def _uisp_device_record(item: Dict[str, Any], base_url: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Normalize the useful UISP fields while retaining the complete source payload."""
identification = item.get("identification") if isinstance(item.get("identification"), dict) else {}
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
external_id = identification.get("id") or item.get("id") or item.get("device_id")
if not external_id:
return None
def text(*values: Any) -> Optional[str]:
for value in values:
value = str(value or "").strip()
if value:
return value
return None
ips: List[str] = []
for value in (item.get("ipAddress"), item.get("ip"), identification.get("ipAddress"), overview.get("ipAddress")):
value = str(value or "").strip()
if value and value not in ips:
ips.append(value)
for key in ("ipAddressList", "ipv6AddressList", "ipv6LinkLocalList"):
for value in item.get(key) or []:
value = str(value or "").strip()
if value and value not in ips:
ips.append(value)
last_seen = overview.get("lastSeen")
last_seen_dt = None
if last_seen:
try:
last_seen_dt = datetime.fromisoformat(str(last_seen).replace("Z", "+00:00"))
except ValueError:
pass
return {
"external_id": str(external_id),
"name": text(identification.get("name"), identification.get("displayName"), item.get("name")),
"display_name": text(identification.get("displayName"), identification.get("name")),
"hostname": text(identification.get("hostname"), identification.get("systemName")),
"mac_address": text(identification.get("mac"), item.get("mac")),
"serial_number": text(identification.get("serialNumber"), item.get("serialNumber")),
"vendor": text(identification.get("vendorName"), identification.get("vendor")),
"model": text(identification.get("modelName"), identification.get("model")),
"platform": text(identification.get("platformName"), identification.get("platformId")),
"device_type": text(identification.get("type"), identification.get("category")),
"device_role": text(identification.get("role")),
"ip_addresses": ips,
"status": text(overview.get("status"), identification.get("status"), item.get("status")),
"last_seen": last_seen_dt,
"device_link": _extract_device_link(item, base_url, str(external_id)),
"raw_json": item,
}
def _upsert_uisp_devices(payload: Any, base_url: Optional[str] = None) -> int:
"""Cache UISP devices and enrich every hardware asset explicitly linked to one."""
count = 0
for item in _parse_uisp_payload(payload):
device = _uisp_device_record(item, base_url)
if not device:
continue
rows = execute_query(
"""INSERT INTO uisp_devices
(external_id, name, display_name, hostname, mac_address, serial_number, vendor, model,
platform, device_type, device_role, ip_addresses, status, last_seen, device_link, raw_json, synced_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name, display_name = EXCLUDED.display_name, hostname = EXCLUDED.hostname,
mac_address = EXCLUDED.mac_address, serial_number = EXCLUDED.serial_number, vendor = EXCLUDED.vendor,
model = EXCLUDED.model, platform = EXCLUDED.platform, device_type = EXCLUDED.device_type,
device_role = EXCLUDED.device_role, ip_addresses = EXCLUDED.ip_addresses, status = EXCLUDED.status,
last_seen = EXCLUDED.last_seen, device_link = EXCLUDED.device_link, raw_json = EXCLUDED.raw_json,
synced_at = NOW(), updated_at = NOW()
RETURNING id""",
(
device["external_id"], device["name"], device["display_name"], device["hostname"], device["mac_address"],
device["serial_number"], device["vendor"], device["model"], device["platform"], device["device_type"],
device["device_role"], Json(device["ip_addresses"]), device["status"], device["last_seen"], device["device_link"], Json(device["raw_json"]),
),
) or []
if not rows:
continue
device_id = rows[0]["id"]
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
uisp_specs = {
"uisp_device_id": device["external_id"], "name": device["name"], "hostname": device["hostname"],
"mac_address": device["mac_address"], "ip_addresses": device["ip_addresses"], "platform": device["platform"],
"type": device["device_type"], "role": device["device_role"], "firmware": (item.get("firmware") or {}).get("version") if isinstance(item.get("firmware"), dict) else item.get("firmware"),
"status": device["status"], "last_seen": str(device["last_seen"] or ""),
"overview": overview,
}
execute_query(
"""UPDATE hardware_assets h
SET brand = COALESCE(NULLIF(%s, ''), h.brand),
model = COALESCE(NULLIF(%s, ''), h.model),
serial_number = COALESCE(NULLIF(%s, ''), h.serial_number),
hardware_specs = COALESCE(h.hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
FROM hardware_uisp_links link
WHERE link.hardware_id = h.id AND link.uisp_device_id = %s""",
(device["vendor"] or "", device["model"] or "", device["serial_number"] or "", Json({"uisp": uisp_specs}), device_id),
)
count += 1
return count
def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]: def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]:
items = _parse_uisp_payload(payload) items = _parse_uisp_payload(payload)
events: List[Dict[str, Any]] = [] events: List[Dict[str, Any]] = []
@ -1283,6 +1402,21 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
"api/v2/sites", "api/v2/sites",
], ],
) )
cached_devices = _upsert_uisp_devices(payload, base_url)
# The inventory endpoint does not contain switch interface telemetry. Fetch
# details only for explicitly linked hardware, keeping the 2-minute sync light.
linked_devices = execute_query(
"""SELECT d.external_id FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id"""
) or []
detailed_devices = 0
for linked in linked_devices:
external_id = str(linked.get("external_id") or "").strip()
if not external_id:
continue
detail = _fetch_uisp_device_detail(base_url, token, external_id)
if isinstance(detail, dict) and detail:
detailed_devices += _upsert_uisp_devices([detail], base_url)
events = _build_events_from_uisp_payload(payload, source.get("id"), base_url) events = _build_events_from_uisp_payload(payload, source.get("id"), base_url)
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
logger.warning("⚠️ Drift UISP sync failed: %s", exc) logger.warning("⚠️ Drift UISP sync failed: %s", exc)
@ -1313,6 +1447,8 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
"source": source.get("name"), "source": source.get("name"),
"mode": "live", "mode": "live",
"blacklisted_skipped": skipped, "blacklisted_skipped": skipped,
"cached_devices": cached_devices,
"detailed_devices": detailed_devices,
} }

View File

@ -1,3 +1,4 @@
import json
import logging import logging
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query, UploadFile, File
@ -370,7 +371,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 +379,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 +393,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"),
@ -496,6 +498,202 @@ async def get_hardware(hardware_id: int):
return result[0] return result[0]
def _uisp_match_score(hardware: dict, device: dict) -> int:
"""Score explicit, human-reviewable UISP suggestions without auto-linking anything."""
specs = hardware.get("hardware_specs") or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
values = {
"serial": str(hardware.get("serial_number") or "").strip().lower(),
"model": str(hardware.get("model") or "").strip().lower(),
"name": str(hardware.get("brand") or "") + " " + str(hardware.get("model") or ""),
"mac": str((specs.get("uisp") or {}).get("mac_address") or specs.get("mac_address") or "").replace(":", "").lower(),
}
score = 0
if values["serial"] and values["serial"] == str(device.get("serial_number") or "").strip().lower():
score += 100
if values["mac"] and values["mac"] == str(device.get("mac_address") or "").replace(":", "").lower():
score += 90
device_name = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "model")).lower()
if values["model"] and values["model"] in device_name:
score += 20
if values["name"].strip() and values["name"].strip().lower() in device_name:
score += 10
return score
def _uisp_device_payload(row: dict) -> dict:
return {
"id": row.get("id"), "external_id": row.get("external_id"), "name": row.get("name"),
"display_name": row.get("display_name"), "hostname": row.get("hostname"), "mac_address": row.get("mac_address"),
"serial_number": row.get("serial_number"), "vendor": row.get("vendor"), "model": row.get("model"),
"platform": row.get("platform"), "device_type": row.get("device_type"), "device_role": row.get("device_role"),
"ip_addresses": row.get("ip_addresses") or [], "status": row.get("status"), "last_seen": row.get("last_seen"),
"device_link": row.get("device_link"), "raw_json": row.get("raw_json") or {}, "synced_at": row.get("synced_at"),
}
@router.get("/hardware/{hardware_id}/uisp-devices", response_model=dict)
async def list_uisp_devices_for_hardware(hardware_id: int, query: Optional[str] = Query(None)):
hardware_rows = execute_query("SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)) or []
if not hardware_rows:
raise HTTPException(status_code=404, detail="Hardware not found")
devices = execute_query(
"""SELECT d.*, link.hardware_id AS linked_hardware_id
FROM uisp_devices d
LEFT JOIN hardware_uisp_links link ON link.uisp_device_id = d.id
WHERE link.hardware_id IS NULL OR link.hardware_id = %s
ORDER BY d.name NULLS LAST, d.id""",
(hardware_id,),
) or []
needle = str(query or "").strip().lower()
candidates = []
for device in devices:
searchable = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "serial_number", "mac_address", "vendor", "model")).lower()
if needle and needle not in searchable:
continue
item = _uisp_device_payload(device)
item["match_score"] = _uisp_match_score(hardware_rows[0], device)
candidates.append(item)
candidates.sort(key=lambda item: (-item["match_score"], str(item.get("name") or "").lower()))
return {"devices": candidates}
@router.get("/hardware/{hardware_id}/uisp", response_model=dict)
async def get_hardware_uisp(hardware_id: int):
rows = execute_query(
"""SELECT d.* FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id
WHERE link.hardware_id = %s""",
(hardware_id,),
) or []
return {"device": _uisp_device_payload(rows[0]) if rows else None}
@router.post("/hardware/{hardware_id}/uisp", response_model=dict)
async def link_hardware_uisp(hardware_id: int, data: dict):
device_id = data.get("uisp_device_id")
if not device_id:
raise HTTPException(status_code=400, detail="UISP-enhed er påkrævet")
if not execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)):
raise HTTPException(status_code=404, detail="Hardware not found")
if not execute_query("SELECT id FROM uisp_devices WHERE id = %s", (device_id,)):
raise HTTPException(status_code=404, detail="UISP-enhed blev ikke fundet")
try:
rows = execute_query(
"""INSERT INTO hardware_uisp_links (hardware_id, uisp_device_id, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (hardware_id) DO UPDATE SET uisp_device_id = EXCLUDED.uisp_device_id, updated_at = NOW()
RETURNING id""",
(hardware_id, device_id),
) or []
except Exception as exc:
if "unique" in str(exc).lower():
raise HTTPException(status_code=409, detail="Denne UISP-enhed er allerede koblet til andet hardware") from exc
raise
# Apply cached technical identity immediately; the next UISP refresh adds current measurements.
device = execute_query("SELECT * FROM uisp_devices WHERE id = %s", (device_id,))[0]
raw = device.get("raw_json") or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
raw = {}
overview = raw.get("overview") if isinstance(raw, dict) and isinstance(raw.get("overview"), dict) else {}
firmware = raw.get("firmware") if isinstance(raw, dict) else None
uisp_specs = {
"uisp_device_id": device.get("external_id"), "name": device.get("name"), "hostname": device.get("hostname"),
"mac_address": device.get("mac_address"), "ip_addresses": device.get("ip_addresses") or [],
"platform": device.get("platform"), "type": device.get("device_type"), "role": device.get("device_role"),
"firmware": (firmware or {}).get("version") if isinstance(firmware, dict) else firmware,
"status": device.get("status"), "last_seen": str(device.get("last_seen") or ""), "overview": overview,
}
execute_query(
"""UPDATE hardware_assets SET brand = COALESCE(NULLIF(%s, ''), brand), model = COALESCE(NULLIF(%s, ''), model),
serial_number = COALESCE(NULLIF(%s, ''), serial_number),
hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW() WHERE id = %s""",
(device.get("vendor") or "", device.get("model") or "", device.get("serial_number") or "", Json({"uisp": uisp_specs}), hardware_id),
)
return {"id": rows[0]["id"], "device": _uisp_device_payload(device)}
@router.delete("/hardware/{hardware_id}/uisp", response_model=dict)
async def unlink_hardware_uisp(hardware_id: int):
rows = execute_query("DELETE FROM hardware_uisp_links WHERE hardware_id = %s RETURNING id", (hardware_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail="Ingen UISP-kobling fundet")
return {"deleted": True}
@router.post("/hardware/{hardware_id}/uisp/refresh", response_model=dict)
async def refresh_hardware_uisp(hardware_id: int):
link = execute_query("SELECT uisp_device_id FROM hardware_uisp_links WHERE hardware_id = %s", (hardware_id,)) or []
if not link:
raise HTTPException(status_code=404, detail="Hardware er ikke koblet til en UISP-enhed")
from app.modules.drift.backend.router import _run_uisp_sync_internal
result = _run_uisp_sync_internal()
if result.get("warning"):
raise HTTPException(status_code=502, detail=result["warning"])
return await get_hardware_uisp(hardware_id)
@router.get("/hardware/{hardware_id}/network-links", response_model=List[dict])
async def get_hardware_network_links(hardware_id: int):
"""Return physical network links where this hardware is either endpoint."""
return execute_query(
'''SELECT l.*, sb.brand AS source_brand, sb.model AS source_model,
tb.brand AS target_brand, tb.model AS target_model
FROM hardware_network_links l
JOIN hardware_assets sb ON sb.id = l.source_hardware_id
JOIN hardware_assets tb ON tb.id = l.target_hardware_id
WHERE l.deleted_at IS NULL AND (l.source_hardware_id = %s OR l.target_hardware_id = %s)
ORDER BY l.source_port, l.id''',
(hardware_id, hardware_id),
) or []
@router.post("/hardware/{hardware_id}/network-links", response_model=dict, status_code=201)
async def create_hardware_network_link(hardware_id: int, data: dict):
source_port = str(data.get('source_port') or '').strip()
target_hardware_id = data.get('target_hardware_id')
target_port = str(data.get('target_port') or '').strip() or None
if not source_port or not target_hardware_id:
raise HTTPException(status_code=400, detail='Kildeport og mål-hardware er påkrævet')
if int(target_hardware_id) == hardware_id:
raise HTTPException(status_code=400, detail='Hardware kan ikke forbindes til sig selv')
exists = execute_query('SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL', (target_hardware_id,)) or []
if not exists:
raise HTTPException(status_code=404, detail='Mål-hardware blev ikke fundet')
try:
rows = execute_query(
'''INSERT INTO hardware_network_links (source_hardware_id, source_port, target_hardware_id, target_port, notes)
VALUES (%s, %s, %s, %s, %s) RETURNING id''',
(hardware_id, source_port, target_hardware_id, target_port, data.get('notes') or None),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=409, detail='Denne switch-port er allerede forbundet. Fjern den eksisterende forbindelse først.') from exc
raise
return {'id': rows[0]['id']}
@router.delete("/hardware/{hardware_id}/network-links/{link_id}")
async def delete_hardware_network_link(hardware_id: int, link_id: int):
rows = execute_query(
'''UPDATE hardware_network_links SET deleted_at = NOW(), updated_at = NOW()
WHERE id = %s AND deleted_at IS NULL AND (source_hardware_id = %s OR target_hardware_id = %s)
RETURNING id''',
(link_id, hardware_id, hardware_id),
) or []
if not rows:
raise HTTPException(status_code=404, detail='Forbindelsen blev ikke fundet')
return {'deleted': True}
@router.patch("/hardware/{hardware_id}", response_model=dict) @router.patch("/hardware/{hardware_id}", response_model=dict)
async def update_hardware(hardware_id: int, data: dict): async def update_hardware(hardware_id: int, data: dict):
"""Update hardware asset.""" """Update hardware asset."""
@ -511,7 +709,8 @@ async def update_hardware(hardware_id: int, data: dict):
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link", "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group", "eset_uuid", "hardware_specs", "eset_group",
"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",
"location_display_order"
] ]
for field in allowed_fields: for field in allowed_fields:
@ -1172,4 +1371,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

@ -1,4 +1,6 @@
import json
import logging import logging
import re
from typing import Optional, Any from typing import Optional, Any
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
@ -447,6 +449,116 @@ async def hardware_detail(request: Request, hardware_id: int):
hardware = result[0] hardware = result[0]
# Network switches expose their wall-outlet connections as a port map.
switch_ports = []
if str(hardware.get('asset_type') or '').lower() == 'netværk':
connection_rows = execute_query(
'''SELECT o.id, o.switch_port, o.outlet_number, o.status, o.patch_panel, o.patch_port,
l.id AS location_id, l.name AS location_name
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
WHERE o.switch_hardware_id = %s AND o.deleted_at IS NULL AND o.is_active = TRUE
ORDER BY o.switch_port''',
(hardware_id,),
) or []
connections = {str(row.get('switch_port')): row for row in connection_rows if row.get('switch_port')}
specs = hardware.get('hardware_specs') or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (ValueError, TypeError):
specs = {}
port_count = int((specs or {}).get('port_count') or 0)
if port_count > 0:
switch_ports = [
{'port_number': str(port), 'connection': connections.pop(str(port), None)}
for port in range(1, port_count + 1)
]
switch_ports.extend(
{'port_number': port, 'connection': connection}
for port, connection in connections.items()
)
switch_outlet_choices = []
if str(hardware.get('asset_type') or '').lower() == 'netværk' and hardware.get('current_location_id'):
switch_outlet_choices = execute_query(
'''SELECT id, outlet_number, status, switch_hardware_id, switch_name, switch_port
FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
ORDER BY outlet_number''',
(hardware['current_location_id'],),
) or []
network_links = execute_query(
'''SELECT l.*, tb.brand AS target_brand, tb.model AS target_model, tb.serial_number AS target_serial,
sb.brand AS source_brand, sb.model AS source_model, sb.serial_number AS source_serial
FROM hardware_network_links l
JOIN hardware_assets sb ON sb.id = l.source_hardware_id
JOIN hardware_assets tb ON tb.id = l.target_hardware_id
WHERE l.deleted_at IS NULL AND (l.source_hardware_id = %s OR l.target_hardware_id = %s)
ORDER BY l.source_port, l.id''',
(hardware_id, hardware_id),
) or []
uisp_rows = execute_query(
"""SELECT d.* FROM hardware_uisp_links link
JOIN uisp_devices d ON d.id = link.uisp_device_id
WHERE link.hardware_id = %s""",
(hardware_id,),
) or []
uisp_device = uisp_rows[0] if uisp_rows else None
if uisp_device:
raw = uisp_device.get('raw_json') or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
raw = {}
uisp_device['overview'] = raw.get('overview') if isinstance(raw, dict) else {}
uisp_device['firmware'] = (raw.get('firmware') or {}) if isinstance(raw, dict) and isinstance(raw.get('firmware'), dict) else {}
live_ports = {}
for interface in (raw.get('interfaces') or []) if isinstance(raw, dict) else []:
if not isinstance(interface, dict):
continue
identification = interface.get('identification') or {}
status = interface.get('status') or {}
statistics = interface.get('statistics') or {}
name = str(identification.get('name') or '')
match = re.fullmatch(r'(?:port|eth)(\d+)', name, flags=re.IGNORECASE)
if not match:
continue
port_number = str(int(match.group(1)))
live_ports[port_number] = {
'plugged': bool(status.get('plugged')),
'status': status.get('status'),
'speed': status.get('currentSpeed') or status.get('speed'),
'rxrate': statistics.get('rxrate'), 'txrate': statistics.get('txrate'),
'poe_power': statistics.get('poePower'), 'errors': statistics.get('errors'),
}
for port in switch_ports:
port['live'] = live_ports.get(str(port['port_number']))
outbound_links = {}
for link in network_links:
if int(link.get('source_hardware_id') or 0) == hardware_id and link.get('source_port'):
outbound_links[str(link['source_port'])] = link
elif int(link.get('target_hardware_id') or 0) == hardware_id and link.get('target_port'):
# Render the physical connection from the target switch's perspective too.
reverse_link = dict(link)
reverse_link.update({
'target_hardware_id': link.get('source_hardware_id'),
'target_brand': link.get('source_brand'),
'target_model': link.get('source_model'),
'target_serial': link.get('source_serial'),
'target_port': link.get('source_port'),
})
outbound_links[str(link['target_port'])] = reverse_link
for port in switch_ports:
port['hardware_link'] = outbound_links.get(str(port['port_number']))
available_network_hardware = execute_query(
'''SELECT id, brand, model, serial_number, asset_type
FROM hardware_assets
WHERE current_location_id = %s AND id <> %s AND deleted_at IS NULL
ORDER BY brand, model, serial_number''',
(hardware.get('current_location_id') or -1, hardware_id),
) or []
# Get customer name if applicable # Get customer name if applicable
if hardware.get('current_owner_customer_id'): if hardware.get('current_owner_customer_id'):
customer_query = "SELECT name AS navn FROM customers WHERE id = %s" customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
@ -482,6 +594,19 @@ async def hardware_detail(request: Request, hardware_id: int):
""" """
locations = execute_query(location_query, (hardware_id,)) locations = execute_query(location_query, (hardware_id,))
# current_location_id is the authoritative placement. Hardware created from a
# location can legitimately have no history row yet, so do not hide its location.
current_location = None
if hardware.get('current_location_id'):
current_location_rows = execute_query(
"""SELECT id AS location_id, name AS location_name
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL""",
(hardware['current_location_id'],),
) or []
if current_location_rows:
current_location = current_location_rows[0]
# Get attachments # Get attachments
attachment_query = """ attachment_query = """
SELECT * FROM hardware_attachments SELECT * FROM hardware_attachments
@ -670,6 +795,7 @@ async def hardware_detail(request: Request, hardware_id: int):
"hardware": hardware, "hardware": hardware,
"ownership": ownership or [], "ownership": ownership or [],
"locations": locations or [], "locations": locations or [],
"current_location": current_location,
"attachments": attachments or [], "attachments": attachments or [],
"cases": cases or [], "cases": cases or [],
"tags": tags or [], "tags": tags or [],
@ -679,6 +805,11 @@ async def hardware_detail(request: Request, hardware_id: int):
"owner_contacts": owner_contacts or [], "owner_contacts": owner_contacts or [],
"location_tree": location_tree or [], "location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware), "eset_specs": extract_eset_specs_summary(hardware),
"switch_ports": switch_ports,
"switch_outlet_choices": switch_outlet_choices,
"network_links": network_links,
"uisp_device": uisp_device,
"available_network_hardware": available_network_hardware,
"rental_stats": rental_stats, "rental_stats": rental_stats,
"recent_rentals": recent_rentals or [], "recent_rentals": recent_rentals or [],
}) })

View File

@ -54,6 +54,19 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
.switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; }
.switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; }
.switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; 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%; }
.switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); }
.switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; }
.switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; }
.switch-port-button.live-up { box-shadow: inset 0 -5px 0 #20c997; }
.switch-port-button.live-down { box-shadow: inset 0 -5px 0 #dc3545; }
.switch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
.switch-port-live { font-size:.55rem; font-weight:800; letter-spacing:.03em; }
@media (max-width: 1100px) { .switch-port-grid { grid-template-columns: repeat(12, minmax(44px, 1fr)); } }
@media (max-width: 700px) { .switch-port-grid { grid-template-columns: repeat(6, minmax(44px, 1fr)); } }
/* Timeline Styling */ /* Timeline Styling */
.timeline { .timeline {
position: relative; position: relative;
@ -223,8 +236,8 @@
{% endif %} {% endif %}
<!-- Location (Current) --> <!-- Location (Current) -->
{% set current_loc = locations[0] if locations else None %} {% set current_loc = current_location or (locations[0] if locations else None) %}
{% if current_loc and not current_loc.end_date %} {% if current_loc and (current_location or not current_loc.end_date) %}
<div class="quick-info-item"> <div class="quick-info-item">
<span class="quick-info-label">Lokation:</span> <span class="quick-info-label">Lokation:</span>
<span>{{ current_loc.location_name }}</span> <span>{{ current_loc.location_name }}</span>
@ -409,11 +422,11 @@
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button> <button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if current_loc and not current_loc.end_date %} {% if current_loc and (current_location or not current_loc.end_date) %}
<div class="text-center py-3"> <div class="text-center py-3">
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div> <div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
<h5 class="fw-bold">{{ current_loc.location_name }}</h5> <h5 class="fw-bold">{{ current_loc.location_name }}</h5>
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p> <p class="text-muted small mb-0">{% if current_loc.start_date %}Siden: {{ current_loc.start_date }}{% else %}Aktuel placering{% endif %}</p>
{% if current_loc.notes %} {% if current_loc.notes %}
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div> <div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
{% endif %} {% endif %}
@ -675,6 +688,64 @@
</div> </div>
</div> </div>
{% if switch_ports %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-hdd-network me-2"></i>Switch-porte</h6>
<span class="text-muted small">{{ switch_ports | length }} porte</span>
</div>
<div class="card-body">
<div class="switch-port-panel"><div class="switch-port-grid">
{% for port in switch_ports %}
<button type="button" class="switch-port-button {% if port.hardware_link %}hardware-linked{% elif port.connection %}connected{% endif %}{% if port.live %} {{ 'live-up' if port.live.plugged else 'live-down' }}{% endif %}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.connection.id if port.connection else '' }}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.connection %}{{ port.connection.outlet_number }} · klik for at ændre{% else %}Ledig port — klik for at tilknytte vægstik{% endif %}">
<span>Port {{ port.port_number }}</span>
{% if port.live %}<span class="switch-port-live">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% endif %}
{% if port.hardware_link %}<span class="switch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.connection %}<span class="switch-port-outlet">{{ port.connection.outlet_number }}</span>{% else %}<span class="switch-port-outlet">Ledig</span>{% endif %}
</button>
{% endfor %}
</div></div>
</div>
</div>
{% endif %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<div><h6 class="text-primary mb-0"><i class="bi bi-broadcast-pin me-2"></i>UISP live-data</h6><div class="small text-muted">{{ 'Koblet til UISP-enhed' if uisp_device else 'Ingen UISP-enhed koblet endnu' }}</div></div>
<div class="d-flex gap-2">{% if uisp_device %}<button type="button" class="btn btn-sm btn-outline-primary" id="refreshUispBtn"><i class="bi bi-arrow-repeat me-1"></i>Opdatér nu</button><button type="button" class="btn btn-sm btn-outline-danger" id="unlinkUispBtn">Fjern kobling</button>{% else %}<button type="button" class="btn btn-sm btn-primary" id="linkUispBtn"><i class="bi bi-link-45deg me-1"></i>Kobl UISP-enhed</button>{% endif %}</div>
</div>
<div class="card-body">
{% if uisp_device %}
{% set overview = uisp_device.overview or {} %}
<div class="row g-3 small">
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp_device.last_seen or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp_device.firmware.version or uisp_device.firmware.name or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp_device.platform or '—' }}{% if uisp_device.device_role %} · {{ uisp_device.device_role }}{% endif %}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Signal</span><strong>{{ overview.signal or overview.signalMax or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Kapacitet</span><strong>{{ overview.totalCapacity or overview.uplinkCapacity or '—' }}</strong></div>
<div class="col-md-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp_device.synced_at or '—' }}</strong></div>
</div>
<div class="mt-3 d-flex justify-content-between align-items-center"><span class="small text-muted">{{ uisp_device.vendor or '' }} {{ uisp_device.model or '' }}{% if uisp_device.serial_number %} · {{ uisp_device.serial_number }}{% endif %}</span>{% if uisp_device.device_link %}<a href="{{ uisp_device.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
{% else %}<span class="text-muted">Kobl en synkroniseret UISP-enhed for at se live-status og tekniske data her.</span>{% endif %}
</div>
</div>
{% if hardware.asset_type == 'netværk' %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"><h6 class="text-primary mb-0"><i class="bi bi-diagram-3 me-2"></i>Hardwareforbindelser</h6><button type="button" class="btn btn-sm btn-outline-primary" id="addHardwareLinkBtn"><i class="bi bi-plus-lg me-1"></i>Forbind hardware</button></div>
<div class="card-body"><div class="list-group list-group-flush" id="hardwareNetworkLinksList">
{% for link in network_links %}
<div class="list-group-item d-flex justify-content-between align-items-center px-0"><div><strong>Port {{ link.source_port }}</strong> → {{ link.target_brand or '' }} {{ link.target_model }}{% if link.target_serial %} · {{ link.target_serial }}{% endif %}{% if link.target_port %}<span class="text-muted"> · port {{ link.target_port }}</span>{% endif %}</div><button class="btn btn-sm btn-outline-danger delete-network-link-btn" data-link-id="{{ link.id }}"><i class="bi bi-x"></i></button></div>
{% else %}<span class="text-muted small">Ingen hardwareforbindelser registreret endnu.</span>{% endfor %}
</div></div>
</div>
{% endif %}
{% if hardware.hardware_specs %} {% if hardware.hardware_specs %}
<div class="card mt-4 shadow-sm border-0"> <div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3">
@ -1234,6 +1305,21 @@
</div> </div>
</div> </div>
<div class="modal fade" id="switchPortAssignModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><form class="modal-content" id="switchPortAssignForm">
<div class="modal-header"><h5 class="modal-title">Tilknyt vægstik til port <span id="switchPortAssignNumber"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<input type="hidden" id="switchPortAssignPort">
<div class="mb-3"><label class="form-label">Vægstik</label><select id="switchPortAssignOutlet" class="form-select" required></select><div class="form-text">Vælges et stik, der allerede sidder på en anden switch-port, bliver du bedt om at bekræfte flytningen.</div></div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-outline-primary me-auto" id="switchPortAssignHardwareLink"><i class="bi bi-diagram-3 me-1"></i>Forbind hardware / switch</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem vægstik</button></div>
</form></div>
</div>
<div class="modal fade" id="hardwareLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="hardwareLinkForm"><div class="modal-header"><h5 class="modal-title">Forbind switch til hardware</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">Switch-port</label><input id="hardwareLinkSourcePort" class="form-control" required placeholder="Fx 1 eller Gi1/0/1"></div><div class="mb-3"><label class="form-label">Tilsluttet hardware</label><select id="hardwareLinkTarget" class="form-select" required><option value="">Vælg hardware</option>{% for item in available_network_hardware %}<option value="{{ item.id }}">{{ item.brand or '' }} {{ item.model }}{% if item.serial_number %} · {{ item.serial_number }}{% endif %}</option>{% endfor %}</select></div><div class="mb-3"><label class="form-label">Port på mål-hardware</label><input id="hardwareLinkTargetPort" class="form-control" placeholder="Valgfri, fx WAN eller 0"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem forbindelse</button></div></form></div></div>
<div class="modal fade" id="uispLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg"><form class="modal-content" id="uispLinkForm"><div class="modal-header"><h5 class="modal-title">Kobl UISP-enhed</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">Søg UISP-enhed</label><input id="uispDeviceSearch" class="form-control" placeholder="Navn, MAC, serienummer eller model"></div><div class="form-text mb-2">Forslag med højest match vises først. Koblingen bekræftes først når du gemmer.</div><select id="uispDeviceSelect" class="form-select" size="8" required><option value="">Indlæser UISP-enheder…</option></select></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Kobl enhed</button></div></form></div></div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -1251,6 +1337,131 @@
} }
} }
document.addEventListener('DOMContentLoaded', function() {
const modalElement = document.getElementById('switchPortAssignModal');
const modal = modalElement ? new bootstrap.Modal(modalElement) : null;
const uispLinkModalElement = document.getElementById('uispLinkModal');
const uispLinkModal = uispLinkModalElement ? new bootstrap.Modal(uispLinkModalElement) : null;
const uispDeviceSelect = document.getElementById('uispDeviceSelect');
let uispSearchTimer = null;
async function loadUispDevices(search = '') {
if (!uispDeviceSelect) return;
uispDeviceSelect.innerHTML = '<option value="">Indlæser…</option>';
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/uisp-devices?query=${encodeURIComponent(search)}`);
if (!response.ok) { uispDeviceSelect.innerHTML = '<option value="">Kunne ikke indlæse UISP-enheder</option>'; return; }
const data = await response.json();
const devices = data.devices || [];
uispDeviceSelect.innerHTML = '';
if (!devices.length) {
uispDeviceSelect.innerHTML = '<option value="">Ingen ledige UISP-enheder fundet</option>';
return;
}
devices.forEach(device => {
const option = document.createElement('option');
option.value = String(device.id);
const identity = [device.vendor, device.model, device.serial_number, device.mac_address].filter(Boolean).join(' · ');
const suggestion = device.match_score ? ` — forslag (${device.match_score})` : '';
option.textContent = `${device.name || device.hostname || device.external_id}${suggestion}\n${identity || device.external_id} · ${device.status || 'ukendt'}`;
uispDeviceSelect.appendChild(option);
});
}
document.getElementById('linkUispBtn')?.addEventListener('click', async () => { await loadUispDevices(); uispLinkModal?.show(); });
document.getElementById('uispDeviceSearch')?.addEventListener('input', event => {
clearTimeout(uispSearchTimer);
uispSearchTimer = setTimeout(() => loadUispDevices(event.target.value), 200);
});
document.getElementById('uispLinkForm')?.addEventListener('submit', async event => {
event.preventDefault();
const uispDeviceId = Number(uispDeviceSelect?.value || 0);
if (!uispDeviceId) return;
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({uisp_device_id: uispDeviceId})});
if (response.ok) location.reload();
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Kunne ikke koble UISP-enheden'); }
});
document.getElementById('unlinkUispBtn')?.addEventListener('click', async () => {
if (!confirm('Fjern UISP-koblingen fra dette hardware?')) return;
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'DELETE'});
if (response.ok) location.reload();
else alert('Kunne ikke fjerne UISP-koblingen');
});
document.getElementById('refreshUispBtn')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opdaterer';
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp/refresh', {method: 'POST'});
if (response.ok) location.reload();
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'UISP kunne ikke opdateres'); button.disabled = false; button.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Opdatér nu'; }
});
const switchHardware = {{ {'id': hardware.id, 'brand': hardware.brand, 'model': hardware.model, 'serial_number': hardware.serial_number} | tojson }};
const outlets = {{ switch_outlet_choices | tojson }};
const switchName = [switchHardware.brand, switchHardware.model, switchHardware.serial_number].filter(Boolean).join(' · ');
const outletSelect = document.getElementById('switchPortAssignOutlet');
function populateOutletSelect(selectedOutletId) {
outletSelect.innerHTML = '<option value="">Vælg vægstik</option>';
outlets.forEach(outlet => {
const option = document.createElement('option');
option.value = String(outlet.id);
const connectedElsewhere = outlet.switch_port && Number(outlet.switch_hardware_id) === Number(switchHardware.id)
? ` — nu på port ${outlet.switch_port}` : '';
option.textContent = `${outlet.outlet_number} (${outlet.status})${connectedElsewhere}`;
option.selected = String(outlet.id) === String(selectedOutletId || '');
outletSelect.appendChild(option);
});
}
document.querySelectorAll('.switch-port-button').forEach(button => button.addEventListener('click', () => {
const port = button.dataset.switchPort;
document.getElementById('switchPortAssignPort').value = port;
document.getElementById('switchPortAssignNumber').textContent = port;
populateOutletSelect(button.dataset.outletId || null);
modal?.show();
}));
document.getElementById('switchPortAssignForm')?.addEventListener('submit', async event => {
event.preventDefault();
const outletId = outletSelect.value;
const port = document.getElementById('switchPortAssignPort').value;
if (!outletId || !port) return;
const selectedOutlet = outlets.find(outlet => String(outlet.id) === String(outletId));
if (selectedOutlet?.switch_port && String(selectedOutlet.switch_port) !== String(port)) {
if (!confirm(`${selectedOutlet.outlet_number} er allerede koblet på port ${selectedOutlet.switch_port}. Flyt forbindelsen til port ${port}?`)) return;
}
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({switch_hardware_id: switchHardware.id, switch_name: switchName, switch_port: port, replace_existing_switch_port: true})
});
if (response.ok) location.reload();
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); }
});
const hardwareLinkModalElement = document.getElementById('hardwareLinkModal');
const hardwareLinkModal = hardwareLinkModalElement ? new bootstrap.Modal(hardwareLinkModalElement) : null;
document.getElementById('addHardwareLinkBtn')?.addEventListener('click', () => hardwareLinkModal?.show());
document.getElementById('switchPortAssignHardwareLink')?.addEventListener('click', () => {
const sourcePort = document.getElementById('switchPortAssignPort').value;
if (!sourcePort) return;
modal?.hide();
document.getElementById('hardwareLinkSourcePort').value = sourcePort;
hardwareLinkModal?.show();
});
document.getElementById('hardwareLinkForm')?.addEventListener('submit', async event => {
event.preventDefault();
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/network-links', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({source_port: document.getElementById('hardwareLinkSourcePort').value.trim(), target_hardware_id: Number(document.getElementById('hardwareLinkTarget').value), target_port: document.getElementById('hardwareLinkTargetPort').value.trim() || null})
});
if (response.ok) location.reload();
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); }
});
document.querySelectorAll('.delete-network-link-btn').forEach(button => button.addEventListener('click', async () => {
if (!confirm('Fjern hardwareforbindelsen?')) return;
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/network-links/${button.dataset.linkId}`, {method: 'DELETE'});
if (response.ok) location.reload();
}));
});
async function submitQuickRent() { async function submitQuickRent() {
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0); const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
const sagId = Number(document.getElementById('quickRentSagId').value || 0); const sagId = Number(document.getElementById('quickRentSagId').value || 0);

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,15 +909,33 @@ 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
def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]: def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]:
value = row.get("hub_customer_id") or row.get("customer_key") value = row.get("hub_customer_id")
if isinstance(value, int): if not isinstance(value, int):
return value
return None return None
customer = execute_query_single(
"SELECT id FROM customers WHERE id = %s",
(value,),
)
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,9 @@ 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,
CrossFieldPortLabelsUpdate
) )
router = APIRouter() router = APIRouter()
@ -164,15 +166,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 +209,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 +229,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 +435,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 +508,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 +538,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 +564,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 +610,317 @@ 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')
def _cross_field_port_insert_sql() -> str:
"""Generate physical labels in a stable order, e.g. 1A, 1B, 2A, 2B."""
return '''
INSERT INTO locations_cross_field_ports (cross_field_id, port_number, port_order)
SELECT %s,
CASE WHEN %s = 'paired'
THEN (%s + ((port_no + 1) / 2) - 1)::TEXT || CASE WHEN port_no %% 2 = 1 THEN 'A' ELSE 'B' END
ELSE (%s + port_no - 1)::TEXT
END,
port_no
FROM generate_series(%s, %s) AS port_no
'''
def _validate_cross_field_layout(port_count: int, label_format: str) -> None:
if label_format == 'paired' and port_count % 2:
raise HTTPException(status_code=400, detail='Parrede A/B-porte kræver et lige antal porte')
@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.display_order, cf.id''', params) or []
for field in fields:
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (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_order
''') 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')
_validate_cross_field_layout(data.port_count, data.port_label_format)
try:
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes)
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, (SELECT COALESCE(MAX(display_order), 0) + 1 FROM locations_cross_fields WHERE location_id = %s)), %s)
RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.port_label_format, data.start_port_number, data.panel_row_size, data.display_order, data.location_id, data.notes)) or []
if not created:
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
field = created[0]
execute_query(_cross_field_port_insert_sql(), (field['id'], data.port_label_format, data.start_port_number, data.start_port_number, 1, 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, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (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']:
start_number = field.get('start_port_number', 1)
execute_query(_cross_field_port_insert_sql(), (cross_field_id, field.get('port_label_format', 'numeric'), start_number, start_number, 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, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (cross_field_id,)) or []
return CrossField(**field)
@router.patch('/locations/cross-fields/{cross_field_id}/port-labels')
async def update_cross_field_port_labels(cross_field_id: int, data: CrossFieldPortLabelsUpdate):
"""Rename physical port labels without changing the linked outlet/port identity."""
existing = execute_query(
'''SELECT id FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''',
(cross_field_id,),
) or []
if not existing:
raise HTTPException(status_code=404, detail='Krydsfeltet eller dets porte blev ikke fundet')
submitted = {item.id: item.port_number.strip() for item in data.ports}
existing_ids = {row['id'] for row in existing}
if set(submitted) != existing_ids:
raise HTTPException(status_code=400, detail='Alle porte skal have en mærkning')
if any(not label for label in submitted.values()):
raise HTTPException(status_code=400, detail='Portmærkning må ikke være tom')
labels_lower = [label.casefold() for label in submitted.values()]
if len(labels_lower) != len(set(labels_lower)):
raise HTTPException(status_code=400, detail='Hver portmærkning skal være unik i panelet')
# Use temporary labels first, so labels can safely be swapped (e.g. 1A ↔ 1B).
placeholders = ', '.join(['%s'] * len(existing_ids))
execute_query(
f'''UPDATE locations_cross_field_ports SET port_number = '__tmp__' || id::TEXT
WHERE cross_field_id = %s AND id IN ({placeholders})''',
(cross_field_id, *existing_ids),
fetch=False,
)
values_sql = ', '.join(['(%s::INTEGER, %s::VARCHAR)'] * len(submitted))
params = []
for port_id, label in submitted.items():
params.extend((port_id, label))
updated = execute_query(
f'''UPDATE locations_cross_field_ports AS p
SET port_number = incoming.port_number
FROM (VALUES {values_sql}) AS incoming(id, port_number)
WHERE p.id = incoming.id AND p.cross_field_id = %s
RETURNING p.id, p.port_number, p.port_order''',
tuple(params) + (cross_field_id,),
) or []
return {'updated': len(updated), 'ports': updated}
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,
outlet_customer.name AS outlet_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 customers outlet_customer ON outlet_customer.id = o.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]
def _replace_switch_port_if_confirmed(
*, switch_hardware_id: Optional[int], switch_name: Optional[str], switch_port: Optional[str],
exclude_outlet_id: Optional[int], confirmed: bool,
) -> None:
"""Guard the one-to-one physical switch-port assignment."""
if not switch_port or not (switch_hardware_id or switch_name):
return
where = ['deleted_at IS NULL', 'is_active = TRUE', 'switch_port = %s']
params: List[Any] = [switch_port]
if switch_hardware_id:
where.append('switch_hardware_id = %s')
params.append(switch_hardware_id)
else:
where.append('LOWER(COALESCE(switch_name, \'\')) = LOWER(%s)')
params.append(switch_name)
if exclude_outlet_id:
where.append('id <> %s')
params.append(exclude_outlet_id)
conflicts = execute_query(
f'''SELECT id, outlet_number FROM locations_wall_outlets WHERE {' AND '.join(where)}''',
tuple(params),
) or []
if not conflicts:
return
if not confirmed:
raise HTTPException(
status_code=409,
detail=f"Switch-porten bruges allerede af vægstik {conflicts[0]['outlet_number']}. Bekræft overskrivning for at flytte forbindelsen.",
)
conflict_ids = tuple(row['id'] for row in conflicts)
placeholders = ', '.join(['%s'] * len(conflict_ids))
execute_query(
f'''UPDATE locations_wall_outlets
SET switch_hardware_id = NULL, switch_name = NULL, switch_port = NULL, updated_at = NOW()
WHERE id IN ({placeholders})''',
conflict_ids,
fetch=False,
)
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id)
_replace_switch_port_if_confirmed(
switch_hardware_id=data.switch_hardware_id,
switch_name=data.switch_name,
switch_port=data.switch_port,
exclude_outlet_id=None,
confirmed=data.replace_existing_switch_port,
)
try:
rows = execute_query(
"""INSERT INTO locations_wall_outlets
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_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)
replace_existing_switch_port = changes.pop('replace_existing_switch_port', False)
if not changes:
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
if 'outlet_number' in changes:
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
current = execute_query(
'''SELECT switch_hardware_id, switch_name, switch_port
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
(outlet_id,),
) or []
if not current:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
_replace_switch_port_if_confirmed(
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_name=changes.get('switch_name', current[0].get('switch_name')),
switch_port=changes.get('switch_port', current[0].get('switch_port')),
exclude_outlet_id=outlet_id,
confirmed=replace_existing_switch_port,
)
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 +972,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 +1026,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 +1074,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 +1115,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 +1138,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 +1179,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

@ -21,6 +21,7 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from jinja2 import Environment, FileSystemLoader, TemplateNotFound from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from pathlib import Path as PathlibPath from pathlib import Path as PathlibPath
import json
import logging import logging
from typing import Optional from typing import Optional
from app.core.database import execute_query, execute_update from app.core.database import execute_query, execute_update
@ -57,6 +58,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 +262,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 +342,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 +366,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 +390,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 +419,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 +448,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)
@ -465,14 +597,96 @@ def detail_location_view(id: int = Path(..., gt=0)):
hardware = execute_query( hardware = execute_query(
""" """
SELECT id, asset_type, brand, model, serial_number, status SELECT id, asset_type, brand, model, serial_number, status, hardware_specs, location_display_order
FROM hardware_assets FROM hardware_assets
WHERE current_location_id = %s AND deleted_at IS NULL WHERE current_location_id = %s AND deleted_at IS NULL
ORDER BY brand ASC, model ASC, serial_number ASC ORDER BY location_display_order NULLS LAST, brand ASC, model ASC, serial_number ASC
""", """,
(id,) (id,)
) )
wall_outlets = execute_query(
"""
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, 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,),
)
# Render the same physical port map directly under each switch on the location page.
hardware_link_map = {}
hardware_ids = [hw['id'] for hw in (hardware or [])]
if hardware_ids:
hardware_link_rows = execute_query(
"""SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port,
target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial,
source.brand AS source_brand, source.model AS source_model, source.serial_number AS source_serial
FROM hardware_network_links l
JOIN hardware_assets target ON target.id = l.target_hardware_id
JOIN hardware_assets source ON source.id = l.source_hardware_id
WHERE (l.source_hardware_id = ANY(%s) OR l.target_hardware_id = ANY(%s)) AND l.deleted_at IS NULL
ORDER BY l.id""",
(hardware_ids, hardware_ids),
) or []
for row in hardware_link_rows:
if row.get('source_port'):
hardware_link_map[(row['source_hardware_id'], str(row['source_port']))] = row
if row.get('target_port'):
reverse_row = dict(row)
reverse_row.update({
'target_hardware_id': row.get('source_hardware_id'),
'target_brand': row.get('source_brand'),
'target_model': row.get('source_model'),
'target_serial': row.get('source_serial'),
'target_port': row.get('source_port'),
})
hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row
for hw in hardware or []:
hw['switch_ports'] = []
if str(hw.get('asset_type') or '').lower() != 'netværk':
continue
specs = hw.get('hardware_specs') or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
port_count = int((specs or {}).get('port_count') or 0)
linked = {
str(outlet.get('switch_port')): outlet
for outlet in (wall_outlets or [])
if outlet.get('switch_hardware_id') == hw.get('id') and outlet.get('switch_port')
}
hw['switch_ports'] = [
{
'port_number': str(port),
'outlet': linked.get(str(port)),
'hardware_link': hardware_link_map.get((hw['id'], str(port))),
}
for port in range(1, port_count + 1)
]
cross_fields = execute_query(
"""SELECT id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes, is_active
FROM locations_cross_fields
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
ORDER BY display_order, id""",
(id,),
)
for cross_field in cross_fields or []:
cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.port_order, 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_order""",
(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 +704,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 +721,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 +773,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 +799,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 +841,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 +859,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,126 @@ 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: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, ge=1)
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_hardware_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):
replace_existing_switch_port: bool = False
class WallOutletUpdate(BaseModel):
outlet_number: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, ge=1)
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_hardware_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
replace_existing_switch_port: bool = False
@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
outlet_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)
port_label_format: str = Field(default='numeric', pattern='^(numeric|paired)$')
start_port_number: int = Field(default=1, ge=1, le=9999)
panel_row_size: int = Field(default=24, ge=1, le=48)
display_order: Optional[int] = Field(default=None, ge=1, le=9999)
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)
start_port_number: Optional[int] = Field(None, ge=1, le=9999)
panel_row_size: Optional[int] = Field(None, ge=1, le=48)
display_order: Optional[int] = Field(None, ge=1, le=9999)
notes: Optional[str] = None
class CrossFieldPort(BaseModel):
id: int
port_number: str
port_order: int
is_active: bool
class CrossFieldPortLabelUpdate(BaseModel):
id: int = Field(..., ge=1)
port_number: str = Field(..., min_length=1, max_length=20)
class CrossFieldPortLabelsUpdate(BaseModel):
ports: List[CrossFieldPortLabelUpdate] = Field(..., min_length=1, max_length=999)
class CrossField(BaseModel):
id: int
location_id: int
name: str
port_count: int
port_label_format: str = 'numeric'
start_port_number: int = 1
display_order: int = 1
panel_row_size: int = 24
notes: Optional[str] = None
is_active: bool
created_at: datetime
ports: List[CrossFieldPort] = []
# ============================================================================ # ============================================================================
# 2. CONTACT MODELS # 2. CONTACT MODELS
# ============================================================================ # ============================================================================
@ -360,6 +482,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,18 @@
{% 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.hardware-linked { background: #6f42c1; border-color: #59359f; 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 +259,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 +343,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 +737,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 +775,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 +791,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 or 'Ikke navngivet' }}</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 or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" 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 +857,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">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><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-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% 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">
@ -813,12 +884,14 @@
{% if location.hardware %} {% if location.hardware %}
<div class="list-group"> <div class="list-group">
{% for hw in location.hardware %} {% for hw in location.hardware %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
<div> <div class="d-flex justify-content-between align-items-center gap-3">
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div> <div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div> <div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
</div> </div>
<span class="badge bg-secondary">{{ hw.status }}</span> {% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -976,6 +1049,50 @@
</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 48"></div>
<div class="mb-3"><label class="form-label">Portmærkning</label><select class="form-select" id="crossFieldPortLabelFormat"><option value="numeric">1, 2, 3 …</option><option value="paired">1A, 1B, 2A, 2B …</option></select><div class="form-text">A/B-par kræver et lige antal porte, fx 48 porte = 1A24B.</div></div>
<div class="mb-3"><label class="form-label">Startnummer</label><input class="form-control" type="number" id="crossFieldStartPortNumber" min="1" max="9999" value="1"><div class="form-text">Næste panel kan fx starte ved 25, så det bliver 25A, 25B …</div></div>
<div class="mb-3"><label class="form-label">Porte pr. række</label><input class="form-control" type="number" id="crossFieldPanelRowSize" min="1" max="48" value="24"><div class="form-text">Sæt fx 24 for samme brede panelopstilling som på billedet.</div></div>
<div class="mb-3"><label class="form-label">Visningsrækkefølge</label><input class="form-control" type="number" id="crossFieldDisplayOrder" min="1" max="9999" value="1"><div class="form-text">Laveste nummer vises øverst. Ændr fx et panel til 1 og et andet til 2.</div></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="crossFieldPortLabelsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="crossFieldPortLabelsForm">
<div class="modal-header"><h5 class="modal-title">Rediger portnumre: <span id="crossFieldPortLabelsName"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><p class="small text-muted">Ændr de fysiske mærkninger frit, fx <code>1A</code>, <code>Kontor-12</code> eller <code>Rack2-07</code>. Forbindelser bevares på den samme port.</p><div id="crossFieldPortLabelsList" class="row g-2"></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem portnumre</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" placeholder="Valgfrit, 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-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></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" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></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">
@ -1005,6 +1122,8 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal')); const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | tojson }};
const existingContactSearchInput = document.getElementById('existingContactSearch'); const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults'); const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId'); const existingContactIdInput = document.getElementById('existingContactId');
@ -1292,6 +1411,270 @@ 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('crossFieldPortLabelFormat').disabled = false; document.getElementById('crossFieldStartPortNumber').disabled = false; document.getElementById('crossFieldStartPortNumber').value = 1; document.getElementById('crossFieldPanelRowSize').value = 24; document.getElementById('crossFieldDisplayOrder').value = 1; 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('crossFieldPortLabelFormat').value = button.dataset.labelFormat || 'numeric'; document.getElementById('crossFieldStartPortNumber').value = button.dataset.startNumber || 1; document.getElementById('crossFieldPanelRowSize').value = button.dataset.rowSize || 24; document.getElementById('crossFieldDisplayOrder').value = button.dataset.displayOrder || 1; document.getElementById('crossFieldPortLabelFormat').disabled = true; document.getElementById('crossFieldStartPortNumber').disabled = true; 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 payload = {location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), panel_row_size: Number(document.getElementById('crossFieldPanelRowSize').value), display_order: Number(document.getElementById('crossFieldDisplayOrder').value), notes: document.getElementById('crossFieldNotes').value || null};
if (!crossFieldId) { payload.port_label_format = document.getElementById('crossFieldPortLabelFormat').value; payload.start_port_number = Number(document.getElementById('crossFieldStartPortNumber').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(payload)});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
const crossFieldPortLabelsModalElement = document.getElementById('crossFieldPortLabelsModal');
const crossFieldPortLabelsModal = crossFieldPortLabelsModalElement ? new bootstrap.Modal(crossFieldPortLabelsModalElement) : null;
let activeCrossFieldPortLabelsId = null;
document.querySelectorAll('.edit-port-labels-btn').forEach(button => button.addEventListener('click', async () => {
try {
const response = await fetch(`/api/v1/locations/cross-fields?location_id=${locationId}`);
const fields = await response.json();
const field = Array.isArray(fields) ? fields.find(item => Number(item.id) === Number(button.dataset.id)) : null;
if (!response.ok || !field) throw new Error('Krydsfeltet kunne ikke indlæses');
activeCrossFieldPortLabelsId = field.id;
document.getElementById('crossFieldPortLabelsName').textContent = field.name;
const list = document.getElementById('crossFieldPortLabelsList');
list.innerHTML = '';
field.ports.forEach(port => {
const wrapper = document.createElement('div');
wrapper.className = 'col-md-4';
const label = document.createElement('label');
label.className = 'form-label small mb-1';
label.textContent = `Port ${port.port_number}`;
const input = document.createElement('input');
input.className = 'form-control form-control-sm cross-field-port-label-input';
input.maxLength = 20;
input.required = true;
input.value = port.port_number;
input.dataset.portId = port.id;
wrapper.append(label, input);
list.appendChild(wrapper);
});
crossFieldPortLabelsModal.show();
} catch (error) {
alert(error.message || 'Kunne ikke indlæse portnumre');
}
}));
document.getElementById('crossFieldPortLabelsForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!activeCrossFieldPortLabelsId) return;
const ports = Array.from(document.querySelectorAll('.cross-field-port-label-input')).map(input => ({id: Number(input.dataset.portId), port_number: input.value.trim()}));
const response = await fetch(`/api/v1/locations/cross-fields/${activeCrossFieldPortLabelsId}/port-labels`, {method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ports})});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Portnumrene kunne ikke gemmes'); }
});
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;
function switchDisplayName(hardware) {
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
}
function switchPortCount(hardware) {
let specs = hardware.hardware_specs || {};
if (typeof specs === 'string') {
try { specs = JSON.parse(specs); } catch (_) { specs = {}; }
}
const count = Number(specs?.port_count || specs?.ports || 0);
return Number.isInteger(count) && count > 0 ? count : 0;
}
function selectedSwitchHardwareId() {
const selectedName = document.getElementById('outletSwitch').value;
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
return match ? Number(match.id) : null;
}
function normalizeSwitchName(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
function switchPortConflict(switchHardware, switchName, portNumber, currentOutletId = null) {
if (!portNumber) return null;
return (locationWallOutlets || []).find(outlet => {
if (currentOutletId && Number(outlet.id) === Number(currentOutletId)) return false;
if (String(outlet.switch_port || '') !== String(portNumber)) return false;
if (switchHardware?.id && outlet.switch_hardware_id) return Number(outlet.switch_hardware_id) === Number(switchHardware.id);
return normalizeSwitchName(outlet.switch_name) === normalizeSwitchName(switchName);
}) || null;
}
function loadSwitchChoices(selectedName = '', selectedPort = '', currentOutletId = null) {
const switchInput = document.getElementById('outletSwitch');
const switchOptions = document.getElementById('outletSwitchOptions');
const portOptions = document.getElementById('outletSwitchPort');
const portHelp = document.getElementById('outletSwitchPortHelp');
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
switchOptions.innerHTML = '';
switches.forEach(item => {
const option = document.createElement('option');
option.value = switchDisplayName(item);
option.label = switchPortCount(item) ? `${switchPortCount(item)} porte` : 'Antal porte ikke angivet';
switchOptions.appendChild(option);
});
switchInput.value = selectedName || '';
const selectedSwitch = switches.find(item => switchDisplayName(item) === switchInput.value);
const count = selectedSwitch ? switchPortCount(selectedSwitch) : 0;
portOptions.innerHTML = '<option value="">Vælg port</option>';
if (count) {
for (let number = 1; number <= count; number += 1) {
const option = document.createElement('option');
option.value = String(number);
const conflict = switchPortConflict(selectedSwitch, switchInput.value, number, currentOutletId);
option.textContent = conflict
? `Port ${number} — OPTAGET: ${conflict.outlet_number} (${conflict.status || 'ukendt'})`
: `Port ${number} — ledig`;
portOptions.appendChild(option);
}
portHelp.textContent = `${count} porte på den valgte switch.`;
} else if (switchInput.value) {
portHelp.textContent = 'Ingen portliste på switchen — du kan skrive porten manuelt.';
} else {
portHelp.textContent = switches.length ? 'Vælg en switch for at se dens porte.' : 'Ingen registrerede switches på denne lokation endnu.';
}
document.getElementById('outletSwitchPort').value = selectedPort || '';
}
document.getElementById('outletSwitch')?.addEventListener('input', () => {
loadSwitchChoices(document.getElementById('outletSwitch').value, '', document.getElementById('outletId').value || 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 loadOutletCustomers(selectedCustomerId = null) {
const select = document.getElementById('outletCustomerId');
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(customers || []).forEach(customer => {
const option = document.createElement('option');
option.value = String(customer.id);
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
option.selected = String(customer.id) === String(selectedCustomerId || '');
select.appendChild(option);
});
}
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
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 || '';
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
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, customerId: btn.dataset.customerId, 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'), customer_id: document.getElementById('outletCustomerId').value ? Number(document.getElementById('outletCustomerId').value) : null, 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_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
const conflict = switchPortConflict(selectedSwitch, payload.switch_name, payload.switch_port, outletId || null);
if (conflict) {
const message = `Switch-port ${payload.switch_port} er allerede registreret på vægstik ${conflict.outlet_number}. Vil du flytte forbindelsen til dette vægstik?`;
if (!confirm(message)) return;
payload.replace_existing_switch_port = true;
}
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.status === 409) {
const error = await response.json().catch(() => ({}));
if (confirm(`${error.detail || 'Porten er allerede i brug.'}\n\nVil du overskrive forbindelsen?`)) {
payload.replace_existing_switch_port = true;
const retry = 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 (retry.ok) { location.reload(); return; }
}
return;
}
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');
});
document.querySelectorAll('.save-hardware-order-btn').forEach(button => button.addEventListener('click', async () => {
const hardwareId = button.dataset.hardwareId;
const input = document.querySelector(`.hardware-display-order[data-hardware-id="${hardwareId}"]`);
const order = Number(input?.value);
if (!Number.isInteger(order) || order < 1) {
alert('Angiv et positivt heltal for rækkefølgen.');
return;
}
button.disabled = true;
const response = await fetch(`/api/v1/hardware/${hardwareId}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({location_display_order: order})
});
if (response.ok) location.reload();
else {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme rækkefølgen');
button.disabled = false;
}
}));
}); });
</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
@ -310,6 +311,17 @@ class RewriteTextResponse(BaseModel):
context: Optional[str] = None context: Optional[str] = None
class CaseCreateRewriteRequest(BaseModel):
title: str = Field(default="", max_length=500)
description: str = Field(..., min_length=1, max_length=10000)
class CaseCreateRewriteResponse(BaseModel):
title: str
description: str
model: Optional[str] = None
class SagSendEmailRequest(BaseModel): class SagSendEmailRequest(BaseModel):
to: List[str] to: List[str]
subject: str = Field(..., min_length=1, max_length=998) subject: str = Field(..., min_length=1, max_length=998)
@ -730,6 +742,19 @@ async def analyze_quick_create(request: QuickCreateRequest):
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
@router.post("/sag/rewrite-case-create", response_model=CaseCreateRewriteResponse)
async def rewrite_case_create(request: CaseCreateRewriteRequest):
"""Return a fact-preserving title and description suggestion for a new case."""
result = await ollama_service.rewrite_case_creation(request.title, request.description)
if not result or result.get("error"):
raise HTTPException(status_code=502, detail=(result or {}).get("error") or "Could not rewrite case")
return CaseCreateRewriteResponse(
title=result.get("title", ""),
description=result.get("description", ""),
model=result.get("model"),
)
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse) @router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
async def rewrite_sag_text(request: RewriteTextRequest): async def rewrite_sag_text(request: RewriteTextRequest):
"""Rewrite case/email text using Ollama with configurable prompt.""" """Rewrite case/email text using Ollama with configurable prompt."""
@ -903,7 +928,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 +944,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">
@ -206,15 +212,24 @@
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label> <div class="d-flex justify-content-between align-items-center mb-2">
<label for="beskrivelse" class="form-label mb-0">Beskrivelse</label>
<button type="button" id="caseCreateRewriteBtn" class="btn btn-sm btn-outline-primary" title="Renskriv kun det, du allerede har skrevet">
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea> <textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div> <div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
</div>
</div> </div>
</div> </div>
<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 +261,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 +300,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 +310,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>
@ -317,6 +347,34 @@
</div> </div>
</div> </div>
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="caseCreateRewriteModalLabel"><i class="bi bi-magic me-2"></i>AI-forslag til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">
Gennemgå forslaget før du bruger det. AI må kun have rettet formulering og foreslået en titel ud fra din egen tekst.
</div>
<div class="mb-3">
<label for="caseCreateSuggestedTitle" class="form-label">Foreslået titel</label>
<input id="caseCreateSuggestedTitle" class="form-control" type="text">
</div>
<div>
<label for="caseCreateSuggestedDescription" class="form-label">Renskrevet beskrivelse</label>
<textarea id="caseCreateSuggestedDescription" class="form-control" rows="10"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="caseCreateApplyRewriteBtn" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Brug forslag</button>
</div>
</div>
</div>
</div>
<script> <script>
let selectedCustomer = null; let selectedCustomer = null;
let selectedContacts = {}; let selectedContacts = {};
@ -324,6 +382,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;
@ -421,6 +480,76 @@
}); });
} }
// --- AI renskrivning ved oprettelse ---
// Forslaget bliver altid vist først; formularens titel og beskrivelse ændres
// først når brugeren aktivt vælger "Brug forslag".
async function requestCaseCreateRewrite() {
const descriptionInput = document.getElementById('beskrivelse');
const titleInput = document.getElementById('titel');
const button = document.getElementById('caseCreateRewriteBtn');
const source = (descriptionInput?.value || '').trim();
if (!source) {
descriptionInput?.focus();
alert('Skriv en beskrivelse først.');
return;
}
const originalButton = button?.innerHTML || '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const response = await fetch('/api/v1/sag/rewrite-case-create', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleInput?.value || '', description: source })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.detail || `HTTP ${response.status}`);
}
const suggestion = {
title: String(payload?.title || '').trim(),
description: String(payload?.description || '').trim()
};
if (!suggestion.description) {
throw new Error('AI returnerede ikke en beskrivelse');
}
document.getElementById('caseCreateSuggestedTitle').value = suggestion.title || titleInput?.value || '';
document.getElementById('caseCreateSuggestedDescription').value = suggestion.description;
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalButton;
}
}
}
document.getElementById('caseCreateRewriteBtn')?.addEventListener('click', requestCaseCreateRewrite);
document.getElementById('caseCreateApplyRewriteBtn')?.addEventListener('click', () => {
const suggestedTitle = document.getElementById('caseCreateSuggestedTitle').value.trim();
const suggestedDescription = document.getElementById('caseCreateSuggestedDescription').value.trim();
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input'));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
// --- Search Logic --- // --- Search Logic ---
function initializeSearch() { function initializeSearch() {
// Customer Search // Customer Search
@ -889,28 +1018,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 +1207,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',

File diff suppressed because it is too large Load Diff

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

@ -45,7 +45,8 @@ class EmailActivityLogger:
log_id = execute_insert( log_id = execute_insert(
"""INSERT INTO email_activity_log """INSERT INTO email_activity_log
(email_id, event_type, event_category, description, metadata, user_id, created_by) (email_id, event_type, event_category, description, metadata, user_id, created_by)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)""", VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
RETURNING id""",
(email_id, event_type, category, description, metadata_json, user_id, created_by) (email_id, event_type, category, description, metadata_json, user_id, created_by)
) )

View File

@ -148,10 +148,10 @@ Din opgave er at renskrive en rå tekst til klart, professionelt og venligt dans
Teksten kan være enten en e-mail eller en sagsbeskrivelse. Teksten kan være enten en e-mail eller en sagsbeskrivelse.
Regler: Regler:
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer. 1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer uændret.
2. Ret stavefejl, tegnsætning og grammatik. 2. Ret kun stavefejl, tegnsætning, grammatik og tydelig formulering.
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information. 3. Tilføj, gæt eller udled ALDRIG nye oplysninger. Ændr heller ikke rækkefølge, betydning, ansvar, omfang eller tekniske detaljer.
4. Fjern fyldord, gentagelser og intern støj. 4. Fjern kun en gentagelse, hvis den er helt identisk; behold ellers hele indholdet.
5. Bevar tone og intention: neutral, serviceminded og professionel. 5. Bevar tone og intention: neutral, serviceminded og professionel.
6. Opfind aldrig nye oplysninger. 6. Opfind aldrig nye oplysninger.
7. Hvis input er e-mail: returner i formatet: 7. Hvis input er e-mail: returner i formatet:
@ -260,6 +260,100 @@ Output:
logger.error("❌ Ollama text rewrite failed: %s", e) logger.error("❌ Ollama text rewrite failed: %s", e)
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0} return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
async def rewrite_case_creation(self, title: str, description: str) -> Dict:
"""Create a conservative, structured rewrite for the new-case form.
This intentionally does not use the configurable generic rewrite prompt:
case creation needs a reliable title and must never make up case facts.
"""
clean_description = (description or "").strip()
if not clean_description:
return {"error": "Input text is empty"}
system_prompt = """Du renskriver sagsbeskrivelser for et IT-system.
Du skal returnere KUN gyldig JSON præcis denne form:
{"title":"...", "description":"..."}
ABSOLUTTE REGLER FOR description:
- Bevar alle fakta, navne, tal, datoer, versioner, IP-adresser, tekniske termer og usikkerheder præcist.
- Ret kun stavning, tegnsætning, grammatik og åbenlyst uklare formuleringer.
- Tilføj, gæt, forklar eller udled aldrig noget, der ikke står i input.
- Fjern ikke detaljer. Bevar også ønsker, spørgsmål, fejl og forbehold.
- Brug korte afsnit eller punktopstilling kun når input allerede tydeligt indeholder flere separate punkter.
REGLER FOR title:
- Skriv en kort, konkret titel 4-10 ord, der beskriver det faktiske arbejde eller problem i teksten.
- Brug de mest specifikke ord fra teksten, f.eks. produkt, funktion, fejl eller handling.
- Brug ikke tomme titler som "Support", "Henvendelse", "Problem" eller "Ny sag" alene.
- Opfind ikke kunde, produkt, årsag eller løsning. Hvis teksten ikke giver nok grundlag, behold den eksisterende titel (kun med stavning rettet).
"""
user_message = (
"Eksisterende titel (kan være tom):\n"
f"{(title or '').strip()}\n\n"
"Sagsbeskrivelse, som er eneste kilde til fakta:\n"
f"{clean_description}"
)
try:
import httpx
model_normalized = (self.model or "").strip().lower()
use_chat_api = model_normalized.startswith("qwen")
async with httpx.AsyncClient(timeout=120.0) as client:
if use_chat_api:
response = await client.post(
f"{self.endpoint}/api/chat",
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"stream": False,
"format": "json",
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
},
)
else:
response = await client.post(
f"{self.endpoint}/api/generate",
json={
"model": self.model,
"prompt": f"{system_prompt}\n\nBrugerinput:\n{user_message}",
"stream": False,
"format": "json",
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
},
)
if response.status_code != 200:
return {"error": f"Ollama returned status {response.status_code}: {response.text[:300]}"}
payload = response.json()
if use_chat_api:
raw = str(((payload.get("message") or {}).get("content") or "")).strip()
else:
raw = str((payload or {}).get("response") or "").strip()
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip()
structured = json.loads(raw)
result_title = str(structured.get("title") or "").strip()
result_description = str(structured.get("description") or "").strip()
if not result_title or not result_description:
return {"error": "Ollama returned an incomplete case rewrite"}
return {
"title": result_title[:200],
"description": result_description,
"model": self.model,
}
except (TypeError, ValueError, json.JSONDecodeError) as e:
logger.warning("⚠️ Ollama returned invalid case-create rewrite: %s", e)
return {"error": "Ollama returned an invalid case rewrite"}
except Exception as e:
logger.error("❌ Ollama case-create rewrite failed: %s", e)
return {"error": f"Ollama rewrite failed: {str(e)}"}
async def extract_from_text(self, text: str) -> Dict: async def extract_from_text(self, text: str) -> Dict:
""" """
Extract structured invoice data from text using Ollama Extract structured invoice data from text using Ollama

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

@ -7,9 +7,9 @@ SYNC ARCHITECTURE - Field Ownership:
E-CONOMIC owns and syncs: 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) - name, phone, address, city, postal_code, country (company and 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:
@ -163,6 +163,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
country = eco_customer.get('country', 'DK') country = eco_customer.get('country', 'DK')
email = eco_customer.get('email', '') email = eco_customer.get('email', '')
website = eco_customer.get('website', '') website = eco_customer.get('website', '')
phone = eco_customer.get('phone') or eco_customer.get('telephone') or ''
if not customer_number or not name: if not customer_number or not name:
skipped_count += 1 skipped_count += 1
@ -192,7 +193,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, phone, 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 +222,9 @@ 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 = {
"name": existing[0].get("name"),
"phone": existing[0].get("phone"),
"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 +233,9 @@ 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 = {
"name": name,
"phone": phone,
"cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
"address": address, "address": address,
"city": city, "city": city,
@ -252,6 +259,9 @@ 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,
name = %s,
phone = %s,
cvr_number = %s,
email_domain = %s, email_domain = %s,
address = %s, address = %s,
city = %s, city = %s,
@ -262,7 +272,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, name, phone, 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)",
@ -276,6 +286,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
else: else:
would_create.append({ would_create.append({
"name": name, "name": name,
"phone": phone,
"economic_customer_number": customer_number, "economic_customer_number": customer_number,
"cvr_number": cvr, "cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
@ -289,13 +300,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if apply_changes: if apply_changes:
insert_query = """ insert_query = """
INSERT INTO customers INSERT INTO customers
(name, economic_customer_number, cvr_number, email_domain, (name, phone, economic_customer_number, cvr_number, email_domain,
address, city, postal_code, country, website, last_synced_at) address, city, postal_code, country, website, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id RETURNING id
""" """
result = execute_query(insert_query, ( result = execute_query(insert_query, (
name, customer_number, cvr, email_domain, address, city, zip_code, country, website name, phone, customer_number, cvr, email_domain, address, city, zip_code, country, website
)) ))
if result: if result:
logger.info( logger.info(

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

@ -18,6 +18,16 @@ ALTER TABLE email_messages
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500); ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
-- Cleanup duplicates before adding unique constraint/PK -- Cleanup duplicates before adding unique constraint/PK
-- Old installations can contain links to emails or cases that have since
-- been deleted. Remove those orphan links before enforcing foreign keys.
DELETE FROM sag_emails se
WHERE NOT EXISTS (
SELECT 1 FROM email_messages em WHERE em.id = se.email_id
)
OR NOT EXISTS (
SELECT 1 FROM sag_sager s WHERE s.id = se.sag_id
);
WITH ranked AS ( WITH ranked AS (
SELECT ctid, SELECT ctid,
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn

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

@ -0,0 +1,39 @@
-- Support physical patch-panel layouts such as 1A, 1B … 24A, 24B.
ALTER TABLE locations_cross_fields
ADD COLUMN IF NOT EXISTS port_label_format VARCHAR(20) NOT NULL DEFAULT 'numeric',
ADD COLUMN IF NOT EXISTS panel_row_size INTEGER NOT NULL DEFAULT 24;
ALTER TABLE locations_cross_fields
DROP CONSTRAINT IF EXISTS locations_cross_fields_port_label_format_check;
ALTER TABLE locations_cross_fields
ADD CONSTRAINT locations_cross_fields_port_label_format_check
CHECK (port_label_format IN ('numeric', 'paired'));
ALTER TABLE locations_cross_fields
DROP CONSTRAINT IF EXISTS locations_cross_fields_panel_row_size_check;
ALTER TABLE locations_cross_fields
ADD CONSTRAINT locations_cross_fields_panel_row_size_check
CHECK (panel_row_size BETWEEN 1 AND 48);
-- Port labels are physical labels, not necessarily numbers (for example 1A/1B).
ALTER TABLE locations_cross_field_ports
DROP CONSTRAINT IF EXISTS locations_cross_field_ports_port_number_check;
ALTER TABLE locations_cross_field_ports
ALTER COLUMN port_number TYPE VARCHAR(20) USING port_number::VARCHAR;
ALTER TABLE locations_cross_field_ports
ADD COLUMN IF NOT EXISTS port_order INTEGER;
UPDATE locations_cross_field_ports
SET port_order = CASE
WHEN port_number ~ '^[0-9]+$' THEN port_number::INTEGER
ELSE id
END
WHERE port_order IS NULL;
ALTER TABLE locations_cross_field_ports
ALTER COLUMN port_order SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_field_ports_field_order
ON locations_cross_field_ports(cross_field_id, port_order);

View File

@ -0,0 +1,9 @@
-- A physical patch panel may continue the labelling from the preceding panel.
ALTER TABLE locations_cross_fields
ADD COLUMN IF NOT EXISTS start_port_number INTEGER NOT NULL DEFAULT 1;
ALTER TABLE locations_cross_fields
DROP CONSTRAINT IF EXISTS locations_cross_fields_start_port_number_check;
ALTER TABLE locations_cross_fields
ADD CONSTRAINT locations_cross_fields_start_port_number_check
CHECK (start_port_number BETWEEN 1 AND 9999);

View File

@ -0,0 +1,20 @@
-- Physical panels may be displayed in a different order than their names.
ALTER TABLE locations_cross_fields
ADD COLUMN IF NOT EXISTS display_order INTEGER;
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY location_id ORDER BY name, id) AS row_number
FROM locations_cross_fields
WHERE display_order IS NULL
)
UPDATE locations_cross_fields cf
SET display_order = ordered.row_number
FROM ordered
WHERE cf.id = ordered.id;
ALTER TABLE locations_cross_fields
ALTER COLUMN display_order SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_cross_fields_location_display_order
ON locations_cross_fields(location_id, display_order)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,8 @@
-- Link a wall outlet to the actual switch hardware, rather than only its display name.
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS switch_hardware_id INTEGER
REFERENCES hardware_assets(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_wall_outlets_switch_hardware_port
ON locations_wall_outlets(switch_hardware_id, switch_port)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,11 @@
-- A patch/switch port can be registered before the wall-outlet label is known.
ALTER TABLE locations_wall_outlets
ALTER COLUMN outlet_number DROP NOT NULL;
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS customer_id INTEGER
REFERENCES customers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_wall_outlets_customer_id
ON locations_wall_outlets(customer_id)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS hardware_network_links (
id SERIAL PRIMARY KEY,
source_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
source_port VARCHAR(100) NOT NULL,
target_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
target_port VARCHAR(100),
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP,
CHECK (source_hardware_id <> target_hardware_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_hardware_network_links_source_port_active
ON hardware_network_links(source_hardware_id, source_port)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_hardware_network_links_target_active
ON hardware_network_links(target_hardware_id)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,16 @@
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS location_display_order INTEGER;
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY current_location_id ORDER BY brand, model, serial_number, id) AS row_number
FROM hardware_assets
WHERE current_location_id IS NOT NULL AND location_display_order IS NULL
)
UPDATE hardware_assets h
SET location_display_order = ordered.row_number
FROM ordered
WHERE h.id = ordered.id;
CREATE INDEX IF NOT EXISTS idx_hardware_assets_location_display_order
ON hardware_assets(current_location_id, location_display_order)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,40 @@
-- Cached UISP inventory and the one-to-one link to a hardware asset.
CREATE TABLE IF NOT EXISTS uisp_devices (
id SERIAL PRIMARY KEY,
external_id VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255),
display_name VARCHAR(255),
hostname VARCHAR(255),
mac_address VARCHAR(64),
serial_number VARCHAR(255),
vendor VARCHAR(255),
model VARCHAR(255),
platform VARCHAR(255),
device_type VARCHAR(100),
device_role VARCHAR(100),
ip_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
status VARCHAR(100),
last_seen TIMESTAMPTZ,
device_link TEXT,
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_uisp_devices_name ON uisp_devices(name);
CREATE INDEX IF NOT EXISTS idx_uisp_devices_serial_number ON uisp_devices(serial_number);
CREATE INDEX IF NOT EXISTS idx_uisp_devices_mac_address ON uisp_devices(mac_address);
CREATE TABLE IF NOT EXISTS hardware_uisp_links (
id SERIAL PRIMARY KEY,
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
uisp_device_id INTEGER NOT NULL REFERENCES uisp_devices(id) ON DELETE CASCADE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
linked_by_user_id INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (hardware_id),
UNIQUE (uisp_device_id)
);
CREATE INDEX IF NOT EXISTS idx_hardware_uisp_links_hardware ON hardware_uisp_links(hardware_id);

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))
@ -179,6 +180,109 @@ def test_detection_service_reopens_expired_ignored_issue(monkeypatch):
assert "ignored_until = NULL" in updates[0][0] assert "ignored_until = NULL" in updates[0][0]
def test_detection_service_ignores_non_hub_customer_keys(monkeypatch):
from app.modules.invoice_error_finder.services.detection_service import DetectionService
def fake_execute_query_single(query, params=None):
if "SELECT id FROM customers" in query:
return None
return None
monkeypatch.setattr(
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
fake_execute_query_single,
)
service = DetectionService()
customer_id = service._resolve_hub_customer_id(
{
"customer_key": 56283338,
"hub_customer_id": 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
@ -211,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"