Compare commits
2 Commits
db2e8c3157
...
d4d9ac22a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d9ac22a5 | ||
|
|
0655b4c4f8 |
@ -217,6 +217,22 @@ class UserProfileUpdate(BaseModel):
|
||||
phone: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
anydesk_id: Optional[str] = None
|
||||
default_case_type: Optional[str] = None
|
||||
|
||||
|
||||
def _allowed_case_types() -> list[str]:
|
||||
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
|
||||
try:
|
||||
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
||||
if rows:
|
||||
import json
|
||||
configured = json.loads(rows[0].get("value") or "[]")
|
||||
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
||||
if values:
|
||||
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
|
||||
except Exception:
|
||||
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
@router.get("/me/profile")
|
||||
@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return dict(rows[0])
|
||||
profile = dict(rows[0])
|
||||
profile["default_case_type"] = "ticket"
|
||||
try:
|
||||
preference = execute_query(
|
||||
"SELECT default_case_type FROM user_sag_create_preferences WHERE user_id = %s",
|
||||
(current_user["id"],),
|
||||
) or []
|
||||
if preference:
|
||||
profile["default_case_type"] = preference[0].get("default_case_type") or "ticket"
|
||||
except Exception:
|
||||
logger.warning("Sag create preferences table not available yet")
|
||||
return profile
|
||||
|
||||
|
||||
@router.patch("/me/profile")
|
||||
@ -253,16 +280,33 @@ async def update_my_profile(
|
||||
fields.append("anydesk_id = %s")
|
||||
values.append(payload.anydesk_id.strip() or None)
|
||||
|
||||
if not fields:
|
||||
if payload.default_case_type is not None:
|
||||
default_case_type = payload.default_case_type.strip().lower() or "ticket"
|
||||
if default_case_type not in _allowed_case_types():
|
||||
raise HTTPException(status_code=400, detail="Ukendt sagstype")
|
||||
try:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO user_sag_create_preferences (user_id, default_case_type, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET default_case_type = EXCLUDED.default_case_type, updated_at = NOW()
|
||||
""",
|
||||
(current_user["id"], default_case_type),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=409, detail="Profilindstillingen er ikke klar. Kør migration 214 først.") from exc
|
||||
|
||||
if not fields and payload.default_case_type is None:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
fields.append("updated_at = NOW()")
|
||||
values.append(current_user["id"])
|
||||
|
||||
execute_query(
|
||||
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
||||
tuple(values)
|
||||
)
|
||||
if fields:
|
||||
fields.append("updated_at = NOW()")
|
||||
values.append(current_user["id"])
|
||||
execute_query(
|
||||
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
||||
tuple(values)
|
||||
)
|
||||
return {"message": "Profil opdateret"}
|
||||
|
||||
|
||||
|
||||
@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
|
||||
<td>${escapeHtml(stage)}</td>
|
||||
<td>${probability}</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/opportunities/${o.id}">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int):
|
||||
return rows or []
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/economic-invoices")
|
||||
async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)):
|
||||
"""Get imported e-conomic invoices for a customer from invoice_error_finder staging data."""
|
||||
customer = execute_query_single(
|
||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
economic_customer_number = customer.get("economic_customer_number")
|
||||
if not economic_customer_number:
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"economic_customer_number": None,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH ranked_invoices AS (
|
||||
SELECT
|
||||
inv.id,
|
||||
inv.source_invoice_number,
|
||||
inv.invoice_date,
|
||||
inv.due_date,
|
||||
inv.total_amount,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.currency,
|
||||
inv.source_type,
|
||||
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||
NULLIF(
|
||||
CONCAT_WS(
|
||||
E'\n',
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||
),
|
||||
''
|
||||
) AS note_text,
|
||||
CASE inv.source_type
|
||||
WHEN 'paid' THEN 1
|
||||
WHEN 'booked' THEN 2
|
||||
WHEN 'unpaid' THEN 3
|
||||
WHEN 'draft' THEN 4
|
||||
ELSE 9
|
||||
END AS source_rank
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
WHERE inv.customer_number = %s
|
||||
),
|
||||
selected_invoices AS (
|
||||
SELECT DISTINCT ON (source_invoice_number)
|
||||
id,
|
||||
source_invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total_amount,
|
||||
net_amount,
|
||||
vat_amount,
|
||||
currency,
|
||||
source_type,
|
||||
heading,
|
||||
note_text
|
||||
FROM ranked_invoices
|
||||
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
|
||||
)
|
||||
SELECT
|
||||
si.id AS invoice_id,
|
||||
si.source_invoice_number,
|
||||
si.invoice_date,
|
||||
si.due_date,
|
||||
si.total_amount,
|
||||
si.net_amount,
|
||||
si.vat_amount,
|
||||
si.currency,
|
||||
si.source_type,
|
||||
si.heading,
|
||||
si.note_text,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount
|
||||
FROM selected_invoices si
|
||||
LEFT JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = si.id
|
||||
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(economic_customer_number, limit * 25),
|
||||
) or []
|
||||
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
invoices_by_id: Dict[int, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
invoice_id = row.get("invoice_id")
|
||||
if invoice_id is None:
|
||||
continue
|
||||
if invoice_id not in invoices_by_id:
|
||||
payload = {
|
||||
"invoice_id": invoice_id,
|
||||
"invoice_number": row.get("source_invoice_number"),
|
||||
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
|
||||
"due_date": row["due_date"].isoformat() if row.get("due_date") else None,
|
||||
"total_amount": float(row.get("total_amount") or 0),
|
||||
"net_amount": float(row.get("net_amount") or 0),
|
||||
"vat_amount": float(row.get("vat_amount") or 0),
|
||||
"currency": row.get("currency") or "DKK",
|
||||
"source_type": row.get("source_type"),
|
||||
"heading": row.get("heading") or None,
|
||||
"note_text": row.get("note_text") or None,
|
||||
"lines": [],
|
||||
}
|
||||
invoices_by_id[invoice_id] = payload
|
||||
invoices.append(payload)
|
||||
if row.get("line_number") is not None:
|
||||
invoices_by_id[invoice_id]["lines"].append(
|
||||
{
|
||||
"line_number": int(row.get("line_number") or 0),
|
||||
"product_number": row.get("product_number"),
|
||||
"product_name": row.get("product_name"),
|
||||
"description": row.get("description"),
|
||||
"quantity": float(row.get("quantity") or 0),
|
||||
"unit_price": float(row.get("unit_price") or 0),
|
||||
"line_net_amount": float(row.get("line_net_amount") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"economic_customer_number": economic_customer_number,
|
||||
"items": invoices[:limit],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/kontakt")
|
||||
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
|
||||
"""Get unified contact communication history (calls + SMS) for all company contacts."""
|
||||
|
||||
@ -259,6 +259,253 @@
|
||||
.subscription-column {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.customer-invoice-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.customer-invoice-shell {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.customer-invoice-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(15, 76, 117, 0.02);
|
||||
}
|
||||
|
||||
.customer-invoice-toolbar-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-invoice-toolbar-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.customer-invoice-toolbar-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.customer-invoice-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.customer-invoice-summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.customer-invoice-month-group + .customer-invoice-month-group {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.customer-invoice-month-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.25rem;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
.customer-invoice-month-label {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-invoice-month-total {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.customer-invoice-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.customer-invoice-table thead th {
|
||||
background: rgba(15, 76, 117, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.customer-invoice-row td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.customer-invoice-row:hover {
|
||||
background: rgba(15, 76, 117, 0.025);
|
||||
}
|
||||
|
||||
.customer-invoice-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.customer-invoice-period-cell {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.customer-invoice-period-text {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.customer-invoice-period-sub {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.customer-invoice-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 76px;
|
||||
padding: 0.38rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-invoice-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.customer-invoice-open,
|
||||
.customer-invoice-toggle {
|
||||
border-radius: 999px;
|
||||
padding-inline: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.customer-invoice-expanded-row td {
|
||||
background: rgba(15, 76, 117, 0.025);
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.customer-invoice-expanded {
|
||||
padding: 1rem 1.25rem 1.1rem;
|
||||
}
|
||||
|
||||
.customer-invoice-note-panel {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 0.8rem 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.customer-invoice-totals {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.matrix-cell-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.matrix-cell-button:hover {
|
||||
color: #0b3b5a;
|
||||
}
|
||||
|
||||
.invoice-detail-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.invoice-detail-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 76, 117, 0.04);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.invoice-detail-card .label {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.invoice-detail-card .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.customer-invoice-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.customer-invoice-summary {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header {
|
||||
position: sticky;
|
||||
@ -990,9 +1237,27 @@
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<div class="tab-pane fade" id="invoices">
|
||||
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
||||
<div class="text-muted text-center py-5">
|
||||
Fakturamodul kommer snart...
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0">Fakturaer</h5>
|
||||
<small class="text-muted">Importerede e-conomic-fakturaer for kunden</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadCustomerInvoices(true)">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
||||
</button>
|
||||
</div>
|
||||
<div class="contacts-toolbar mb-3">
|
||||
<div class="input-group contacts-search">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" id="customerInvoiceSearchInput" placeholder="Søg i fakturanr., periode, note eller dato" oninput="filterCustomerInvoices(this.value)">
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearCustomerInvoiceSearch()">Nulstil</button>
|
||||
<span class="badge text-bg-light border" id="customerInvoiceResultCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="customerInvoicesContainer" class="text-muted text-center py-5">
|
||||
Åbn fanen for at indlæse fakturaer...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1088,9 +1353,9 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">
|
||||
<i class="bi bi-table me-2"></i>Abonnements-matrix
|
||||
<small class="text-muted fw-normal">(fra e-conomic)</small>
|
||||
<small class="text-muted fw-normal">(fra importerede e-conomic-fakturaer)</small>
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent matrix fra importerede e-conomic-fakturaer">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</button>
|
||||
</div>
|
||||
@ -2007,12 +2272,29 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="customerWorklogSuggestionBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="customerInvoiceDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Fakturadetaljer</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="customerInvoiceDetailBody">
|
||||
<div class="text-center py-4 text-muted">Indlæser...</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -2032,6 +2314,9 @@ let pipelineStages = [];
|
||||
let allTagsCache = [];
|
||||
let customerKontaktItems = [];
|
||||
let customerKontaktFilter = 'all';
|
||||
let customerInvoicesLoaded = false;
|
||||
let customerInvoicesData = [];
|
||||
let customerInvoiceSearchTerm = '';
|
||||
|
||||
let eventListenersAdded = false;
|
||||
|
||||
@ -2157,6 +2442,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
const invoicesTab = document.querySelector('a[href="#invoices"]');
|
||||
if (invoicesTab) {
|
||||
invoicesTab.addEventListener('shown.bs.tab', () => {
|
||||
loadCustomerInvoices();
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
if (window.location.hash) {
|
||||
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
|
||||
if (hashTab && window.bootstrap?.Tab) {
|
||||
@ -3953,7 +4245,7 @@ function renderCustomerPipeline(opportunities) {
|
||||
</td>
|
||||
<td>${o.probability || 0}%</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/sag/${o.id}/v3'">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
</td>
|
||||
@ -4472,6 +4764,349 @@ function formatCurrency(value, currency) {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||||
}
|
||||
|
||||
function renderCustomerInvoiceLineRows(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
return '<div class="text-muted small">Ingen fakturalinjer fundet</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Linje</th>
|
||||
<th>Varenr</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Antal</th>
|
||||
<th>Pris</th>
|
||||
<th>Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${lines.map(line => {
|
||||
const description = line.description || line.product_name || '-';
|
||||
return `
|
||||
<tr>
|
||||
<td>${Number(line.line_number || 0).toLocaleString('da-DK')}</td>
|
||||
<td>${escapeHtml(line.product_number || '-')}</td>
|
||||
<td class="text-truncate" style="max-width: 520px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(description)}">${escapeHtml(description)}</td>
|
||||
<td>${Number(line.quantity || 0).toLocaleString('da-DK')}</td>
|
||||
<td>${formatCurrency(line.unit_price, 'DKK')}</td>
|
||||
<td>${formatCurrency(line.line_net_amount, 'DKK')}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCustomerInvoiceCards(invoices) {
|
||||
if (!Array.isArray(invoices) || invoices.length === 0) {
|
||||
return '<div class="text-muted text-center py-5">Ingen importerede e-conomic-fakturaer fundet for denne kunde</div>';
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
invoices.forEach((invoice) => {
|
||||
const key = (invoice.invoice_date || '').slice(0, 7) || 'unknown';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(invoice);
|
||||
});
|
||||
|
||||
const summaryTotal = invoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
|
||||
const monthKeys = Object.keys(grouped).sort().reverse();
|
||||
|
||||
const groupsHtml = monthKeys.map((monthKey) => {
|
||||
const monthInvoices = grouped[monthKey] || [];
|
||||
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
|
||||
const monthLabel = formatInvoiceMonthLabel(monthKey);
|
||||
|
||||
const rows = monthInvoices.map((invoice, idx) => {
|
||||
const itemId = `customer-economic-invoice-${monthKey}-${idx}`;
|
||||
const status = invoice.source_type || 'booked';
|
||||
const periodText = invoice.heading || invoice.note_text || 'Ingen periodetekst';
|
||||
const lineCount = Array.isArray(invoice.lines) ? invoice.lines.length : 0;
|
||||
const detailLabel = lineCount > 0 ? `Vis linjer (${lineCount})` : 'Vis detaljer';
|
||||
|
||||
return `
|
||||
<tr class="customer-invoice-row">
|
||||
<td>
|
||||
<button class="btn btn-link p-0 text-decoration-none customer-invoice-number" type="button" onclick="toggleLineItems('${itemId}')">
|
||||
<i class="bi bi-chevron-right" id="${itemId}-icon"></i>
|
||||
<span>${escapeHtml(invoice.invoice_number || 'Ukendt faktura')}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="customer-invoice-period-cell">
|
||||
<span class="customer-invoice-period-text" title="${escapeHtml(periodText)}">${escapeHtml(periodText)}</span>
|
||||
<span class="customer-invoice-period-sub">${lineCount} linjer</span>
|
||||
</td>
|
||||
<td>${invoice.invoice_date ? escapeHtml(formatDate(invoice.invoice_date)) : '-'}</td>
|
||||
<td>${invoice.due_date ? escapeHtml(formatDate(invoice.due_date)) : '-'}</td>
|
||||
<td class="text-end fw-semibold">${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</td>
|
||||
<td><span class="badge bg-${getStatusColor(status)} customer-invoice-status">${escapeHtml(status)}</span></td>
|
||||
<td>
|
||||
<div class="customer-invoice-actions">
|
||||
<button class="btn btn-sm btn-outline-primary customer-invoice-open" type="button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(invoice.invoice_number || '')).replace(/'/g, "\\'")}')">Faktura</button>
|
||||
<button class="btn btn-sm btn-outline-secondary customer-invoice-toggle" type="button" onclick="toggleLineItems('${itemId}')">${detailLabel}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="customer-invoice-expanded-row">
|
||||
<td colspan="7">
|
||||
<div id="${itemId}-lines" class="customer-invoice-expanded" style="display: none;">
|
||||
${invoice.heading || invoice.note_text ? `
|
||||
<div class="customer-invoice-note-panel">
|
||||
${invoice.heading ? `<div class="fw-semibold">${escapeHtml(invoice.heading)}</div>` : ''}
|
||||
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${renderCustomerInvoiceLineRows(invoice.lines || [])}
|
||||
<div class="customer-invoice-totals">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Netto:</span>
|
||||
<strong>${formatCurrency(invoice.net_amount, invoice.currency || 'DKK')}</strong>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Moms:</span>
|
||||
<strong>${formatCurrency(invoice.vat_amount, invoice.currency || 'DKK')}</strong>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between text-info fw-bold">
|
||||
<span>Total:</span>
|
||||
<strong>${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<section class="customer-invoice-month-group">
|
||||
<div class="customer-invoice-month-header">
|
||||
<div class="customer-invoice-month-label">${escapeHtml(monthLabel)}</div>
|
||||
<div class="customer-invoice-month-total">${monthInvoices.length} fakturaer · ${formatCurrency(monthTotal, monthInvoices[0]?.currency || 'DKK')}</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle customer-invoice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Faktura</th>
|
||||
<th>Periode</th>
|
||||
<th>Dato</th>
|
||||
<th>Forfald</th>
|
||||
<th class="text-end">Beløb</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="customer-invoice-shell">
|
||||
<div class="customer-invoice-toolbar">
|
||||
<div class="customer-invoice-toolbar-copy">
|
||||
<div class="customer-invoice-toolbar-title">Fakturaoversigt</div>
|
||||
<div class="customer-invoice-toolbar-subtitle">Importerede e-conomic-fakturaer vist som en almindelig oversigt med detaljer pr. faktura</div>
|
||||
</div>
|
||||
<div class="customer-invoice-summary">
|
||||
<span class="customer-invoice-summary-chip"><i class="bi bi-receipt"></i>${invoices.length} fakturaer</span>
|
||||
<span class="customer-invoice-summary-chip"><i class="bi bi-calendar3"></i>${monthKeys.length} måneder</span>
|
||||
<span class="customer-invoice-summary-chip"><i class="bi bi-cash-stack"></i>${formatCurrency(summaryTotal, invoices[0]?.currency || 'DKK')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customer-invoice-list">${groupsHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatInvoiceMonthLabel(yearMonth) {
|
||||
if (!yearMonth || yearMonth === 'unknown') return 'Uden dato';
|
||||
try {
|
||||
const date = new Date(`${yearMonth}-01`);
|
||||
return date.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' });
|
||||
} catch {
|
||||
return yearMonth;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredCustomerInvoices() {
|
||||
const term = String(customerInvoiceSearchTerm || '').trim().toLowerCase();
|
||||
if (!term) return customerInvoicesData;
|
||||
|
||||
return customerInvoicesData.filter((invoice) => {
|
||||
const haystack = [
|
||||
invoice.invoice_number,
|
||||
invoice.invoice_date,
|
||||
invoice.due_date,
|
||||
invoice.heading,
|
||||
invoice.note_text,
|
||||
...(Array.isArray(invoice.lines) ? invoice.lines.flatMap((line) => [
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description
|
||||
]) : [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFilteredCustomerInvoices() {
|
||||
const container = document.getElementById('customerInvoicesContainer');
|
||||
const countBadge = document.getElementById('customerInvoiceResultCount');
|
||||
if (!container) return;
|
||||
|
||||
const filteredInvoices = getFilteredCustomerInvoices();
|
||||
container.innerHTML = renderCustomerInvoiceCards(filteredInvoices);
|
||||
|
||||
if (countBadge) {
|
||||
countBadge.textContent = String(filteredInvoices.length);
|
||||
}
|
||||
}
|
||||
|
||||
function filterCustomerInvoices(value) {
|
||||
customerInvoiceSearchTerm = String(value || '');
|
||||
renderFilteredCustomerInvoices();
|
||||
}
|
||||
|
||||
function clearCustomerInvoiceSearch() {
|
||||
const input = document.getElementById('customerInvoiceSearchInput');
|
||||
customerInvoiceSearchTerm = '';
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
renderFilteredCustomerInvoices();
|
||||
}
|
||||
|
||||
async function loadCustomerInvoices(force = false) {
|
||||
const container = document.getElementById('customerInvoicesContainer');
|
||||
const countBadge = document.getElementById('customerInvoiceResultCount');
|
||||
if (!container) return;
|
||||
if (customerInvoicesLoaded && !force) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
<div class="small text-muted mt-2">Indlæser fakturaer...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await fetchCustomerInvoices(force);
|
||||
|
||||
if (!data.economic_customer_number) {
|
||||
container.innerHTML = '<div class="alert alert-info mb-0">Kunden har ikke et e-conomic kundenummer i BMC Hub endnu.</div>';
|
||||
if (countBadge) countBadge.textContent = '0';
|
||||
customerInvoicesLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
renderFilteredCustomerInvoices();
|
||||
customerInvoicesLoaded = true;
|
||||
} catch (error) {
|
||||
if (countBadge) countBadge.textContent = '0';
|
||||
container.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente fakturaer')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCustomerInvoices(force = false) {
|
||||
if (customerInvoicesData.length > 0 && !force) {
|
||||
return {
|
||||
customer_id: customerId,
|
||||
items: customerInvoicesData,
|
||||
economic_customer_number: customerData?.economic_customer_number || true
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/economic-invoices`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Kunne ikke hente fakturaer');
|
||||
}
|
||||
|
||||
customerInvoicesData = Array.isArray(data.items) ? data.items : [];
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderInvoiceDetailModal(invoice) {
|
||||
const linesHtml = renderCustomerInvoiceLineRows(invoice.lines || []);
|
||||
const notePanel = (invoice.heading || invoice.note_text) ? `
|
||||
<div class="customer-invoice-note-panel">
|
||||
${invoice.heading ? `<div class="fw-semibold mb-1">${escapeHtml(invoice.heading)}</div>` : ''}
|
||||
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="invoice-detail-summary">
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Fakturanummer</span>
|
||||
<span class="value">${escapeHtml(invoice.invoice_number || '-')}</span>
|
||||
</div>
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Status</span>
|
||||
<span class="value">${escapeHtml(invoice.source_type || '-')}</span>
|
||||
</div>
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Fakturadato</span>
|
||||
<span class="value">${escapeHtml(formatDate(invoice.invoice_date) || '-')}</span>
|
||||
</div>
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Forfald</span>
|
||||
<span class="value">${escapeHtml(formatDate(invoice.due_date) || '-')}</span>
|
||||
</div>
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Netto</span>
|
||||
<span class="value">${escapeHtml(formatCurrency(invoice.net_amount, invoice.currency || 'DKK'))}</span>
|
||||
</div>
|
||||
<div class="invoice-detail-card">
|
||||
<span class="label">Total</span>
|
||||
<span class="value">${escapeHtml(formatCurrency(invoice.total_amount, invoice.currency || 'DKK'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
${notePanel}
|
||||
${linesHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
async function openCustomerInvoiceDetail(invoiceNumber) {
|
||||
const body = document.getElementById('customerInvoiceDetailBody');
|
||||
const modalElement = document.getElementById('customerInvoiceDetailModal');
|
||||
if (!body || !modalElement) return;
|
||||
|
||||
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary"></div></div>';
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
await fetchCustomerInvoices(false);
|
||||
const matches = customerInvoicesData.filter(invoice => String(invoice.invoice_number || '') === String(invoiceNumber || ''));
|
||||
|
||||
if (!matches.length) {
|
||||
body.innerHTML = '<div class="alert alert-warning mb-0">Kunne ikke finde fakturaen i de importerede kundedata.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = matches.map(renderInvoiceDetailModal).join('<hr class="my-4">');
|
||||
} catch (error) {
|
||||
body.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente faktura')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
const container = document.getElementById('activityContainer');
|
||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||
@ -6166,11 +6801,15 @@ function renderBillingMatrix(matrix) {
|
||||
const amount = cell.amount || 0;
|
||||
const statusBadge = getStatusBadge(cell.status);
|
||||
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
|
||||
const detailButton = cell.invoice_number
|
||||
? `<button type="button" class="matrix-cell-button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(cell.invoice_number)).replace(/'/g, "\\'")}')"><i class="bi bi-receipt-cutoff"></i>Se faktura</button>`
|
||||
: '';
|
||||
|
||||
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
|
||||
<div class="d-flex flex-column align-items-center">
|
||||
<div class="fw-500">${formatDKK(amount)}</div>
|
||||
<div>${statusBadge}</div>
|
||||
${detailButton}
|
||||
</div>
|
||||
</td>`;
|
||||
}).join('');
|
||||
|
||||
@ -370,7 +370,7 @@ async def create_hardware(data: dict):
|
||||
try:
|
||||
query = """
|
||||
INSERT INTO hardware_assets (
|
||||
asset_type, brand, model, serial_number, customer_asset_id,
|
||||
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
|
||||
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
||||
status, status_reason, warranty_until, end_of_life,
|
||||
anydesk_id, anydesk_link,
|
||||
@ -378,7 +378,7 @@ async def create_hardware(data: dict):
|
||||
rental_default_start_price, rental_default_freight_price,
|
||||
rental_default_preparation_price, rental_default_operations_monthly_price
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
@ -392,6 +392,7 @@ async def create_hardware(data: dict):
|
||||
data.get("model"),
|
||||
data.get("serial_number"),
|
||||
data.get("customer_asset_id"),
|
||||
data.get("current_location_id"),
|
||||
data.get("internal_asset_id"),
|
||||
data.get("notes"),
|
||||
data.get("current_owner_type", "bmc"),
|
||||
@ -1172,4 +1173,3 @@ async def list_eset_incidents(
|
||||
"""
|
||||
result = execute_query(query, (severity_list, limit))
|
||||
return result or []
|
||||
|
||||
|
||||
@ -483,7 +483,14 @@ async def _build_customer_document_hits(customer_id: int, customer_name: str, qu
|
||||
title_hit = _count_term_hits(title, terms["query_terms"])
|
||||
|
||||
if has_query:
|
||||
if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
|
||||
# A block is only relevant when it contains every word from the user's
|
||||
# search. A query such as "sales management" must not return a block
|
||||
# containing just one of the two words.
|
||||
all_query_terms_match = all(
|
||||
term in _normalize_text_for_match(searchable)
|
||||
for term in terms["query_terms"]
|
||||
)
|
||||
if not all_query_terms_match:
|
||||
continue
|
||||
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
|
||||
continue
|
||||
@ -3106,7 +3113,9 @@ async def get_migration_wizard_v2_context(
|
||||
if summary_source_parts:
|
||||
summary_input = (
|
||||
f"Kunde: {customer_name}\n"
|
||||
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n"
|
||||
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n"
|
||||
"VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, "
|
||||
"eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\n\n"
|
||||
+ "\n\n".join(summary_source_parts)
|
||||
)
|
||||
ai_summary = await ollama_service.generate_summary(summary_input)
|
||||
@ -3120,3 +3129,35 @@ async def get_migration_wizard_v2_context(
|
||||
"invoice_hits": invoice_hits,
|
||||
"ai_summary": ai_summary,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/internet-connections/customer-documents/segments/{segment_id}")
|
||||
async def get_customer_document_segment(segment_id: int):
|
||||
"""Return the complete, indexed text block for the migration wizard."""
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
seg.id AS segment_id,
|
||||
seg.document_id,
|
||||
seg.block_index,
|
||||
seg.block_title AS title,
|
||||
seg.content,
|
||||
doc.original_filename
|
||||
FROM internet_connections_customer_document_segments seg
|
||||
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
|
||||
WHERE seg.id = %s
|
||||
AND doc.deleted_at IS NULL
|
||||
""",
|
||||
(segment_id,),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Text block not found")
|
||||
|
||||
return {
|
||||
"segment_id": int(row["segment_id"]),
|
||||
"document_id": int(row["document_id"]),
|
||||
"block_index": int(row.get("block_index") or 0),
|
||||
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
|
||||
"original_filename": row.get("original_filename") or "Tekstfil",
|
||||
"content": str(row.get("content") or ""),
|
||||
}
|
||||
|
||||
@ -107,6 +107,28 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wiz-segment-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wiz-segment-button:hover .wiz-card,
|
||||
.wiz-segment-button:focus-visible .wiz-card {
|
||||
border-color: rgba(15, 76, 117, 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
||||
}
|
||||
|
||||
.wiz-full-block {
|
||||
white-space: pre-wrap;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.wiz-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@ -256,11 +278,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="segmentModal" tabindex="-1" aria-labelledby="segmentModalTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title" id="segmentModalTitle">Tekstblok</h5>
|
||||
<div class="wiz-meta" id="segmentModalMeta"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body"><pre class="wiz-full-block" id="segmentModalContent">Henter tekstblok…</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let customerSearchTimer = null;
|
||||
let customerSearchRequest = 0;
|
||||
let customerOptions = [];
|
||||
let localUploadedDocuments = [];
|
||||
|
||||
@ -346,6 +384,7 @@
|
||||
async function onCustomerInputChanged() {
|
||||
const input = document.getElementById('customerSearchInput');
|
||||
const query = input.value.trim();
|
||||
const requestId = ++customerSearchRequest;
|
||||
document.getElementById('selectedCustomerId').value = '';
|
||||
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
|
||||
|
||||
@ -358,6 +397,8 @@
|
||||
|
||||
if (query.length < 2) return;
|
||||
const items = await searchCustomers(query);
|
||||
// Ignore a slower response from an earlier, shorter search phrase.
|
||||
if (requestId !== customerSearchRequest || input.value.trim() !== query) return;
|
||||
renderCustomerOptions(items);
|
||||
}
|
||||
|
||||
@ -457,22 +498,24 @@
|
||||
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
|
||||
} else {
|
||||
segmentList.innerHTML = segmentHits.map(item => `
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || 'Blok')}</h3>
|
||||
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score}</div>
|
||||
<button class="wiz-segment-button" type="button" onclick="openSegment(${Number(item.segment_id)})" aria-label="Vis hele tekstblokken: ${escapeHtml(item.title || 'Blok')}">
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || 'Blok')}</h3>
|
||||
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken</div>
|
||||
</div>
|
||||
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
|
||||
</div>
|
||||
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
|
||||
<div class="wiz-meta mt-2">
|
||||
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
|
||||
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
|
||||
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
|
||||
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
|
||||
</div>
|
||||
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
|
||||
</div>
|
||||
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
|
||||
<div class="wiz-meta mt-2">
|
||||
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
|
||||
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
|
||||
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
|
||||
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@ -496,6 +539,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openSegment(segmentId) {
|
||||
const modalElement = document.getElementById('segmentModal');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||
document.getElementById('segmentModalTitle').textContent = 'Tekstblok';
|
||||
document.getElementById('segmentModalMeta').textContent = '';
|
||||
document.getElementById('segmentModalContent').textContent = 'Henter tekstblok…';
|
||||
modal.show();
|
||||
try {
|
||||
const response = await fetch(`/api/v1/internet-connections/customer-documents/segments/${segmentId}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || 'Kunne ikke hente tekstblokken');
|
||||
document.getElementById('segmentModalTitle').textContent = payload.title || 'Tekstblok';
|
||||
document.getElementById('segmentModalMeta').textContent = `${payload.original_filename || 'Tekstfil'} · blok ${Number(payload.block_index || 0) + 1}`;
|
||||
document.getElementById('segmentModalContent').textContent = payload.content || 'Blokken er tom.';
|
||||
} catch (error) {
|
||||
document.getElementById('segmentModalContent').textContent = error.message || 'Kunne ikke hente tekstblokken.';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCustomerFile() {
|
||||
const fileInput = document.getElementById('customerFileInput');
|
||||
const notes = document.getElementById('customerFileNotes').value.trim();
|
||||
|
||||
@ -3,6 +3,7 @@ Invoice Error Finder API router.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@ -26,6 +27,7 @@ ALLOWED_ISSUE_STATUSES = {
|
||||
"ready_to_invoice",
|
||||
"invoiced",
|
||||
"ignored",
|
||||
"resolved",
|
||||
}
|
||||
|
||||
ISSUE_STATUS_LABELS = {
|
||||
@ -33,9 +35,10 @@ ISSUE_STATUS_LABELS = {
|
||||
"investigating": "Under undersøgelse",
|
||||
"approved_change": "Godkendt ændring",
|
||||
"error_found": "Fejl fundet",
|
||||
"ready_to_invoice": "Klar til fakturering",
|
||||
"ready_to_invoice": "Opret ordrekladde",
|
||||
"invoiced": "Faktureret",
|
||||
"ignored": "Ignoreret",
|
||||
"resolved": "Løst",
|
||||
}
|
||||
|
||||
ISSUE_TYPE_LABELS = {
|
||||
@ -67,6 +70,95 @@ class CreateOrdreDraftRequest(BaseModel):
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
def _tokenize_product_text(value: Optional[str]) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
return re.findall(r"[a-z0-9]+", value.lower())
|
||||
|
||||
|
||||
_PRODUCT_MATCH_STOP_TOKENS = {
|
||||
"periode", "period", "forbrugsperiode",
|
||||
"jan", "januar", "january",
|
||||
"feb", "februar", "february",
|
||||
"mar", "marts", "march",
|
||||
"apr", "april",
|
||||
"maj", "may",
|
||||
"jun", "juni", "june",
|
||||
"jul", "juli", "july",
|
||||
"aug", "august",
|
||||
"sep", "sept", "september",
|
||||
"okt", "oct", "october", "oktober",
|
||||
"nov", "november",
|
||||
"dec", "december",
|
||||
"til", "from", "to", "fra",
|
||||
}
|
||||
|
||||
|
||||
def _normalized_product_tokens(value: Optional[str]) -> List[str]:
|
||||
tokens = []
|
||||
for token in _tokenize_product_text(value):
|
||||
if token in _PRODUCT_MATCH_STOP_TOKENS:
|
||||
continue
|
||||
if token.isdigit() and len(token) == 4:
|
||||
continue
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _line_matches_issue_product(
|
||||
line_product_number: Optional[str],
|
||||
line_product_name: Optional[str],
|
||||
line_description: Optional[str],
|
||||
issue_product_number: Optional[str],
|
||||
issue_product_name: Optional[str],
|
||||
extra_text: Optional[str] = None,
|
||||
) -> bool:
|
||||
line_number = (line_product_number or "").strip().lower()
|
||||
issue_number = (issue_product_number or "").strip().lower()
|
||||
|
||||
issue_name = (issue_product_name or "").strip().lower()
|
||||
combined_text = " ".join(
|
||||
part.strip().lower()
|
||||
for part in [line_product_name or "", line_description or "", extra_text or ""]
|
||||
if part and part.strip()
|
||||
)
|
||||
if not issue_name:
|
||||
return bool(line_number and issue_number and line_number == issue_number)
|
||||
if not combined_text:
|
||||
return False
|
||||
|
||||
issue_tokens = _normalized_product_tokens(issue_name)
|
||||
line_tokens = _normalized_product_tokens(combined_text)
|
||||
issue_token_set = set(issue_tokens)
|
||||
line_token_set = set(line_tokens)
|
||||
shared_tokens = issue_token_set & line_token_set
|
||||
alpha_shared = {token for token in shared_tokens if any(ch.isalpha() for ch in token)}
|
||||
|
||||
if issue_name in combined_text or combined_text in issue_name:
|
||||
return True
|
||||
|
||||
if line_number and issue_number and line_number == issue_number:
|
||||
if not issue_token_set:
|
||||
return True
|
||||
if len(shared_tokens) >= max(1, min(2, len(issue_token_set))):
|
||||
return True
|
||||
|
||||
if not issue_token_set or not line_token_set:
|
||||
return False
|
||||
|
||||
coverage = len(shared_tokens) / max(1, len(issue_token_set))
|
||||
|
||||
if len(issue_token_set) == 1:
|
||||
return len(shared_tokens) >= 1
|
||||
if len(issue_token_set) == 2:
|
||||
return len(shared_tokens) >= 2
|
||||
if coverage >= 0.75:
|
||||
return True
|
||||
if coverage >= 0.5 and len(alpha_shared) >= 1:
|
||||
return True
|
||||
return len(shared_tokens) >= 3 and len(alpha_shared) >= 1
|
||||
|
||||
|
||||
def _get_user_id(request: Request) -> Optional[int]:
|
||||
value = getattr(request.state, "user_id", None)
|
||||
if value is not None:
|
||||
@ -255,9 +347,22 @@ async def list_issues(
|
||||
f"""
|
||||
SELECT
|
||||
i.*,
|
||||
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
|
||||
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
||||
sg.titel AS sag_title
|
||||
FROM invoice_error_finder_issues i
|
||||
LEFT JOIN customers c ON c.id = i.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
WHERE c.economic_customer_number IS NOT NULL
|
||||
AND inv.customer_number = c.economic_customer_number
|
||||
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
|
||||
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
|
||||
LIMIT 1
|
||||
) latest_line ON TRUE
|
||||
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
||||
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
||||
WHERE {where_clause}
|
||||
@ -289,9 +394,22 @@ async def get_issue(
|
||||
"""
|
||||
SELECT
|
||||
i.*,
|
||||
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
|
||||
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
||||
sg.titel AS sag_title
|
||||
FROM invoice_error_finder_issues i
|
||||
LEFT JOIN customers c ON c.id = i.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
WHERE c.economic_customer_number IS NOT NULL
|
||||
AND inv.customer_number = c.economic_customer_number
|
||||
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
|
||||
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
|
||||
LIMIT 1
|
||||
) latest_line ON TRUE
|
||||
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
||||
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
||||
WHERE i.id = %s
|
||||
@ -308,6 +426,479 @@ async def get_issue(
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/issues/{issue_id}/invoice-history")
|
||||
async def get_issue_invoice_history(
|
||||
issue_id: int,
|
||||
current_user: dict = Depends(require_permission("invoice_error_finder.view")),
|
||||
):
|
||||
"""Return monthly invoice history around an issue for the same customer/product."""
|
||||
try:
|
||||
issue = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.customer_id,
|
||||
i.customer_name,
|
||||
i.product_number,
|
||||
i.product_name,
|
||||
i.reference_period_start,
|
||||
i.reference_period_end
|
||||
FROM invoice_error_finder_issues i
|
||||
WHERE i.id = %s
|
||||
""",
|
||||
(issue_id,),
|
||||
)
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
customer_id = issue.get("customer_id")
|
||||
product_number = issue.get("product_number")
|
||||
reference_period_start = issue.get("reference_period_start")
|
||||
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="Issue has no mapped customer")
|
||||
if not product_number:
|
||||
raise HTTPException(status_code=400, detail="Issue has no product number")
|
||||
if not reference_period_start:
|
||||
raise HTTPException(status_code=400, detail="Issue has no reference period")
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer or not customer.get("economic_customer_number"):
|
||||
raise HTTPException(status_code=400, detail="Customer has no e-conomic mapping")
|
||||
|
||||
source_rank = {"paid": 1, "booked": 2, "unpaid": 3, "draft": 4}
|
||||
|
||||
def dedupe_invoice_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
best_by_number: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
invoice_number = row.get("source_invoice_number")
|
||||
if not invoice_number:
|
||||
continue
|
||||
current_best = best_by_number.get(invoice_number)
|
||||
candidate_rank = (
|
||||
source_rank.get(row.get("source_type"), 9),
|
||||
-(row.get("invoice_date").toordinal() if row.get("invoice_date") else 0),
|
||||
-(int(row.get("invoice_id") or 0)),
|
||||
)
|
||||
if current_best is None:
|
||||
best_by_number[invoice_number] = row
|
||||
continue
|
||||
current_rank = (
|
||||
source_rank.get(current_best.get("source_type"), 9),
|
||||
-(current_best.get("invoice_date").toordinal() if current_best.get("invoice_date") else 0),
|
||||
-(int(current_best.get("invoice_id") or 0)),
|
||||
)
|
||||
if candidate_rank < current_rank:
|
||||
best_by_number[invoice_number] = row
|
||||
|
||||
selected_ids = {row.get("invoice_id") for row in best_by_number.values() if row.get("invoice_id")}
|
||||
return [row for row in rows if row.get("invoice_id") in selected_ids]
|
||||
|
||||
def build_invoice_payloads(rows: List[Dict[str, Any]]) -> tuple[Dict[int, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
|
||||
invoices_by_id: Dict[int, Dict[str, Any]] = {}
|
||||
invoices_by_month: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
invoice_id = row["invoice_id"]
|
||||
month_key = row["month_start"].isoformat() if row.get("month_start") else None
|
||||
if invoice_id not in invoices_by_id:
|
||||
payload = {
|
||||
"invoice_id": invoice_id,
|
||||
"invoice_number": row.get("source_invoice_number"),
|
||||
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
|
||||
"total_amount": float(row.get("total_amount") or 0),
|
||||
"net_amount": float(row.get("net_amount") or 0),
|
||||
"vat_amount": float(row.get("vat_amount") or 0),
|
||||
"currency": row.get("currency") or "DKK",
|
||||
"source_type": row.get("source_type"),
|
||||
"heading": row.get("heading") or None,
|
||||
"note_text": row.get("note_text") or None,
|
||||
"lines": [],
|
||||
}
|
||||
invoices_by_id[invoice_id] = payload
|
||||
if month_key:
|
||||
invoices_by_month.setdefault(month_key, []).append(payload)
|
||||
invoices_by_id[invoice_id]["lines"].append(
|
||||
{
|
||||
"line_number": int(row.get("line_number") or 0),
|
||||
"product_number": row.get("product_number"),
|
||||
"product_name": row.get("product_name"),
|
||||
"description": row.get("description"),
|
||||
"quantity": float(row.get("quantity") or 0),
|
||||
"unit_price": float(row.get("unit_price") or 0),
|
||||
"line_net_amount": float(row.get("line_net_amount") or 0),
|
||||
}
|
||||
)
|
||||
return invoices_by_id, invoices_by_month
|
||||
|
||||
def aggregate_months(month_rows: List[Dict[str, Any]], matched_rows: List[Dict[str, Any]], invoices_by_month: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
agg_by_month: Dict[str, Dict[str, Any]] = {}
|
||||
for row in matched_rows:
|
||||
month_key = row["month_start"].isoformat() if row.get("month_start") else None
|
||||
if not month_key:
|
||||
continue
|
||||
bucket = agg_by_month.setdefault(
|
||||
month_key,
|
||||
{
|
||||
"line_count": 0,
|
||||
"total_quantity": 0.0,
|
||||
"total_amount": 0.0,
|
||||
"invoice_numbers": [],
|
||||
"invoice_dates": [],
|
||||
"descriptions": [],
|
||||
"_seen_invoice_numbers": set(),
|
||||
"_seen_descriptions": set(),
|
||||
},
|
||||
)
|
||||
bucket["line_count"] += 1
|
||||
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||
invoice_number = row.get("source_invoice_number")
|
||||
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||
bucket["invoice_numbers"].append(invoice_number)
|
||||
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||
description = row.get("description")
|
||||
if description and description not in bucket["_seen_descriptions"]:
|
||||
bucket["descriptions"].append(description)
|
||||
bucket["_seen_descriptions"].add(description)
|
||||
|
||||
month_payloads: List[Dict[str, Any]] = []
|
||||
for month_row in month_rows:
|
||||
month_key = month_row["month_start"].isoformat() if month_row.get("month_start") else None
|
||||
bucket = agg_by_month.get(month_key) or {}
|
||||
month_payloads.append(
|
||||
{
|
||||
"month_start": month_key,
|
||||
"line_count": int(bucket.get("line_count") or 0),
|
||||
"total_quantity": float(bucket.get("total_quantity") or 0),
|
||||
"total_amount": float(bucket.get("total_amount") or 0),
|
||||
"invoice_numbers": bucket.get("invoice_numbers") or [],
|
||||
"invoice_dates": bucket.get("invoice_dates") or [],
|
||||
"descriptions": bucket.get("descriptions") or [],
|
||||
"invoices": invoices_by_month.get(month_key, []),
|
||||
"is_reference_month": month_key == reference_period_start.replace(day=1).isoformat(),
|
||||
"is_fallback_history": False,
|
||||
}
|
||||
)
|
||||
return month_payloads
|
||||
|
||||
month_rows = execute_query(
|
||||
"""
|
||||
SELECT generate_series(
|
||||
date_trunc('month', %s::date) - interval '13 months',
|
||||
date_trunc('month', %s::date) + interval '2 months',
|
||||
interval '1 month'
|
||||
)::date AS month_start
|
||||
ORDER BY month_start
|
||||
""",
|
||||
(reference_period_start, reference_period_start),
|
||||
) or []
|
||||
|
||||
candidate_window_rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
inv.id AS invoice_id,
|
||||
inv.source_invoice_number,
|
||||
inv.invoice_date,
|
||||
inv.total_amount,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.currency,
|
||||
inv.source_type,
|
||||
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||
NULLIF(
|
||||
CONCAT_WS(
|
||||
E'\n',
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||
),
|
||||
''
|
||||
) AS note_text,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
WHERE inv.customer_number = %s
|
||||
AND inv.invoice_date >= date_trunc('month', %s::date) - interval '13 months'
|
||||
AND inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
|
||||
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||
""",
|
||||
(
|
||||
customer["economic_customer_number"],
|
||||
reference_period_start,
|
||||
reference_period_start,
|
||||
),
|
||||
) or []
|
||||
|
||||
window_rows = dedupe_invoice_rows(candidate_window_rows)
|
||||
matched_window_rows = [
|
||||
row for row in window_rows
|
||||
if _line_matches_issue_product(
|
||||
row.get("product_number"),
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
product_number,
|
||||
issue.get("product_name"),
|
||||
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||
)
|
||||
]
|
||||
matched_window_invoice_ids = {row["invoice_id"] for row in matched_window_rows}
|
||||
invoice_rows = [row for row in window_rows if row.get("invoice_id") in matched_window_invoice_ids]
|
||||
_, invoices_by_month = build_invoice_payloads(invoice_rows)
|
||||
months_payload = aggregate_months(month_rows, matched_window_rows, invoices_by_month)
|
||||
|
||||
fallback_month_rows: List[Dict[str, Any]] = []
|
||||
if not matched_window_invoice_ids:
|
||||
candidate_older_rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
inv.id AS invoice_id,
|
||||
inv.source_invoice_number,
|
||||
inv.invoice_date,
|
||||
inv.total_amount,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.currency,
|
||||
inv.source_type,
|
||||
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||
NULLIF(
|
||||
CONCAT_WS(
|
||||
E'\n',
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||
),
|
||||
''
|
||||
) AS note_text,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
WHERE inv.customer_number = %s
|
||||
AND inv.invoice_date < date_trunc('month', %s::date) - interval '13 months'
|
||||
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||
""",
|
||||
(
|
||||
customer["economic_customer_number"],
|
||||
reference_period_start,
|
||||
),
|
||||
) or []
|
||||
|
||||
older_rows = dedupe_invoice_rows(candidate_older_rows)
|
||||
matched_older_rows = [
|
||||
row for row in older_rows
|
||||
if _line_matches_issue_product(
|
||||
row.get("product_number"),
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
product_number,
|
||||
issue.get("product_name"),
|
||||
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||
)
|
||||
]
|
||||
top3_invoice_ids: List[int] = []
|
||||
seen_ids = set()
|
||||
for row in matched_older_rows:
|
||||
invoice_id = row.get("invoice_id")
|
||||
if invoice_id and invoice_id not in seen_ids:
|
||||
seen_ids.add(invoice_id)
|
||||
top3_invoice_ids.append(invoice_id)
|
||||
if len(top3_invoice_ids) == 3:
|
||||
break
|
||||
|
||||
fallback_invoice_rows = [row for row in older_rows if row.get("invoice_id") in set(top3_invoice_ids)]
|
||||
_, fallback_invoices_by_month = build_invoice_payloads(fallback_invoice_rows)
|
||||
|
||||
fallback_month_map: Dict[str, Dict[str, Any]] = {}
|
||||
for row in matched_older_rows:
|
||||
if row.get("invoice_id") not in top3_invoice_ids or not row.get("month_start"):
|
||||
continue
|
||||
month_key = row["month_start"].isoformat()
|
||||
bucket = fallback_month_map.setdefault(
|
||||
month_key,
|
||||
{
|
||||
"month_start": month_key,
|
||||
"line_count": 0,
|
||||
"total_quantity": 0.0,
|
||||
"total_amount": 0.0,
|
||||
"invoice_numbers": [],
|
||||
"invoice_dates": [],
|
||||
"descriptions": [],
|
||||
"invoices": fallback_invoices_by_month.get(month_key, []),
|
||||
"is_reference_month": False,
|
||||
"is_fallback_history": True,
|
||||
"_seen_invoice_numbers": set(),
|
||||
"_seen_descriptions": set(),
|
||||
},
|
||||
)
|
||||
bucket["line_count"] += 1
|
||||
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||
invoice_number = row.get("source_invoice_number")
|
||||
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||
bucket["invoice_numbers"].append(invoice_number)
|
||||
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||
description = row.get("description")
|
||||
if description and description not in bucket["_seen_descriptions"]:
|
||||
bucket["descriptions"].append(description)
|
||||
bucket["_seen_descriptions"].add(description)
|
||||
|
||||
fallback_month_rows = sorted(
|
||||
[
|
||||
{key: value for key, value in month.items() if not key.startswith("_")}
|
||||
for month in fallback_month_map.values()
|
||||
],
|
||||
key=lambda item: item["month_start"],
|
||||
)
|
||||
|
||||
if not matched_window_invoice_ids and not fallback_month_rows:
|
||||
candidate_global_rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
inv.id AS invoice_id,
|
||||
inv.source_invoice_number,
|
||||
inv.invoice_date,
|
||||
inv.total_amount,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.currency,
|
||||
inv.source_type,
|
||||
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||
NULLIF(
|
||||
CONCAT_WS(
|
||||
E'\n',
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||
),
|
||||
''
|
||||
) AS note_text,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
WHERE inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
|
||||
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||
""",
|
||||
(reference_period_start,),
|
||||
) or []
|
||||
|
||||
global_rows = dedupe_invoice_rows(candidate_global_rows)
|
||||
matched_global_rows = [
|
||||
row for row in global_rows
|
||||
if _line_matches_issue_product(
|
||||
row.get("product_number"),
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
product_number,
|
||||
issue.get("product_name"),
|
||||
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||
)
|
||||
]
|
||||
|
||||
top3_global_invoice_ids: List[int] = []
|
||||
seen_ids = set()
|
||||
for row in matched_global_rows:
|
||||
invoice_id = row.get("invoice_id")
|
||||
if invoice_id and invoice_id not in seen_ids:
|
||||
seen_ids.add(invoice_id)
|
||||
top3_global_invoice_ids.append(invoice_id)
|
||||
if len(top3_global_invoice_ids) == 3:
|
||||
break
|
||||
|
||||
global_fallback_invoice_rows = [
|
||||
row for row in global_rows if row.get("invoice_id") in set(top3_global_invoice_ids)
|
||||
]
|
||||
_, global_fallback_invoices_by_month = build_invoice_payloads(global_fallback_invoice_rows)
|
||||
|
||||
global_fallback_month_map: Dict[str, Dict[str, Any]] = {}
|
||||
for row in matched_global_rows:
|
||||
if row.get("invoice_id") not in top3_global_invoice_ids:
|
||||
continue
|
||||
if not row.get("month_start"):
|
||||
continue
|
||||
month_key = row["month_start"].isoformat()
|
||||
bucket = global_fallback_month_map.setdefault(
|
||||
month_key,
|
||||
{
|
||||
"month_start": month_key,
|
||||
"line_count": 0,
|
||||
"total_quantity": 0.0,
|
||||
"total_amount": 0.0,
|
||||
"invoice_numbers": [],
|
||||
"invoice_dates": [],
|
||||
"descriptions": [],
|
||||
"invoices": global_fallback_invoices_by_month.get(month_key, []),
|
||||
"is_reference_month": False,
|
||||
"is_fallback_history": True,
|
||||
"fallback_label": "Seneste lignende fakturaer",
|
||||
"_seen_invoice_numbers": set(),
|
||||
"_seen_descriptions": set(),
|
||||
},
|
||||
)
|
||||
bucket["line_count"] += 1
|
||||
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||
invoice_number = row.get("source_invoice_number")
|
||||
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||
bucket["invoice_numbers"].append(invoice_number)
|
||||
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||
description = row.get("description")
|
||||
if description and description not in bucket["_seen_descriptions"]:
|
||||
bucket["descriptions"].append(description)
|
||||
bucket["_seen_descriptions"].add(description)
|
||||
|
||||
fallback_month_rows = sorted(
|
||||
[
|
||||
{key: value for key, value in month.items() if not key.startswith("_")}
|
||||
for month in global_fallback_month_map.values()
|
||||
],
|
||||
key=lambda item: item["month_start"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"issue_id": issue_id,
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name") or issue.get("customer_name"),
|
||||
"economic_customer_number": customer.get("economic_customer_number"),
|
||||
"product_number": product_number,
|
||||
"product_name": issue.get("product_name"),
|
||||
"reference_period_start": reference_period_start.isoformat(),
|
||||
"months": [*fallback_month_rows, *months_payload],
|
||||
}
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("❌ Get issue invoice history failed: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch("/issues/{issue_id}/status")
|
||||
async def update_issue_status(
|
||||
issue_id: int,
|
||||
@ -320,7 +911,7 @@ async def update_issue_status(
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
resolved_at = None
|
||||
if payload.status in {"invoiced", "ignored"}:
|
||||
if payload.status in {"invoiced", "ignored", "resolved"}:
|
||||
resolved_at = "CURRENT_TIMESTAMP"
|
||||
|
||||
extra_fields = []
|
||||
@ -334,7 +925,11 @@ async def update_issue_status(
|
||||
extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s")
|
||||
extra_values.append(payload.notes)
|
||||
|
||||
resolved_sql = f"resolved_at = COALESCE(resolved_at, {resolved_at})" if resolved_at else "resolved_at = resolved_at"
|
||||
resolved_sql = (
|
||||
f"resolved_at = COALESCE(resolved_at, {resolved_at})"
|
||||
if resolved_at
|
||||
else "resolved_at = NULL"
|
||||
)
|
||||
|
||||
execute_query(
|
||||
f"""
|
||||
|
||||
@ -116,7 +116,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
||||
'error_found',
|
||||
'ready_to_invoice',
|
||||
'invoiced',
|
||||
'ignored'
|
||||
'ignored',
|
||||
'resolved'
|
||||
)),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
customer_name VARCHAR(255),
|
||||
|
||||
@ -4,6 +4,8 @@ Compares imported e-conomic invoices with subscriptions / Simply orders and
|
||||
writes issues to invoice_error_finder_issues.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@ -12,6 +14,38 @@ from app.core.database import execute_query, execute_query_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_IGNORED_PRODUCT_TEXTS = [
|
||||
"faktureringsgebyr",
|
||||
"gebyr",
|
||||
"porto",
|
||||
"fragt",
|
||||
"fragtomkostning",
|
||||
"forsendelse",
|
||||
"shipping",
|
||||
"levering",
|
||||
"engangsydelse",
|
||||
"engangsarbejde",
|
||||
"oprettelse",
|
||||
"opstartsgebyr",
|
||||
"installation",
|
||||
"installationsgebyr",
|
||||
"timeforbrug",
|
||||
"arbejdstid",
|
||||
"konsulenttimer",
|
||||
"supporttid",
|
||||
"teknikertid",
|
||||
"montørtimer",
|
||||
"projektarbejde",
|
||||
]
|
||||
|
||||
DEFAULT_IGNORED_PRODUCT_PATTERNS = [
|
||||
re.compile(r"\bcase\s*id\s*cc[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\bcase\s*id\b", re.IGNORECASE),
|
||||
re.compile(r"\bsag\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\bprojekt\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\b(?:timeforbrug|arbejdstid|konsulenttimer|supporttid|teknikertid|montørtimer)\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
class DetectionService:
|
||||
"""Detect invoice anomalies and write issues."""
|
||||
@ -23,16 +57,40 @@ class DetectionService:
|
||||
):
|
||||
self.quantity_drop_threshold = quantity_drop_threshold
|
||||
self.open_order_days_threshold = open_order_days_threshold
|
||||
self._ignored_product_texts_cache: Optional[List[str]] = None
|
||||
self._seen_issue_ids: set[int] = set()
|
||||
|
||||
def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
|
||||
"""
|
||||
Run all detection rules for the given reference month (defaults to current month).
|
||||
Returns counts per issue_type.
|
||||
Run detection rules for a specific month or sweep historical invoice months.
|
||||
Returns aggregated counts per issue_type.
|
||||
"""
|
||||
if reference_month is None:
|
||||
reference_month = date.today().replace(day=1)
|
||||
self._ignore_existing_issues()
|
||||
|
||||
if reference_month is not None:
|
||||
return self._analyze_single_month(reference_month)
|
||||
|
||||
totals = {
|
||||
"missing_line": 0,
|
||||
"open_order_not_invoiced": 0,
|
||||
"quantity_drop": 0,
|
||||
"price_change": 0,
|
||||
}
|
||||
|
||||
months = self._get_analysis_months()
|
||||
logger.info("🔍 Running historical invoice error detection for %s month(s)", len(months))
|
||||
|
||||
for month in months:
|
||||
month_counts = self._analyze_single_month(month)
|
||||
for key, value in month_counts.items():
|
||||
totals[key] = totals.get(key, 0) + int(value or 0)
|
||||
|
||||
logger.info("✅ Historical detection complete: %s", totals)
|
||||
return totals
|
||||
|
||||
def _analyze_single_month(self, reference_month: date) -> Dict[str, int]:
|
||||
previous_month = reference_month - relativedelta(months=1)
|
||||
self._seen_issue_ids = set()
|
||||
|
||||
logger.info("🔍 Running invoice error detection for %s", reference_month)
|
||||
|
||||
@ -43,9 +101,40 @@ class DetectionService:
|
||||
"price_change": self._detect_price_changes(reference_month, previous_month),
|
||||
}
|
||||
|
||||
logger.info("✅ Detection complete: %s", counts)
|
||||
self._resolve_stale_issues(reference_month)
|
||||
|
||||
logger.info("✅ Detection complete for %s: %s", reference_month, counts)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _get_analysis_months() -> List[date]:
|
||||
current_month = date.today().replace(day=1)
|
||||
bounds = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
DATE_TRUNC('month', MIN(invoice_date))::date AS first_month,
|
||||
GREATEST(
|
||||
DATE_TRUNC('month', MAX(invoice_date))::date,
|
||||
DATE_TRUNC('month', CURRENT_DATE)::date
|
||||
) AS last_month
|
||||
FROM invoice_error_finder_economic_invoices
|
||||
"""
|
||||
) or {}
|
||||
|
||||
first_month = bounds.get("first_month")
|
||||
last_month = bounds.get("last_month") or current_month
|
||||
|
||||
if not first_month:
|
||||
return [current_month]
|
||||
|
||||
month = first_month + relativedelta(months=1)
|
||||
months: List[date] = []
|
||||
while month <= last_month:
|
||||
months.append(month)
|
||||
month += relativedelta(months=1)
|
||||
|
||||
return months or [last_month]
|
||||
|
||||
def _detect_missing_lines(self, current_month: date, previous_month: date) -> int:
|
||||
"""
|
||||
Products invoiced in previous month but missing in current month for same customer.
|
||||
@ -69,7 +158,9 @@ class DetectionService:
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
SUM(line.quantity) AS quantity,
|
||||
MAX(inv.invoice_date) AS last_invoice_date
|
||||
MAX(inv.invoice_date) AS last_invoice_date,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
@ -97,6 +188,8 @@ class DetectionService:
|
||||
prev.product_key,
|
||||
prev.quantity AS expected_quantity,
|
||||
prev.last_invoice_date,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m2.hub_customer_id
|
||||
FROM previous_lines prev
|
||||
@ -118,12 +211,18 @@ class DetectionService:
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="missing_line",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_quantity=row.get("expected_quantity"),
|
||||
@ -186,6 +285,11 @@ class DetectionService:
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("subject"),
|
||||
):
|
||||
continue
|
||||
|
||||
# Check if there is any e-conomic invoice line for this customer + product recently
|
||||
has_invoice = self._has_recent_invoice_for_product(
|
||||
@ -238,7 +342,9 @@ class DetectionService:
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||
SUM(line.quantity) AS quantity
|
||||
SUM(line.quantity) AS quantity,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
@ -249,7 +355,7 @@ class DetectionService:
|
||||
GROUP BY customer_key, product_key, period
|
||||
),
|
||||
prev AS (
|
||||
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
||||
SELECT customer_key, product_key, quantity, product_name, description FROM monthly_qty WHERE period = %s
|
||||
),
|
||||
cur AS (
|
||||
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
||||
@ -259,6 +365,8 @@ class DetectionService:
|
||||
prev.product_key,
|
||||
prev.quantity AS expected_quantity,
|
||||
cur.quantity AS actual_quantity,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m.hub_customer_id
|
||||
FROM prev
|
||||
@ -286,12 +394,18 @@ class DetectionService:
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="quantity_drop",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_quantity=row["expected_quantity"],
|
||||
@ -324,7 +438,9 @@ class DetectionService:
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||
AVG(line.unit_price) AS avg_price
|
||||
AVG(line.unit_price) AS avg_price,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
@ -336,7 +452,7 @@ class DetectionService:
|
||||
GROUP BY customer_key, product_key, period
|
||||
),
|
||||
prev AS (
|
||||
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
||||
SELECT customer_key, product_key, avg_price, product_name, description FROM monthly_price WHERE period = %s
|
||||
),
|
||||
cur AS (
|
||||
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
||||
@ -346,6 +462,8 @@ class DetectionService:
|
||||
prev.product_key,
|
||||
prev.avg_price AS expected_price,
|
||||
cur.avg_price AS actual_price,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m.hub_customer_id
|
||||
FROM prev
|
||||
@ -371,6 +489,11 @@ class DetectionService:
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
expected_price = row["expected_price"]
|
||||
actual_price = row["actual_price"]
|
||||
@ -383,6 +506,7 @@ class DetectionService:
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_price=expected_price,
|
||||
@ -473,10 +597,130 @@ class DetectionService:
|
||||
|
||||
return False
|
||||
|
||||
def _ignore_existing_issues(self) -> None:
|
||||
ignored_terms = self._load_ignored_product_texts()
|
||||
if not ignored_terms:
|
||||
return
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id, product_name
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE status IN ('open', 'investigating', 'approved_change', 'error_found', 'ready_to_invoice')
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
for row in rows:
|
||||
if not self._is_ignored_product_text(row.get("product_name")):
|
||||
continue
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = 'ignored',
|
||||
ignored_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
|
||||
def _load_ignored_product_texts(self) -> List[str]:
|
||||
if self._ignored_product_texts_cache is not None:
|
||||
return self._ignored_product_texts_cache
|
||||
|
||||
setting = execute_query_single(
|
||||
"SELECT value FROM settings WHERE key = %s",
|
||||
("invoice_error_finder_ignored_product_texts",),
|
||||
) or {}
|
||||
raw_value = setting.get("value")
|
||||
|
||||
values: List[str] = []
|
||||
if raw_value:
|
||||
try:
|
||||
parsed = json.loads(str(raw_value))
|
||||
if isinstance(parsed, list):
|
||||
values = [str(item) for item in parsed]
|
||||
elif isinstance(parsed, str):
|
||||
values = [parsed]
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
values = str(raw_value).replace(";", "\n").replace(",", "\n").splitlines()
|
||||
|
||||
combined = DEFAULT_IGNORED_PRODUCT_TEXTS + values
|
||||
normalized: List[str] = []
|
||||
seen = set()
|
||||
for value in combined:
|
||||
item = self._normalize_text(value)
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
self._ignored_product_texts_cache = normalized
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().lower().split())
|
||||
|
||||
def _is_ignored_product_text(self, *values: Any) -> bool:
|
||||
ignore_terms = self._load_ignored_product_texts()
|
||||
if not ignore_terms:
|
||||
ignore_terms = []
|
||||
|
||||
normalized_values = [
|
||||
self._normalize_text(value)
|
||||
for value in values
|
||||
if self._normalize_text(value)
|
||||
]
|
||||
if not normalized_values:
|
||||
return False
|
||||
|
||||
for candidate in normalized_values:
|
||||
for term in ignore_terms:
|
||||
if term and term in candidate:
|
||||
return True
|
||||
for pattern in DEFAULT_IGNORED_PRODUCT_PATTERNS:
|
||||
if pattern.search(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_stale_issues(self, reference_month: date) -> None:
|
||||
period_start, period_end = self._month_bounds(reference_month)
|
||||
active_statuses = ("open", "investigating", "approved_change", "error_found", "ready_to_invoice")
|
||||
issue_types = ("missing_line", "open_order_not_invoiced", "quantity_drop", "price_change")
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE reference_period_start = %s
|
||||
AND reference_period_end = %s
|
||||
AND issue_type = ANY(%s::text[])
|
||||
AND status = ANY(%s::text[])
|
||||
""",
|
||||
(period_start, period_end, list(issue_types), list(active_statuses)),
|
||||
) or []
|
||||
|
||||
stale_ids = [row["id"] for row in rows if int(row["id"]) not in self._seen_issue_ids]
|
||||
if not stale_ids:
|
||||
return
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = 'resolved',
|
||||
resolved_at = COALESCE(resolved_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY(%s::int[])
|
||||
""",
|
||||
(stale_ids,),
|
||||
)
|
||||
|
||||
def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
|
||||
"""Insert a new issue or update an existing open one."""
|
||||
issue_type = kwargs["issue_type"]
|
||||
customer_id = kwargs.get("customer_id")
|
||||
customer_id = self._resolve_existing_customer_id(kwargs.get("customer_id"))
|
||||
product_number = kwargs.get("product_number")
|
||||
reference_period_start = kwargs.get("reference_period_start")
|
||||
reference_period_end = kwargs.get("reference_period_end")
|
||||
@ -513,6 +757,41 @@ class DetectionService:
|
||||
),
|
||||
)
|
||||
|
||||
if existing and existing.get("status") == "resolved":
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = %s,
|
||||
resolved_at = NULL,
|
||||
expected_quantity = COALESCE(%s, expected_quantity),
|
||||
actual_quantity = COALESCE(%s, actual_quantity),
|
||||
expected_price = COALESCE(%s, expected_price),
|
||||
actual_price = COALESCE(%s, actual_price),
|
||||
amount_impact = COALESCE(%s, amount_impact),
|
||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||
sales_order_number = COALESCE(%s, sales_order_number),
|
||||
product_name = COALESCE(%s, product_name),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
kwargs.get("status", "open"),
|
||||
kwargs.get("expected_quantity"),
|
||||
kwargs.get("actual_quantity"),
|
||||
kwargs.get("expected_price"),
|
||||
kwargs.get("actual_price"),
|
||||
kwargs.get("amount_impact"),
|
||||
kwargs.get("last_invoice_number"),
|
||||
kwargs.get("last_invoice_date"),
|
||||
kwargs.get("sales_order_number"),
|
||||
kwargs.get("product_name"),
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
self._seen_issue_ids.add(int(existing["id"]))
|
||||
return existing["id"]
|
||||
|
||||
if existing and existing.get("status") not in {"ignored", "invoiced"}:
|
||||
execute_query(
|
||||
"""
|
||||
@ -525,6 +804,7 @@ class DetectionService:
|
||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||
sales_order_number = COALESCE(%s, sales_order_number),
|
||||
product_name = COALESCE(%s, product_name),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
@ -537,9 +817,11 @@ class DetectionService:
|
||||
kwargs.get("last_invoice_number"),
|
||||
kwargs.get("last_invoice_date"),
|
||||
kwargs.get("sales_order_number"),
|
||||
kwargs.get("product_name"),
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
self._seen_issue_ids.add(int(existing["id"]))
|
||||
return existing["id"]
|
||||
|
||||
if existing and existing.get("status") == "invoiced":
|
||||
@ -568,6 +850,7 @@ class DetectionService:
|
||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||
sales_order_number = COALESCE(%s, sales_order_number),
|
||||
product_name = COALESCE(%s, product_name),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
@ -581,9 +864,11 @@ class DetectionService:
|
||||
kwargs.get("last_invoice_number"),
|
||||
kwargs.get("last_invoice_date"),
|
||||
kwargs.get("sales_order_number"),
|
||||
kwargs.get("product_name"),
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
self._seen_issue_ids.add(int(existing["id"]))
|
||||
return existing["id"]
|
||||
|
||||
row = execute_query_single(
|
||||
@ -624,6 +909,8 @@ class DetectionService:
|
||||
kwargs.get("notes"),
|
||||
),
|
||||
)
|
||||
if row and row.get("id") is not None:
|
||||
self._seen_issue_ids.add(int(row["id"]))
|
||||
return row["id"] if row else None
|
||||
|
||||
@staticmethod
|
||||
@ -638,6 +925,17 @@ class DetectionService:
|
||||
)
|
||||
return value if customer else None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_existing_customer_id(value: Any) -> Optional[int]:
|
||||
if not isinstance(value, int):
|
||||
return None
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT id FROM customers WHERE id = %s",
|
||||
(value,),
|
||||
)
|
||||
return value if customer else None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
|
||||
if hub_customer_id:
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Klar til fakturering</h6>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Ordrekladder klar</h6>
|
||||
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 p-2 rounded">
|
||||
|
||||
@ -30,9 +30,10 @@
|
||||
<option value="investigating">Under undersøgelse</option>
|
||||
<option value="approved_change">Godkendt ændring</option>
|
||||
<option value="error_found">Fejl fundet</option>
|
||||
<option value="ready_to_invoice">Klar til fakturering</option>
|
||||
<option value="ready_to_invoice">Opret ordrekladde</option>
|
||||
<option value="invoiced">Faktureret</option>
|
||||
<option value="ignored">Ignoreret</option>
|
||||
<option value="resolved">Løst</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
@ -99,12 +100,61 @@
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<span class="text-muted small" id="paginationInfo"></span>
|
||||
<div class="btn-group" id="paginationControls"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable" style="max-width: 90vw; width: 90vw;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title mb-1">Fakturahistorik</h5>
|
||||
<div class="text-muted small" id="historyModalMeta"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="historyModalStatus" class="alert d-none mb-3"></div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="historyModalActions">
|
||||
<button type="button" class="btn btn-outline-success btn-sm" id="historyApproveBtn" disabled>
|
||||
<i class="bi bi-check2 me-1"></i>Godkend
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm" id="historyInvestigatingBtn" disabled>
|
||||
<i class="bi bi-search me-1"></i>Undersøgelse
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="historyReadyBtn" disabled>
|
||||
<i class="bi bi-receipt me-1"></i>Opret ordrekladde
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm" id="historyCreateSagBtn" disabled>
|
||||
<i class="bi bi-folder-plus me-1"></i>Opret sag
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Måned</th>
|
||||
<th>Status</th>
|
||||
<th>Antal linjer</th>
|
||||
<th>Samlet mængde</th>
|
||||
<th>Samlet beløb</th>
|
||||
<th>Fakturaer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyModalBody">
|
||||
<tr><td colspan="6" class="text-muted text-center py-4">Vælg en fejl for at se historik</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentOffset = 0;
|
||||
const pageSize = 100;
|
||||
let currentHistoryIssueId = null;
|
||||
|
||||
const issueTypeLabels = {
|
||||
missing_line: 'Manglende varelinje',
|
||||
@ -119,9 +169,10 @@ const statusLabels = {
|
||||
investigating: 'Under undersøgelse',
|
||||
approved_change: 'Godkendt ændring',
|
||||
error_found: 'Fejl fundet',
|
||||
ready_to_invoice: 'Klar til fakturering',
|
||||
ready_to_invoice: 'Opret ordrekladde',
|
||||
invoiced: 'Faktureret',
|
||||
ignored: 'Ignoreret'
|
||||
ignored: 'Ignoreret',
|
||||
resolved: 'Løst'
|
||||
};
|
||||
|
||||
function escapeHtml(text) {
|
||||
@ -143,6 +194,11 @@ function formatNumber(value) {
|
||||
return new Intl.NumberFormat('da-DK').format(value);
|
||||
}
|
||||
|
||||
function formatMonth(value) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat('da-DK', { year: 'numeric', month: 'short' }).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
open: 'bg-danger',
|
||||
@ -151,7 +207,8 @@ function statusBadge(status) {
|
||||
error_found: 'bg-danger',
|
||||
ready_to_invoice: 'bg-success',
|
||||
invoiced: 'bg-secondary',
|
||||
ignored: 'bg-light text-dark'
|
||||
ignored: 'bg-light text-dark',
|
||||
resolved: 'bg-secondary-subtle text-dark'
|
||||
};
|
||||
const cls = map[status] || 'bg-light text-dark';
|
||||
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
|
||||
@ -198,7 +255,7 @@ async function loadIssues() {
|
||||
<tr>
|
||||
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
|
||||
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
|
||||
<td>${escapeHtml(issue.product_name || issue.product_number || '-')}</td>
|
||||
<td>${escapeHtml(issue.resolved_product_name || issue.product_name || issue.product_number || '-')}</td>
|
||||
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
|
||||
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
|
||||
<td>${issue.reference_period_start || '-'}</td>
|
||||
@ -222,13 +279,16 @@ async function loadIssues() {
|
||||
}
|
||||
|
||||
function renderActionButtons(issue) {
|
||||
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</span>';
|
||||
if (issue.status === 'invoiced') return '<span class="text-muted small">Faktureret</span>';
|
||||
const historyButton = `<button class="btn btn-outline-dark" title="Se 13 måneder tilbage og 2 frem" onclick="showInvoiceHistory(${issue.id})"><i class="bi bi-clock-history"></i></button>`;
|
||||
if (issue.status === 'ignored') return `${historyButton}<span class="text-muted small ms-2">Ignoreret</span>`;
|
||||
if (issue.status === 'invoiced') return `${historyButton}<span class="text-muted small ms-2">Faktureret</span>`;
|
||||
if (issue.status === 'resolved') return `${historyButton}<span class="text-muted small ms-2">Løst</span>`;
|
||||
|
||||
return `
|
||||
${historyButton}
|
||||
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
|
||||
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
|
||||
<button class="btn btn-outline-primary" title="Klar til fakturering" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
|
||||
<button class="btn btn-outline-primary" title="Opret ordrekladde" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
|
||||
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
|
||||
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
|
||||
`;
|
||||
@ -257,7 +317,8 @@ function goToPage(page) {
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
async function updateStatus(issueId, status) {
|
||||
async function updateStatus(issueId, status, options = {}) {
|
||||
const { target = 'page', successMessage = 'Status opdateret' } = options;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
|
||||
method: 'PATCH',
|
||||
@ -265,14 +326,23 @@ async function updateStatus(issueId, status) {
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (!res.ok) throw new Error('Opdatering fejlede');
|
||||
showStatus('Status opdateret', 'success');
|
||||
if (target === 'history') {
|
||||
showHistoryStatus(successMessage, 'success');
|
||||
} else {
|
||||
showStatus(successMessage, 'success');
|
||||
}
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
if (target === 'history') {
|
||||
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||
} else {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createSag(issueId) {
|
||||
async function createSag(issueId, options = {}) {
|
||||
const { target = 'page' } = options;
|
||||
const titel = prompt('Titel på sag:');
|
||||
if (!titel) return;
|
||||
try {
|
||||
@ -283,10 +353,18 @@ async function createSag(issueId) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
|
||||
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
||||
if (target === 'history') {
|
||||
showHistoryStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
||||
} else {
|
||||
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
||||
}
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
if (target === 'history') {
|
||||
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||
} else {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,6 +406,162 @@ function showStatus(message, type) {
|
||||
setTimeout(() => el.classList.add('d-none'), 4000);
|
||||
}
|
||||
|
||||
function showHistoryStatus(message, type) {
|
||||
const el = document.getElementById('historyModalStatus');
|
||||
el.className = `alert alert-${type} mb-3`;
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function setHistoryActionState(enabled) {
|
||||
['historyApproveBtn', 'historyInvestigatingBtn', 'historyReadyBtn', 'historyCreateSagBtn']
|
||||
.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoiceLines(lines) {
|
||||
if (!lines || lines.length === 0) {
|
||||
return '<div class="text-muted small">Ingen linjer fundet</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Linje</th>
|
||||
<th>Varenr</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Antal</th>
|
||||
<th>Pris</th>
|
||||
<th>Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${lines.map(line => `
|
||||
<tr>
|
||||
<td>${formatNumber(line.line_number)}</td>
|
||||
<td>${escapeHtml(line.product_number || '-')}</td>
|
||||
<td class="text-truncate" style="max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(line.description || line.product_name || '-')}">${escapeHtml(line.description || line.product_name || '-')}</td>
|
||||
<td>${formatNumber(line.quantity)}</td>
|
||||
<td>${formatCurrency(line.unit_price)}</td>
|
||||
<td>${formatCurrency(line.line_net_amount)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInvoiceNotes(invoice) {
|
||||
const parts = [invoice.heading, invoice.note_text].filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="border rounded bg-light p-2 mb-2 small">
|
||||
${parts.map(part => `<div>${escapeHtml(part)}</div>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInvoiceCard(invoice, monthKey, index) {
|
||||
const collapseId = `invoice-lines-${monthKey}-${index}`;
|
||||
return `
|
||||
<div class="border rounded p-2 mb-2 bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="text-truncate small" style="min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(`${invoice.invoice_number || '-'} (${invoice.invoice_date || '-'}) · ${invoice.heading || invoice.source_type || ''} · Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}`)}">
|
||||
<strong>${escapeHtml(invoice.invoice_number || '-')}</strong>
|
||||
<span class="text-muted">(${escapeHtml(invoice.invoice_date || '-')})</span>
|
||||
<span class="text-muted">· ${escapeHtml(invoice.heading || invoice.source_type || '')}</span>
|
||||
<span>· Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false">
|
||||
Vis linjer
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="${collapseId}">
|
||||
${renderInvoiceNotes(invoice)}
|
||||
${renderInvoiceLines(invoice.lines)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showInvoiceHistory(issueId) {
|
||||
const body = document.getElementById('historyModalBody');
|
||||
const meta = document.getElementById('historyModalMeta');
|
||||
const status = document.getElementById('historyModalStatus');
|
||||
currentHistoryIssueId = issueId;
|
||||
setHistoryActionState(true);
|
||||
status.classList.add('d-none');
|
||||
meta.textContent = '';
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Indlæser historik...</td></tr>';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/invoice-history`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente historik');
|
||||
|
||||
meta.textContent = `${data.customer_name || 'Ukendt kunde'} · ${data.product_name || data.product_number || '-'}`;
|
||||
|
||||
body.innerHTML = data.months.map(row => {
|
||||
const stateBadge = row.line_count > 0
|
||||
? '<span class="badge bg-success">Faktureret</span>'
|
||||
: '<span class="badge bg-danger">Manglende</span>';
|
||||
const monthBadge = row.is_reference_month
|
||||
? ' <span class="badge bg-warning text-dark">Reference</span>'
|
||||
: row.is_fallback_history
|
||||
? ` <span class="badge bg-info text-dark">${escapeHtml(row.fallback_label || 'Seneste tidligere faktura')}</span>`
|
||||
: '';
|
||||
const invoiceList = row.invoices && row.invoices.length
|
||||
? row.invoices.map((invoice, index) => renderInvoiceCard(invoice, row.month_start || 'month', index)).join('')
|
||||
: '<span class="text-muted">Ingen faktura</span>';
|
||||
|
||||
return `
|
||||
<tr class="${row.is_reference_month ? 'table-warning' : row.is_fallback_history ? 'table-info' : ''}">
|
||||
<td>${formatMonth(row.month_start)}${monthBadge}</td>
|
||||
<td>${stateBadge}</td>
|
||||
<td>${formatNumber(row.line_count)}</td>
|
||||
<td>${formatNumber(row.total_quantity)}</td>
|
||||
<td>${formatCurrency(row.total_amount)}</td>
|
||||
<td>${invoiceList}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Kunne ikke hente historik</td></tr>';
|
||||
showHistoryStatus(`Fejl: ${err.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('historyApproveBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'approved_change', { target: 'history', successMessage: 'Fejlen er godkendt' });
|
||||
});
|
||||
|
||||
document.getElementById('historyInvestigatingBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'investigating', { target: 'history', successMessage: 'Fejlen er sat til undersøgelse' });
|
||||
});
|
||||
|
||||
document.getElementById('historyReadyBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'ready_to_invoice', { target: 'history', successMessage: 'Ordrekladde markeret som klar' });
|
||||
});
|
||||
|
||||
document.getElementById('historyCreateSagBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
createSag(currentHistoryIssueId, { target: 'history' });
|
||||
});
|
||||
|
||||
loadIssues();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -40,7 +40,8 @@ from app.modules.locations.models.schemas import (
|
||||
Service, ServiceCreate, ServiceUpdate,
|
||||
Capacity, CapacityCreate, CapacityUpdate,
|
||||
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
||||
LocationWizardCreateRequest, LocationWizardCreateResponse
|
||||
LocationWizardCreateRequest, LocationWizardCreateResponse,
|
||||
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@ -164,15 +165,21 @@ async def create_location(request: Request):
|
||||
logger.warning("⚠️ Invalid location payload")
|
||||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
# Check for duplicate name
|
||||
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
||||
existing = execute_query(check_query, (data.name,))
|
||||
# Names only need to be unique within the same customer and hierarchy.
|
||||
check_query = """
|
||||
SELECT id FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
|
||||
|
||||
if existing:
|
||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location with name '{data.name}' already exists"
|
||||
detail=f"Location with name '{data.name}' already exists under the same customer/location"
|
||||
)
|
||||
|
||||
if data.customer_id is not None:
|
||||
@ -201,9 +208,9 @@ async def create_location(request: Request):
|
||||
INSERT INTO locations_locations (
|
||||
name, location_type, parent_location_id, customer_id, address_street, address_city,
|
||||
address_postal_code, address_country, latitude, longitude,
|
||||
phone, email, notes, is_active, created_at, updated_at
|
||||
phone, email, notes, is_active, has_cross_field, created_at, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
@ -221,7 +228,8 @@ async def create_location(request: Request):
|
||||
data.phone,
|
||||
data.email,
|
||||
data.notes,
|
||||
data.is_active
|
||||
data.is_active,
|
||||
data.has_cross_field if data.location_type == 'rum' else False
|
||||
)
|
||||
|
||||
result = execute_query(insert_query, params)
|
||||
@ -426,32 +434,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
def _normalize_name(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
def _name_exists(value: str) -> bool:
|
||||
def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
|
||||
normalized = _normalize_name(value)
|
||||
if normalized in reserved_names:
|
||||
scope_key = (normalized, parent_location_id, customer_id)
|
||||
if scope_key in reserved_names:
|
||||
return True
|
||||
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
||||
existing = execute_query(check_query, (value,))
|
||||
check_query = """
|
||||
SELECT 1 FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
existing = execute_query(check_query, (value, parent_location_id, customer_id))
|
||||
return bool(existing)
|
||||
|
||||
def _reserve_name(value: str) -> None:
|
||||
def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
|
||||
normalized = _normalize_name(value)
|
||||
if normalized:
|
||||
reserved_names.add(normalized)
|
||||
reserved_names.add((normalized, parent_location_id, customer_id))
|
||||
|
||||
def _resolve_unique_name(base_name: str) -> str:
|
||||
def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
|
||||
if not auto_suffix:
|
||||
_reserve_name(base_name)
|
||||
_reserve_name(base_name, parent_location_id, customer_id)
|
||||
return base_name
|
||||
base_name = base_name.strip()
|
||||
if not _name_exists(base_name):
|
||||
_reserve_name(base_name)
|
||||
if not _name_exists(base_name, parent_location_id, customer_id):
|
||||
_reserve_name(base_name, parent_location_id, customer_id)
|
||||
return base_name
|
||||
suffix = 2
|
||||
while True:
|
||||
candidate = f"{base_name} ({suffix})"
|
||||
if not _name_exists(candidate):
|
||||
_reserve_name(candidate)
|
||||
if not _name_exists(candidate, parent_location_id, customer_id):
|
||||
_reserve_name(candidate, parent_location_id, customer_id)
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
@ -492,7 +507,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
raise HTTPException(status_code=500, detail="Failed to create location")
|
||||
return Location(**result[0])
|
||||
|
||||
resolved_root_name = _resolve_unique_name(root.name)
|
||||
resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
|
||||
root_location = insert_location_record(
|
||||
name=resolved_root_name,
|
||||
location_type=root.location_type,
|
||||
@ -522,7 +537,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
room_ids: List[int] = []
|
||||
|
||||
for floor in data.floors:
|
||||
resolved_floor_name = _resolve_unique_name(floor.name)
|
||||
resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
|
||||
floor_location = insert_location_record(
|
||||
name=resolved_floor_name,
|
||||
location_type=floor.location_type,
|
||||
@ -548,7 +563,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
))
|
||||
|
||||
for room in floor.rooms:
|
||||
resolved_room_name = _resolve_unique_name(room.name)
|
||||
resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
|
||||
room_location = insert_location_record(
|
||||
name=resolved_room_name,
|
||||
location_type=room.location_type,
|
||||
@ -594,6 +609,187 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
||||
# ============================================================================
|
||||
|
||||
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
|
||||
|
||||
|
||||
@router.get('/locations/cross-fields', response_model=List[CrossField])
|
||||
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
|
||||
where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE'
|
||||
params: tuple = ()
|
||||
if location_id:
|
||||
where += ' AND cf.location_id = %s'
|
||||
params = (location_id,)
|
||||
fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.name''', params) or []
|
||||
for field in fields:
|
||||
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
|
||||
return [CrossField(**field) for field in fields]
|
||||
|
||||
|
||||
@router.get('/locations/cross-field-ports')
|
||||
async def list_cross_field_ports():
|
||||
return execute_query('''
|
||||
SELECT p.id, p.port_number, cf.name AS cross_field_name,
|
||||
l.name AS location_name, l.id AS location_id
|
||||
FROM locations_cross_field_ports p
|
||||
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
|
||||
JOIN locations_locations l ON l.id = cf.location_id
|
||||
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
||||
WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL
|
||||
ORDER BY l.name, cf.name, p.port_number
|
||||
''') or []
|
||||
|
||||
|
||||
@router.post('/locations/cross-fields', response_model=CrossField, status_code=201)
|
||||
async def create_cross_field(data: CrossFieldCreate):
|
||||
location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or []
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
|
||||
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
|
||||
raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt')
|
||||
try:
|
||||
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, notes) VALUES (%s, %s, %s, %s) RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.notes)) or []
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
|
||||
field = created[0]
|
||||
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(1, %s)''', (field['id'], data.port_count))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if 'unique' in str(exc).lower():
|
||||
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||
raise
|
||||
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (field['id'],)) or []
|
||||
return CrossField(**field)
|
||||
|
||||
|
||||
@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField)
|
||||
async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate):
|
||||
field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or []
|
||||
if not field_rows:
|
||||
raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet')
|
||||
field = field_rows[0]
|
||||
changes = data.model_dump(exclude_unset=True)
|
||||
requested_ports = changes.pop('port_count', None)
|
||||
if requested_ports is not None and requested_ports < field['port_count']:
|
||||
raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt')
|
||||
if changes:
|
||||
assignments = ', '.join(f'{column} = %s' for column in changes)
|
||||
try:
|
||||
updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or []
|
||||
field = updated[0]
|
||||
except Exception as exc:
|
||||
if 'unique' in str(exc).lower():
|
||||
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||
raise
|
||||
if requested_ports and requested_ports > field['port_count']:
|
||||
execute_query('''INSERT INTO locations_cross_field_ports (cross_field_id, port_number) SELECT %s, generate_series(%s, %s)''', (cross_field_id, field['port_count'] + 1, requested_ports))
|
||||
field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0]
|
||||
field['ports'] = execute_query('''SELECT id, port_number, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_number''', (cross_field_id,)) or []
|
||||
return CrossField(**field)
|
||||
|
||||
|
||||
def _outlet_location(location_id: int) -> dict:
|
||||
rows = execute_query(
|
||||
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
|
||||
(location_id,),
|
||||
) or []
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
|
||||
location = dict(rows[0])
|
||||
if location.get('location_type') not in _OUTLET_LOCATION_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum")
|
||||
return location
|
||||
|
||||
|
||||
_OUTLET_SELECT = """
|
||||
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
|
||||
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
|
||||
FROM locations_wall_outlets o
|
||||
JOIN locations_locations l ON l.id = o.location_id
|
||||
LEFT JOIN customers c ON c.id = l.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
||||
FROM locations_locations WHERE id = l.id
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.name, parent.parent_location_id,
|
||||
parent.name || ' > ' || ancestors.hierarchy_path
|
||||
FROM locations_locations parent
|
||||
JOIN ancestors ON ancestors.parent_location_id = parent.id
|
||||
)
|
||||
SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1
|
||||
) path ON TRUE
|
||||
"""
|
||||
|
||||
|
||||
@router.get('/locations/outlets', response_model=List[WallOutlet])
|
||||
async def list_wall_outlets(
|
||||
q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1),
|
||||
status: Optional[str] = Query(None), include_inactive: bool = Query(False),
|
||||
):
|
||||
where = ['o.deleted_at IS NULL']
|
||||
params: List[Any] = []
|
||||
if not include_inactive:
|
||||
where.append('o.is_active = TRUE')
|
||||
if location_id:
|
||||
where.append('o.location_id = %s')
|
||||
params.append(location_id)
|
||||
if status:
|
||||
where.append('o.status = %s')
|
||||
params.append(status)
|
||||
if q:
|
||||
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
|
||||
params.extend([f'%{q.strip()}%'] * 7)
|
||||
rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or []
|
||||
return [WallOutlet(**row) for row in rows]
|
||||
|
||||
|
||||
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
|
||||
async def create_wall_outlet(data: WallOutletCreate):
|
||||
_outlet_location(data.location_id)
|
||||
try:
|
||||
rows = execute_query(
|
||||
"""INSERT INTO locations_wall_outlets
|
||||
(location_id, outlet_number, category, patch_panel, patch_port, cross_field_port_id, switch_name, switch_port, status, notes, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
||||
(data.location_id, data.outlet_number.strip(), data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
|
||||
) or []
|
||||
except Exception as exc:
|
||||
if 'unique' in str(exc).lower():
|
||||
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
|
||||
raise
|
||||
outlet_id = rows[0]['id']
|
||||
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
|
||||
return WallOutlet(**result[0])
|
||||
|
||||
|
||||
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
|
||||
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
||||
changes = data.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
|
||||
if 'outlet_number' in changes:
|
||||
changes['outlet_number'] = changes['outlet_number'].strip()
|
||||
fields = ', '.join(f'{field} = %s' for field in changes)
|
||||
try:
|
||||
rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or []
|
||||
except Exception as exc:
|
||||
if 'unique' in str(exc).lower():
|
||||
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
|
||||
raise
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
|
||||
return WallOutlet(**result[0])
|
||||
|
||||
|
||||
@router.delete('/locations/outlets/{outlet_id}')
|
||||
async def delete_wall_outlet(outlet_id: int):
|
||||
rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or []
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||
return {'status': 'deleted', 'id': outlet_id}
|
||||
|
||||
@router.get("/locations/{id}", response_model=LocationDetail)
|
||||
async def get_location(id: int):
|
||||
"""
|
||||
@ -645,6 +841,12 @@ async def get_location(id: int):
|
||||
capacity_result = execute_query(capacity_query, (id,))
|
||||
capacity = [dict(row) for row in capacity_result] if capacity_result else []
|
||||
|
||||
outlet_result = execute_query(
|
||||
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
|
||||
(id,),
|
||||
)
|
||||
wall_outlets = [dict(row) for row in (outlet_result or [])]
|
||||
|
||||
# Build hierarchy breadcrumb (ancestors from root to parent)
|
||||
hierarchy_query = """
|
||||
WITH RECURSIVE ancestors AS (
|
||||
@ -693,7 +895,8 @@ async def get_location(id: int):
|
||||
contacts=contacts,
|
||||
hours=hours,
|
||||
services=services,
|
||||
capacity=capacity
|
||||
capacity=capacity,
|
||||
wall_outlets=wall_outlets,
|
||||
)
|
||||
|
||||
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
|
||||
@ -740,15 +943,26 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
|
||||
old_location = Location(**existing[0])
|
||||
|
||||
# Check for duplicate name if name is being updated
|
||||
if data.name is not None and data.name != old_location.name:
|
||||
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL"
|
||||
dup_check = execute_query(dup_query, (data.name, id))
|
||||
# Check the resulting name/customer/parent scope, including when only
|
||||
# customer or parent is changed.
|
||||
if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
|
||||
candidate_name = data.name if data.name is not None else old_location.name
|
||||
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
|
||||
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
|
||||
dup_query = """
|
||||
SELECT id FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND id != %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
|
||||
if dup_check:
|
||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
||||
logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location with name '{data.name}' already exists"
|
||||
detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
|
||||
)
|
||||
|
||||
# Build UPDATE query with only provided fields
|
||||
@ -770,7 +984,8 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
'phone': 'phone',
|
||||
'email': 'email',
|
||||
'notes': 'notes',
|
||||
'is_active': 'is_active'
|
||||
'is_active': 'is_active',
|
||||
'has_cross_field': 'has_cross_field'
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@ -792,6 +1007,30 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
status_code=400,
|
||||
detail="parent_location_id does not exist"
|
||||
)
|
||||
descendant_check = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id
|
||||
FROM locations_locations
|
||||
WHERE parent_location_id = %s AND deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.id
|
||||
FROM locations_locations l
|
||||
JOIN descendants d ON l.parent_location_id = d.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM descendants WHERE id = %s LIMIT 1
|
||||
""",
|
||||
(id, value),
|
||||
)
|
||||
if descendant_check:
|
||||
logger.warning("⚠️ parent_location_id cannot reference a descendant")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="parent_location_id cannot reference a descendant"
|
||||
)
|
||||
if key == 'customer_id':
|
||||
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
||||
customer = execute_query(customer_query, (value,))
|
||||
@ -809,6 +1048,10 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
status_code=400,
|
||||
detail=f"location_type must be one of: {', '.join(allowed_types)}"
|
||||
)
|
||||
if key == 'has_cross_field' and value:
|
||||
resulting_type = data.location_type or old_location.location_type
|
||||
if resulting_type != 'rum':
|
||||
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
|
||||
update_parts.append(f"{db_column} = %s")
|
||||
params.append(value)
|
||||
update_data[key] = value
|
||||
|
||||
@ -57,6 +57,100 @@ LOCATION_TYPES = [
|
||||
{"value": "vehicle", "label": "Køretøj"},
|
||||
]
|
||||
|
||||
LOCATION_TYPE_LABELS = {
|
||||
"kompleks": "Kompleks",
|
||||
"bygning": "Bygning",
|
||||
"etage": "Etage",
|
||||
"customer_site": "Kundesite",
|
||||
"rum": "Rum",
|
||||
"kantine": "Kantine",
|
||||
"moedelokale": "Mødelokale",
|
||||
"vehicle": "Køretøj",
|
||||
}
|
||||
|
||||
|
||||
def get_location_type_label(location_type: Optional[str]) -> str:
|
||||
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
|
||||
|
||||
|
||||
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
|
||||
exclude_ids = []
|
||||
if exclude_id is not None:
|
||||
exclude_tree = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id
|
||||
FROM locations_locations
|
||||
WHERE id = %s
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.id
|
||||
FROM locations_locations l
|
||||
JOIN descendants d ON l.parent_location_id = d.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
""",
|
||||
(exclude_id,),
|
||||
)
|
||||
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
|
||||
|
||||
parent_locations = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE location_tree AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
location_type,
|
||||
parent_location_id,
|
||||
customer_id,
|
||||
is_active,
|
||||
name::text AS hierarchy_path,
|
||||
0 AS depth
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND parent_location_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
l.id,
|
||||
l.name,
|
||||
l.location_type,
|
||||
l.parent_location_id,
|
||||
l.customer_id,
|
||||
l.is_active,
|
||||
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
|
||||
lt.depth + 1 AS depth
|
||||
FROM locations_locations l
|
||||
JOIN location_tree lt ON l.parent_location_id = lt.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
location_type,
|
||||
parent_location_id,
|
||||
customer_id,
|
||||
is_active,
|
||||
hierarchy_path,
|
||||
depth
|
||||
FROM location_tree
|
||||
WHERE is_active = true
|
||||
ORDER BY hierarchy_path
|
||||
LIMIT 2000
|
||||
"""
|
||||
)
|
||||
|
||||
choices = []
|
||||
for row in parent_locations or []:
|
||||
if row.get("id") in exclude_ids:
|
||||
continue
|
||||
row["type_label"] = get_location_type_label(row.get("location_type"))
|
||||
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
|
||||
choices.append(row)
|
||||
return choices
|
||||
|
||||
|
||||
def render_template(template_name: str, **context) -> str:
|
||||
"""
|
||||
@ -167,7 +261,7 @@ def list_locations_view(
|
||||
"""
|
||||
query_params.extend([limit, skip])
|
||||
|
||||
locations = execute_query(query, tuple(query_params))
|
||||
locations = execute_query(query, tuple(query_params)) or []
|
||||
|
||||
def build_tree(items: list) -> list:
|
||||
nodes = {}
|
||||
@ -247,7 +341,10 @@ def list_locations_view(
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/app/locations/create", response_class=HTMLResponse)
|
||||
def create_location_view():
|
||||
def create_location_view(
|
||||
parent_location_id: Optional[int] = Query(None, gt=0),
|
||||
customer_id: Optional[int] = Query(None, gt=0),
|
||||
):
|
||||
"""
|
||||
Render the location creation form.
|
||||
|
||||
@ -268,14 +365,11 @@ def create_location_view():
|
||||
try:
|
||||
logger.info("🆕 Rendering create location form")
|
||||
|
||||
# Query parent locations
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
parent_locations = get_parent_location_choices() or []
|
||||
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
|
||||
|
||||
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
|
||||
customer_id = selected_parent.get("customer_id")
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
@ -295,7 +389,10 @@ def create_location_view():
|
||||
cancel_url="/app/locations",
|
||||
location_types=LOCATION_TYPES,
|
||||
parent_locations=parent_locations,
|
||||
customers=customers,
|
||||
customers=customers or [],
|
||||
selected_parent_id=parent_location_id,
|
||||
selected_customer_id=customer_id,
|
||||
selected_parent=selected_parent,
|
||||
location=None, # No location data for create form
|
||||
)
|
||||
|
||||
@ -321,13 +418,7 @@ def location_wizard_view():
|
||||
try:
|
||||
logger.info("🧭 Rendering location wizard")
|
||||
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
parent_locations = get_parent_location_choices()
|
||||
|
||||
customers = execute_query("""
|
||||
SELECT id, name, email, phone
|
||||
@ -356,7 +447,47 @@ def location_wizard_view():
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. GET /app/locations/{id} - Detail view (HTML)
|
||||
# 3. GET /app/locations/outlets - Wall outlet overview
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/app/locations/outlets", response_class=HTMLResponse)
|
||||
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
|
||||
try:
|
||||
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
|
||||
params = []
|
||||
if status:
|
||||
where.append("o.status = %s")
|
||||
params.append(status)
|
||||
if q:
|
||||
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
|
||||
params.extend([f"%{q.strip()}%"] * 7)
|
||||
outlets = execute_query(f"""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
||||
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
|
||||
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
|
||||
FROM locations_wall_outlets o
|
||||
JOIN locations_locations l ON l.id = o.location_id
|
||||
LEFT JOIN customers c ON c.id = l.customer_id
|
||||
LEFT JOIN tree ON tree.id = l.id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY tree.hierarchy_path, o.outlet_number
|
||||
""", tuple(params)) or []
|
||||
return HTMLResponse(render_template(
|
||||
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.error("Error rendering wall outlets overview: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. GET /app/locations/{id} - Detail view (HTML)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/app/locations/{id}", response_class=HTMLResponse)
|
||||
@ -473,6 +604,35 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
(id,)
|
||||
)
|
||||
|
||||
wall_outlets = execute_query(
|
||||
"""
|
||||
SELECT id, outlet_number, category, patch_panel, patch_port, switch_name, switch_port, status, notes, is_active
|
||||
FROM locations_wall_outlets
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY outlet_number
|
||||
""",
|
||||
(id,),
|
||||
)
|
||||
|
||||
cross_fields = execute_query(
|
||||
"""SELECT id, name, port_count, notes, is_active
|
||||
FROM locations_cross_fields
|
||||
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
||||
ORDER BY name""",
|
||||
(id,),
|
||||
)
|
||||
for cross_field in cross_fields or []:
|
||||
cross_field["ports"] = execute_query(
|
||||
"""SELECT p.id, p.port_number, p.is_active,
|
||||
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
|
||||
l.name AS outlet_location_name
|
||||
FROM locations_cross_field_ports p
|
||||
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
||||
LEFT JOIN locations_locations l ON l.id = o.location_id
|
||||
WHERE p.cross_field_id = %s ORDER BY p.port_number""",
|
||||
(cross_field["id"],),
|
||||
) or []
|
||||
|
||||
audit_log = execute_query(
|
||||
"""
|
||||
SELECT id, location_id, event_type, user_id, changes, created_at
|
||||
@ -490,6 +650,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
location["services"] = services or []
|
||||
location["capacity"] = capacity or []
|
||||
location["hardware"] = hardware or []
|
||||
location["wall_outlets"] = wall_outlets or []
|
||||
location["cross_fields"] = cross_fields or []
|
||||
location["audit_log"] = audit_log or []
|
||||
|
||||
# Query customers
|
||||
@ -505,6 +667,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
||||
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
||||
|
||||
customers = customers or []
|
||||
|
||||
# Render template with context
|
||||
html = render_template(
|
||||
"modules/locations/templates/detail.html",
|
||||
@ -555,14 +719,11 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
||||
|
||||
location = location[0] # Get first result
|
||||
|
||||
# Query parent locations (exclude self)
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE is_active = true AND id != %s
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""", (id,))
|
||||
parent_locations = get_parent_location_choices(exclude_id=id) or []
|
||||
selected_parent = next(
|
||||
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
|
||||
None,
|
||||
)
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
@ -584,7 +745,8 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
||||
cancel_url=f"/app/locations/{id}",
|
||||
location_types=LOCATION_TYPES,
|
||||
parent_locations=parent_locations,
|
||||
customers=customers,
|
||||
customers=customers or [],
|
||||
selected_parent=selected_parent,
|
||||
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
|
||||
)
|
||||
|
||||
@ -625,6 +787,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
||||
latitude = %s,
|
||||
longitude = %s,
|
||||
notes = %s,
|
||||
has_cross_field = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (
|
||||
@ -642,6 +805,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
||||
float(form.get("latitude")) if form.get("latitude") else None,
|
||||
float(form.get("longitude")) if form.get("longitude") else None,
|
||||
form.get("notes"),
|
||||
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
|
||||
id
|
||||
))
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ from decimal import Decimal
|
||||
|
||||
class LocationBase(BaseModel):
|
||||
"""Shared fields for location models"""
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
|
||||
location_type: str = Field(
|
||||
...,
|
||||
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
||||
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = Field(True, description="Whether location is active")
|
||||
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
|
||||
|
||||
@field_validator('location_type')
|
||||
@classmethod
|
||||
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
has_cross_field: Optional[bool] = None
|
||||
|
||||
@field_validator('location_type')
|
||||
@classmethod
|
||||
@ -101,6 +103,99 @@ class Location(LocationBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NETWORK WALL OUTLET MODELS
|
||||
# ============================================================================
|
||||
|
||||
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
|
||||
|
||||
|
||||
class WallOutletBase(BaseModel):
|
||||
location_id: int = Field(..., ge=1)
|
||||
outlet_number: str = Field(..., min_length=1, max_length=100)
|
||||
category: Optional[str] = Field(None, max_length=50)
|
||||
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||
patch_port: Optional[str] = Field(None, max_length=100)
|
||||
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
||||
switch_name: Optional[str] = Field(None, max_length=255)
|
||||
switch_port: Optional[str] = Field(None, max_length=100)
|
||||
status: str = Field('unknown')
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator('status')
|
||||
@classmethod
|
||||
def validate_outlet_status(cls, value):
|
||||
if value not in OUTLET_STATUSES:
|
||||
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
|
||||
return value
|
||||
|
||||
|
||||
class WallOutletCreate(WallOutletBase):
|
||||
pass
|
||||
|
||||
|
||||
class WallOutletUpdate(BaseModel):
|
||||
outlet_number: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
category: Optional[str] = Field(None, max_length=50)
|
||||
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||
patch_port: Optional[str] = Field(None, max_length=100)
|
||||
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
||||
switch_name: Optional[str] = Field(None, max_length=255)
|
||||
switch_port: Optional[str] = Field(None, max_length=100)
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator('status')
|
||||
@classmethod
|
||||
def validate_outlet_status(cls, value):
|
||||
if value is not None and value not in OUTLET_STATUSES:
|
||||
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
|
||||
return value
|
||||
|
||||
|
||||
class WallOutlet(WallOutletBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
location_name: Optional[str] = None
|
||||
location_type: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
hierarchy_path: Optional[str] = None
|
||||
|
||||
|
||||
class CrossFieldCreate(BaseModel):
|
||||
location_id: int = Field(..., ge=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
port_count: int = Field(..., ge=1, le=999)
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CrossFieldUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
port_count: Optional[int] = Field(None, ge=1, le=999)
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CrossFieldPort(BaseModel):
|
||||
id: int
|
||||
port_number: int
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CrossField(BaseModel):
|
||||
id: int
|
||||
location_id: int
|
||||
name: str
|
||||
port_count: int
|
||||
notes: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
ports: List[CrossFieldPort] = []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. CONTACT MODELS
|
||||
# ============================================================================
|
||||
@ -360,6 +455,7 @@ class LocationDetail(Location):
|
||||
hours: List[OperatingHours] = Field(default_factory=list)
|
||||
services: List[Service] = Field(default_factory=list)
|
||||
capacity: List[Capacity] = Field(default_factory=list)
|
||||
wall_outlets: List[WallOutlet] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
|
||||
@ -2,6 +2,40 @@
|
||||
|
||||
{% block title %}Opret lokation - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.relation-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.relation-summary {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.relation-summary.empty {
|
||||
background: #f8fafc;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.relation-path {
|
||||
font-weight: 600;
|
||||
color: #1f3b53;
|
||||
}
|
||||
|
||||
.relation-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
@ -57,33 +91,66 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
||||
<div class="relation-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
|
||||
<div class="text-muted small">Vælg hurtigt, hvor lokationen skal ligge, og søg i hele træet.</div>
|
||||
</div>
|
||||
{% if selected_parent %}
|
||||
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-eye me-2"></i>Åbn valgt parent
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
|
||||
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, bygningsdel eller sti...">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||
<option value="">Ingen (øverste niveau)</option>
|
||||
{% if parent_locations %}
|
||||
{% for parent in parent_locations %}
|
||||
<option value="{{ parent.id }}">
|
||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
||||
<option
|
||||
value="{{ parent.id }}"
|
||||
data-path="{{ parent.hierarchy_path }}"
|
||||
data-type="{{ parent.type_label }}"
|
||||
data-customer-id="{{ parent.customer_id | default('') }}"
|
||||
{% if selected_parent_id == parent.id %}selected{% endif %}>
|
||||
{{ parent.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
||||
<div class="form-text">Bruges til hierarki, fx Kompleks → Bygning → Etage → Rum.</div>
|
||||
</div>
|
||||
|
||||
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
|
||||
{% if selected_parent %}
|
||||
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
|
||||
<div class="relation-meta">{{ selected_parent.type_label }}</div>
|
||||
{% else %}
|
||||
<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="customerId" class="form-label">Kunde (valgfri)</label>
|
||||
<select class="form-select" id="customerId" name="customer_id">
|
||||
<option value="">Ingen</option>
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Valgfri – kan knyttes til alle typer.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="customerId" class="form-label">Kunde (valgfri)</label>
|
||||
<select class="form-select" id="customerId" name="customer_id">
|
||||
<option value="">Ingen</option>
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
@ -91,6 +158,14 @@
|
||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="crossFieldOption" hidden>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
|
||||
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section 2: Address -->
|
||||
@ -185,12 +260,79 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const notesField = document.getElementById('notes');
|
||||
const charCount = document.getElementById('charCount');
|
||||
const parentLocationSelect = document.getElementById('parentLocation');
|
||||
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||
const customerSelect = document.getElementById('customerId');
|
||||
const parentSummary = document.getElementById('parentSummary');
|
||||
|
||||
// Character counter for notes
|
||||
notesField.addEventListener('input', function() {
|
||||
charCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function updateParentSummary() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const path = selectedOption?.dataset?.path || '';
|
||||
const type = selectedOption?.dataset?.type || '';
|
||||
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
parentSummary.classList.add('empty');
|
||||
parentSummary.innerHTML = '<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
parentSummary.classList.remove('empty');
|
||||
parentSummary.innerHTML = `
|
||||
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||
<div class="relation-path">${escapeHtml(path)}</div>
|
||||
<div class="relation-meta">${escapeHtml(type)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterParentLocations() {
|
||||
const query = (parentLocationSearch.value || '').trim().toLowerCase();
|
||||
Array.from(parentLocationSelect.options).forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
|
||||
option.hidden = query ? !haystack.includes(query) : false;
|
||||
});
|
||||
}
|
||||
|
||||
if (parentLocationSearch) {
|
||||
parentLocationSearch.addEventListener('input', filterParentLocations);
|
||||
}
|
||||
|
||||
if (parentLocationSelect) {
|
||||
parentLocationSelect.addEventListener('change', function() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const parentCustomerId = selectedOption?.dataset?.customerId;
|
||||
if ((!customerSelect.value || customerSelect.dataset.autofilled === 'true') && parentCustomerId) {
|
||||
customerSelect.value = parentCustomerId;
|
||||
customerSelect.dataset.autofilled = 'true';
|
||||
}
|
||||
updateParentSummary();
|
||||
});
|
||||
updateParentSummary();
|
||||
}
|
||||
|
||||
if (customerSelect) {
|
||||
customerSelect.addEventListener('change', function() {
|
||||
customerSelect.dataset.autofilled = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@ -202,7 +344,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
location_type: formData.get('location_type'),
|
||||
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||
is_active: formData.get('is_active') === 'on',
|
||||
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||
address_street: formData.get('address_street'),
|
||||
address_city: formData.get('address_city'),
|
||||
address_postal_code: formData.get('address_postal_code'),
|
||||
@ -239,6 +384,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
|
||||
}
|
||||
});
|
||||
|
||||
const locationType = document.getElementById('locationType');
|
||||
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||
const hasCrossField = document.getElementById('hasCrossField');
|
||||
const updateCrossFieldOption = () => {
|
||||
const isRoom = locationType.value === 'rum';
|
||||
crossFieldOption.hidden = !isRoom;
|
||||
if (!isRoom) hasCrossField.checked = false;
|
||||
};
|
||||
locationType.addEventListener('change', updateCrossFieldOption);
|
||||
updateCrossFieldOption();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -4,6 +4,17 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
|
||||
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
|
||||
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
|
||||
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
|
||||
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
|
||||
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
|
||||
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||||
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||||
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
|
||||
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
|
||||
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
|
||||
.locations-detail-page {
|
||||
--loc-accent: var(--accent, #0f4c75);
|
||||
}
|
||||
@ -247,6 +258,9 @@
|
||||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
{% if location.has_cross_field %}
|
||||
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
|
||||
{% endif %}
|
||||
{% if location.is_active %}
|
||||
<span class="case-status-chip open">
|
||||
<span class="case-status-dot"></span>Aktiv
|
||||
@ -328,6 +342,13 @@
|
||||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Relationer
|
||||
@ -715,9 +736,15 @@
|
||||
</div>
|
||||
<div class="card border-0">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h5 class="card-title mb-0">Tilføj underlokation</h5>
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<h5 class="card-title mb-0">Tilføj underlokation</h5>
|
||||
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
|
||||
<form action="/api/v1/locations" method="post" class="row g-2">
|
||||
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
|
||||
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
|
||||
@ -747,7 +774,7 @@
|
||||
<option value="">Ingen</option>
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
@ -763,6 +790,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 mt-4">
|
||||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">Vægstik</h5>
|
||||
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
|
||||
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||||
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||||
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
|
||||
{% elif location.wall_outlets %}
|
||||
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
|
||||
{% for outlet in location.wall_outlets %}
|
||||
<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 mt-4">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
||||
@ -803,6 +856,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||||
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
|
||||
<div class="card border-0">
|
||||
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||||
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
|
||||
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for field in location.cross_fields %}
|
||||
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">{{ field.port_count }} porte</span></div><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div>
|
||||
<div class="patch-panel"><div class="patch-panel-grid">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
|
||||
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tab 7: Hardware -->
|
||||
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
|
||||
<div class="card border-0">
|
||||
@ -976,6 +1046,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wall outlet modal -->
|
||||
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
|
||||
<input type="hidden" id="crossFieldId">
|
||||
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 24"></div>
|
||||
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
|
||||
|
||||
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<form id="outletForm"><div class="modal-body">
|
||||
<input type="hidden" id="outletId"><div class="row g-3">
|
||||
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
|
||||
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer *</label><input class="form-control" id="outletNumber" required placeholder="Fx A-12 eller 1.23.04"></div>
|
||||
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
|
||||
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
|
||||
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
|
||||
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
|
||||
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" placeholder="Fx Switch 3"></div>
|
||||
<div class="col-md-6"><label class="form-label">Switch-port</label><input class="form-control" id="outletSwitchPort" placeholder="Fx Gi1/0/12"></div>
|
||||
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
|
||||
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
||||
</div>
|
||||
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@ -1292,6 +1393,103 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const crossFieldButton = document.getElementById('addCrossFieldBtn');
|
||||
const crossFieldModalElement = document.getElementById('crossFieldModal');
|
||||
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
|
||||
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
|
||||
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
|
||||
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const crossFieldId = document.getElementById('crossFieldId').value;
|
||||
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), notes: document.getElementById('crossFieldNotes').value || null})});
|
||||
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
|
||||
});
|
||||
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
|
||||
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
|
||||
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
|
||||
|
||||
const outletModalElement = document.getElementById('outletModal');
|
||||
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
|
||||
const outletForm = document.getElementById('outletForm');
|
||||
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
||||
|
||||
async function loadCrossFieldPorts() {
|
||||
const select = document.getElementById('outletCrossFieldPort');
|
||||
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
|
||||
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
|
||||
}
|
||||
|
||||
async function loadOutletLocations() {
|
||||
const select = document.getElementById('outletLocationId');
|
||||
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
|
||||
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
|
||||
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
|
||||
select.value = String(locationId);
|
||||
}
|
||||
|
||||
async function openOutletModal(outlet = null, selectedPort = null) {
|
||||
if (!outletModal) return;
|
||||
await Promise.all([loadCrossFieldPorts(), loadOutletLocations()]);
|
||||
document.getElementById('outletId').value = outlet?.id || '';
|
||||
document.getElementById('outletNumber').value = outlet?.number || '';
|
||||
document.getElementById('outletCategory').value = outlet?.category || '';
|
||||
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
|
||||
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||||
document.getElementById('outletSwitch').value = outlet?.switchName || '';
|
||||
document.getElementById('outletSwitchPort').value = outlet?.switchPort || '';
|
||||
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||||
document.getElementById('outletNotes').value = outlet?.notes || '';
|
||||
if (selectedPort) {
|
||||
const portSelect = document.getElementById('outletCrossFieldPort');
|
||||
portSelect.value = String(selectedPort.id);
|
||||
if (portSelect.value !== String(selectedPort.id)) {
|
||||
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
|
||||
}
|
||||
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
||||
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
||||
}
|
||||
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||||
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
|
||||
outletModal.show();
|
||||
}
|
||||
|
||||
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
||||
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
|
||||
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
|
||||
id: btn.dataset.id, number: btn.dataset.number, category: btn.dataset.category, panel: btn.dataset.panel,
|
||||
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
|
||||
status: btn.dataset.status, notes: btn.dataset.notes
|
||||
})));
|
||||
|
||||
outletForm?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const outletId = document.getElementById('outletId').value;
|
||||
const payload = {
|
||||
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), category: outletValue('outletCategory'),
|
||||
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||||
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
||||
switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
|
||||
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
||||
};
|
||||
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
|
||||
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
alert(error.detail || 'Kunne ikke gemme vægstik');
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
});
|
||||
|
||||
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
|
||||
const outletId = document.getElementById('outletId').value;
|
||||
if (!outletId || !confirm('Slet dette vægstik?')) return;
|
||||
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
|
||||
if (response.ok) location.reload();
|
||||
else alert('Kunne ikke slette vægstik');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -2,6 +2,40 @@
|
||||
|
||||
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.relation-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.relation-summary {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.relation-summary.empty {
|
||||
background: #f8fafc;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.relation-path {
|
||||
font-weight: 600;
|
||||
color: #1f3b53;
|
||||
}
|
||||
|
||||
.relation-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
@ -58,19 +92,52 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||
<option value="">Ingen (øverste niveau)</option>
|
||||
{% if parent_locations %}
|
||||
{% for parent in parent_locations %}
|
||||
<option value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}>
|
||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<div class="relation-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
|
||||
<div class="text-muted small">Flyt lokationen ved at vælge en ny overordnet lokation.</div>
|
||||
</div>
|
||||
{% if selected_parent %}
|
||||
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-eye me-2"></i>Åbn valgt parent
|
||||
</a>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
|
||||
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, sti eller type...">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||
<option value="">Ingen (øverste niveau)</option>
|
||||
{% if parent_locations %}
|
||||
{% for parent in parent_locations %}
|
||||
<option
|
||||
value="{{ parent.id }}"
|
||||
data-path="{{ parent.hierarchy_path }}"
|
||||
data-type="{{ parent.type_label }}"
|
||||
data-customer-id="{{ parent.customer_id | default('') }}"
|
||||
{% if location.parent_location_id == parent.id %}selected{% endif %}>
|
||||
{{ parent.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.</div>
|
||||
</div>
|
||||
|
||||
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
|
||||
{% if selected_parent %}
|
||||
<div class="small text-muted mb-1">Nuværende overordnet lokation</div>
|
||||
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
|
||||
<div class="relation-meta">{{ selected_parent.type_label }}</div>
|
||||
{% else %}
|
||||
<div class="small text-muted">Lokationen ligger i topniveau.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -92,6 +159,13 @@
|
||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
|
||||
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section 2: Address -->
|
||||
@ -130,7 +204,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk">
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -160,7 +234,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Noter og kommentarer</label>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
|
||||
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small>
|
||||
<small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -216,12 +290,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteModalElement = document.getElementById('deleteModal');
|
||||
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
|
||||
const locationId = '{{ location.id }}';
|
||||
const parentLocationSelect = document.getElementById('parentLocation');
|
||||
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||
const parentSummary = document.getElementById('parentSummary');
|
||||
|
||||
// Character counter for notes
|
||||
notesField.addEventListener('input', function() {
|
||||
charCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function updateParentSummary() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const path = selectedOption?.dataset?.path || '';
|
||||
const type = selectedOption?.dataset?.type || '';
|
||||
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
parentSummary.classList.add('empty');
|
||||
parentSummary.innerHTML = '<div class="small text-muted">Lokationen ligger i topniveau.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
parentSummary.classList.remove('empty');
|
||||
parentSummary.innerHTML = `
|
||||
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||
<div class="relation-path">${escapeHtml(path)}</div>
|
||||
<div class="relation-meta">${escapeHtml(type)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterParentLocations() {
|
||||
const query = (parentLocationSearch.value || '').trim().toLowerCase();
|
||||
Array.from(parentLocationSelect.options).forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
|
||||
option.hidden = query ? !haystack.includes(query) : false;
|
||||
});
|
||||
}
|
||||
|
||||
if (parentLocationSearch) {
|
||||
parentLocationSearch.addEventListener('input', filterParentLocations);
|
||||
}
|
||||
|
||||
if (parentLocationSelect) {
|
||||
parentLocationSelect.addEventListener('change', updateParentSummary);
|
||||
updateParentSummary();
|
||||
}
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
if (form.dataset.noIntercept === 'true') {
|
||||
@ -239,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||
is_active: formData.get('is_active') === 'on',
|
||||
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||
address_street: formData.get('address_street'),
|
||||
address_city: formData.get('address_city'),
|
||||
address_postal_code: formData.get('address_postal_code'),
|
||||
@ -275,6 +402,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
const locationType = document.getElementById('locationType');
|
||||
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||
const hasCrossField = document.getElementById('hasCrossField');
|
||||
const updateCrossFieldOption = () => {
|
||||
const isRoom = locationType.value === 'rum';
|
||||
crossFieldOption.hidden = !isRoom;
|
||||
if (!isRoom) hasCrossField.checked = false;
|
||||
};
|
||||
locationType.addEventListener('change', updateCrossFieldOption);
|
||||
updateCrossFieldOption();
|
||||
|
||||
// Delete location
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||
fetch(`/api/v1/locations/${locationId}`, {
|
||||
|
||||
@ -6,81 +6,271 @@
|
||||
<style>
|
||||
.locations-list-page {
|
||||
--loc-accent: #0f4c75;
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.08);
|
||||
--loc-border: rgba(15, 76, 117, 0.16);
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.06);
|
||||
--loc-border: rgba(15, 76, 117, 0.12);
|
||||
--loc-surface: #f7f9fc;
|
||||
}
|
||||
|
||||
.locations-list-page .locations-hero {
|
||||
border: 1px solid var(--loc-border);
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .locations-hero {
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
|
||||
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--bg-card);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
background: var(--loc-accent-soft);
|
||||
border: 1px solid var(--loc-border);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
border: 1px solid rgba(15, 76, 117, 0.08);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.locations-list-page .section-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile .stat-value {
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--loc-accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
|
||||
color: #8fd0ff;
|
||||
.locations-list-page .stat-tile .stat-note {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.locations-list-page .table thead th {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row {
|
||||
transition: background-color 0.18s ease, transform 0.16s ease;
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row:hover {
|
||||
background-color: rgba(52, 152, 219, 0.08);
|
||||
background-color: rgba(15, 76, 117, 0.03);
|
||||
}
|
||||
|
||||
.locations-list-page .toggle-row {
|
||||
.locations-list-page .table > :not(caption) > * > * {
|
||||
padding-top: 0.95rem;
|
||||
padding-bottom: 0.95rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.locations-list-page .table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .toggle-row,
|
||||
.locations-list-page .location-tree-spacer {
|
||||
color: var(--loc-accent);
|
||||
width: 1.2rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
.locations-list-page .filter-shell,
|
||||
.locations-list-page .content-shell {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
|
||||
}
|
||||
|
||||
.locations-list-page .filter-shell {
|
||||
padding: 1.05rem 1.1rem;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-toolbar,
|
||||
.locations-list-page .filter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-title {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-subtitle {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-subtitle span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-link {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-link:hover {
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-list-page .location-primary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-indent {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.locations-list-page .location-meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 76, 117, 0.07);
|
||||
color: #2d5670;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.32rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill.active {
|
||||
background: rgba(25, 135, 84, 0.12);
|
||||
color: #146c43;
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill.inactive {
|
||||
background: rgba(108, 117, 125, 0.12);
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.locations-list-page .shortcut-hint {
|
||||
border: 1px dashed var(--loc-border);
|
||||
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.locations-list-page .hero-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.locations-list-page .list-status-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--loc-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .actions-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.locations-list-page .actions-inline .btn {
|
||||
border-radius: 0.7rem;
|
||||
}
|
||||
|
||||
.locations-list-page .empty-state-soft {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-list-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
.locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
|
||||
.locations-list-page .content-shell-header { align-items: flex-start; flex-direction: column; }
|
||||
.locations-list-page .content-shell-toolbar,
|
||||
.locations-list-page .filter-toolbar { align-items: flex-start; flex-direction: column; }
|
||||
.locations-list-page .hero-actions { width: 100%; }
|
||||
.locations-list-page .hero-actions .btn { flex: 1 1 auto; }
|
||||
.locations-list-page .location-summary { gap: 0.55rem; }
|
||||
.locations-list-page .location-name-link { font-size: 0.94rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -96,121 +286,144 @@
|
||||
</nav>
|
||||
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="locations-hero p-3 p-lg-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
|
||||
</div>
|
||||
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Aktive</div>
|
||||
<div class="stat-value" id="statActive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Inaktive</div>
|
||||
<div class="stat-value" id="statInactive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Synlige nu</div>
|
||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="locations-hero p-3 p-lg-4 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="section-label mb-2">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
Lokationsstruktur
|
||||
</div>
|
||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Card -->
|
||||
<div class="card mb-4 border-0">
|
||||
<div class="card-body">
|
||||
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter navn, hierarki eller by...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
||||
<select class="form-select" id="locationTypeFilter" name="location_type">
|
||||
<option value="">Alle typer</option>
|
||||
{% if location_types %}
|
||||
{% for type_option in location_types %}
|
||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
|
||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||
<select class="form-select" id="statusFilter" name="is_active">
|
||||
<option value="">Alle</option>
|
||||
<option value="true" {% if is_active == True %}selected{% endif %}>Aktive</option>
|
||||
<option value="false" {% if is_active == False %}selected{% endif %}>Inaktive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-funnel me-2"></i>Anvend filtre
|
||||
</button>
|
||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar Section -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex gap-2">
|
||||
<div class="d-flex gap-2 flex-wrap hero-actions">
|
||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||
</a>
|
||||
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
||||
<i class="bi bi-trash me-2"></i>Slet valgte
|
||||
</button>
|
||||
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-ethernet me-2"></i>Vægstik
|
||||
</a>
|
||||
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
{% if total %}
|
||||
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
||||
{% else %}
|
||||
Ingen lokationer
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||
<div class="stat-note">Alle registrerede lokationer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Aktive</div>
|
||||
<div class="stat-value" id="statActive">0</div>
|
||||
<div class="stat-note">Klar til daglig brug</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Inaktive</div>
|
||||
<div class="stat-value" id="statInactive">0</div>
|
||||
<div class="stat-note">Skjulte eller lukkede</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Synlige nu</div>
|
||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||
<div class="stat-note">Efter søgning og foldning</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Card -->
|
||||
<div class="filter-shell mb-4">
|
||||
<div class="filter-toolbar mb-3">
|
||||
<div>
|
||||
<div class="content-shell-title">Filtre</div>
|
||||
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
|
||||
</div>
|
||||
<div class="list-status-note">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
Hierarkiet kan foldes direkte i listen
|
||||
</div>
|
||||
</div>
|
||||
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
||||
<div class="col-lg-5">
|
||||
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
||||
<select class="form-select" id="locationTypeFilter" name="location_type">
|
||||
<option value="">Alle typer</option>
|
||||
{% if location_types %}
|
||||
{% for type_option in location_types %}
|
||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
|
||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-lg-2">
|
||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||
<select class="form-select" id="statusFilter" name="is_active">
|
||||
<option value="">Alle</option>
|
||||
<option value="true" {% if is_active == True %}selected{% endif %}>Aktive</option>
|
||||
<option value="false" {% if is_active == False %}selected{% endif %}>Inaktive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5 col-lg-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-funnel me-2"></i>Filtrer
|
||||
</button>
|
||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Section -->
|
||||
<div class="card border-0">
|
||||
<div class="content-shell">
|
||||
<div class="content-shell-header">
|
||||
<div class="content-shell-toolbar w-100">
|
||||
<div>
|
||||
<div class="content-shell-title">Lokationsliste</div>
|
||||
<div class="content-shell-subtitle">
|
||||
{% if total %}
|
||||
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
||||
<span>med tydeligt hierarki og status</span>
|
||||
{% else %}
|
||||
Ingen lokationer endnu
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="list-status-note">
|
||||
<i class="bi bi-check2-circle"></i>
|
||||
Klik kun på navn eller handlinger for at åbne
|
||||
</span>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
||||
<i class="bi bi-trash me-2"></i>Slet valgte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
{% if location_tree %}
|
||||
<table class="table table-hover mb-0">
|
||||
@ -250,26 +463,44 @@
|
||||
'vehicle': '#8e44ad'
|
||||
}.get(node.location_type, '#6c757d') %}
|
||||
|
||||
{% set child_count = node.children|length if node.children else 0 %}
|
||||
|
||||
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;">
|
||||
{% if node.children %}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted me-2">•</span>
|
||||
{% endif %}
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500">
|
||||
{{ node.name }}
|
||||
</a>
|
||||
<div class="location-summary">
|
||||
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
|
||||
{% if node.children %}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="location-tree-spacer text-muted">•</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="location-name-block">
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
|
||||
{{ node.name }}
|
||||
</a>
|
||||
<div class="location-meta">
|
||||
{% if depth > 0 %}
|
||||
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
|
||||
{% endif %}
|
||||
{% if child_count %}
|
||||
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
|
||||
{% endif %}
|
||||
{% if node.address_city %}
|
||||
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
|
||||
{% endif %}
|
||||
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {{ type_color }}; color: white;">
|
||||
<span class="type-pill" style="background-color: {{ type_color }};">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
</td>
|
||||
@ -278,20 +509,20 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if node.is_active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
<span class="status-pill active"><span>●</span>Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
<span class="status-pill inactive"><span>●</span>Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis">
|
||||
<div class="actions-inline" role="group" aria-label="Handlinger">
|
||||
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger">
|
||||
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger delete-location-btn"
|
||||
<button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
|
||||
data-location-id="{{ node.id }}"
|
||||
data-location-name="{{ node.name }}"
|
||||
title="Slet">
|
||||
@ -315,14 +546,16 @@
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
||||
<div class="empty-state-soft">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
||||
</div>
|
||||
<h5 class="text-muted">Ingen lokationer endnu</h5>
|
||||
<p class="text-muted small mb-3">Opret din første lokation ved at klikke på knappen nedenfor</p>
|
||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||
</a>
|
||||
</div>
|
||||
<h5 class="text-muted">Ingen lokationer endnu</h5>
|
||||
<p class="text-muted small mb-3">Opret din første lokation ved at klikke på knappen nedenfor</p>
|
||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -608,12 +841,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let currentDeleteId = null;
|
||||
|
||||
// Select all functionality
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
locationCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
locationCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkbox functionality
|
||||
locationCheckboxes.forEach(checkbox => {
|
||||
@ -632,6 +867,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
if (!selectAllCheckbox) return;
|
||||
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
|
||||
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
@ -678,36 +914,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Bulk delete
|
||||
bulkDeleteBtn.addEventListener('click', function() {
|
||||
const selectedIds = Array.from(locationCheckboxes)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
if (confirm(`Slet ${selectedIds.length} lokation(er)? Dette kan ikke fortrydes.`)) {
|
||||
Promise.all(selectedIds.map(id =>
|
||||
fetch(`/api/v1/locations/${id}`, { method: 'DELETE' })
|
||||
))
|
||||
.then(() => location.reload())
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Fejl ved sletning af lokationer');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clickable rows
|
||||
document.querySelectorAll('.location-row').forEach(row => {
|
||||
row.addEventListener('click', function(e) {
|
||||
// Don't navigate if clicking checkbox or action buttons
|
||||
if (e.target.tagName === 'INPUT' || e.target.closest('.btn-group')) {
|
||||
return;
|
||||
if (bulkDeleteBtn) {
|
||||
bulkDeleteBtn.addEventListener('click', function() {
|
||||
const selectedIds = Array.from(locationCheckboxes)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
if (confirm(`Slet ${selectedIds.length} lokation(er)? Dette kan ikke fortrydes.`)) {
|
||||
Promise.all(selectedIds.map(id =>
|
||||
fetch(`/api/v1/locations/${id}`, { method: 'DELETE' })
|
||||
))
|
||||
.then(() => location.reload())
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Fejl ved sletning af lokationer');
|
||||
});
|
||||
}
|
||||
const link = this.querySelector('a');
|
||||
if (link) link.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
22
app/modules/locations/templates/outlets.html
Normal file
22
app/modules/locations/templates/outlets.html
Normal 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 %}
|
||||
@ -14,7 +14,8 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||
from app.core.config import settings
|
||||
from app.services.email_service import EmailService
|
||||
@ -903,7 +904,7 @@ async def list_all_sale_items(
|
||||
|
||||
@router.post("/sag")
|
||||
async def create_sag(data: dict):
|
||||
"""Create a new case."""
|
||||
"""Create a case and its optional pipeline/order data atomically."""
|
||||
try:
|
||||
if not data.get("titel"):
|
||||
raise HTTPException(status_code=400, detail="titel is required")
|
||||
@ -919,32 +920,118 @@ async def create_sag(data: dict):
|
||||
_validate_user_id(ansvarlig_bruger_id)
|
||||
_validate_group_id(assigned_group_id)
|
||||
|
||||
query = """
|
||||
INSERT INTO sag_sager
|
||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
data.get("titel"),
|
||||
data.get("beskrivelse", ""),
|
||||
data.get("template_key") or data.get("type", "ticket"),
|
||||
status,
|
||||
data.get("customer_id"),
|
||||
ansvarlig_bruger_id,
|
||||
assigned_group_id,
|
||||
data.get("created_by_user_id", 1),
|
||||
deadline,
|
||||
deferred_until,
|
||||
data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"),
|
||||
)
|
||||
|
||||
result = execute_query(query, params)
|
||||
if result:
|
||||
logger.info("✅ Case created: %s", result[0]["id"])
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||
pipeline = data.get("pipeline") if case_type == "pipeline" else None
|
||||
order_items = data.get("order_items") if case_type == "ordre" else []
|
||||
if pipeline is not None and not isinstance(pipeline, dict):
|
||||
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
|
||||
if not isinstance(order_items, list):
|
||||
raise HTTPException(status_code=400, detail="order_items skal være en liste")
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
pipeline_values = {"amount": None, "probability": None, "stage_id": None, "description": None}
|
||||
if pipeline:
|
||||
pipeline_values.update({key: pipeline.get(key) for key in pipeline_values})
|
||||
if pipeline_values["amount"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["amount"] = float(pipeline_values["amount"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
||||
else:
|
||||
pipeline_values["amount"] = None
|
||||
if pipeline_values["probability"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["probability"] = int(pipeline_values["probability"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være et helt tal") from exc
|
||||
if not 0 <= pipeline_values["probability"] <= 100:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være mellem 0 og 100")
|
||||
else:
|
||||
pipeline_values["probability"] = None
|
||||
if pipeline_values["stage_id"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["stage_id"] = int(pipeline_values["stage_id"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage") from exc
|
||||
cursor.execute("SELECT id FROM pipeline_stages WHERE id = %s", (pipeline_values["stage_id"],))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage")
|
||||
else:
|
||||
pipeline_values["stage_id"] = None
|
||||
|
||||
normalized_items = []
|
||||
for item in order_items:
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinje")
|
||||
description = str(item.get("description") or "").strip()
|
||||
item_type = str(item.get("type") or "sale").lower()
|
||||
if not description:
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens beskrivelse er påkrævet")
|
||||
if item_type not in ("sale", "purchase"):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjetype skal være køb eller salg")
|
||||
if item.get("amount") in (None, ""):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens beløb er påkrævet")
|
||||
try:
|
||||
normalized_items.append({
|
||||
"type": item_type,
|
||||
"description": description,
|
||||
"quantity": float(item["quantity"]) if item.get("quantity") not in (None, "") else None,
|
||||
"unit": item.get("unit") or None,
|
||||
"unit_price": float(item["unit_price"]) if item.get("unit_price") not in (None, "") else None,
|
||||
"amount": float(item["amount"]),
|
||||
"currency": str(item.get("currency") or "DKK").upper(),
|
||||
"status": str(item.get("status") or "draft").lower(),
|
||||
"line_date": item.get("line_date") or None,
|
||||
"external_ref": item.get("external_ref") or None,
|
||||
"purchase_purpose": _normalize_purchase_purpose(item.get("purchase_purpose"), item_type),
|
||||
})
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
||||
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_sager
|
||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
|
||||
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
||||
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
for item in normalized_items:
|
||||
if has_purchase_columns:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, purchase_purpose)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"], item["purchase_purpose"]),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("✅ Case created: %s", result["id"])
|
||||
return dict(result)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@ -162,6 +162,12 @@
|
||||
</div>
|
||||
|
||||
<form id="createForm" novalidate>
|
||||
<div class="mb-4 p-3 rounded-3 border bg-light">
|
||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||
<select class="form-select form-select-lg" id="type" required></select>
|
||||
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Relations -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
@ -215,6 +221,7 @@
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Hardware & AnyDesk -->
|
||||
<section id="hardwareSection">
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12">
|
||||
@ -246,23 +253,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pipelineSection" class="d-none">
|
||||
<hr class="my-4 opacity-25">
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
|
||||
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
|
||||
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="orderSection" class="d-none">
|
||||
<hr class="my-4 opacity-25">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="orderLines" class="vstack gap-3 mb-4"></div>
|
||||
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Metadata -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="type" required>
|
||||
<option value="ticket" selected>🎫 Ticket</option>
|
||||
<option value="opgave">🧩 Opgave</option>
|
||||
<option value="ordre">🧾 Ordre</option>
|
||||
<option value="projekt">📁 Projekt</option>
|
||||
<option value="service">🛠️ Service</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="status" required>
|
||||
<option value="åben" selected>🟢 Åben</option>
|
||||
@ -270,7 +292,7 @@
|
||||
<option value="lukket">🔴 Lukket</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||
<select class="form-select" id="ansvarlig_bruger_id">
|
||||
<option value="">Ingen</option>
|
||||
@ -280,7 +302,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||
<select class="form-select" id="assigned_group_id">
|
||||
<option value="">Ingen</option>
|
||||
@ -324,6 +346,7 @@
|
||||
let customerSearchTimeout;
|
||||
let contactSearchTimeout;
|
||||
let successAlertTimeout;
|
||||
let orderLineCounter = 0;
|
||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
||||
let topAlertLoadToken = 0;
|
||||
|
||||
@ -889,28 +912,113 @@
|
||||
}
|
||||
}
|
||||
|
||||
const caseTypeLabels = {
|
||||
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
|
||||
};
|
||||
|
||||
function updateCaseTypeSections() {
|
||||
const type = document.getElementById('type')?.value || 'ticket';
|
||||
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
|
||||
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
|
||||
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
|
||||
const help = document.getElementById('caseTypeHelp');
|
||||
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
|
||||
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||
: 'Denne sagstype bruger kun de fælles sagsfelter.';
|
||||
}
|
||||
|
||||
function renderOrderLinesEmptyState() {
|
||||
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
|
||||
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
|
||||
}
|
||||
|
||||
function addOrderLine(type = 'sale') {
|
||||
const id = ++orderLineCounter;
|
||||
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
|
||||
const purpose = type === 'purchase' ? `
|
||||
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
|
||||
const container = document.getElementById('orderLines');
|
||||
container.insertAdjacentHTML('beforeend', `
|
||||
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
|
||||
<input type="hidden" class="order-type" value="${type}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
|
||||
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
|
||||
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
|
||||
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
|
||||
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
|
||||
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
|
||||
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
|
||||
${purpose}
|
||||
</div>
|
||||
</div>`);
|
||||
renderOrderLinesEmptyState();
|
||||
}
|
||||
|
||||
function removeOrderLine(id) {
|
||||
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
|
||||
renderOrderLinesEmptyState();
|
||||
}
|
||||
|
||||
function collectOrderItems() {
|
||||
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
|
||||
type: line.querySelector('.order-type').value,
|
||||
description: line.querySelector('.order-description').value.trim(),
|
||||
quantity: line.querySelector('.order-quantity').value || null,
|
||||
unit: line.querySelector('.order-unit').value.trim() || null,
|
||||
unit_price: line.querySelector('.order-unit-price').value || null,
|
||||
amount: line.querySelector('.order-amount').value || null,
|
||||
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
|
||||
status: line.querySelector('.order-status').value,
|
||||
external_ref: line.querySelector('.order-reference').value.trim() || null,
|
||||
purchase_purpose: line.querySelector('.order-purpose')?.value || null
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPipelineStages() {
|
||||
const select = document.getElementById('pipeline_stage_id');
|
||||
if (!select) return;
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const stages = await response.json();
|
||||
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||||
} catch (err) { console.error('Failed to load pipeline stages', err); }
|
||||
}
|
||||
|
||||
async function loadCaseTypesSelect() {
|
||||
const select = document.getElementById('type');
|
||||
if (!select) return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/case_types');
|
||||
if (!res.ok) return;
|
||||
const setting = await res.json();
|
||||
const types = JSON.parse(setting.value || '[]');
|
||||
if (!Array.isArray(types) || types.length === 0) return;
|
||||
|
||||
select.innerHTML = types
|
||||
.map((type) => `<option value="${type}">${type}</option>`)
|
||||
.join('');
|
||||
const [typesRes, profileRes] = await Promise.all([
|
||||
fetch('/api/v1/settings/case_types', { credentials: 'include' }),
|
||||
fetch('/api/v1/auth/me/profile', { credentials: 'include' })
|
||||
]);
|
||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
} catch (err) {
|
||||
console.error('Failed to load case types', err);
|
||||
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
||||
}
|
||||
updateCaseTypeSections();
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeSearch();
|
||||
loadCaseTypesSelect();
|
||||
loadPipelineStages();
|
||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||
applyTelefoniPrefill();
|
||||
});
|
||||
|
||||
@ -993,6 +1101,18 @@
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
if (data.type === 'pipeline') {
|
||||
data.pipeline = {
|
||||
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
||||
amount: document.getElementById('pipeline_amount').value || null,
|
||||
probability: document.getElementById('pipeline_probability').value || null,
|
||||
description: document.getElementById('pipeline_description').value || null
|
||||
};
|
||||
}
|
||||
if (data.type === 'ordre') {
|
||||
data.order_items = collectOrderItems();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/sag', {
|
||||
method: 'POST',
|
||||
|
||||
@ -16,7 +16,9 @@ router = APIRouter()
|
||||
async def list_opportunities(
|
||||
q: Optional[str] = None,
|
||||
stage: Optional[str] = None,
|
||||
status: Optional[str] = None
|
||||
status: Optional[str] = None,
|
||||
customer_id: Optional[int] = Query(default=None),
|
||||
contact_id: Optional[int] = Query(default=None),
|
||||
):
|
||||
"""
|
||||
List all 'pipeline' cases.
|
||||
@ -70,6 +72,33 @@ async def list_opportunities(
|
||||
if q:
|
||||
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
|
||||
if customer_id is not None:
|
||||
query += """
|
||||
AND (
|
||||
s.customer_id = %s
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM sag_kunder sk
|
||||
WHERE sk.sag_id = s.id
|
||||
AND sk.customer_id = %s
|
||||
AND sk.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
"""
|
||||
params.extend([customer_id, customer_id])
|
||||
|
||||
if contact_id is not None:
|
||||
query += """
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sag_kontakter sk
|
||||
WHERE sk.sag_id = s.id
|
||||
AND sk.contact_id = %s
|
||||
AND sk.deleted_at IS NULL
|
||||
)
|
||||
"""
|
||||
params.append(contact_id)
|
||||
|
||||
if status and status != 'all':
|
||||
if status == 'open':
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app")
|
||||
@router.get("/opportunities", response_class=HTMLResponse)
|
||||
async def opportunities_page(request: Request):
|
||||
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/opportunities/{opportunity_id}", include_in_schema=False)
|
||||
async def opportunity_detail_redirect(opportunity_id: int):
|
||||
return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307)
|
||||
|
||||
@ -14,7 +14,6 @@ import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict
|
||||
from app.services.economic_service import get_economic_service
|
||||
from app.core.database import execute_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -23,9 +22,6 @@ logger = logging.getLogger(__name__)
|
||||
class SubscriptionMatrixService:
|
||||
"""Generate billing matrix for customer subscriptions"""
|
||||
|
||||
def __init__(self):
|
||||
self.economic_service = get_economic_service()
|
||||
|
||||
async def generate_billing_matrix(
|
||||
self,
|
||||
customer_id: int,
|
||||
@ -87,13 +83,9 @@ class SubscriptionMatrixService:
|
||||
economic_customer_number = customer[0]['economic_customer_number']
|
||||
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
|
||||
|
||||
# Fetch invoices from e-conomic
|
||||
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
|
||||
invoices = await self.economic_service.get_customer_invoices(
|
||||
economic_customer_number,
|
||||
include_lines=True
|
||||
)
|
||||
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
|
||||
# Fetch imported invoice snapshot from local invoice_error_finder tables
|
||||
invoices = self._load_imported_invoices(str(economic_customer_number))
|
||||
logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
|
||||
|
||||
if not invoices:
|
||||
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
|
||||
@ -130,6 +122,125 @@ class SubscriptionMatrixService:
|
||||
"error": str(e),
|
||||
"products": []
|
||||
}
|
||||
|
||||
def _load_imported_invoices(self, economic_customer_number: str) -> List[Dict]:
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH ranked_invoices AS (
|
||||
SELECT
|
||||
inv.id,
|
||||
inv.source_invoice_number,
|
||||
inv.source_type,
|
||||
inv.invoice_date,
|
||||
inv.due_date,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.total_amount,
|
||||
inv.currency,
|
||||
inv.source_raw,
|
||||
CASE inv.source_type
|
||||
WHEN 'paid' THEN 1
|
||||
WHEN 'booked' THEN 2
|
||||
WHEN 'unpaid' THEN 3
|
||||
WHEN 'draft' THEN 4
|
||||
ELSE 9
|
||||
END AS source_rank
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
WHERE inv.customer_number = %s
|
||||
),
|
||||
selected_invoices AS (
|
||||
SELECT DISTINCT ON (source_invoice_number)
|
||||
id,
|
||||
source_invoice_number,
|
||||
source_type,
|
||||
invoice_date,
|
||||
due_date,
|
||||
net_amount,
|
||||
vat_amount,
|
||||
total_amount,
|
||||
currency,
|
||||
source_raw
|
||||
FROM ranked_invoices
|
||||
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
|
||||
)
|
||||
SELECT
|
||||
si.id AS invoice_id,
|
||||
si.source_invoice_number,
|
||||
si.source_type,
|
||||
si.invoice_date,
|
||||
si.due_date,
|
||||
si.net_amount,
|
||||
si.vat_amount,
|
||||
si.total_amount,
|
||||
si.currency,
|
||||
si.source_raw AS invoice_source_raw,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount,
|
||||
line.source_raw AS line_source_raw
|
||||
FROM selected_invoices si
|
||||
LEFT JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = si.id
|
||||
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
|
||||
""",
|
||||
(economic_customer_number,),
|
||||
) or []
|
||||
|
||||
invoices: List[Dict] = []
|
||||
invoices_by_id: Dict[int, Dict] = {}
|
||||
|
||||
for row in rows:
|
||||
invoice_id = row.get("invoice_id")
|
||||
if invoice_id is None:
|
||||
continue
|
||||
|
||||
invoice_source_raw = self._ensure_dict(row.get("invoice_source_raw"))
|
||||
line_source_raw = self._ensure_dict(row.get("line_source_raw"))
|
||||
|
||||
if invoice_id not in invoices_by_id:
|
||||
invoice_payload = {
|
||||
"id": invoice_id,
|
||||
"status": row.get("source_type"),
|
||||
"bookedInvoiceNumber": row.get("source_invoice_number"),
|
||||
"date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
|
||||
"dueDate": row.get("due_date").isoformat() if row.get("due_date") else None,
|
||||
"netAmount": float(row.get("net_amount") or 0),
|
||||
"vatAmount": float(row.get("vat_amount") or 0),
|
||||
"grossAmount": float(row.get("total_amount") or 0),
|
||||
"currency": row.get("currency") or "DKK",
|
||||
"notes": invoice_source_raw.get("notes"),
|
||||
"heading": invoice_source_raw.get("heading"),
|
||||
"description": invoice_source_raw.get("description"),
|
||||
"text": invoice_source_raw.get("text"),
|
||||
"subject": invoice_source_raw.get("subject"),
|
||||
"otherReference": invoice_source_raw.get("otherReference"),
|
||||
"orderNumberDb": invoice_source_raw.get("orderNumberDb"),
|
||||
"lines": [],
|
||||
}
|
||||
invoices_by_id[invoice_id] = invoice_payload
|
||||
invoices.append(invoice_payload)
|
||||
|
||||
if row.get("line_number") is None:
|
||||
continue
|
||||
|
||||
invoices_by_id[invoice_id]["lines"].append({
|
||||
"lineNumber": row.get("line_number"),
|
||||
"description": row.get("description"),
|
||||
"quantity": float(row.get("quantity") or 0),
|
||||
"unitNetPrice": float(row.get("unit_price") or 0),
|
||||
"totalNetAmount": float(row.get("line_net_amount") or 0),
|
||||
"period": (line_source_raw.get("period") if isinstance(line_source_raw, dict) else None) or {},
|
||||
"product": {
|
||||
"productNumber": row.get("product_number"),
|
||||
"name": row.get("product_name"),
|
||||
},
|
||||
})
|
||||
|
||||
return invoices
|
||||
|
||||
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
|
||||
"""
|
||||
@ -377,6 +488,18 @@ class SubscriptionMatrixService:
|
||||
})
|
||||
|
||||
return products
|
||||
|
||||
@staticmethod
|
||||
def _ensure_dict(value) -> Dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _generate_month_range(num_months: int) -> List[str]:
|
||||
|
||||
@ -168,7 +168,7 @@ async def get_setting(key: str):
|
||||
seed_query,
|
||||
(
|
||||
"case_types",
|
||||
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
|
||||
"system",
|
||||
"Sags-typer",
|
||||
"json",
|
||||
@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
|
||||
return execute_migration(model)
|
||||
|
||||
|
||||
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
|
||||
async def execute_missing_migrations_api():
|
||||
"""Run schema-detected missing migrations via the API namespace."""
|
||||
from app.settings.backend.views import execute_missing_migrations
|
||||
|
||||
return execute_missing_migrations()
|
||||
|
||||
|
||||
@router.post("/settings/sync-from-env", tags=["Settings"])
|
||||
async def sync_settings_from_env():
|
||||
"""Sync settings from .env file into database (only updates empty values)"""
|
||||
@ -992,4 +1000,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ Settings Frontend Views
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import time
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
|
||||
release_db_connection(conn)
|
||||
|
||||
return {"message": "Migration executed successfully"}
|
||||
|
||||
|
||||
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
|
||||
def execute_missing_migrations():
|
||||
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
|
||||
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
|
||||
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
|
||||
conn = get_db_connection()
|
||||
logs = []
|
||||
try:
|
||||
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
|
||||
candidates = []
|
||||
for migration_file in files:
|
||||
sql = migration_file.read_text(encoding="utf-8")
|
||||
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
|
||||
if status["status"] == "red":
|
||||
candidates.append((migration_file, sql, status))
|
||||
|
||||
for migration_file, sql, status in candidates:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
conn.commit()
|
||||
logs.append({
|
||||
"file_name": migration_file.name,
|
||||
"status": "success",
|
||||
"summary": status["summary"],
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
})
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logs.append({
|
||||
"file_name": migration_file.name,
|
||||
"status": "failed",
|
||||
"summary": str(exc).splitlines()[0],
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
})
|
||||
|
||||
return {
|
||||
"message": "Manglende migrationer behandlet",
|
||||
"checked": len(files),
|
||||
"candidates": len(candidates),
|
||||
"executed": sum(item["status"] == "success" for item in logs),
|
||||
"failed": sum(item["status"] == "failed" for item in logs),
|
||||
"logs": logs,
|
||||
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
|
||||
@ -69,6 +69,9 @@
|
||||
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
||||
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
||||
</button>
|
||||
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
|
||||
<i class="bi bi-play-fill me-1"></i>Kør manglende
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@ -356,5 +359,35 @@
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMissingMigrations() {
|
||||
const button = document.getElementById('runMissingMigrationsBtn');
|
||||
const feedback = document.getElementById('migrationFeedback');
|
||||
button.disabled = true;
|
||||
feedback.className = 'alert alert-info mt-3';
|
||||
feedback.textContent = 'Tjekker og kører manglende migrationer...';
|
||||
feedback.classList.remove('d-none');
|
||||
try {
|
||||
const urls = buildMigrationActionUrls('execute-missing');
|
||||
let data = null;
|
||||
let lastError = null;
|
||||
for (const url of urls) {
|
||||
const response = await fetch(url, {method: 'POST', credentials: 'include'});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.ok) { data = payload; break; }
|
||||
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
|
||||
lastError = payload.detail || `HTTP ${response.status}`;
|
||||
}
|
||||
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
|
||||
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
|
||||
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
|
||||
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
|
||||
} catch (error) {
|
||||
feedback.className = 'alert alert-danger mt-3';
|
||||
feedback.textContent = `Fejl: ${error.message}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -184,6 +184,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
|
||||
<div>
|
||||
<h5 class="mb-1 fw-bold">Faktura-fejl-finder</h5>
|
||||
<p class="text-muted mb-0">Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="invoiceErrorFinderSettingsCard"></div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
@ -2374,7 +2384,8 @@ async function testTelefoniCall() {
|
||||
|
||||
function renderDriftConnectors() {
|
||||
const container = document.getElementById('driftConnectorCards');
|
||||
if (!container) return;
|
||||
const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
|
||||
if (!container && !invoiceErrorFinderContainer) return;
|
||||
|
||||
const connectors = [
|
||||
{
|
||||
@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
|
||||
<span id="uispSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
key: 'invoice-error-finder',
|
||||
title: 'Faktura-fejl-finder',
|
||||
description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.',
|
||||
badge: 'Okonomi',
|
||||
body: `
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Ignorer varetekster</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
|
||||
</div>
|
||||
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
|
||||
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 mt-4">
|
||||
<button class="btn btn-primary" onclick="saveInvoiceErrorFinderSettings()">
|
||||
<i class="bi bi-save me-2"></i>Gem faktura-fejl-finder
|
||||
</button>
|
||||
<span id="invoiceErrorFinderSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
container.innerHTML = connectors.map(connector => `
|
||||
const renderConnectorCard = (connector) => `
|
||||
<div class="card border-0 bg-light p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
|
||||
</div>
|
||||
${connector.body}
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = connectors
|
||||
.filter(connector => connector.key !== 'invoice-error-finder')
|
||||
.map(renderConnectorCard)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (invoiceErrorFinderContainer) {
|
||||
const invoiceErrorFinderConnector = connectors.find(connector => connector.key === 'invoice-error-finder');
|
||||
invoiceErrorFinderContainer.innerHTML = invoiceErrorFinderConnector
|
||||
? renderConnectorCard(invoiceErrorFinderConnector)
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
@ -2477,6 +2527,7 @@ async function loadSettings() {
|
||||
renderDriftConnectors();
|
||||
await loadUptimeKumaSettings();
|
||||
await loadUISPSettings();
|
||||
await loadInvoiceErrorFinderSettings();
|
||||
await loadLabelPrinterSettings();
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
@ -2805,6 +2856,30 @@ async function loadUISPSettings() {
|
||||
}
|
||||
|
||||
let driftBlacklistItems = [];
|
||||
const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [
|
||||
'faktureringsgebyr',
|
||||
'gebyr',
|
||||
'porto',
|
||||
'fragt',
|
||||
'fragtomkostning',
|
||||
'forsendelse',
|
||||
'shipping',
|
||||
'levering',
|
||||
'engangsydelse',
|
||||
'engangsarbejde',
|
||||
'oprettelse',
|
||||
'opstartsgebyr',
|
||||
'installation',
|
||||
'installationsgebyr',
|
||||
'timeforbrug',
|
||||
'arbejdstid',
|
||||
'konsulenttimer',
|
||||
'supporttid',
|
||||
'teknikertid',
|
||||
'montørtimer',
|
||||
'projektarbejde'
|
||||
];
|
||||
let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
|
||||
function parseDriftBlacklistValue(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) {
|
||||
renderDriftBlacklistList();
|
||||
}
|
||||
|
||||
function parseInvoiceErrorFinderIgnoreValue(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
|
||||
let values = [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed;
|
||||
} else if (typeof parsed === 'string') {
|
||||
values = [parsed];
|
||||
}
|
||||
} catch (e) {
|
||||
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const cleaned = [];
|
||||
[...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => {
|
||||
const normalized = String(item || '').trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
cleaned.push(normalized);
|
||||
});
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function renderInvoiceErrorFinderIgnoreList() {
|
||||
const list = document.getElementById('invoiceErrorFinderIgnoreList');
|
||||
if (!list) return;
|
||||
if (!invoiceErrorFinderIgnoreItems.length) {
|
||||
list.innerHTML = '<span class="text-muted small">Ingen varetekster ignoreres endnu.</span>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => {
|
||||
const safe = String(item).replace(/</g, '<').replace(/>/g, '>');
|
||||
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">×</button></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addInvoiceErrorFinderIgnoreItem() {
|
||||
const input = document.getElementById('invoiceErrorFinderIgnoreInput');
|
||||
if (!input) return;
|
||||
const value = String(input.value || '').trim().toLowerCase();
|
||||
if (!value) return;
|
||||
if (!invoiceErrorFinderIgnoreItems.includes(value)) {
|
||||
invoiceErrorFinderIgnoreItems.push(value);
|
||||
}
|
||||
input.value = '';
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
|
||||
function removeInvoiceErrorFinderIgnoreItem(item) {
|
||||
invoiceErrorFinderIgnoreItems = invoiceErrorFinderIgnoreItems.filter(v => v !== item);
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
|
||||
async function loadInvoiceErrorFinderSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await response.json();
|
||||
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(setting?.value || '[]');
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
} catch (e) {
|
||||
console.warn('Invoice Error Finder settings load failed:', e);
|
||||
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInvoiceErrorFinderSettings() {
|
||||
const statusEl = document.getElementById('invoiceErrorFinderSaveStatus');
|
||||
statusEl.textContent = 'Gemmer...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
const value = JSON.stringify(parseInvoiceErrorFinderIgnoreValue(invoiceErrorFinderIgnoreItems));
|
||||
const payload = {
|
||||
key: 'invoice_error_finder_ignored_product_texts',
|
||||
value,
|
||||
category: 'finance',
|
||||
description: 'JSON array of invoice product texts/descriptions ignored by Invoice Error Finder',
|
||||
value_type: 'string',
|
||||
is_public: false
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
response = await fetch('/api/v1/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme ignore-listen'));
|
||||
}
|
||||
|
||||
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(value);
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
statusEl.textContent = '✅ Gemt';
|
||||
statusEl.className = 'small text-success';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
showNotification('Ignore-liste gemt', 'success');
|
||||
} catch (error) {
|
||||
statusEl.textContent = '❌ Kunne ikke gemme';
|
||||
statusEl.className = 'small text-danger';
|
||||
showNotification(error.message || 'Kunne ikke gemme ignore-listen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUISPSettings() {
|
||||
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
|
||||
const apiToken = (document.getElementById('uispApiToken').value || '').trim();
|
||||
|
||||
@ -1704,13 +1704,13 @@ if (bmcOriginalFetch) {
|
||||
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
||||
e.preventDefault();
|
||||
openQuickCreateModal();
|
||||
openNewCasePage();
|
||||
}
|
||||
|
||||
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
openQuickCreateModal();
|
||||
openNewCasePage();
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
@ -1719,22 +1719,14 @@ if (bmcOriginalFetch) {
|
||||
}
|
||||
});
|
||||
|
||||
// QuickCreate modal opener function
|
||||
function openQuickCreateModal() {
|
||||
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
|
||||
quickCreateModal.show();
|
||||
setTimeout(() => {
|
||||
const textInput = document.getElementById('quickCreateText');
|
||||
if (textInput) {
|
||||
textInput.focus();
|
||||
}
|
||||
}, 300);
|
||||
function openNewCasePage() {
|
||||
window.location.href = '/sag/new';
|
||||
}
|
||||
|
||||
// QuickCreate button click handler
|
||||
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openQuickCreateModal();
|
||||
openNewCasePage();
|
||||
});
|
||||
|
||||
// Reset search when modal is closed
|
||||
@ -2257,9 +2249,6 @@ if (bmcOriginalFetch) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
||||
|
||||
<!-- Manual Help Modal -->
|
||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||
|
||||
@ -2305,6 +2294,11 @@ if (bmcOriginalFetch) {
|
||||
<label class="form-label fw-semibold">Mobilnummer</label>
|
||||
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Standard sagstype</label>
|
||||
<select class="form-select" id="prof_default_case_type"></select>
|
||||
<div class="form-text">Bruges som udgangspunkt på “Ny sag”.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
|
||||
@ -2673,10 +2667,27 @@ if (bmcOriginalFetch) {
|
||||
document.getElementById('prof_full_name').value = p.full_name || '';
|
||||
document.getElementById('prof_title').value = p.title || '';
|
||||
document.getElementById('prof_phone').value = p.phone || '';
|
||||
await loadProfileCaseTypes(p.default_case_type || 'ticket');
|
||||
} catch (e) { console.error('Failed to load profile', e); }
|
||||
loadAnyDeskChips();
|
||||
}
|
||||
|
||||
async function loadProfileCaseTypes(selectedType = 'ticket') {
|
||||
const select = document.getElementById('prof_default_case_type');
|
||||
if (!select) return;
|
||||
const labels = { ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service' };
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/case_types', { credentials: 'include' });
|
||||
const setting = res.ok ? await res.json() : { value: '[]' };
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(value => String(value).toLowerCase()) : [];
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(labels);
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${labels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(selectedType) ? selectedType : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
} catch (e) { console.error('Failed to load profile case types', e); }
|
||||
}
|
||||
|
||||
function buildInitials(name) {
|
||||
const clean = String(name || '').trim();
|
||||
if (!clean) return 'BR';
|
||||
@ -2763,6 +2774,7 @@ if (bmcOriginalFetch) {
|
||||
full_name: document.getElementById('prof_full_name').value || null,
|
||||
title: document.getElementById('prof_title').value || null,
|
||||
phone: document.getElementById('prof_phone').value || null,
|
||||
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me/profile', {
|
||||
|
||||
@ -9,7 +9,7 @@ E-CONOMIC owns and syncs:
|
||||
- economic_customer_number (primary key from e-conomic)
|
||||
- address, city, postal_code, country (physical address)
|
||||
- email_domain, website (contact information)
|
||||
- cvr_number (used for matching only, not overwritten if already set)
|
||||
- cvr_number (company metadata; may be shared by several customers)
|
||||
|
||||
vTIGER owns and syncs:
|
||||
- vtiger_id (primary key from vTiger)
|
||||
@ -18,7 +18,7 @@ vTIGER owns and syncs:
|
||||
|
||||
HUB owns (manual or first-sync only):
|
||||
- name (can be synced initially but not overwritten)
|
||||
- cvr_number (used for matching, set once)
|
||||
- cvr_number (informational; can be refreshed from e-conomic)
|
||||
- Tags, notes, custom fields
|
||||
|
||||
SYNC RULES:
|
||||
@ -192,7 +192,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
# Strict matching: ONLY match by economic_customer_number
|
||||
existing = execute_query(
|
||||
"""
|
||||
SELECT id, name, email_domain, address, city, postal_code, country, website
|
||||
SELECT id, name, cvr_number, email_domain, address, city, postal_code, country, website
|
||||
FROM customers
|
||||
WHERE economic_customer_number = %s
|
||||
ORDER BY id
|
||||
@ -221,6 +221,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
if existing:
|
||||
target_customer_id = existing[0]['id']
|
||||
current_values = {
|
||||
"cvr_number": existing[0].get("cvr_number"),
|
||||
"email_domain": existing[0].get("email_domain"),
|
||||
"address": existing[0].get("address"),
|
||||
"city": existing[0].get("city"),
|
||||
@ -229,6 +230,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
"website": existing[0].get("website"),
|
||||
}
|
||||
proposed_values = {
|
||||
"cvr_number": cvr,
|
||||
"email_domain": email_domain,
|
||||
"address": address,
|
||||
"city": city,
|
||||
@ -252,6 +254,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
update_query = """
|
||||
UPDATE customers SET
|
||||
economic_customer_number = %s,
|
||||
cvr_number = %s,
|
||||
email_domain = %s,
|
||||
address = %s,
|
||||
city = %s,
|
||||
@ -262,7 +265,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_query(update_query, (
|
||||
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||
customer_number, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||
))
|
||||
logger.info(
|
||||
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
||||
|
||||
13
migrations/1000_customers_allow_duplicate_cvr.sql
Normal file
13
migrations/1000_customers_allow_duplicate_cvr.sql
Normal 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.';
|
||||
@ -108,7 +108,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
||||
'error_found',
|
||||
'ready_to_invoice',
|
||||
'invoiced',
|
||||
'ignored'
|
||||
'ignored',
|
||||
'resolved'
|
||||
)),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
customer_name VARCHAR(255),
|
||||
|
||||
26
migrations/213_invoice_error_finder_resolved_status.sql
Normal file
26
migrations/213_invoice_error_finder_resolved_status.sql
Normal 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'
|
||||
));
|
||||
15
migrations/214_user_sag_create_preferences.sql
Normal file
15
migrations/214_user_sag_create_preferences.sql
Normal 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');
|
||||
38
migrations/215_locations_wall_outlets.sql
Normal file
38
migrations/215_locations_wall_outlets.sql
Normal 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();
|
||||
17
migrations/216_locations_scoped_name_uniqueness.sql
Normal file
17
migrations/216_locations_scoped_name_uniqueness.sql
Normal 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;
|
||||
8
migrations/217_locations_cross_field_marker.sql
Normal file
8
migrations/217_locations_cross_field_marker.sql
Normal 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;
|
||||
25
migrations/218_locations_cross_fields.sql
Normal file
25
migrations/218_locations_cross_fields.sql
Normal 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);
|
||||
7
migrations/219_wall_outlet_cross_field_port.sql
Normal file
7
migrations/219_wall_outlet_cross_field_port.sql
Normal 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;
|
||||
@ -182,6 +182,52 @@ def test_migration_wizard_v2_query_keeps_precise_segment_hits(monkeypatch):
|
||||
assert payload["documents"][0]["snippet_count"] == 1
|
||||
|
||||
|
||||
def test_migration_wizard_v2_returns_full_text_for_selected_segment(monkeypatch):
|
||||
from app.modules.internet_connections.backend import router as internet_router
|
||||
|
||||
monkeypatch.setattr(internet_router, "execute_query_single", lambda query, params: {
|
||||
"segment_id": 202,
|
||||
"document_id": 102,
|
||||
"block_index": 3,
|
||||
"title": "StageOne uplink",
|
||||
"original_filename": "karise.txt",
|
||||
"content": "Hele den valgte tekstblok\nmed alle linjer.",
|
||||
})
|
||||
|
||||
payload = asyncio.run(internet_router.get_customer_document_segment(202))
|
||||
|
||||
assert payload["title"] == "StageOne uplink"
|
||||
assert payload["content"] == "Hele den valgte tekstblok\nmed alle linjer."
|
||||
|
||||
|
||||
def test_migration_wizard_block_search_requires_all_words(monkeypatch):
|
||||
from app.modules.internet_connections.backend import router as internet_router
|
||||
|
||||
def fake_execute_query(query, params=None):
|
||||
if "FROM internet_connections_customer_documents" in query:
|
||||
return [{
|
||||
"id": 103, "customer_id": 77, "connection_id": None,
|
||||
"original_filename": "sales.txt", "filename": "sales.txt",
|
||||
"file_size": 1, "mime_type": "text/plain", "notes": None,
|
||||
"created_at": None, "extracted_text": "Management network notes",
|
||||
}]
|
||||
if "FROM internet_connections_customer_document_segments" in query:
|
||||
return [{
|
||||
"id": 203, "document_id": 103, "block_index": 0,
|
||||
"block_title": "Management", "content": "Management network notes",
|
||||
"ip_addresses": [], "cidr_blocks": [], "references_json": [], "socket_numbers": [],
|
||||
}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
|
||||
monkeypatch.setattr(internet_router, "_ensure_document_segments", lambda document_id, extracted_text: 1)
|
||||
|
||||
payload = asyncio.run(internet_router._build_customer_document_hits(77, "Karise", "sales management"))
|
||||
|
||||
assert payload["segments"] == []
|
||||
assert payload["documents"] == []
|
||||
|
||||
|
||||
def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
|
||||
from app.modules.internet_connections.backend import router as internet_router
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@ -203,6 +204,85 @@ def test_detection_service_ignores_non_hub_customer_keys(monkeypatch):
|
||||
assert customer_id is None
|
||||
|
||||
|
||||
def test_detection_service_upsert_nulls_unknown_customer_id(monkeypatch):
|
||||
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||
|
||||
inserted = {}
|
||||
|
||||
def fake_execute_query_single(query, params=None):
|
||||
if "SELECT id, status" in query and "FROM invoice_error_finder_issues" in query:
|
||||
return None
|
||||
if "SELECT id FROM customers" in query:
|
||||
return None
|
||||
if "INSERT INTO invoice_error_finder_issues" in query:
|
||||
inserted["params"] = params
|
||||
return {"id": 21}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||
fake_execute_query_single,
|
||||
)
|
||||
|
||||
service = DetectionService()
|
||||
issue_id = service._upsert_issue(
|
||||
issue_type="missing_line",
|
||||
customer_id=702222153,
|
||||
customer_name="Unknown Mapping",
|
||||
product_number="INET-1000",
|
||||
reference_period_start=date(2026, 7, 1),
|
||||
reference_period_end=date(2026, 7, 31),
|
||||
)
|
||||
|
||||
assert issue_id == 21
|
||||
assert inserted["params"][2] is None
|
||||
|
||||
|
||||
def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
|
||||
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||
|
||||
scanned_months = []
|
||||
current_month = date.today().replace(day=1)
|
||||
first_month = current_month - __import__("dateutil.relativedelta").relativedelta.relativedelta(months=3)
|
||||
|
||||
def fake_execute_query_single(query, params=None):
|
||||
if "MIN(invoice_date)" in query and "MAX(invoice_date)" in query:
|
||||
return {
|
||||
"first_month": first_month,
|
||||
"last_month": current_month,
|
||||
}
|
||||
return None
|
||||
|
||||
def fake_analyze_single_month(self, month):
|
||||
scanned_months.append(month)
|
||||
return {
|
||||
"missing_line": 1,
|
||||
"open_order_not_invoiced": 0,
|
||||
"quantity_drop": 0,
|
||||
"price_change": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||
fake_execute_query_single,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
DetectionService,
|
||||
"_analyze_single_month",
|
||||
fake_analyze_single_month,
|
||||
)
|
||||
|
||||
service = DetectionService()
|
||||
counts = service.analyze()
|
||||
|
||||
assert scanned_months == [
|
||||
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=1),
|
||||
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=2),
|
||||
current_month,
|
||||
]
|
||||
assert counts["missing_line"] == 3
|
||||
|
||||
|
||||
def test_list_issues_supports_unassigned_filter(monkeypatch):
|
||||
from app.modules.invoice_error_finder.backend.router import list_issues
|
||||
|
||||
@ -235,3 +315,266 @@ def test_list_issues_supports_unassigned_filter(monkeypatch):
|
||||
assert payload["total"] == 0
|
||||
assert "assigned_user_id IS NULL" in captured["count_query"]
|
||||
assert "assigned_user_id IS NULL" in captured["list_query"]
|
||||
|
||||
|
||||
def test_list_issues_returns_resolved_product_name(monkeypatch):
|
||||
from app.modules.invoice_error_finder.backend.router import list_issues
|
||||
|
||||
def fake_execute_query_single(query, params=None):
|
||||
if "COUNT(*) AS c" in query:
|
||||
return {"c": 1}
|
||||
return None
|
||||
|
||||
def fake_execute_query(query, params=None):
|
||||
return [
|
||||
{
|
||||
"id": 44,
|
||||
"customer_name": "Karise Anlæg & Byg A/S",
|
||||
"product_number": "PRO563",
|
||||
"product_name": None,
|
||||
"resolved_product_name": "Fiberforbindelse 1/1 Gbit.",
|
||||
"status": "open",
|
||||
"issue_type": "missing_line",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||
fake_execute_query_single,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||
fake_execute_query,
|
||||
)
|
||||
|
||||
payload = asyncio.run(
|
||||
list_issues(
|
||||
status=None,
|
||||
issue_type=None,
|
||||
customer_id=None,
|
||||
assigned_user_id=None,
|
||||
limit=100,
|
||||
offset=0,
|
||||
current_user={},
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["items"][0]["resolved_product_name"] == "Fiberforbindelse 1/1 Gbit."
|
||||
|
||||
|
||||
def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
|
||||
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
||||
|
||||
def fake_execute_query_single(query, params=None):
|
||||
if "FROM invoice_error_finder_issues" in query:
|
||||
return {
|
||||
"id": 14,
|
||||
"customer_id": 77,
|
||||
"customer_name": "ACME",
|
||||
"product_number": "INET-1000",
|
||||
"product_name": "1/1 Gbit Internet",
|
||||
"reference_period_start": date(2026, 7, 1),
|
||||
"reference_period_end": date(2026, 7, 31),
|
||||
}
|
||||
if "FROM customers WHERE id = %s" in query:
|
||||
return {
|
||||
"id": 77,
|
||||
"name": "ACME",
|
||||
"economic_customer_number": 56283338,
|
||||
}
|
||||
return None
|
||||
|
||||
def fake_execute_query(query, params=None):
|
||||
if "FROM month_window mw" in query:
|
||||
return [
|
||||
{
|
||||
"month_start": date(2026, 6, 1),
|
||||
"line_count": 1,
|
||||
"total_quantity": 1,
|
||||
"total_amount": 999.0,
|
||||
"invoice_numbers": ["18912"],
|
||||
"invoice_dates": ["2026-06-03"],
|
||||
"descriptions": ["1/1 Gbit Internet"],
|
||||
"is_reference_month": False,
|
||||
},
|
||||
{
|
||||
"month_start": date(2026, 7, 1),
|
||||
"line_count": 0,
|
||||
"total_quantity": 0,
|
||||
"total_amount": 0,
|
||||
"invoice_numbers": [],
|
||||
"invoice_dates": [],
|
||||
"descriptions": [],
|
||||
"is_reference_month": True,
|
||||
},
|
||||
{
|
||||
"month_start": date(2026, 8, 1),
|
||||
"line_count": 1,
|
||||
"total_quantity": 1,
|
||||
"total_amount": 999.0,
|
||||
"invoice_numbers": ["19001"],
|
||||
"invoice_dates": ["2026-08-04"],
|
||||
"descriptions": ["1/1 Gbit Internet"],
|
||||
"is_reference_month": False,
|
||||
},
|
||||
]
|
||||
if "WITH ranked_invoices AS" in query:
|
||||
return [
|
||||
{
|
||||
"month_start": date(2026, 6, 1),
|
||||
"invoice_id": 101,
|
||||
"source_invoice_number": "18912",
|
||||
"invoice_date": date(2026, 6, 3),
|
||||
"total_amount": 1248.75,
|
||||
"net_amount": 999.0,
|
||||
"vat_amount": 249.75,
|
||||
"currency": "DKK",
|
||||
"source_type": "booked",
|
||||
"heading": "Periode June 2026",
|
||||
"note_text": "Kundeperiode juni\nEkstra note",
|
||||
"line_number": 1,
|
||||
"product_number": "INET-1000",
|
||||
"product_name": None,
|
||||
"description": "1/1 Gbit Internet",
|
||||
"quantity": 1,
|
||||
"unit_price": 999.0,
|
||||
"line_net_amount": 999.0,
|
||||
},
|
||||
{
|
||||
"month_start": date(2026, 6, 1),
|
||||
"invoice_id": 101,
|
||||
"source_invoice_number": "18912",
|
||||
"invoice_date": date(2026, 6, 3),
|
||||
"total_amount": 1248.75,
|
||||
"net_amount": 999.0,
|
||||
"vat_amount": 249.75,
|
||||
"currency": "DKK",
|
||||
"source_type": "booked",
|
||||
"heading": "Periode June 2026",
|
||||
"note_text": "Kundeperiode juni\nEkstra note",
|
||||
"line_number": 2,
|
||||
"product_number": "RTR-1",
|
||||
"product_name": None,
|
||||
"description": "Leje af router",
|
||||
"quantity": 1,
|
||||
"unit_price": 249.0,
|
||||
"line_net_amount": 249.0,
|
||||
},
|
||||
{
|
||||
"month_start": date(2026, 8, 1),
|
||||
"invoice_id": 102,
|
||||
"source_invoice_number": "19001",
|
||||
"invoice_date": date(2026, 8, 4),
|
||||
"total_amount": 1248.75,
|
||||
"net_amount": 999.0,
|
||||
"vat_amount": 249.75,
|
||||
"currency": "DKK",
|
||||
"source_type": "booked",
|
||||
"heading": "Periode August 2026",
|
||||
"note_text": None,
|
||||
"line_number": 1,
|
||||
"product_number": "INET-1000",
|
||||
"product_name": None,
|
||||
"description": "1/1 Gbit Internet",
|
||||
"quantity": 1,
|
||||
"unit_price": 999.0,
|
||||
"line_net_amount": 999.0,
|
||||
},
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||
fake_execute_query_single,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||
fake_execute_query,
|
||||
)
|
||||
|
||||
payload = asyncio.run(get_issue_invoice_history(14, current_user={}))
|
||||
|
||||
assert payload["customer_name"] == "ACME"
|
||||
assert payload["product_number"] == "INET-1000"
|
||||
assert len(payload["months"]) == 3
|
||||
assert payload["months"][1]["is_reference_month"] is True
|
||||
assert payload["months"][1]["line_count"] == 0
|
||||
assert payload["months"][0]["invoices"][0]["invoice_number"] == "18912"
|
||||
assert payload["months"][0]["invoices"][0]["note_text"] == "Kundeperiode juni\nEkstra note"
|
||||
assert len(payload["months"][0]["invoices"][0]["lines"]) == 2
|
||||
|
||||
|
||||
def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch):
|
||||
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
||||
|
||||
def fake_execute_query_single(query, params=None):
|
||||
if "FROM invoice_error_finder_issues" in query:
|
||||
return {
|
||||
"id": 15,
|
||||
"customer_id": 77,
|
||||
"customer_name": "ACME",
|
||||
"product_number": "INET-1000",
|
||||
"product_name": "1/1 Gbit Internet",
|
||||
"reference_period_start": date(2026, 7, 1),
|
||||
"reference_period_end": date(2026, 7, 31),
|
||||
}
|
||||
if "FROM customers WHERE id = %s" in query:
|
||||
return {
|
||||
"id": 77,
|
||||
"name": "ACME",
|
||||
"economic_customer_number": 56283338,
|
||||
}
|
||||
return None
|
||||
|
||||
def fake_execute_query(query, params=None):
|
||||
if "FROM month_window mw" in query:
|
||||
return [
|
||||
{
|
||||
"month_start": date(2026, 7, 1),
|
||||
"line_count": 2,
|
||||
"total_quantity": 2,
|
||||
"total_amount": 1444.0,
|
||||
"invoice_numbers": ["20098", "20098"],
|
||||
"invoice_dates": ["2026-03-13", "2026-03-13"],
|
||||
"descriptions": ["Fiberforbindelse", "Fiberforbindelse"],
|
||||
"is_reference_month": True,
|
||||
},
|
||||
]
|
||||
if "WITH ranked_invoices AS" in query:
|
||||
return [
|
||||
{
|
||||
"month_start": date(2026, 7, 1),
|
||||
"invoice_id": 201,
|
||||
"source_invoice_number": "20098",
|
||||
"invoice_date": date(2026, 3, 13),
|
||||
"total_amount": 902.5,
|
||||
"net_amount": 722.0,
|
||||
"vat_amount": 180.5,
|
||||
"currency": "DKK",
|
||||
"source_type": "paid",
|
||||
"heading": None,
|
||||
"note_text": None,
|
||||
"line_number": 1,
|
||||
"product_number": "INET-1000",
|
||||
"product_name": None,
|
||||
"description": "Fiberforbindelse",
|
||||
"quantity": 1,
|
||||
"unit_price": 722.0,
|
||||
"line_net_amount": 722.0,
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||
fake_execute_query_single,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||
fake_execute_query,
|
||||
)
|
||||
|
||||
payload = asyncio.run(get_issue_invoice_history(15, current_user={}))
|
||||
|
||||
assert len(payload["months"][0]["invoices"]) == 1
|
||||
assert payload["months"][0]["invoices"][0]["source_type"] == "paid"
|
||||
|
||||
78
tests/test_locations_wall_outlets.py
Normal file
78
tests/test_locations_wall_outlets.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user