Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be68148448 | ||
|
|
d4d9ac22a5 | ||
|
|
0655b4c4f8 | ||
|
|
db2e8c3157 |
@ -217,6 +217,22 @@ class UserProfileUpdate(BaseModel):
|
|||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
anydesk_id: Optional[str] = None
|
anydesk_id: Optional[str] = None
|
||||||
|
default_case_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_case_types() -> list[str]:
|
||||||
|
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
|
||||||
|
try:
|
||||||
|
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
||||||
|
if rows:
|
||||||
|
import json
|
||||||
|
configured = json.loads(rows[0].get("value") or "[]")
|
||||||
|
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
||||||
|
if values:
|
||||||
|
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/profile")
|
@router.get("/me/profile")
|
||||||
@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
|
|||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return dict(rows[0])
|
profile = dict(rows[0])
|
||||||
|
profile["default_case_type"] = "ticket"
|
||||||
|
try:
|
||||||
|
preference = execute_query(
|
||||||
|
"SELECT default_case_type FROM user_sag_create_preferences WHERE user_id = %s",
|
||||||
|
(current_user["id"],),
|
||||||
|
) or []
|
||||||
|
if preference:
|
||||||
|
profile["default_case_type"] = preference[0].get("default_case_type") or "ticket"
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Sag create preferences table not available yet")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/profile")
|
@router.patch("/me/profile")
|
||||||
@ -253,12 +280,29 @@ async def update_my_profile(
|
|||||||
fields.append("anydesk_id = %s")
|
fields.append("anydesk_id = %s")
|
||||||
values.append(payload.anydesk_id.strip() or None)
|
values.append(payload.anydesk_id.strip() or None)
|
||||||
|
|
||||||
if not fields:
|
if payload.default_case_type is not None:
|
||||||
|
default_case_type = payload.default_case_type.strip().lower() or "ticket"
|
||||||
|
if default_case_type not in _allowed_case_types():
|
||||||
|
raise HTTPException(status_code=400, detail="Ukendt sagstype")
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_sag_create_preferences (user_id, default_case_type, updated_at)
|
||||||
|
VALUES (%s, %s, NOW())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET default_case_type = EXCLUDED.default_case_type, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(current_user["id"], default_case_type),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=409, detail="Profilindstillingen er ikke klar. Kør migration 214 først.") from exc
|
||||||
|
|
||||||
|
if not fields and payload.default_case_type is None:
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
if fields:
|
||||||
fields.append("updated_at = NOW()")
|
fields.append("updated_at = NOW()")
|
||||||
values.append(current_user["id"])
|
values.append(current_user["id"])
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
||||||
tuple(values)
|
tuple(values)
|
||||||
|
|||||||
@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
|
|||||||
<td>${escapeHtml(stage)}</td>
|
<td>${escapeHtml(stage)}</td>
|
||||||
<td>${probability}</td>
|
<td>${probability}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/opportunities/${o.id}">
|
<a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int):
|
|||||||
return rows or []
|
return rows or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/economic-invoices")
|
||||||
|
async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)):
|
||||||
|
"""Get imported e-conomic invoices for a customer from invoice_error_finder staging data."""
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
economic_customer_number = customer.get("economic_customer_number")
|
||||||
|
if not economic_customer_number:
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer.get("name"),
|
||||||
|
"economic_customer_number": None,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
WITH ranked_invoices AS (
|
||||||
|
SELECT
|
||||||
|
inv.id,
|
||||||
|
inv.source_invoice_number,
|
||||||
|
inv.invoice_date,
|
||||||
|
inv.due_date,
|
||||||
|
inv.total_amount,
|
||||||
|
inv.net_amount,
|
||||||
|
inv.vat_amount,
|
||||||
|
inv.currency,
|
||||||
|
inv.source_type,
|
||||||
|
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||||
|
NULLIF(
|
||||||
|
CONCAT_WS(
|
||||||
|
E'\n',
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS note_text,
|
||||||
|
CASE inv.source_type
|
||||||
|
WHEN 'paid' THEN 1
|
||||||
|
WHEN 'booked' THEN 2
|
||||||
|
WHEN 'unpaid' THEN 3
|
||||||
|
WHEN 'draft' THEN 4
|
||||||
|
ELSE 9
|
||||||
|
END AS source_rank
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
WHERE inv.customer_number = %s
|
||||||
|
),
|
||||||
|
selected_invoices AS (
|
||||||
|
SELECT DISTINCT ON (source_invoice_number)
|
||||||
|
id,
|
||||||
|
source_invoice_number,
|
||||||
|
invoice_date,
|
||||||
|
due_date,
|
||||||
|
total_amount,
|
||||||
|
net_amount,
|
||||||
|
vat_amount,
|
||||||
|
currency,
|
||||||
|
source_type,
|
||||||
|
heading,
|
||||||
|
note_text
|
||||||
|
FROM ranked_invoices
|
||||||
|
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
si.id AS invoice_id,
|
||||||
|
si.source_invoice_number,
|
||||||
|
si.invoice_date,
|
||||||
|
si.due_date,
|
||||||
|
si.total_amount,
|
||||||
|
si.net_amount,
|
||||||
|
si.vat_amount,
|
||||||
|
si.currency,
|
||||||
|
si.source_type,
|
||||||
|
si.heading,
|
||||||
|
si.note_text,
|
||||||
|
line.line_number,
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.line_net_amount
|
||||||
|
FROM selected_invoices si
|
||||||
|
LEFT JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = si.id
|
||||||
|
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(economic_customer_number, limit * 25),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
invoices: List[Dict[str, Any]] = []
|
||||||
|
invoices_by_id: Dict[int, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
invoice_id = row.get("invoice_id")
|
||||||
|
if invoice_id is None:
|
||||||
|
continue
|
||||||
|
if invoice_id not in invoices_by_id:
|
||||||
|
payload = {
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"invoice_number": row.get("source_invoice_number"),
|
||||||
|
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
|
||||||
|
"due_date": row["due_date"].isoformat() if row.get("due_date") else None,
|
||||||
|
"total_amount": float(row.get("total_amount") or 0),
|
||||||
|
"net_amount": float(row.get("net_amount") or 0),
|
||||||
|
"vat_amount": float(row.get("vat_amount") or 0),
|
||||||
|
"currency": row.get("currency") or "DKK",
|
||||||
|
"source_type": row.get("source_type"),
|
||||||
|
"heading": row.get("heading") or None,
|
||||||
|
"note_text": row.get("note_text") or None,
|
||||||
|
"lines": [],
|
||||||
|
}
|
||||||
|
invoices_by_id[invoice_id] = payload
|
||||||
|
invoices.append(payload)
|
||||||
|
if row.get("line_number") is not None:
|
||||||
|
invoices_by_id[invoice_id]["lines"].append(
|
||||||
|
{
|
||||||
|
"line_number": int(row.get("line_number") or 0),
|
||||||
|
"product_number": row.get("product_number"),
|
||||||
|
"product_name": row.get("product_name"),
|
||||||
|
"description": row.get("description"),
|
||||||
|
"quantity": float(row.get("quantity") or 0),
|
||||||
|
"unit_price": float(row.get("unit_price") or 0),
|
||||||
|
"line_net_amount": float(row.get("line_net_amount") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer.get("name"),
|
||||||
|
"economic_customer_number": economic_customer_number,
|
||||||
|
"items": invoices[:limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers/{customer_id}/kontakt")
|
@router.get("/customers/{customer_id}/kontakt")
|
||||||
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
|
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
|
||||||
"""Get unified contact communication history (calls + SMS) for all company contacts."""
|
"""Get unified contact communication history (calls + SMS) for all company contacts."""
|
||||||
|
|||||||
@ -260,6 +260,253 @@
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customer-invoice-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-shell {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
background: rgba(15, 76, 117, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-toolbar-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-toolbar-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-toolbar-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-summary-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-month-group + .customer-invoice-month-group {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-month-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-month-label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-month-total {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-table thead th {
|
||||||
|
background: rgba(15, 76, 117, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-row td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-row:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-period-cell {
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-period-text {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-period-sub {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 76px;
|
||||||
|
padding: 0.38rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-open,
|
||||||
|
.customer-invoice-toggle {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 0.8rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-expanded-row td {
|
||||||
|
background: rgba(15, 76, 117, 0.025);
|
||||||
|
padding: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-expanded {
|
||||||
|
padding: 1rem 1.25rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-note-panel {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-totals {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-cell-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-cell-button:hover {
|
||||||
|
color: #0b3b5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 76, 117, 0.04);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-card .label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-card .value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.customer-invoice-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-invoice-summary {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.column-header {
|
.column-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -990,9 +1237,27 @@
|
|||||||
|
|
||||||
<!-- Invoices Tab -->
|
<!-- Invoices Tab -->
|
||||||
<div class="tab-pane fade" id="invoices">
|
<div class="tab-pane fade" id="invoices">
|
||||||
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div class="text-muted text-center py-5">
|
<div>
|
||||||
Fakturamodul kommer snart...
|
<h5 class="fw-bold mb-0">Fakturaer</h5>
|
||||||
|
<small class="text-muted">Importerede e-conomic-fakturaer for kunden</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadCustomerInvoices(true)">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="contacts-toolbar mb-3">
|
||||||
|
<div class="input-group contacts-search">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" id="customerInvoiceSearchInput" placeholder="Søg i fakturanr., periode, note eller dato" oninput="filterCustomerInvoices(this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearCustomerInvoiceSearch()">Nulstil</button>
|
||||||
|
<span class="badge text-bg-light border" id="customerInvoiceResultCount">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="customerInvoicesContainer" class="text-muted text-center py-5">
|
||||||
|
Åbn fanen for at indlæse fakturaer...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1088,9 +1353,9 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h5 class="fw-bold mb-0">
|
<h5 class="fw-bold mb-0">
|
||||||
<i class="bi bi-table me-2"></i>Abonnements-matrix
|
<i class="bi bi-table me-2"></i>Abonnements-matrix
|
||||||
<small class="text-muted fw-normal">(fra e-conomic)</small>
|
<small class="text-muted fw-normal">(fra importerede e-conomic-fakturaer)</small>
|
||||||
</h5>
|
</h5>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
|
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent matrix fra importerede e-conomic-fakturaer">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -2013,6 +2278,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="customerInvoiceDetailModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Fakturadetaljer</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="customerInvoiceDetailBody">
|
||||||
|
<div class="text-center py-4 text-muted">Indlæser...</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -2032,6 +2314,9 @@ let pipelineStages = [];
|
|||||||
let allTagsCache = [];
|
let allTagsCache = [];
|
||||||
let customerKontaktItems = [];
|
let customerKontaktItems = [];
|
||||||
let customerKontaktFilter = 'all';
|
let customerKontaktFilter = 'all';
|
||||||
|
let customerInvoicesLoaded = false;
|
||||||
|
let customerInvoicesData = [];
|
||||||
|
let customerInvoiceSearchTerm = '';
|
||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
@ -2157,6 +2442,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoicesTab = document.querySelector('a[href="#invoices"]');
|
||||||
|
if (invoicesTab) {
|
||||||
|
invoicesTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerInvoices();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
|
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
|
||||||
if (hashTab && window.bootstrap?.Tab) {
|
if (hashTab && window.bootstrap?.Tab) {
|
||||||
@ -3953,7 +4245,7 @@ function renderCustomerPipeline(opportunities) {
|
|||||||
</td>
|
</td>
|
||||||
<td>${o.probability || 0}%</td>
|
<td>${o.probability || 0}%</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
|
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/sag/${o.id}/v3'">
|
||||||
<i class="bi bi-arrow-right"></i>
|
<i class="bi bi-arrow-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -4472,6 +4764,349 @@ function formatCurrency(value, currency) {
|
|||||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCustomerInvoiceLineRows(lines) {
|
||||||
|
if (!Array.isArray(lines) || lines.length === 0) {
|
||||||
|
return '<div class="text-muted small">Ingen fakturalinjer fundet</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="table-responsive mt-2">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Linje</th>
|
||||||
|
<th>Varenr</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Antal</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th>Beløb</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${lines.map(line => {
|
||||||
|
const description = line.description || line.product_name || '-';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${Number(line.line_number || 0).toLocaleString('da-DK')}</td>
|
||||||
|
<td>${escapeHtml(line.product_number || '-')}</td>
|
||||||
|
<td class="text-truncate" style="max-width: 520px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(description)}">${escapeHtml(description)}</td>
|
||||||
|
<td>${Number(line.quantity || 0).toLocaleString('da-DK')}</td>
|
||||||
|
<td>${formatCurrency(line.unit_price, 'DKK')}</td>
|
||||||
|
<td>${formatCurrency(line.line_net_amount, 'DKK')}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerInvoiceCards(invoices) {
|
||||||
|
if (!Array.isArray(invoices) || invoices.length === 0) {
|
||||||
|
return '<div class="text-muted text-center py-5">Ingen importerede e-conomic-fakturaer fundet for denne kunde</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
invoices.forEach((invoice) => {
|
||||||
|
const key = (invoice.invoice_date || '').slice(0, 7) || 'unknown';
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(invoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryTotal = invoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
|
||||||
|
const monthKeys = Object.keys(grouped).sort().reverse();
|
||||||
|
|
||||||
|
const groupsHtml = monthKeys.map((monthKey) => {
|
||||||
|
const monthInvoices = grouped[monthKey] || [];
|
||||||
|
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + parseFloat(invoice.total_amount || 0), 0);
|
||||||
|
const monthLabel = formatInvoiceMonthLabel(monthKey);
|
||||||
|
|
||||||
|
const rows = monthInvoices.map((invoice, idx) => {
|
||||||
|
const itemId = `customer-economic-invoice-${monthKey}-${idx}`;
|
||||||
|
const status = invoice.source_type || 'booked';
|
||||||
|
const periodText = invoice.heading || invoice.note_text || 'Ingen periodetekst';
|
||||||
|
const lineCount = Array.isArray(invoice.lines) ? invoice.lines.length : 0;
|
||||||
|
const detailLabel = lineCount > 0 ? `Vis linjer (${lineCount})` : 'Vis detaljer';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="customer-invoice-row">
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-link p-0 text-decoration-none customer-invoice-number" type="button" onclick="toggleLineItems('${itemId}')">
|
||||||
|
<i class="bi bi-chevron-right" id="${itemId}-icon"></i>
|
||||||
|
<span>${escapeHtml(invoice.invoice_number || 'Ukendt faktura')}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="customer-invoice-period-cell">
|
||||||
|
<span class="customer-invoice-period-text" title="${escapeHtml(periodText)}">${escapeHtml(periodText)}</span>
|
||||||
|
<span class="customer-invoice-period-sub">${lineCount} linjer</span>
|
||||||
|
</td>
|
||||||
|
<td>${invoice.invoice_date ? escapeHtml(formatDate(invoice.invoice_date)) : '-'}</td>
|
||||||
|
<td>${invoice.due_date ? escapeHtml(formatDate(invoice.due_date)) : '-'}</td>
|
||||||
|
<td class="text-end fw-semibold">${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</td>
|
||||||
|
<td><span class="badge bg-${getStatusColor(status)} customer-invoice-status">${escapeHtml(status)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="customer-invoice-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-primary customer-invoice-open" type="button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(invoice.invoice_number || '')).replace(/'/g, "\\'")}')">Faktura</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary customer-invoice-toggle" type="button" onclick="toggleLineItems('${itemId}')">${detailLabel}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="customer-invoice-expanded-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<div id="${itemId}-lines" class="customer-invoice-expanded" style="display: none;">
|
||||||
|
${invoice.heading || invoice.note_text ? `
|
||||||
|
<div class="customer-invoice-note-panel">
|
||||||
|
${invoice.heading ? `<div class="fw-semibold">${escapeHtml(invoice.heading)}</div>` : ''}
|
||||||
|
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${renderCustomerInvoiceLineRows(invoice.lines || [])}
|
||||||
|
<div class="customer-invoice-totals">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span>Netto:</span>
|
||||||
|
<strong>${formatCurrency(invoice.net_amount, invoice.currency || 'DKK')}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span>Moms:</span>
|
||||||
|
<strong>${formatCurrency(invoice.vat_amount, invoice.currency || 'DKK')}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between text-info fw-bold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<strong>${formatCurrency(invoice.total_amount, invoice.currency || 'DKK')}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="customer-invoice-month-group">
|
||||||
|
<div class="customer-invoice-month-header">
|
||||||
|
<div class="customer-invoice-month-label">${escapeHtml(monthLabel)}</div>
|
||||||
|
<div class="customer-invoice-month-total">${monthInvoices.length} fakturaer · ${formatCurrency(monthTotal, monthInvoices[0]?.currency || 'DKK')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle customer-invoice-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Faktura</th>
|
||||||
|
<th>Periode</th>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Forfald</th>
|
||||||
|
<th class="text-end">Beløb</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="customer-invoice-shell">
|
||||||
|
<div class="customer-invoice-toolbar">
|
||||||
|
<div class="customer-invoice-toolbar-copy">
|
||||||
|
<div class="customer-invoice-toolbar-title">Fakturaoversigt</div>
|
||||||
|
<div class="customer-invoice-toolbar-subtitle">Importerede e-conomic-fakturaer vist som en almindelig oversigt med detaljer pr. faktura</div>
|
||||||
|
</div>
|
||||||
|
<div class="customer-invoice-summary">
|
||||||
|
<span class="customer-invoice-summary-chip"><i class="bi bi-receipt"></i>${invoices.length} fakturaer</span>
|
||||||
|
<span class="customer-invoice-summary-chip"><i class="bi bi-calendar3"></i>${monthKeys.length} måneder</span>
|
||||||
|
<span class="customer-invoice-summary-chip"><i class="bi bi-cash-stack"></i>${formatCurrency(summaryTotal, invoices[0]?.currency || 'DKK')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="customer-invoice-list">${groupsHtml}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInvoiceMonthLabel(yearMonth) {
|
||||||
|
if (!yearMonth || yearMonth === 'unknown') return 'Uden dato';
|
||||||
|
try {
|
||||||
|
const date = new Date(`${yearMonth}-01`);
|
||||||
|
return date.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return yearMonth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredCustomerInvoices() {
|
||||||
|
const term = String(customerInvoiceSearchTerm || '').trim().toLowerCase();
|
||||||
|
if (!term) return customerInvoicesData;
|
||||||
|
|
||||||
|
return customerInvoicesData.filter((invoice) => {
|
||||||
|
const haystack = [
|
||||||
|
invoice.invoice_number,
|
||||||
|
invoice.invoice_date,
|
||||||
|
invoice.due_date,
|
||||||
|
invoice.heading,
|
||||||
|
invoice.note_text,
|
||||||
|
...(Array.isArray(invoice.lines) ? invoice.lines.flatMap((line) => [
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description
|
||||||
|
]) : [])
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return haystack.includes(term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilteredCustomerInvoices() {
|
||||||
|
const container = document.getElementById('customerInvoicesContainer');
|
||||||
|
const countBadge = document.getElementById('customerInvoiceResultCount');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const filteredInvoices = getFilteredCustomerInvoices();
|
||||||
|
container.innerHTML = renderCustomerInvoiceCards(filteredInvoices);
|
||||||
|
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.textContent = String(filteredInvoices.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCustomerInvoices(value) {
|
||||||
|
customerInvoiceSearchTerm = String(value || '');
|
||||||
|
renderFilteredCustomerInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCustomerInvoiceSearch() {
|
||||||
|
const input = document.getElementById('customerInvoiceSearchInput');
|
||||||
|
customerInvoiceSearchTerm = '';
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
renderFilteredCustomerInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerInvoices(force = false) {
|
||||||
|
const container = document.getElementById('customerInvoicesContainer');
|
||||||
|
const countBadge = document.getElementById('customerInvoiceResultCount');
|
||||||
|
if (!container) return;
|
||||||
|
if (customerInvoicesLoaded && !force) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
<div class="small text-muted mt-2">Indlæser fakturaer...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCustomerInvoices(force);
|
||||||
|
|
||||||
|
if (!data.economic_customer_number) {
|
||||||
|
container.innerHTML = '<div class="alert alert-info mb-0">Kunden har ikke et e-conomic kundenummer i BMC Hub endnu.</div>';
|
||||||
|
if (countBadge) countBadge.textContent = '0';
|
||||||
|
customerInvoicesLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilteredCustomerInvoices();
|
||||||
|
customerInvoicesLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (countBadge) countBadge.textContent = '0';
|
||||||
|
container.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente fakturaer')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCustomerInvoices(force = false) {
|
||||||
|
if (customerInvoicesData.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
customer_id: customerId,
|
||||||
|
items: customerInvoicesData,
|
||||||
|
economic_customer_number: customerData?.economic_customer_number || true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/customers/${customerId}/economic-invoices`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.detail || 'Kunne ikke hente fakturaer');
|
||||||
|
}
|
||||||
|
|
||||||
|
customerInvoicesData = Array.isArray(data.items) ? data.items : [];
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceDetailModal(invoice) {
|
||||||
|
const linesHtml = renderCustomerInvoiceLineRows(invoice.lines || []);
|
||||||
|
const notePanel = (invoice.heading || invoice.note_text) ? `
|
||||||
|
<div class="customer-invoice-note-panel">
|
||||||
|
${invoice.heading ? `<div class="fw-semibold mb-1">${escapeHtml(invoice.heading)}</div>` : ''}
|
||||||
|
${invoice.note_text ? `<div style="white-space: pre-wrap;">${escapeHtml(invoice.note_text)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="invoice-detail-summary">
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Fakturanummer</span>
|
||||||
|
<span class="value">${escapeHtml(invoice.invoice_number || '-')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Status</span>
|
||||||
|
<span class="value">${escapeHtml(invoice.source_type || '-')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Fakturadato</span>
|
||||||
|
<span class="value">${escapeHtml(formatDate(invoice.invoice_date) || '-')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Forfald</span>
|
||||||
|
<span class="value">${escapeHtml(formatDate(invoice.due_date) || '-')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Netto</span>
|
||||||
|
<span class="value">${escapeHtml(formatCurrency(invoice.net_amount, invoice.currency || 'DKK'))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-detail-card">
|
||||||
|
<span class="label">Total</span>
|
||||||
|
<span class="value">${escapeHtml(formatCurrency(invoice.total_amount, invoice.currency || 'DKK'))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${notePanel}
|
||||||
|
${linesHtml}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCustomerInvoiceDetail(invoiceNumber) {
|
||||||
|
const body = document.getElementById('customerInvoiceDetailBody');
|
||||||
|
const modalElement = document.getElementById('customerInvoiceDetailModal');
|
||||||
|
if (!body || !modalElement) return;
|
||||||
|
|
||||||
|
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary"></div></div>';
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchCustomerInvoices(false);
|
||||||
|
const matches = customerInvoicesData.filter(invoice => String(invoice.invoice_number || '') === String(invoiceNumber || ''));
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
body.innerHTML = '<div class="alert alert-warning mb-0">Kunne ikke finde fakturaen i de importerede kundedata.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = matches.map(renderInvoiceDetailModal).join('<hr class="my-4">');
|
||||||
|
} catch (error) {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Kunne ikke hente faktura')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadActivity() {
|
async function loadActivity() {
|
||||||
const container = document.getElementById('activityContainer');
|
const container = document.getElementById('activityContainer');
|
||||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||||
@ -6166,11 +6801,15 @@ function renderBillingMatrix(matrix) {
|
|||||||
const amount = cell.amount || 0;
|
const amount = cell.amount || 0;
|
||||||
const statusBadge = getStatusBadge(cell.status);
|
const statusBadge = getStatusBadge(cell.status);
|
||||||
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
|
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
|
||||||
|
const detailButton = cell.invoice_number
|
||||||
|
? `<button type="button" class="matrix-cell-button" onclick="openCustomerInvoiceDetail('${escapeHtml(String(cell.invoice_number)).replace(/'/g, "\\'")}')"><i class="bi bi-receipt-cutoff"></i>Se faktura</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
|
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
|
||||||
<div class="d-flex flex-column align-items-center">
|
<div class="d-flex flex-column align-items-center">
|
||||||
<div class="fw-500">${formatDKK(amount)}</div>
|
<div class="fw-500">${formatDKK(amount)}</div>
|
||||||
<div>${statusBadge}</div>
|
<div>${statusBadge}</div>
|
||||||
|
${detailButton}
|
||||||
</div>
|
</div>
|
||||||
</td>`;
|
</td>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
@ -738,6 +739,18 @@ def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str]
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_uisp_device_detail(base_url: str, token: str, external_id: str) -> Any:
|
||||||
|
"""Fetch interface telemetry for one linked UISP device."""
|
||||||
|
return _fetch_json_from_candidates(
|
||||||
|
base_url,
|
||||||
|
token,
|
||||||
|
[
|
||||||
|
f"nms/api/v2.1/devices/{external_id}/detail",
|
||||||
|
f"nms/api/v2/devices/{external_id}/detail",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_prometheus_labels(raw: str) -> Dict[str, str]:
|
def _parse_prometheus_labels(raw: str) -> Dict[str, str]:
|
||||||
labels: Dict[str, str] = {}
|
labels: Dict[str, str] = {}
|
||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
@ -1068,6 +1081,112 @@ def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _uisp_device_record(item: Dict[str, Any], base_url: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Normalize the useful UISP fields while retaining the complete source payload."""
|
||||||
|
identification = item.get("identification") if isinstance(item.get("identification"), dict) else {}
|
||||||
|
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
|
||||||
|
external_id = identification.get("id") or item.get("id") or item.get("device_id")
|
||||||
|
if not external_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def text(*values: Any) -> Optional[str]:
|
||||||
|
for value in values:
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
ips: List[str] = []
|
||||||
|
for value in (item.get("ipAddress"), item.get("ip"), identification.get("ipAddress"), overview.get("ipAddress")):
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if value and value not in ips:
|
||||||
|
ips.append(value)
|
||||||
|
for key in ("ipAddressList", "ipv6AddressList", "ipv6LinkLocalList"):
|
||||||
|
for value in item.get(key) or []:
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if value and value not in ips:
|
||||||
|
ips.append(value)
|
||||||
|
|
||||||
|
last_seen = overview.get("lastSeen")
|
||||||
|
last_seen_dt = None
|
||||||
|
if last_seen:
|
||||||
|
try:
|
||||||
|
last_seen_dt = datetime.fromisoformat(str(last_seen).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"external_id": str(external_id),
|
||||||
|
"name": text(identification.get("name"), identification.get("displayName"), item.get("name")),
|
||||||
|
"display_name": text(identification.get("displayName"), identification.get("name")),
|
||||||
|
"hostname": text(identification.get("hostname"), identification.get("systemName")),
|
||||||
|
"mac_address": text(identification.get("mac"), item.get("mac")),
|
||||||
|
"serial_number": text(identification.get("serialNumber"), item.get("serialNumber")),
|
||||||
|
"vendor": text(identification.get("vendorName"), identification.get("vendor")),
|
||||||
|
"model": text(identification.get("modelName"), identification.get("model")),
|
||||||
|
"platform": text(identification.get("platformName"), identification.get("platformId")),
|
||||||
|
"device_type": text(identification.get("type"), identification.get("category")),
|
||||||
|
"device_role": text(identification.get("role")),
|
||||||
|
"ip_addresses": ips,
|
||||||
|
"status": text(overview.get("status"), identification.get("status"), item.get("status")),
|
||||||
|
"last_seen": last_seen_dt,
|
||||||
|
"device_link": _extract_device_link(item, base_url, str(external_id)),
|
||||||
|
"raw_json": item,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_uisp_devices(payload: Any, base_url: Optional[str] = None) -> int:
|
||||||
|
"""Cache UISP devices and enrich every hardware asset explicitly linked to one."""
|
||||||
|
count = 0
|
||||||
|
for item in _parse_uisp_payload(payload):
|
||||||
|
device = _uisp_device_record(item, base_url)
|
||||||
|
if not device:
|
||||||
|
continue
|
||||||
|
rows = execute_query(
|
||||||
|
"""INSERT INTO uisp_devices
|
||||||
|
(external_id, name, display_name, hostname, mac_address, serial_number, vendor, model,
|
||||||
|
platform, device_type, device_role, ip_addresses, status, last_seen, device_link, raw_json, synced_at, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT (external_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name, display_name = EXCLUDED.display_name, hostname = EXCLUDED.hostname,
|
||||||
|
mac_address = EXCLUDED.mac_address, serial_number = EXCLUDED.serial_number, vendor = EXCLUDED.vendor,
|
||||||
|
model = EXCLUDED.model, platform = EXCLUDED.platform, device_type = EXCLUDED.device_type,
|
||||||
|
device_role = EXCLUDED.device_role, ip_addresses = EXCLUDED.ip_addresses, status = EXCLUDED.status,
|
||||||
|
last_seen = EXCLUDED.last_seen, device_link = EXCLUDED.device_link, raw_json = EXCLUDED.raw_json,
|
||||||
|
synced_at = NOW(), updated_at = NOW()
|
||||||
|
RETURNING id""",
|
||||||
|
(
|
||||||
|
device["external_id"], device["name"], device["display_name"], device["hostname"], device["mac_address"],
|
||||||
|
device["serial_number"], device["vendor"], device["model"], device["platform"], device["device_type"],
|
||||||
|
device["device_role"], Json(device["ip_addresses"]), device["status"], device["last_seen"], device["device_link"], Json(device["raw_json"]),
|
||||||
|
),
|
||||||
|
) or []
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
device_id = rows[0]["id"]
|
||||||
|
overview = item.get("overview") if isinstance(item.get("overview"), dict) else {}
|
||||||
|
uisp_specs = {
|
||||||
|
"uisp_device_id": device["external_id"], "name": device["name"], "hostname": device["hostname"],
|
||||||
|
"mac_address": device["mac_address"], "ip_addresses": device["ip_addresses"], "platform": device["platform"],
|
||||||
|
"type": device["device_type"], "role": device["device_role"], "firmware": (item.get("firmware") or {}).get("version") if isinstance(item.get("firmware"), dict) else item.get("firmware"),
|
||||||
|
"status": device["status"], "last_seen": str(device["last_seen"] or ""),
|
||||||
|
"overview": overview,
|
||||||
|
}
|
||||||
|
execute_query(
|
||||||
|
"""UPDATE hardware_assets h
|
||||||
|
SET brand = COALESCE(NULLIF(%s, ''), h.brand),
|
||||||
|
model = COALESCE(NULLIF(%s, ''), h.model),
|
||||||
|
serial_number = COALESCE(NULLIF(%s, ''), h.serial_number),
|
||||||
|
hardware_specs = COALESCE(h.hardware_specs, '{}'::jsonb) || %s::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM hardware_uisp_links link
|
||||||
|
WHERE link.hardware_id = h.id AND link.uisp_device_id = %s""",
|
||||||
|
(device["vendor"] or "", device["model"] or "", device["serial_number"] or "", Json({"uisp": uisp_specs}), device_id),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
items = _parse_uisp_payload(payload)
|
items = _parse_uisp_payload(payload)
|
||||||
events: List[Dict[str, Any]] = []
|
events: List[Dict[str, Any]] = []
|
||||||
@ -1283,6 +1402,21 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
|
|||||||
"api/v2/sites",
|
"api/v2/sites",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
cached_devices = _upsert_uisp_devices(payload, base_url)
|
||||||
|
# The inventory endpoint does not contain switch interface telemetry. Fetch
|
||||||
|
# details only for explicitly linked hardware, keeping the 2-minute sync light.
|
||||||
|
linked_devices = execute_query(
|
||||||
|
"""SELECT d.external_id FROM hardware_uisp_links link
|
||||||
|
JOIN uisp_devices d ON d.id = link.uisp_device_id"""
|
||||||
|
) or []
|
||||||
|
detailed_devices = 0
|
||||||
|
for linked in linked_devices:
|
||||||
|
external_id = str(linked.get("external_id") or "").strip()
|
||||||
|
if not external_id:
|
||||||
|
continue
|
||||||
|
detail = _fetch_uisp_device_detail(base_url, token, external_id)
|
||||||
|
if isinstance(detail, dict) and detail:
|
||||||
|
detailed_devices += _upsert_uisp_devices([detail], base_url)
|
||||||
events = _build_events_from_uisp_payload(payload, source.get("id"), base_url)
|
events = _build_events_from_uisp_payload(payload, source.get("id"), base_url)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
logger.warning("⚠️ Drift UISP sync failed: %s", exc)
|
logger.warning("⚠️ Drift UISP sync failed: %s", exc)
|
||||||
@ -1313,6 +1447,8 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
|
|||||||
"source": source.get("name"),
|
"source": source.get("name"),
|
||||||
"mode": "live",
|
"mode": "live",
|
||||||
"blacklisted_skipped": skipped,
|
"blacklisted_skipped": skipped,
|
||||||
|
"cached_devices": cached_devices,
|
||||||
|
"detailed_devices": detailed_devices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||||
@ -370,7 +371,7 @@ async def create_hardware(data: dict):
|
|||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO hardware_assets (
|
INSERT INTO hardware_assets (
|
||||||
asset_type, brand, model, serial_number, customer_asset_id,
|
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
|
||||||
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
||||||
status, status_reason, warranty_until, end_of_life,
|
status, status_reason, warranty_until, end_of_life,
|
||||||
anydesk_id, anydesk_link,
|
anydesk_id, anydesk_link,
|
||||||
@ -378,7 +379,7 @@ async def create_hardware(data: dict):
|
|||||||
rental_default_start_price, rental_default_freight_price,
|
rental_default_start_price, rental_default_freight_price,
|
||||||
rental_default_preparation_price, rental_default_operations_monthly_price
|
rental_default_preparation_price, rental_default_operations_monthly_price
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -392,6 +393,7 @@ async def create_hardware(data: dict):
|
|||||||
data.get("model"),
|
data.get("model"),
|
||||||
data.get("serial_number"),
|
data.get("serial_number"),
|
||||||
data.get("customer_asset_id"),
|
data.get("customer_asset_id"),
|
||||||
|
data.get("current_location_id"),
|
||||||
data.get("internal_asset_id"),
|
data.get("internal_asset_id"),
|
||||||
data.get("notes"),
|
data.get("notes"),
|
||||||
data.get("current_owner_type", "bmc"),
|
data.get("current_owner_type", "bmc"),
|
||||||
@ -496,6 +498,202 @@ async def get_hardware(hardware_id: int):
|
|||||||
return result[0]
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _uisp_match_score(hardware: dict, device: dict) -> int:
|
||||||
|
"""Score explicit, human-reviewable UISP suggestions without auto-linking anything."""
|
||||||
|
specs = hardware.get("hardware_specs") or {}
|
||||||
|
if isinstance(specs, str):
|
||||||
|
try:
|
||||||
|
specs = json.loads(specs)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
specs = {}
|
||||||
|
values = {
|
||||||
|
"serial": str(hardware.get("serial_number") or "").strip().lower(),
|
||||||
|
"model": str(hardware.get("model") or "").strip().lower(),
|
||||||
|
"name": str(hardware.get("brand") or "") + " " + str(hardware.get("model") or ""),
|
||||||
|
"mac": str((specs.get("uisp") or {}).get("mac_address") or specs.get("mac_address") or "").replace(":", "").lower(),
|
||||||
|
}
|
||||||
|
score = 0
|
||||||
|
if values["serial"] and values["serial"] == str(device.get("serial_number") or "").strip().lower():
|
||||||
|
score += 100
|
||||||
|
if values["mac"] and values["mac"] == str(device.get("mac_address") or "").replace(":", "").lower():
|
||||||
|
score += 90
|
||||||
|
device_name = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "model")).lower()
|
||||||
|
if values["model"] and values["model"] in device_name:
|
||||||
|
score += 20
|
||||||
|
if values["name"].strip() and values["name"].strip().lower() in device_name:
|
||||||
|
score += 10
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _uisp_device_payload(row: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.get("id"), "external_id": row.get("external_id"), "name": row.get("name"),
|
||||||
|
"display_name": row.get("display_name"), "hostname": row.get("hostname"), "mac_address": row.get("mac_address"),
|
||||||
|
"serial_number": row.get("serial_number"), "vendor": row.get("vendor"), "model": row.get("model"),
|
||||||
|
"platform": row.get("platform"), "device_type": row.get("device_type"), "device_role": row.get("device_role"),
|
||||||
|
"ip_addresses": row.get("ip_addresses") or [], "status": row.get("status"), "last_seen": row.get("last_seen"),
|
||||||
|
"device_link": row.get("device_link"), "raw_json": row.get("raw_json") or {}, "synced_at": row.get("synced_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware/{hardware_id}/uisp-devices", response_model=dict)
|
||||||
|
async def list_uisp_devices_for_hardware(hardware_id: int, query: Optional[str] = Query(None)):
|
||||||
|
hardware_rows = execute_query("SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)) or []
|
||||||
|
if not hardware_rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
||||||
|
devices = execute_query(
|
||||||
|
"""SELECT d.*, link.hardware_id AS linked_hardware_id
|
||||||
|
FROM uisp_devices d
|
||||||
|
LEFT JOIN hardware_uisp_links link ON link.uisp_device_id = d.id
|
||||||
|
WHERE link.hardware_id IS NULL OR link.hardware_id = %s
|
||||||
|
ORDER BY d.name NULLS LAST, d.id""",
|
||||||
|
(hardware_id,),
|
||||||
|
) or []
|
||||||
|
needle = str(query or "").strip().lower()
|
||||||
|
candidates = []
|
||||||
|
for device in devices:
|
||||||
|
searchable = " ".join(str(device.get(key) or "") for key in ("name", "display_name", "hostname", "serial_number", "mac_address", "vendor", "model")).lower()
|
||||||
|
if needle and needle not in searchable:
|
||||||
|
continue
|
||||||
|
item = _uisp_device_payload(device)
|
||||||
|
item["match_score"] = _uisp_match_score(hardware_rows[0], device)
|
||||||
|
candidates.append(item)
|
||||||
|
candidates.sort(key=lambda item: (-item["match_score"], str(item.get("name") or "").lower()))
|
||||||
|
return {"devices": candidates}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware/{hardware_id}/uisp", response_model=dict)
|
||||||
|
async def get_hardware_uisp(hardware_id: int):
|
||||||
|
rows = execute_query(
|
||||||
|
"""SELECT d.* FROM hardware_uisp_links link
|
||||||
|
JOIN uisp_devices d ON d.id = link.uisp_device_id
|
||||||
|
WHERE link.hardware_id = %s""",
|
||||||
|
(hardware_id,),
|
||||||
|
) or []
|
||||||
|
return {"device": _uisp_device_payload(rows[0]) if rows else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hardware/{hardware_id}/uisp", response_model=dict)
|
||||||
|
async def link_hardware_uisp(hardware_id: int, data: dict):
|
||||||
|
device_id = data.get("uisp_device_id")
|
||||||
|
if not device_id:
|
||||||
|
raise HTTPException(status_code=400, detail="UISP-enhed er påkrævet")
|
||||||
|
if not execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,)):
|
||||||
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
||||||
|
if not execute_query("SELECT id FROM uisp_devices WHERE id = %s", (device_id,)):
|
||||||
|
raise HTTPException(status_code=404, detail="UISP-enhed blev ikke fundet")
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
"""INSERT INTO hardware_uisp_links (hardware_id, uisp_device_id, updated_at)
|
||||||
|
VALUES (%s, %s, NOW())
|
||||||
|
ON CONFLICT (hardware_id) DO UPDATE SET uisp_device_id = EXCLUDED.uisp_device_id, updated_at = NOW()
|
||||||
|
RETURNING id""",
|
||||||
|
(hardware_id, device_id),
|
||||||
|
) or []
|
||||||
|
except Exception as exc:
|
||||||
|
if "unique" in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=409, detail="Denne UISP-enhed er allerede koblet til andet hardware") from exc
|
||||||
|
raise
|
||||||
|
# Apply cached technical identity immediately; the next UISP refresh adds current measurements.
|
||||||
|
device = execute_query("SELECT * FROM uisp_devices WHERE id = %s", (device_id,))[0]
|
||||||
|
raw = device.get("raw_json") or {}
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw = {}
|
||||||
|
overview = raw.get("overview") if isinstance(raw, dict) and isinstance(raw.get("overview"), dict) else {}
|
||||||
|
firmware = raw.get("firmware") if isinstance(raw, dict) else None
|
||||||
|
uisp_specs = {
|
||||||
|
"uisp_device_id": device.get("external_id"), "name": device.get("name"), "hostname": device.get("hostname"),
|
||||||
|
"mac_address": device.get("mac_address"), "ip_addresses": device.get("ip_addresses") or [],
|
||||||
|
"platform": device.get("platform"), "type": device.get("device_type"), "role": device.get("device_role"),
|
||||||
|
"firmware": (firmware or {}).get("version") if isinstance(firmware, dict) else firmware,
|
||||||
|
"status": device.get("status"), "last_seen": str(device.get("last_seen") or ""), "overview": overview,
|
||||||
|
}
|
||||||
|
execute_query(
|
||||||
|
"""UPDATE hardware_assets SET brand = COALESCE(NULLIF(%s, ''), brand), model = COALESCE(NULLIF(%s, ''), model),
|
||||||
|
serial_number = COALESCE(NULLIF(%s, ''), serial_number),
|
||||||
|
hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
|
||||||
|
updated_at = NOW() WHERE id = %s""",
|
||||||
|
(device.get("vendor") or "", device.get("model") or "", device.get("serial_number") or "", Json({"uisp": uisp_specs}), hardware_id),
|
||||||
|
)
|
||||||
|
return {"id": rows[0]["id"], "device": _uisp_device_payload(device)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/hardware/{hardware_id}/uisp", response_model=dict)
|
||||||
|
async def unlink_hardware_uisp(hardware_id: int):
|
||||||
|
rows = execute_query("DELETE FROM hardware_uisp_links WHERE hardware_id = %s RETURNING id", (hardware_id,)) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingen UISP-kobling fundet")
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hardware/{hardware_id}/uisp/refresh", response_model=dict)
|
||||||
|
async def refresh_hardware_uisp(hardware_id: int):
|
||||||
|
link = execute_query("SELECT uisp_device_id FROM hardware_uisp_links WHERE hardware_id = %s", (hardware_id,)) or []
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(status_code=404, detail="Hardware er ikke koblet til en UISP-enhed")
|
||||||
|
from app.modules.drift.backend.router import _run_uisp_sync_internal
|
||||||
|
result = _run_uisp_sync_internal()
|
||||||
|
if result.get("warning"):
|
||||||
|
raise HTTPException(status_code=502, detail=result["warning"])
|
||||||
|
return await get_hardware_uisp(hardware_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hardware/{hardware_id}/network-links", response_model=List[dict])
|
||||||
|
async def get_hardware_network_links(hardware_id: int):
|
||||||
|
"""Return physical network links where this hardware is either endpoint."""
|
||||||
|
return execute_query(
|
||||||
|
'''SELECT l.*, sb.brand AS source_brand, sb.model AS source_model,
|
||||||
|
tb.brand AS target_brand, tb.model AS target_model
|
||||||
|
FROM hardware_network_links l
|
||||||
|
JOIN hardware_assets sb ON sb.id = l.source_hardware_id
|
||||||
|
JOIN hardware_assets tb ON tb.id = l.target_hardware_id
|
||||||
|
WHERE l.deleted_at IS NULL AND (l.source_hardware_id = %s OR l.target_hardware_id = %s)
|
||||||
|
ORDER BY l.source_port, l.id''',
|
||||||
|
(hardware_id, hardware_id),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hardware/{hardware_id}/network-links", response_model=dict, status_code=201)
|
||||||
|
async def create_hardware_network_link(hardware_id: int, data: dict):
|
||||||
|
source_port = str(data.get('source_port') or '').strip()
|
||||||
|
target_hardware_id = data.get('target_hardware_id')
|
||||||
|
target_port = str(data.get('target_port') or '').strip() or None
|
||||||
|
if not source_port or not target_hardware_id:
|
||||||
|
raise HTTPException(status_code=400, detail='Kildeport og mål-hardware er påkrævet')
|
||||||
|
if int(target_hardware_id) == hardware_id:
|
||||||
|
raise HTTPException(status_code=400, detail='Hardware kan ikke forbindes til sig selv')
|
||||||
|
exists = execute_query('SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL', (target_hardware_id,)) or []
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=404, detail='Mål-hardware blev ikke fundet')
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
'''INSERT INTO hardware_network_links (source_hardware_id, source_port, target_hardware_id, target_port, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s) RETURNING id''',
|
||||||
|
(hardware_id, source_port, target_hardware_id, target_port, data.get('notes') or None),
|
||||||
|
) or []
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=409, detail='Denne switch-port er allerede forbundet. Fjern den eksisterende forbindelse først.') from exc
|
||||||
|
raise
|
||||||
|
return {'id': rows[0]['id']}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/hardware/{hardware_id}/network-links/{link_id}")
|
||||||
|
async def delete_hardware_network_link(hardware_id: int, link_id: int):
|
||||||
|
rows = execute_query(
|
||||||
|
'''UPDATE hardware_network_links SET deleted_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = %s AND deleted_at IS NULL AND (source_hardware_id = %s OR target_hardware_id = %s)
|
||||||
|
RETURNING id''',
|
||||||
|
(link_id, hardware_id, hardware_id),
|
||||||
|
) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail='Forbindelsen blev ikke fundet')
|
||||||
|
return {'deleted': True}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/hardware/{hardware_id}", response_model=dict)
|
@router.patch("/hardware/{hardware_id}", response_model=dict)
|
||||||
async def update_hardware(hardware_id: int, data: dict):
|
async def update_hardware(hardware_id: int, data: dict):
|
||||||
"""Update hardware asset."""
|
"""Update hardware asset."""
|
||||||
@ -511,7 +709,8 @@ async def update_hardware(hardware_id: int, data: dict):
|
|||||||
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
||||||
"eset_uuid", "hardware_specs", "eset_group",
|
"eset_uuid", "hardware_specs", "eset_group",
|
||||||
"rental_default_start_price", "rental_default_freight_price",
|
"rental_default_start_price", "rental_default_freight_price",
|
||||||
"rental_default_preparation_price", "rental_default_operations_monthly_price"
|
"rental_default_preparation_price", "rental_default_operations_monthly_price",
|
||||||
|
"location_display_order"
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in allowed_fields:
|
for field in allowed_fields:
|
||||||
@ -1172,4 +1371,3 @@ async def list_eset_incidents(
|
|||||||
"""
|
"""
|
||||||
result = execute_query(query, (severity_list, limit))
|
result = execute_query(query, (severity_list, limit))
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
@ -447,6 +449,116 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
|
|
||||||
hardware = result[0]
|
hardware = result[0]
|
||||||
|
|
||||||
|
# Network switches expose their wall-outlet connections as a port map.
|
||||||
|
switch_ports = []
|
||||||
|
if str(hardware.get('asset_type') or '').lower() == 'netværk':
|
||||||
|
connection_rows = execute_query(
|
||||||
|
'''SELECT o.id, o.switch_port, o.outlet_number, o.status, o.patch_panel, o.patch_port,
|
||||||
|
l.id AS location_id, l.name AS location_name
|
||||||
|
FROM locations_wall_outlets o
|
||||||
|
JOIN locations_locations l ON l.id = o.location_id
|
||||||
|
WHERE o.switch_hardware_id = %s AND o.deleted_at IS NULL AND o.is_active = TRUE
|
||||||
|
ORDER BY o.switch_port''',
|
||||||
|
(hardware_id,),
|
||||||
|
) or []
|
||||||
|
connections = {str(row.get('switch_port')): row for row in connection_rows if row.get('switch_port')}
|
||||||
|
specs = hardware.get('hardware_specs') or {}
|
||||||
|
if isinstance(specs, str):
|
||||||
|
try:
|
||||||
|
specs = json.loads(specs)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
specs = {}
|
||||||
|
port_count = int((specs or {}).get('port_count') or 0)
|
||||||
|
if port_count > 0:
|
||||||
|
switch_ports = [
|
||||||
|
{'port_number': str(port), 'connection': connections.pop(str(port), None)}
|
||||||
|
for port in range(1, port_count + 1)
|
||||||
|
]
|
||||||
|
switch_ports.extend(
|
||||||
|
{'port_number': port, 'connection': connection}
|
||||||
|
for port, connection in connections.items()
|
||||||
|
)
|
||||||
|
switch_outlet_choices = []
|
||||||
|
if str(hardware.get('asset_type') or '').lower() == 'netværk' and hardware.get('current_location_id'):
|
||||||
|
switch_outlet_choices = execute_query(
|
||||||
|
'''SELECT id, outlet_number, status, switch_hardware_id, switch_name, switch_port
|
||||||
|
FROM locations_wall_outlets
|
||||||
|
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
||||||
|
ORDER BY outlet_number''',
|
||||||
|
(hardware['current_location_id'],),
|
||||||
|
) or []
|
||||||
|
network_links = execute_query(
|
||||||
|
'''SELECT l.*, tb.brand AS target_brand, tb.model AS target_model, tb.serial_number AS target_serial,
|
||||||
|
sb.brand AS source_brand, sb.model AS source_model, sb.serial_number AS source_serial
|
||||||
|
FROM hardware_network_links l
|
||||||
|
JOIN hardware_assets sb ON sb.id = l.source_hardware_id
|
||||||
|
JOIN hardware_assets tb ON tb.id = l.target_hardware_id
|
||||||
|
WHERE l.deleted_at IS NULL AND (l.source_hardware_id = %s OR l.target_hardware_id = %s)
|
||||||
|
ORDER BY l.source_port, l.id''',
|
||||||
|
(hardware_id, hardware_id),
|
||||||
|
) or []
|
||||||
|
uisp_rows = execute_query(
|
||||||
|
"""SELECT d.* FROM hardware_uisp_links link
|
||||||
|
JOIN uisp_devices d ON d.id = link.uisp_device_id
|
||||||
|
WHERE link.hardware_id = %s""",
|
||||||
|
(hardware_id,),
|
||||||
|
) or []
|
||||||
|
uisp_device = uisp_rows[0] if uisp_rows else None
|
||||||
|
if uisp_device:
|
||||||
|
raw = uisp_device.get('raw_json') or {}
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw = {}
|
||||||
|
uisp_device['overview'] = raw.get('overview') if isinstance(raw, dict) else {}
|
||||||
|
uisp_device['firmware'] = (raw.get('firmware') or {}) if isinstance(raw, dict) and isinstance(raw.get('firmware'), dict) else {}
|
||||||
|
live_ports = {}
|
||||||
|
for interface in (raw.get('interfaces') or []) if isinstance(raw, dict) else []:
|
||||||
|
if not isinstance(interface, dict):
|
||||||
|
continue
|
||||||
|
identification = interface.get('identification') or {}
|
||||||
|
status = interface.get('status') or {}
|
||||||
|
statistics = interface.get('statistics') or {}
|
||||||
|
name = str(identification.get('name') or '')
|
||||||
|
match = re.fullmatch(r'(?:port|eth)(\d+)', name, flags=re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
port_number = str(int(match.group(1)))
|
||||||
|
live_ports[port_number] = {
|
||||||
|
'plugged': bool(status.get('plugged')),
|
||||||
|
'status': status.get('status'),
|
||||||
|
'speed': status.get('currentSpeed') or status.get('speed'),
|
||||||
|
'rxrate': statistics.get('rxrate'), 'txrate': statistics.get('txrate'),
|
||||||
|
'poe_power': statistics.get('poePower'), 'errors': statistics.get('errors'),
|
||||||
|
}
|
||||||
|
for port in switch_ports:
|
||||||
|
port['live'] = live_ports.get(str(port['port_number']))
|
||||||
|
outbound_links = {}
|
||||||
|
for link in network_links:
|
||||||
|
if int(link.get('source_hardware_id') or 0) == hardware_id and link.get('source_port'):
|
||||||
|
outbound_links[str(link['source_port'])] = link
|
||||||
|
elif int(link.get('target_hardware_id') or 0) == hardware_id and link.get('target_port'):
|
||||||
|
# Render the physical connection from the target switch's perspective too.
|
||||||
|
reverse_link = dict(link)
|
||||||
|
reverse_link.update({
|
||||||
|
'target_hardware_id': link.get('source_hardware_id'),
|
||||||
|
'target_brand': link.get('source_brand'),
|
||||||
|
'target_model': link.get('source_model'),
|
||||||
|
'target_serial': link.get('source_serial'),
|
||||||
|
'target_port': link.get('source_port'),
|
||||||
|
})
|
||||||
|
outbound_links[str(link['target_port'])] = reverse_link
|
||||||
|
for port in switch_ports:
|
||||||
|
port['hardware_link'] = outbound_links.get(str(port['port_number']))
|
||||||
|
available_network_hardware = execute_query(
|
||||||
|
'''SELECT id, brand, model, serial_number, asset_type
|
||||||
|
FROM hardware_assets
|
||||||
|
WHERE current_location_id = %s AND id <> %s AND deleted_at IS NULL
|
||||||
|
ORDER BY brand, model, serial_number''',
|
||||||
|
(hardware.get('current_location_id') or -1, hardware_id),
|
||||||
|
) or []
|
||||||
|
|
||||||
# Get customer name if applicable
|
# Get customer name if applicable
|
||||||
if hardware.get('current_owner_customer_id'):
|
if hardware.get('current_owner_customer_id'):
|
||||||
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
|
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
|
||||||
@ -482,6 +594,19 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
"""
|
"""
|
||||||
locations = execute_query(location_query, (hardware_id,))
|
locations = execute_query(location_query, (hardware_id,))
|
||||||
|
|
||||||
|
# current_location_id is the authoritative placement. Hardware created from a
|
||||||
|
# location can legitimately have no history row yet, so do not hide its location.
|
||||||
|
current_location = None
|
||||||
|
if hardware.get('current_location_id'):
|
||||||
|
current_location_rows = execute_query(
|
||||||
|
"""SELECT id AS location_id, name AS location_name
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE id = %s AND deleted_at IS NULL""",
|
||||||
|
(hardware['current_location_id'],),
|
||||||
|
) or []
|
||||||
|
if current_location_rows:
|
||||||
|
current_location = current_location_rows[0]
|
||||||
|
|
||||||
# Get attachments
|
# Get attachments
|
||||||
attachment_query = """
|
attachment_query = """
|
||||||
SELECT * FROM hardware_attachments
|
SELECT * FROM hardware_attachments
|
||||||
@ -670,6 +795,7 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
"hardware": hardware,
|
"hardware": hardware,
|
||||||
"ownership": ownership or [],
|
"ownership": ownership or [],
|
||||||
"locations": locations or [],
|
"locations": locations or [],
|
||||||
|
"current_location": current_location,
|
||||||
"attachments": attachments or [],
|
"attachments": attachments or [],
|
||||||
"cases": cases or [],
|
"cases": cases or [],
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
@ -679,6 +805,11 @@ async def hardware_detail(request: Request, hardware_id: int):
|
|||||||
"owner_contacts": owner_contacts or [],
|
"owner_contacts": owner_contacts or [],
|
||||||
"location_tree": location_tree or [],
|
"location_tree": location_tree or [],
|
||||||
"eset_specs": extract_eset_specs_summary(hardware),
|
"eset_specs": extract_eset_specs_summary(hardware),
|
||||||
|
"switch_ports": switch_ports,
|
||||||
|
"switch_outlet_choices": switch_outlet_choices,
|
||||||
|
"network_links": network_links,
|
||||||
|
"uisp_device": uisp_device,
|
||||||
|
"available_network_hardware": available_network_hardware,
|
||||||
"rental_stats": rental_stats,
|
"rental_stats": rental_stats,
|
||||||
"recent_rentals": recent_rentals or [],
|
"recent_rentals": recent_rentals or [],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -54,6 +54,19 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; }
|
||||||
|
.switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; }
|
||||||
|
.switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
|
||||||
|
.switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); }
|
||||||
|
.switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; }
|
||||||
|
.switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; }
|
||||||
|
.switch-port-button.live-up { box-shadow: inset 0 -5px 0 #20c997; }
|
||||||
|
.switch-port-button.live-down { box-shadow: inset 0 -5px 0 #dc3545; }
|
||||||
|
.switch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
|
||||||
|
.switch-port-live { font-size:.55rem; font-weight:800; letter-spacing:.03em; }
|
||||||
|
@media (max-width: 1100px) { .switch-port-grid { grid-template-columns: repeat(12, minmax(44px, 1fr)); } }
|
||||||
|
@media (max-width: 700px) { .switch-port-grid { grid-template-columns: repeat(6, minmax(44px, 1fr)); } }
|
||||||
|
|
||||||
/* Timeline Styling */
|
/* Timeline Styling */
|
||||||
.timeline {
|
.timeline {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -223,8 +236,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Location (Current) -->
|
<!-- Location (Current) -->
|
||||||
{% set current_loc = locations[0] if locations else None %}
|
{% set current_loc = current_location or (locations[0] if locations else None) %}
|
||||||
{% if current_loc and not current_loc.end_date %}
|
{% if current_loc and (current_location or not current_loc.end_date) %}
|
||||||
<div class="quick-info-item">
|
<div class="quick-info-item">
|
||||||
<span class="quick-info-label">Lokation:</span>
|
<span class="quick-info-label">Lokation:</span>
|
||||||
<span>{{ current_loc.location_name }}</span>
|
<span>{{ current_loc.location_name }}</span>
|
||||||
@ -409,11 +422,11 @@
|
|||||||
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
|
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if current_loc and not current_loc.end_date %}
|
{% if current_loc and (current_location or not current_loc.end_date) %}
|
||||||
<div class="text-center py-3">
|
<div class="text-center py-3">
|
||||||
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
||||||
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
|
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
|
||||||
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
|
<p class="text-muted small mb-0">{% if current_loc.start_date %}Siden: {{ current_loc.start_date }}{% else %}Aktuel placering{% endif %}</p>
|
||||||
{% if current_loc.notes %}
|
{% if current_loc.notes %}
|
||||||
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -675,6 +688,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if switch_ports %}
|
||||||
|
<div class="card mt-4 shadow-sm border-0">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="text-primary mb-0"><i class="bi bi-hdd-network me-2"></i>Switch-porte</h6>
|
||||||
|
<span class="text-muted small">{{ switch_ports | length }} porte</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="switch-port-panel"><div class="switch-port-grid">
|
||||||
|
{% for port in switch_ports %}
|
||||||
|
<button type="button" class="switch-port-button {% if port.hardware_link %}hardware-linked{% elif port.connection %}connected{% endif %}{% if port.live %} {{ 'live-up' if port.live.plugged else 'live-down' }}{% endif %}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.connection.id if port.connection else '' }}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.connection %}{{ port.connection.outlet_number }} · klik for at ændre{% else %}Ledig port — klik for at tilknytte vægstik{% endif %}">
|
||||||
|
<span>Port {{ port.port_number }}</span>
|
||||||
|
{% if port.live %}<span class="switch-port-live">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% endif %}
|
||||||
|
{% if port.hardware_link %}<span class="switch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.connection %}<span class="switch-port-outlet">{{ port.connection.outlet_number }}</span>{% else %}<span class="switch-port-outlet">Ledig</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card mt-4 shadow-sm border-0">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div><h6 class="text-primary mb-0"><i class="bi bi-broadcast-pin me-2"></i>UISP live-data</h6><div class="small text-muted">{{ 'Koblet til UISP-enhed' if uisp_device else 'Ingen UISP-enhed koblet endnu' }}</div></div>
|
||||||
|
<div class="d-flex gap-2">{% if uisp_device %}<button type="button" class="btn btn-sm btn-outline-primary" id="refreshUispBtn"><i class="bi bi-arrow-repeat me-1"></i>Opdatér nu</button><button type="button" class="btn btn-sm btn-outline-danger" id="unlinkUispBtn">Fjern kobling</button>{% else %}<button type="button" class="btn btn-sm btn-primary" id="linkUispBtn"><i class="bi bi-link-45deg me-1"></i>Kobl UISP-enhed</button>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if uisp_device %}
|
||||||
|
{% set overview = uisp_device.overview or {} %}
|
||||||
|
<div class="row g-3 small">
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">Senest set</span><strong>{{ uisp_device.last_seen or '—' }}</strong></div>
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">Firmware</span><strong>{{ uisp_device.firmware.version or uisp_device.firmware.name or '—' }}</strong></div>
|
||||||
|
<div class="col-md-3"><span class="text-muted d-block">Platform / rolle</span><strong>{{ uisp_device.platform or '—' }}{% if uisp_device.device_role %} · {{ uisp_device.device_role }}{% endif %}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">Uptime</span><strong>{{ overview.uptime or overview.serviceUptime or '—' }}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">CPU / RAM</span><strong>{{ overview.cpu or '—' }} / {{ overview.ram or '—' }}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">Temperatur</span><strong>{{ overview.temperature or '—' }}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">Signal</span><strong>{{ overview.signal or overview.signalMax or '—' }}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">Kapacitet</span><strong>{{ overview.totalCapacity or overview.uplinkCapacity or '—' }}</strong></div>
|
||||||
|
<div class="col-md-2"><span class="text-muted d-block">Synkroniseret</span><strong>{{ uisp_device.synced_at or '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center"><span class="small text-muted">{{ uisp_device.vendor or '' }} {{ uisp_device.model or '' }}{% if uisp_device.serial_number %} · {{ uisp_device.serial_number }}{% endif %}</span>{% if uisp_device.device_link %}<a href="{{ uisp_device.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
|
||||||
|
{% else %}<span class="text-muted">Kobl en synkroniseret UISP-enhed for at se live-status og tekniske data her.</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if hardware.asset_type == 'netværk' %}
|
||||||
|
<div class="card mt-4 shadow-sm border-0">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"><h6 class="text-primary mb-0"><i class="bi bi-diagram-3 me-2"></i>Hardwareforbindelser</h6><button type="button" class="btn btn-sm btn-outline-primary" id="addHardwareLinkBtn"><i class="bi bi-plus-lg me-1"></i>Forbind hardware</button></div>
|
||||||
|
<div class="card-body"><div class="list-group list-group-flush" id="hardwareNetworkLinksList">
|
||||||
|
{% for link in network_links %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center px-0"><div><strong>Port {{ link.source_port }}</strong> → {{ link.target_brand or '' }} {{ link.target_model }}{% if link.target_serial %} · {{ link.target_serial }}{% endif %}{% if link.target_port %}<span class="text-muted"> · port {{ link.target_port }}</span>{% endif %}</div><button class="btn btn-sm btn-outline-danger delete-network-link-btn" data-link-id="{{ link.id }}"><i class="bi bi-x"></i></button></div>
|
||||||
|
{% else %}<span class="text-muted small">Ingen hardwareforbindelser registreret endnu.</span>{% endfor %}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if hardware.hardware_specs %}
|
{% if hardware.hardware_specs %}
|
||||||
<div class="card mt-4 shadow-sm border-0">
|
<div class="card mt-4 shadow-sm border-0">
|
||||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||||
@ -1234,6 +1305,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="switchPortAssignModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog"><form class="modal-content" id="switchPortAssignForm">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">Tilknyt vægstik til port <span id="switchPortAssignNumber"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="switchPortAssignPort">
|
||||||
|
<div class="mb-3"><label class="form-label">Vægstik</label><select id="switchPortAssignOutlet" class="form-select" required></select><div class="form-text">Vælges et stik, der allerede sidder på en anden switch-port, bliver du bedt om at bekræfte flytningen.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><button type="button" class="btn btn-outline-primary me-auto" id="switchPortAssignHardwareLink"><i class="bi bi-diagram-3 me-1"></i>Forbind hardware / switch</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem vægstik</button></div>
|
||||||
|
</form></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="hardwareLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="hardwareLinkForm"><div class="modal-header"><h5 class="modal-title">Forbind switch til hardware</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Switch-port</label><input id="hardwareLinkSourcePort" class="form-control" required placeholder="Fx 1 eller Gi1/0/1"></div><div class="mb-3"><label class="form-label">Tilsluttet hardware</label><select id="hardwareLinkTarget" class="form-select" required><option value="">Vælg hardware</option>{% for item in available_network_hardware %}<option value="{{ item.id }}">{{ item.brand or '' }} {{ item.model }}{% if item.serial_number %} · {{ item.serial_number }}{% endif %}</option>{% endfor %}</select></div><div class="mb-3"><label class="form-label">Port på mål-hardware</label><input id="hardwareLinkTargetPort" class="form-control" placeholder="Valgfri, fx WAN eller 0"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem forbindelse</button></div></form></div></div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="uispLinkModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg"><form class="modal-content" id="uispLinkForm"><div class="modal-header"><h5 class="modal-title">Kobl UISP-enhed</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Søg UISP-enhed</label><input id="uispDeviceSearch" class="form-control" placeholder="Navn, MAC, serienummer eller model"></div><div class="form-text mb-2">Forslag med højest match vises først. Koblingen bekræftes først når du gemmer.</div><select id="uispDeviceSelect" class="form-select" size="8" required><option value="">Indlæser UISP-enheder…</option></select></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Kobl enhed</button></div></form></div></div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -1251,6 +1337,131 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modalElement = document.getElementById('switchPortAssignModal');
|
||||||
|
const modal = modalElement ? new bootstrap.Modal(modalElement) : null;
|
||||||
|
const uispLinkModalElement = document.getElementById('uispLinkModal');
|
||||||
|
const uispLinkModal = uispLinkModalElement ? new bootstrap.Modal(uispLinkModalElement) : null;
|
||||||
|
const uispDeviceSelect = document.getElementById('uispDeviceSelect');
|
||||||
|
let uispSearchTimer = null;
|
||||||
|
|
||||||
|
async function loadUispDevices(search = '') {
|
||||||
|
if (!uispDeviceSelect) return;
|
||||||
|
uispDeviceSelect.innerHTML = '<option value="">Indlæser…</option>';
|
||||||
|
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/uisp-devices?query=${encodeURIComponent(search)}`);
|
||||||
|
if (!response.ok) { uispDeviceSelect.innerHTML = '<option value="">Kunne ikke indlæse UISP-enheder</option>'; return; }
|
||||||
|
const data = await response.json();
|
||||||
|
const devices = data.devices || [];
|
||||||
|
uispDeviceSelect.innerHTML = '';
|
||||||
|
if (!devices.length) {
|
||||||
|
uispDeviceSelect.innerHTML = '<option value="">Ingen ledige UISP-enheder fundet</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
devices.forEach(device => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(device.id);
|
||||||
|
const identity = [device.vendor, device.model, device.serial_number, device.mac_address].filter(Boolean).join(' · ');
|
||||||
|
const suggestion = device.match_score ? ` — forslag (${device.match_score})` : '';
|
||||||
|
option.textContent = `${device.name || device.hostname || device.external_id}${suggestion}\n${identity || device.external_id} · ${device.status || 'ukendt'}`;
|
||||||
|
uispDeviceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('linkUispBtn')?.addEventListener('click', async () => { await loadUispDevices(); uispLinkModal?.show(); });
|
||||||
|
document.getElementById('uispDeviceSearch')?.addEventListener('input', event => {
|
||||||
|
clearTimeout(uispSearchTimer);
|
||||||
|
uispSearchTimer = setTimeout(() => loadUispDevices(event.target.value), 200);
|
||||||
|
});
|
||||||
|
document.getElementById('uispLinkForm')?.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const uispDeviceId = Number(uispDeviceSelect?.value || 0);
|
||||||
|
if (!uispDeviceId) return;
|
||||||
|
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({uisp_device_id: uispDeviceId})});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Kunne ikke koble UISP-enheden'); }
|
||||||
|
});
|
||||||
|
document.getElementById('unlinkUispBtn')?.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Fjern UISP-koblingen fra dette hardware?')) return;
|
||||||
|
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp', {method: 'DELETE'});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else alert('Kunne ikke fjerne UISP-koblingen');
|
||||||
|
});
|
||||||
|
document.getElementById('refreshUispBtn')?.addEventListener('click', async event => {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opdaterer';
|
||||||
|
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/uisp/refresh', {method: 'POST'});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'UISP kunne ikke opdateres'); button.disabled = false; button.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Opdatér nu'; }
|
||||||
|
});
|
||||||
|
const switchHardware = {{ {'id': hardware.id, 'brand': hardware.brand, 'model': hardware.model, 'serial_number': hardware.serial_number} | tojson }};
|
||||||
|
const outlets = {{ switch_outlet_choices | tojson }};
|
||||||
|
const switchName = [switchHardware.brand, switchHardware.model, switchHardware.serial_number].filter(Boolean).join(' · ');
|
||||||
|
const outletSelect = document.getElementById('switchPortAssignOutlet');
|
||||||
|
|
||||||
|
function populateOutletSelect(selectedOutletId) {
|
||||||
|
outletSelect.innerHTML = '<option value="">Vælg vægstik</option>';
|
||||||
|
outlets.forEach(outlet => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(outlet.id);
|
||||||
|
const connectedElsewhere = outlet.switch_port && Number(outlet.switch_hardware_id) === Number(switchHardware.id)
|
||||||
|
? ` — nu på port ${outlet.switch_port}` : '';
|
||||||
|
option.textContent = `${outlet.outlet_number} (${outlet.status})${connectedElsewhere}`;
|
||||||
|
option.selected = String(outlet.id) === String(selectedOutletId || '');
|
||||||
|
outletSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.switch-port-button').forEach(button => button.addEventListener('click', () => {
|
||||||
|
const port = button.dataset.switchPort;
|
||||||
|
document.getElementById('switchPortAssignPort').value = port;
|
||||||
|
document.getElementById('switchPortAssignNumber').textContent = port;
|
||||||
|
populateOutletSelect(button.dataset.outletId || null);
|
||||||
|
modal?.show();
|
||||||
|
}));
|
||||||
|
|
||||||
|
document.getElementById('switchPortAssignForm')?.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const outletId = outletSelect.value;
|
||||||
|
const port = document.getElementById('switchPortAssignPort').value;
|
||||||
|
if (!outletId || !port) return;
|
||||||
|
const selectedOutlet = outlets.find(outlet => String(outlet.id) === String(outletId));
|
||||||
|
if (selectedOutlet?.switch_port && String(selectedOutlet.switch_port) !== String(port)) {
|
||||||
|
if (!confirm(`${selectedOutlet.outlet_number} er allerede koblet på port ${selectedOutlet.switch_port}. Flyt forbindelsen til port ${port}?`)) return;
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {
|
||||||
|
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({switch_hardware_id: switchHardware.id, switch_name: switchName, switch_port: port, replace_existing_switch_port: true})
|
||||||
|
});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hardwareLinkModalElement = document.getElementById('hardwareLinkModal');
|
||||||
|
const hardwareLinkModal = hardwareLinkModalElement ? new bootstrap.Modal(hardwareLinkModalElement) : null;
|
||||||
|
document.getElementById('addHardwareLinkBtn')?.addEventListener('click', () => hardwareLinkModal?.show());
|
||||||
|
document.getElementById('switchPortAssignHardwareLink')?.addEventListener('click', () => {
|
||||||
|
const sourcePort = document.getElementById('switchPortAssignPort').value;
|
||||||
|
if (!sourcePort) return;
|
||||||
|
modal?.hide();
|
||||||
|
document.getElementById('hardwareLinkSourcePort').value = sourcePort;
|
||||||
|
hardwareLinkModal?.show();
|
||||||
|
});
|
||||||
|
document.getElementById('hardwareLinkForm')?.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const response = await fetch('/api/v1/hardware/{{ hardware.id }}/network-links', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({source_port: document.getElementById('hardwareLinkSourcePort').value.trim(), target_hardware_id: Number(document.getElementById('hardwareLinkTarget').value), target_port: document.getElementById('hardwareLinkTargetPort').value.trim() || null})
|
||||||
|
});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Forbindelsen kunne ikke gemmes'); }
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.delete-network-link-btn').forEach(button => button.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Fjern hardwareforbindelsen?')) return;
|
||||||
|
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/network-links/${button.dataset.linkId}`, {method: 'DELETE'});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
async function submitQuickRent() {
|
async function submitQuickRent() {
|
||||||
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
|
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
|
||||||
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
|
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
|
||||||
|
|||||||
@ -483,7 +483,14 @@ async def _build_customer_document_hits(customer_id: int, customer_name: str, qu
|
|||||||
title_hit = _count_term_hits(title, terms["query_terms"])
|
title_hit = _count_term_hits(title, terms["query_terms"])
|
||||||
|
|
||||||
if has_query:
|
if has_query:
|
||||||
if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
|
# A block is only relevant when it contains every word from the user's
|
||||||
|
# search. A query such as "sales management" must not return a block
|
||||||
|
# containing just one of the two words.
|
||||||
|
all_query_terms_match = all(
|
||||||
|
term in _normalize_text_for_match(searchable)
|
||||||
|
for term in terms["query_terms"]
|
||||||
|
)
|
||||||
|
if not all_query_terms_match:
|
||||||
continue
|
continue
|
||||||
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
|
elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
|
||||||
continue
|
continue
|
||||||
@ -3106,7 +3113,9 @@ async def get_migration_wizard_v2_context(
|
|||||||
if summary_source_parts:
|
if summary_source_parts:
|
||||||
summary_input = (
|
summary_input = (
|
||||||
f"Kunde: {customer_name}\n"
|
f"Kunde: {customer_name}\n"
|
||||||
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n"
|
f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n"
|
||||||
|
"VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, "
|
||||||
|
"eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\n\n"
|
||||||
+ "\n\n".join(summary_source_parts)
|
+ "\n\n".join(summary_source_parts)
|
||||||
)
|
)
|
||||||
ai_summary = await ollama_service.generate_summary(summary_input)
|
ai_summary = await ollama_service.generate_summary(summary_input)
|
||||||
@ -3120,3 +3129,35 @@ async def get_migration_wizard_v2_context(
|
|||||||
"invoice_hits": invoice_hits,
|
"invoice_hits": invoice_hits,
|
||||||
"ai_summary": ai_summary,
|
"ai_summary": ai_summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/internet-connections/customer-documents/segments/{segment_id}")
|
||||||
|
async def get_customer_document_segment(segment_id: int):
|
||||||
|
"""Return the complete, indexed text block for the migration wizard."""
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
seg.id AS segment_id,
|
||||||
|
seg.document_id,
|
||||||
|
seg.block_index,
|
||||||
|
seg.block_title AS title,
|
||||||
|
seg.content,
|
||||||
|
doc.original_filename
|
||||||
|
FROM internet_connections_customer_document_segments seg
|
||||||
|
JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
|
||||||
|
WHERE seg.id = %s
|
||||||
|
AND doc.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(segment_id,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Text block not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"segment_id": int(row["segment_id"]),
|
||||||
|
"document_id": int(row["document_id"]),
|
||||||
|
"block_index": int(row.get("block_index") or 0),
|
||||||
|
"title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
|
||||||
|
"original_filename": row.get("original_filename") or "Tekstfil",
|
||||||
|
"content": str(row.get("content") or ""),
|
||||||
|
}
|
||||||
|
|||||||
@ -107,6 +107,28 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wiz-segment-button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiz-segment-button:hover .wiz-card,
|
||||||
|
.wiz-segment-button:focus-visible .wiz-card {
|
||||||
|
border-color: rgba(15, 76, 117, 0.45);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiz-full-block {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.wiz-grid {
|
.wiz-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -256,11 +278,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="segmentModal" tabindex="-1" aria-labelledby="segmentModalTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h5 class="modal-title" id="segmentModalTitle">Tekstblok</h5>
|
||||||
|
<div class="wiz-meta" id="segmentModalMeta"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"><pre class="wiz-full-block" id="segmentModalContent">Henter tekstblok…</pre></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
let customerSearchTimer = null;
|
let customerSearchTimer = null;
|
||||||
|
let customerSearchRequest = 0;
|
||||||
let customerOptions = [];
|
let customerOptions = [];
|
||||||
let localUploadedDocuments = [];
|
let localUploadedDocuments = [];
|
||||||
|
|
||||||
@ -346,6 +384,7 @@
|
|||||||
async function onCustomerInputChanged() {
|
async function onCustomerInputChanged() {
|
||||||
const input = document.getElementById('customerSearchInput');
|
const input = document.getElementById('customerSearchInput');
|
||||||
const query = input.value.trim();
|
const query = input.value.trim();
|
||||||
|
const requestId = ++customerSearchRequest;
|
||||||
document.getElementById('selectedCustomerId').value = '';
|
document.getElementById('selectedCustomerId').value = '';
|
||||||
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
|
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
|
||||||
|
|
||||||
@ -358,6 +397,8 @@
|
|||||||
|
|
||||||
if (query.length < 2) return;
|
if (query.length < 2) return;
|
||||||
const items = await searchCustomers(query);
|
const items = await searchCustomers(query);
|
||||||
|
// Ignore a slower response from an earlier, shorter search phrase.
|
||||||
|
if (requestId !== customerSearchRequest || input.value.trim() !== query) return;
|
||||||
renderCustomerOptions(items);
|
renderCustomerOptions(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,11 +498,12 @@
|
|||||||
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
|
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
|
||||||
} else {
|
} else {
|
||||||
segmentList.innerHTML = segmentHits.map(item => `
|
segmentList.innerHTML = segmentHits.map(item => `
|
||||||
|
<button class="wiz-segment-button" type="button" onclick="openSegment(${Number(item.segment_id)})" aria-label="Vis hele tekstblokken: ${escapeHtml(item.title || 'Blok')}">
|
||||||
<div class="wiz-card">
|
<div class="wiz-card">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(item.title || 'Blok')}</h3>
|
<h3>${escapeHtml(item.title || 'Blok')}</h3>
|
||||||
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score}</div>
|
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
|
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -473,6 +515,7 @@
|
|||||||
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
|
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +539,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSegment(segmentId) {
|
||||||
|
const modalElement = document.getElementById('segmentModal');
|
||||||
|
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||||
|
document.getElementById('segmentModalTitle').textContent = 'Tekstblok';
|
||||||
|
document.getElementById('segmentModalMeta').textContent = '';
|
||||||
|
document.getElementById('segmentModalContent').textContent = 'Henter tekstblok…';
|
||||||
|
modal.show();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/internet-connections/customer-documents/segments/${segmentId}`);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.detail || 'Kunne ikke hente tekstblokken');
|
||||||
|
document.getElementById('segmentModalTitle').textContent = payload.title || 'Tekstblok';
|
||||||
|
document.getElementById('segmentModalMeta').textContent = `${payload.original_filename || 'Tekstfil'} · blok ${Number(payload.block_index || 0) + 1}`;
|
||||||
|
document.getElementById('segmentModalContent').textContent = payload.content || 'Blokken er tom.';
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('segmentModalContent').textContent = error.message || 'Kunne ikke hente tekstblokken.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadCustomerFile() {
|
async function uploadCustomerFile() {
|
||||||
const fileInput = document.getElementById('customerFileInput');
|
const fileInput = document.getElementById('customerFileInput');
|
||||||
const notes = document.getElementById('customerFileNotes').value.trim();
|
const notes = document.getElementById('customerFileNotes').value.trim();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Invoice Error Finder API router.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ ALLOWED_ISSUE_STATUSES = {
|
|||||||
"ready_to_invoice",
|
"ready_to_invoice",
|
||||||
"invoiced",
|
"invoiced",
|
||||||
"ignored",
|
"ignored",
|
||||||
|
"resolved",
|
||||||
}
|
}
|
||||||
|
|
||||||
ISSUE_STATUS_LABELS = {
|
ISSUE_STATUS_LABELS = {
|
||||||
@ -33,9 +35,10 @@ ISSUE_STATUS_LABELS = {
|
|||||||
"investigating": "Under undersøgelse",
|
"investigating": "Under undersøgelse",
|
||||||
"approved_change": "Godkendt ændring",
|
"approved_change": "Godkendt ændring",
|
||||||
"error_found": "Fejl fundet",
|
"error_found": "Fejl fundet",
|
||||||
"ready_to_invoice": "Klar til fakturering",
|
"ready_to_invoice": "Opret ordrekladde",
|
||||||
"invoiced": "Faktureret",
|
"invoiced": "Faktureret",
|
||||||
"ignored": "Ignoreret",
|
"ignored": "Ignoreret",
|
||||||
|
"resolved": "Løst",
|
||||||
}
|
}
|
||||||
|
|
||||||
ISSUE_TYPE_LABELS = {
|
ISSUE_TYPE_LABELS = {
|
||||||
@ -67,6 +70,95 @@ class CreateOrdreDraftRequest(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize_product_text(value: Optional[str]) -> List[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return re.findall(r"[a-z0-9]+", value.lower())
|
||||||
|
|
||||||
|
|
||||||
|
_PRODUCT_MATCH_STOP_TOKENS = {
|
||||||
|
"periode", "period", "forbrugsperiode",
|
||||||
|
"jan", "januar", "january",
|
||||||
|
"feb", "februar", "february",
|
||||||
|
"mar", "marts", "march",
|
||||||
|
"apr", "april",
|
||||||
|
"maj", "may",
|
||||||
|
"jun", "juni", "june",
|
||||||
|
"jul", "juli", "july",
|
||||||
|
"aug", "august",
|
||||||
|
"sep", "sept", "september",
|
||||||
|
"okt", "oct", "october", "oktober",
|
||||||
|
"nov", "november",
|
||||||
|
"dec", "december",
|
||||||
|
"til", "from", "to", "fra",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_product_tokens(value: Optional[str]) -> List[str]:
|
||||||
|
tokens = []
|
||||||
|
for token in _tokenize_product_text(value):
|
||||||
|
if token in _PRODUCT_MATCH_STOP_TOKENS:
|
||||||
|
continue
|
||||||
|
if token.isdigit() and len(token) == 4:
|
||||||
|
continue
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _line_matches_issue_product(
|
||||||
|
line_product_number: Optional[str],
|
||||||
|
line_product_name: Optional[str],
|
||||||
|
line_description: Optional[str],
|
||||||
|
issue_product_number: Optional[str],
|
||||||
|
issue_product_name: Optional[str],
|
||||||
|
extra_text: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
line_number = (line_product_number or "").strip().lower()
|
||||||
|
issue_number = (issue_product_number or "").strip().lower()
|
||||||
|
|
||||||
|
issue_name = (issue_product_name or "").strip().lower()
|
||||||
|
combined_text = " ".join(
|
||||||
|
part.strip().lower()
|
||||||
|
for part in [line_product_name or "", line_description or "", extra_text or ""]
|
||||||
|
if part and part.strip()
|
||||||
|
)
|
||||||
|
if not issue_name:
|
||||||
|
return bool(line_number and issue_number and line_number == issue_number)
|
||||||
|
if not combined_text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
issue_tokens = _normalized_product_tokens(issue_name)
|
||||||
|
line_tokens = _normalized_product_tokens(combined_text)
|
||||||
|
issue_token_set = set(issue_tokens)
|
||||||
|
line_token_set = set(line_tokens)
|
||||||
|
shared_tokens = issue_token_set & line_token_set
|
||||||
|
alpha_shared = {token for token in shared_tokens if any(ch.isalpha() for ch in token)}
|
||||||
|
|
||||||
|
if issue_name in combined_text or combined_text in issue_name:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if line_number and issue_number and line_number == issue_number:
|
||||||
|
if not issue_token_set:
|
||||||
|
return True
|
||||||
|
if len(shared_tokens) >= max(1, min(2, len(issue_token_set))):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not issue_token_set or not line_token_set:
|
||||||
|
return False
|
||||||
|
|
||||||
|
coverage = len(shared_tokens) / max(1, len(issue_token_set))
|
||||||
|
|
||||||
|
if len(issue_token_set) == 1:
|
||||||
|
return len(shared_tokens) >= 1
|
||||||
|
if len(issue_token_set) == 2:
|
||||||
|
return len(shared_tokens) >= 2
|
||||||
|
if coverage >= 0.75:
|
||||||
|
return True
|
||||||
|
if coverage >= 0.5 and len(alpha_shared) >= 1:
|
||||||
|
return True
|
||||||
|
return len(shared_tokens) >= 3 and len(alpha_shared) >= 1
|
||||||
|
|
||||||
|
|
||||||
def _get_user_id(request: Request) -> Optional[int]:
|
def _get_user_id(request: Request) -> Optional[int]:
|
||||||
value = getattr(request.state, "user_id", None)
|
value = getattr(request.state, "user_id", None)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
@ -255,9 +347,22 @@ async def list_issues(
|
|||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
i.*,
|
i.*,
|
||||||
|
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
|
||||||
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
||||||
sg.titel AS sag_title
|
sg.titel AS sag_title
|
||||||
FROM invoice_error_finder_issues i
|
FROM invoice_error_finder_issues i
|
||||||
|
LEFT JOIN customers c ON c.id = i.customer_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = inv.id
|
||||||
|
WHERE c.economic_customer_number IS NOT NULL
|
||||||
|
AND inv.customer_number = c.economic_customer_number
|
||||||
|
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
|
||||||
|
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_line ON TRUE
|
||||||
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
||||||
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
@ -289,9 +394,22 @@ async def get_issue(
|
|||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
i.*,
|
i.*,
|
||||||
|
COALESCE(NULLIF(i.product_name, ''), latest_line.product_label) AS resolved_product_name,
|
||||||
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
COALESCE(u.full_name, u.username) AS assigned_user_name,
|
||||||
sg.titel AS sag_title
|
sg.titel AS sag_title
|
||||||
FROM invoice_error_finder_issues i
|
FROM invoice_error_finder_issues i
|
||||||
|
LEFT JOIN customers c ON c.id = i.customer_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(NULLIF(line.description, ''), NULLIF(line.product_name, ''), NULLIF(line.product_number, '')) AS product_label
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = inv.id
|
||||||
|
WHERE c.economic_customer_number IS NOT NULL
|
||||||
|
AND inv.customer_number = c.economic_customer_number
|
||||||
|
AND LOWER(TRIM(COALESCE(line.product_number, ''))) = LOWER(TRIM(COALESCE(i.product_number, '')))
|
||||||
|
ORDER BY inv.invoice_date DESC, inv.id DESC, line.line_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_line ON TRUE
|
||||||
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
LEFT JOIN users u ON u.user_id = i.assigned_user_id
|
||||||
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
LEFT JOIN sag_sager sg ON sg.id = i.sag_id
|
||||||
WHERE i.id = %s
|
WHERE i.id = %s
|
||||||
@ -308,6 +426,479 @@ async def get_issue(
|
|||||||
raise HTTPException(status_code=500, detail=str(exc))
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/issues/{issue_id}/invoice-history")
|
||||||
|
async def get_issue_invoice_history(
|
||||||
|
issue_id: int,
|
||||||
|
current_user: dict = Depends(require_permission("invoice_error_finder.view")),
|
||||||
|
):
|
||||||
|
"""Return monthly invoice history around an issue for the same customer/product."""
|
||||||
|
try:
|
||||||
|
issue = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.customer_id,
|
||||||
|
i.customer_name,
|
||||||
|
i.product_number,
|
||||||
|
i.product_name,
|
||||||
|
i.reference_period_start,
|
||||||
|
i.reference_period_end
|
||||||
|
FROM invoice_error_finder_issues i
|
||||||
|
WHERE i.id = %s
|
||||||
|
""",
|
||||||
|
(issue_id,),
|
||||||
|
)
|
||||||
|
if not issue:
|
||||||
|
raise HTTPException(status_code=404, detail="Issue not found")
|
||||||
|
|
||||||
|
customer_id = issue.get("customer_id")
|
||||||
|
product_number = issue.get("product_number")
|
||||||
|
reference_period_start = issue.get("reference_period_start")
|
||||||
|
|
||||||
|
if not customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Issue has no mapped customer")
|
||||||
|
if not product_number:
|
||||||
|
raise HTTPException(status_code=400, detail="Issue has no product number")
|
||||||
|
if not reference_period_start:
|
||||||
|
raise HTTPException(status_code=400, detail="Issue has no reference period")
|
||||||
|
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if not customer or not customer.get("economic_customer_number"):
|
||||||
|
raise HTTPException(status_code=400, detail="Customer has no e-conomic mapping")
|
||||||
|
|
||||||
|
source_rank = {"paid": 1, "booked": 2, "unpaid": 3, "draft": 4}
|
||||||
|
|
||||||
|
def dedupe_invoice_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
best_by_number: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
invoice_number = row.get("source_invoice_number")
|
||||||
|
if not invoice_number:
|
||||||
|
continue
|
||||||
|
current_best = best_by_number.get(invoice_number)
|
||||||
|
candidate_rank = (
|
||||||
|
source_rank.get(row.get("source_type"), 9),
|
||||||
|
-(row.get("invoice_date").toordinal() if row.get("invoice_date") else 0),
|
||||||
|
-(int(row.get("invoice_id") or 0)),
|
||||||
|
)
|
||||||
|
if current_best is None:
|
||||||
|
best_by_number[invoice_number] = row
|
||||||
|
continue
|
||||||
|
current_rank = (
|
||||||
|
source_rank.get(current_best.get("source_type"), 9),
|
||||||
|
-(current_best.get("invoice_date").toordinal() if current_best.get("invoice_date") else 0),
|
||||||
|
-(int(current_best.get("invoice_id") or 0)),
|
||||||
|
)
|
||||||
|
if candidate_rank < current_rank:
|
||||||
|
best_by_number[invoice_number] = row
|
||||||
|
|
||||||
|
selected_ids = {row.get("invoice_id") for row in best_by_number.values() if row.get("invoice_id")}
|
||||||
|
return [row for row in rows if row.get("invoice_id") in selected_ids]
|
||||||
|
|
||||||
|
def build_invoice_payloads(rows: List[Dict[str, Any]]) -> tuple[Dict[int, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
|
||||||
|
invoices_by_id: Dict[int, Dict[str, Any]] = {}
|
||||||
|
invoices_by_month: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
invoice_id = row["invoice_id"]
|
||||||
|
month_key = row["month_start"].isoformat() if row.get("month_start") else None
|
||||||
|
if invoice_id not in invoices_by_id:
|
||||||
|
payload = {
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"invoice_number": row.get("source_invoice_number"),
|
||||||
|
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
|
||||||
|
"total_amount": float(row.get("total_amount") or 0),
|
||||||
|
"net_amount": float(row.get("net_amount") or 0),
|
||||||
|
"vat_amount": float(row.get("vat_amount") or 0),
|
||||||
|
"currency": row.get("currency") or "DKK",
|
||||||
|
"source_type": row.get("source_type"),
|
||||||
|
"heading": row.get("heading") or None,
|
||||||
|
"note_text": row.get("note_text") or None,
|
||||||
|
"lines": [],
|
||||||
|
}
|
||||||
|
invoices_by_id[invoice_id] = payload
|
||||||
|
if month_key:
|
||||||
|
invoices_by_month.setdefault(month_key, []).append(payload)
|
||||||
|
invoices_by_id[invoice_id]["lines"].append(
|
||||||
|
{
|
||||||
|
"line_number": int(row.get("line_number") or 0),
|
||||||
|
"product_number": row.get("product_number"),
|
||||||
|
"product_name": row.get("product_name"),
|
||||||
|
"description": row.get("description"),
|
||||||
|
"quantity": float(row.get("quantity") or 0),
|
||||||
|
"unit_price": float(row.get("unit_price") or 0),
|
||||||
|
"line_net_amount": float(row.get("line_net_amount") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return invoices_by_id, invoices_by_month
|
||||||
|
|
||||||
|
def aggregate_months(month_rows: List[Dict[str, Any]], matched_rows: List[Dict[str, Any]], invoices_by_month: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||||
|
agg_by_month: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in matched_rows:
|
||||||
|
month_key = row["month_start"].isoformat() if row.get("month_start") else None
|
||||||
|
if not month_key:
|
||||||
|
continue
|
||||||
|
bucket = agg_by_month.setdefault(
|
||||||
|
month_key,
|
||||||
|
{
|
||||||
|
"line_count": 0,
|
||||||
|
"total_quantity": 0.0,
|
||||||
|
"total_amount": 0.0,
|
||||||
|
"invoice_numbers": [],
|
||||||
|
"invoice_dates": [],
|
||||||
|
"descriptions": [],
|
||||||
|
"_seen_invoice_numbers": set(),
|
||||||
|
"_seen_descriptions": set(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["line_count"] += 1
|
||||||
|
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||||
|
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||||
|
invoice_number = row.get("source_invoice_number")
|
||||||
|
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||||
|
bucket["invoice_numbers"].append(invoice_number)
|
||||||
|
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||||
|
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||||
|
description = row.get("description")
|
||||||
|
if description and description not in bucket["_seen_descriptions"]:
|
||||||
|
bucket["descriptions"].append(description)
|
||||||
|
bucket["_seen_descriptions"].add(description)
|
||||||
|
|
||||||
|
month_payloads: List[Dict[str, Any]] = []
|
||||||
|
for month_row in month_rows:
|
||||||
|
month_key = month_row["month_start"].isoformat() if month_row.get("month_start") else None
|
||||||
|
bucket = agg_by_month.get(month_key) or {}
|
||||||
|
month_payloads.append(
|
||||||
|
{
|
||||||
|
"month_start": month_key,
|
||||||
|
"line_count": int(bucket.get("line_count") or 0),
|
||||||
|
"total_quantity": float(bucket.get("total_quantity") or 0),
|
||||||
|
"total_amount": float(bucket.get("total_amount") or 0),
|
||||||
|
"invoice_numbers": bucket.get("invoice_numbers") or [],
|
||||||
|
"invoice_dates": bucket.get("invoice_dates") or [],
|
||||||
|
"descriptions": bucket.get("descriptions") or [],
|
||||||
|
"invoices": invoices_by_month.get(month_key, []),
|
||||||
|
"is_reference_month": month_key == reference_period_start.replace(day=1).isoformat(),
|
||||||
|
"is_fallback_history": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return month_payloads
|
||||||
|
|
||||||
|
month_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT generate_series(
|
||||||
|
date_trunc('month', %s::date) - interval '13 months',
|
||||||
|
date_trunc('month', %s::date) + interval '2 months',
|
||||||
|
interval '1 month'
|
||||||
|
)::date AS month_start
|
||||||
|
ORDER BY month_start
|
||||||
|
""",
|
||||||
|
(reference_period_start, reference_period_start),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
candidate_window_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
inv.source_invoice_number,
|
||||||
|
inv.invoice_date,
|
||||||
|
inv.total_amount,
|
||||||
|
inv.net_amount,
|
||||||
|
inv.vat_amount,
|
||||||
|
inv.currency,
|
||||||
|
inv.source_type,
|
||||||
|
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||||
|
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||||
|
NULLIF(
|
||||||
|
CONCAT_WS(
|
||||||
|
E'\n',
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS note_text,
|
||||||
|
line.line_number,
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.line_net_amount
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = inv.id
|
||||||
|
WHERE inv.customer_number = %s
|
||||||
|
AND inv.invoice_date >= date_trunc('month', %s::date) - interval '13 months'
|
||||||
|
AND inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
|
||||||
|
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
customer["economic_customer_number"],
|
||||||
|
reference_period_start,
|
||||||
|
reference_period_start,
|
||||||
|
),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
window_rows = dedupe_invoice_rows(candidate_window_rows)
|
||||||
|
matched_window_rows = [
|
||||||
|
row for row in window_rows
|
||||||
|
if _line_matches_issue_product(
|
||||||
|
row.get("product_number"),
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
product_number,
|
||||||
|
issue.get("product_name"),
|
||||||
|
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
matched_window_invoice_ids = {row["invoice_id"] for row in matched_window_rows}
|
||||||
|
invoice_rows = [row for row in window_rows if row.get("invoice_id") in matched_window_invoice_ids]
|
||||||
|
_, invoices_by_month = build_invoice_payloads(invoice_rows)
|
||||||
|
months_payload = aggregate_months(month_rows, matched_window_rows, invoices_by_month)
|
||||||
|
|
||||||
|
fallback_month_rows: List[Dict[str, Any]] = []
|
||||||
|
if not matched_window_invoice_ids:
|
||||||
|
candidate_older_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
inv.source_invoice_number,
|
||||||
|
inv.invoice_date,
|
||||||
|
inv.total_amount,
|
||||||
|
inv.net_amount,
|
||||||
|
inv.vat_amount,
|
||||||
|
inv.currency,
|
||||||
|
inv.source_type,
|
||||||
|
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||||
|
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||||
|
NULLIF(
|
||||||
|
CONCAT_WS(
|
||||||
|
E'\n',
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS note_text,
|
||||||
|
line.line_number,
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.line_net_amount
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = inv.id
|
||||||
|
WHERE inv.customer_number = %s
|
||||||
|
AND inv.invoice_date < date_trunc('month', %s::date) - interval '13 months'
|
||||||
|
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
customer["economic_customer_number"],
|
||||||
|
reference_period_start,
|
||||||
|
),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
older_rows = dedupe_invoice_rows(candidate_older_rows)
|
||||||
|
matched_older_rows = [
|
||||||
|
row for row in older_rows
|
||||||
|
if _line_matches_issue_product(
|
||||||
|
row.get("product_number"),
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
product_number,
|
||||||
|
issue.get("product_name"),
|
||||||
|
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
top3_invoice_ids: List[int] = []
|
||||||
|
seen_ids = set()
|
||||||
|
for row in matched_older_rows:
|
||||||
|
invoice_id = row.get("invoice_id")
|
||||||
|
if invoice_id and invoice_id not in seen_ids:
|
||||||
|
seen_ids.add(invoice_id)
|
||||||
|
top3_invoice_ids.append(invoice_id)
|
||||||
|
if len(top3_invoice_ids) == 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
fallback_invoice_rows = [row for row in older_rows if row.get("invoice_id") in set(top3_invoice_ids)]
|
||||||
|
_, fallback_invoices_by_month = build_invoice_payloads(fallback_invoice_rows)
|
||||||
|
|
||||||
|
fallback_month_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in matched_older_rows:
|
||||||
|
if row.get("invoice_id") not in top3_invoice_ids or not row.get("month_start"):
|
||||||
|
continue
|
||||||
|
month_key = row["month_start"].isoformat()
|
||||||
|
bucket = fallback_month_map.setdefault(
|
||||||
|
month_key,
|
||||||
|
{
|
||||||
|
"month_start": month_key,
|
||||||
|
"line_count": 0,
|
||||||
|
"total_quantity": 0.0,
|
||||||
|
"total_amount": 0.0,
|
||||||
|
"invoice_numbers": [],
|
||||||
|
"invoice_dates": [],
|
||||||
|
"descriptions": [],
|
||||||
|
"invoices": fallback_invoices_by_month.get(month_key, []),
|
||||||
|
"is_reference_month": False,
|
||||||
|
"is_fallback_history": True,
|
||||||
|
"_seen_invoice_numbers": set(),
|
||||||
|
"_seen_descriptions": set(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["line_count"] += 1
|
||||||
|
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||||
|
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||||
|
invoice_number = row.get("source_invoice_number")
|
||||||
|
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||||
|
bucket["invoice_numbers"].append(invoice_number)
|
||||||
|
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||||
|
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||||
|
description = row.get("description")
|
||||||
|
if description and description not in bucket["_seen_descriptions"]:
|
||||||
|
bucket["descriptions"].append(description)
|
||||||
|
bucket["_seen_descriptions"].add(description)
|
||||||
|
|
||||||
|
fallback_month_rows = sorted(
|
||||||
|
[
|
||||||
|
{key: value for key, value in month.items() if not key.startswith("_")}
|
||||||
|
for month in fallback_month_map.values()
|
||||||
|
],
|
||||||
|
key=lambda item: item["month_start"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matched_window_invoice_ids and not fallback_month_rows:
|
||||||
|
candidate_global_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
inv.source_invoice_number,
|
||||||
|
inv.invoice_date,
|
||||||
|
inv.total_amount,
|
||||||
|
inv.net_amount,
|
||||||
|
inv.vat_amount,
|
||||||
|
inv.currency,
|
||||||
|
inv.source_type,
|
||||||
|
date_trunc('month', inv.invoice_date)::date AS month_start,
|
||||||
|
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||||
|
NULLIF(
|
||||||
|
CONCAT_WS(
|
||||||
|
E'\n',
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||||
|
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS note_text,
|
||||||
|
line.line_number,
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.line_net_amount
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = inv.id
|
||||||
|
WHERE inv.invoice_date < date_trunc('month', %s::date) + interval '3 months'
|
||||||
|
ORDER BY inv.invoice_date DESC, inv.source_invoice_number DESC, line.line_number
|
||||||
|
""",
|
||||||
|
(reference_period_start,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
global_rows = dedupe_invoice_rows(candidate_global_rows)
|
||||||
|
matched_global_rows = [
|
||||||
|
row for row in global_rows
|
||||||
|
if _line_matches_issue_product(
|
||||||
|
row.get("product_number"),
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
product_number,
|
||||||
|
issue.get("product_name"),
|
||||||
|
" ".join(part for part in [row.get("heading") or "", row.get("note_text") or ""] if part),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
top3_global_invoice_ids: List[int] = []
|
||||||
|
seen_ids = set()
|
||||||
|
for row in matched_global_rows:
|
||||||
|
invoice_id = row.get("invoice_id")
|
||||||
|
if invoice_id and invoice_id not in seen_ids:
|
||||||
|
seen_ids.add(invoice_id)
|
||||||
|
top3_global_invoice_ids.append(invoice_id)
|
||||||
|
if len(top3_global_invoice_ids) == 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
global_fallback_invoice_rows = [
|
||||||
|
row for row in global_rows if row.get("invoice_id") in set(top3_global_invoice_ids)
|
||||||
|
]
|
||||||
|
_, global_fallback_invoices_by_month = build_invoice_payloads(global_fallback_invoice_rows)
|
||||||
|
|
||||||
|
global_fallback_month_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in matched_global_rows:
|
||||||
|
if row.get("invoice_id") not in top3_global_invoice_ids:
|
||||||
|
continue
|
||||||
|
if not row.get("month_start"):
|
||||||
|
continue
|
||||||
|
month_key = row["month_start"].isoformat()
|
||||||
|
bucket = global_fallback_month_map.setdefault(
|
||||||
|
month_key,
|
||||||
|
{
|
||||||
|
"month_start": month_key,
|
||||||
|
"line_count": 0,
|
||||||
|
"total_quantity": 0.0,
|
||||||
|
"total_amount": 0.0,
|
||||||
|
"invoice_numbers": [],
|
||||||
|
"invoice_dates": [],
|
||||||
|
"descriptions": [],
|
||||||
|
"invoices": global_fallback_invoices_by_month.get(month_key, []),
|
||||||
|
"is_reference_month": False,
|
||||||
|
"is_fallback_history": True,
|
||||||
|
"fallback_label": "Seneste lignende fakturaer",
|
||||||
|
"_seen_invoice_numbers": set(),
|
||||||
|
"_seen_descriptions": set(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bucket["line_count"] += 1
|
||||||
|
bucket["total_quantity"] += float(row.get("quantity") or 0)
|
||||||
|
bucket["total_amount"] += float(row.get("line_net_amount") or 0)
|
||||||
|
invoice_number = row.get("source_invoice_number")
|
||||||
|
if invoice_number and invoice_number not in bucket["_seen_invoice_numbers"]:
|
||||||
|
bucket["invoice_numbers"].append(invoice_number)
|
||||||
|
bucket["invoice_dates"].append(row["invoice_date"].isoformat() if row.get("invoice_date") else None)
|
||||||
|
bucket["_seen_invoice_numbers"].add(invoice_number)
|
||||||
|
description = row.get("description")
|
||||||
|
if description and description not in bucket["_seen_descriptions"]:
|
||||||
|
bucket["descriptions"].append(description)
|
||||||
|
bucket["_seen_descriptions"].add(description)
|
||||||
|
|
||||||
|
fallback_month_rows = sorted(
|
||||||
|
[
|
||||||
|
{key: value for key, value in month.items() if not key.startswith("_")}
|
||||||
|
for month in global_fallback_month_map.values()
|
||||||
|
],
|
||||||
|
key=lambda item: item["month_start"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer.get("name") or issue.get("customer_name"),
|
||||||
|
"economic_customer_number": customer.get("economic_customer_number"),
|
||||||
|
"product_number": product_number,
|
||||||
|
"product_name": issue.get("product_name"),
|
||||||
|
"reference_period_start": reference_period_start.isoformat(),
|
||||||
|
"months": [*fallback_month_rows, *months_payload],
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Get issue invoice history failed: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/issues/{issue_id}/status")
|
@router.patch("/issues/{issue_id}/status")
|
||||||
async def update_issue_status(
|
async def update_issue_status(
|
||||||
issue_id: int,
|
issue_id: int,
|
||||||
@ -320,7 +911,7 @@ async def update_issue_status(
|
|||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
|
||||||
resolved_at = None
|
resolved_at = None
|
||||||
if payload.status in {"invoiced", "ignored"}:
|
if payload.status in {"invoiced", "ignored", "resolved"}:
|
||||||
resolved_at = "CURRENT_TIMESTAMP"
|
resolved_at = "CURRENT_TIMESTAMP"
|
||||||
|
|
||||||
extra_fields = []
|
extra_fields = []
|
||||||
@ -334,7 +925,11 @@ async def update_issue_status(
|
|||||||
extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s")
|
extra_fields.append("notes = COALESCE(notes, '') || E'\\n' || %s")
|
||||||
extra_values.append(payload.notes)
|
extra_values.append(payload.notes)
|
||||||
|
|
||||||
resolved_sql = f"resolved_at = COALESCE(resolved_at, {resolved_at})" if resolved_at else "resolved_at = resolved_at"
|
resolved_sql = (
|
||||||
|
f"resolved_at = COALESCE(resolved_at, {resolved_at})"
|
||||||
|
if resolved_at
|
||||||
|
else "resolved_at = NULL"
|
||||||
|
)
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
f"""
|
f"""
|
||||||
|
|||||||
@ -116,7 +116,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
|||||||
'error_found',
|
'error_found',
|
||||||
'ready_to_invoice',
|
'ready_to_invoice',
|
||||||
'invoiced',
|
'invoiced',
|
||||||
'ignored'
|
'ignored',
|
||||||
|
'resolved'
|
||||||
)),
|
)),
|
||||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
customer_name VARCHAR(255),
|
customer_name VARCHAR(255),
|
||||||
|
|||||||
@ -4,6 +4,8 @@ Compares imported e-conomic invoices with subscriptions / Simply orders and
|
|||||||
writes issues to invoice_error_finder_issues.
|
writes issues to invoice_error_finder_issues.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@ -12,6 +14,38 @@ from app.core.database import execute_query, execute_query_single
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_IGNORED_PRODUCT_TEXTS = [
|
||||||
|
"faktureringsgebyr",
|
||||||
|
"gebyr",
|
||||||
|
"porto",
|
||||||
|
"fragt",
|
||||||
|
"fragtomkostning",
|
||||||
|
"forsendelse",
|
||||||
|
"shipping",
|
||||||
|
"levering",
|
||||||
|
"engangsydelse",
|
||||||
|
"engangsarbejde",
|
||||||
|
"oprettelse",
|
||||||
|
"opstartsgebyr",
|
||||||
|
"installation",
|
||||||
|
"installationsgebyr",
|
||||||
|
"timeforbrug",
|
||||||
|
"arbejdstid",
|
||||||
|
"konsulenttimer",
|
||||||
|
"supporttid",
|
||||||
|
"teknikertid",
|
||||||
|
"montørtimer",
|
||||||
|
"projektarbejde",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_IGNORED_PRODUCT_PATTERNS = [
|
||||||
|
re.compile(r"\bcase\s*id\s*cc[\w-]+\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcase\s*id\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bsag\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bprojekt\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:timeforbrug|arbejdstid|konsulenttimer|supporttid|teknikertid|montørtimer)\b", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class DetectionService:
|
class DetectionService:
|
||||||
"""Detect invoice anomalies and write issues."""
|
"""Detect invoice anomalies and write issues."""
|
||||||
@ -23,16 +57,40 @@ class DetectionService:
|
|||||||
):
|
):
|
||||||
self.quantity_drop_threshold = quantity_drop_threshold
|
self.quantity_drop_threshold = quantity_drop_threshold
|
||||||
self.open_order_days_threshold = open_order_days_threshold
|
self.open_order_days_threshold = open_order_days_threshold
|
||||||
|
self._ignored_product_texts_cache: Optional[List[str]] = None
|
||||||
|
self._seen_issue_ids: set[int] = set()
|
||||||
|
|
||||||
def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
|
def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Run all detection rules for the given reference month (defaults to current month).
|
Run detection rules for a specific month or sweep historical invoice months.
|
||||||
Returns counts per issue_type.
|
Returns aggregated counts per issue_type.
|
||||||
"""
|
"""
|
||||||
if reference_month is None:
|
self._ignore_existing_issues()
|
||||||
reference_month = date.today().replace(day=1)
|
|
||||||
|
|
||||||
|
if reference_month is not None:
|
||||||
|
return self._analyze_single_month(reference_month)
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"missing_line": 0,
|
||||||
|
"open_order_not_invoiced": 0,
|
||||||
|
"quantity_drop": 0,
|
||||||
|
"price_change": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
months = self._get_analysis_months()
|
||||||
|
logger.info("🔍 Running historical invoice error detection for %s month(s)", len(months))
|
||||||
|
|
||||||
|
for month in months:
|
||||||
|
month_counts = self._analyze_single_month(month)
|
||||||
|
for key, value in month_counts.items():
|
||||||
|
totals[key] = totals.get(key, 0) + int(value or 0)
|
||||||
|
|
||||||
|
logger.info("✅ Historical detection complete: %s", totals)
|
||||||
|
return totals
|
||||||
|
|
||||||
|
def _analyze_single_month(self, reference_month: date) -> Dict[str, int]:
|
||||||
previous_month = reference_month - relativedelta(months=1)
|
previous_month = reference_month - relativedelta(months=1)
|
||||||
|
self._seen_issue_ids = set()
|
||||||
|
|
||||||
logger.info("🔍 Running invoice error detection for %s", reference_month)
|
logger.info("🔍 Running invoice error detection for %s", reference_month)
|
||||||
|
|
||||||
@ -43,9 +101,40 @@ class DetectionService:
|
|||||||
"price_change": self._detect_price_changes(reference_month, previous_month),
|
"price_change": self._detect_price_changes(reference_month, previous_month),
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("✅ Detection complete: %s", counts)
|
self._resolve_stale_issues(reference_month)
|
||||||
|
|
||||||
|
logger.info("✅ Detection complete for %s: %s", reference_month, counts)
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_analysis_months() -> List[date]:
|
||||||
|
current_month = date.today().replace(day=1)
|
||||||
|
bounds = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', MIN(invoice_date))::date AS first_month,
|
||||||
|
GREATEST(
|
||||||
|
DATE_TRUNC('month', MAX(invoice_date))::date,
|
||||||
|
DATE_TRUNC('month', CURRENT_DATE)::date
|
||||||
|
) AS last_month
|
||||||
|
FROM invoice_error_finder_economic_invoices
|
||||||
|
"""
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
first_month = bounds.get("first_month")
|
||||||
|
last_month = bounds.get("last_month") or current_month
|
||||||
|
|
||||||
|
if not first_month:
|
||||||
|
return [current_month]
|
||||||
|
|
||||||
|
month = first_month + relativedelta(months=1)
|
||||||
|
months: List[date] = []
|
||||||
|
while month <= last_month:
|
||||||
|
months.append(month)
|
||||||
|
month += relativedelta(months=1)
|
||||||
|
|
||||||
|
return months or [last_month]
|
||||||
|
|
||||||
def _detect_missing_lines(self, current_month: date, previous_month: date) -> int:
|
def _detect_missing_lines(self, current_month: date, previous_month: date) -> int:
|
||||||
"""
|
"""
|
||||||
Products invoiced in previous month but missing in current month for same customer.
|
Products invoiced in previous month but missing in current month for same customer.
|
||||||
@ -69,7 +158,9 @@ class DetectionService:
|
|||||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||||
SUM(line.quantity) AS quantity,
|
SUM(line.quantity) AS quantity,
|
||||||
MAX(inv.invoice_date) AS last_invoice_date
|
MAX(inv.invoice_date) AS last_invoice_date,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||||
FROM invoice_error_finder_economic_invoices inv
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
JOIN invoice_error_finder_economic_invoice_lines line
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
ON line.invoice_id = inv.id
|
ON line.invoice_id = inv.id
|
||||||
@ -97,6 +188,8 @@ class DetectionService:
|
|||||||
prev.product_key,
|
prev.product_key,
|
||||||
prev.quantity AS expected_quantity,
|
prev.quantity AS expected_quantity,
|
||||||
prev.last_invoice_date,
|
prev.last_invoice_date,
|
||||||
|
prev.product_name,
|
||||||
|
prev.description,
|
||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
m2.hub_customer_id
|
m2.hub_customer_id
|
||||||
FROM previous_lines prev
|
FROM previous_lines prev
|
||||||
@ -118,12 +211,18 @@ class DetectionService:
|
|||||||
|
|
||||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||||
continue
|
continue
|
||||||
|
if self._is_ignored_product_text(
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
issue_id = self._upsert_issue(
|
issue_id = self._upsert_issue(
|
||||||
issue_type="missing_line",
|
issue_type="missing_line",
|
||||||
customer_id=hub_customer_id,
|
customer_id=hub_customer_id,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
product_number=row["product_key"],
|
product_number=row["product_key"],
|
||||||
|
product_name=row.get("product_name") or row.get("description"),
|
||||||
reference_period_start=current_start,
|
reference_period_start=current_start,
|
||||||
reference_period_end=current_end,
|
reference_period_end=current_end,
|
||||||
expected_quantity=row.get("expected_quantity"),
|
expected_quantity=row.get("expected_quantity"),
|
||||||
@ -186,6 +285,11 @@ class DetectionService:
|
|||||||
|
|
||||||
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
|
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
|
||||||
continue
|
continue
|
||||||
|
if self._is_ignored_product_text(
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("subject"),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if there is any e-conomic invoice line for this customer + product recently
|
# Check if there is any e-conomic invoice line for this customer + product recently
|
||||||
has_invoice = self._has_recent_invoice_for_product(
|
has_invoice = self._has_recent_invoice_for_product(
|
||||||
@ -238,7 +342,9 @@ class DetectionService:
|
|||||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||||
SUM(line.quantity) AS quantity
|
SUM(line.quantity) AS quantity,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||||
FROM invoice_error_finder_economic_invoices inv
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
JOIN invoice_error_finder_economic_invoice_lines line
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
ON line.invoice_id = inv.id
|
ON line.invoice_id = inv.id
|
||||||
@ -249,7 +355,7 @@ class DetectionService:
|
|||||||
GROUP BY customer_key, product_key, period
|
GROUP BY customer_key, product_key, period
|
||||||
),
|
),
|
||||||
prev AS (
|
prev AS (
|
||||||
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
SELECT customer_key, product_key, quantity, product_name, description FROM monthly_qty WHERE period = %s
|
||||||
),
|
),
|
||||||
cur AS (
|
cur AS (
|
||||||
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
||||||
@ -259,6 +365,8 @@ class DetectionService:
|
|||||||
prev.product_key,
|
prev.product_key,
|
||||||
prev.quantity AS expected_quantity,
|
prev.quantity AS expected_quantity,
|
||||||
cur.quantity AS actual_quantity,
|
cur.quantity AS actual_quantity,
|
||||||
|
prev.product_name,
|
||||||
|
prev.description,
|
||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
m.hub_customer_id
|
m.hub_customer_id
|
||||||
FROM prev
|
FROM prev
|
||||||
@ -286,12 +394,18 @@ class DetectionService:
|
|||||||
|
|
||||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||||
continue
|
continue
|
||||||
|
if self._is_ignored_product_text(
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
issue_id = self._upsert_issue(
|
issue_id = self._upsert_issue(
|
||||||
issue_type="quantity_drop",
|
issue_type="quantity_drop",
|
||||||
customer_id=hub_customer_id,
|
customer_id=hub_customer_id,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
product_number=row["product_key"],
|
product_number=row["product_key"],
|
||||||
|
product_name=row.get("product_name") or row.get("description"),
|
||||||
reference_period_start=current_start,
|
reference_period_start=current_start,
|
||||||
reference_period_end=current_end,
|
reference_period_end=current_end,
|
||||||
expected_quantity=row["expected_quantity"],
|
expected_quantity=row["expected_quantity"],
|
||||||
@ -324,7 +438,9 @@ class DetectionService:
|
|||||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||||
AVG(line.unit_price) AS avg_price
|
AVG(line.unit_price) AS avg_price,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||||
|
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||||
FROM invoice_error_finder_economic_invoices inv
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
JOIN invoice_error_finder_economic_invoice_lines line
|
JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
ON line.invoice_id = inv.id
|
ON line.invoice_id = inv.id
|
||||||
@ -336,7 +452,7 @@ class DetectionService:
|
|||||||
GROUP BY customer_key, product_key, period
|
GROUP BY customer_key, product_key, period
|
||||||
),
|
),
|
||||||
prev AS (
|
prev AS (
|
||||||
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
SELECT customer_key, product_key, avg_price, product_name, description FROM monthly_price WHERE period = %s
|
||||||
),
|
),
|
||||||
cur AS (
|
cur AS (
|
||||||
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
||||||
@ -346,6 +462,8 @@ class DetectionService:
|
|||||||
prev.product_key,
|
prev.product_key,
|
||||||
prev.avg_price AS expected_price,
|
prev.avg_price AS expected_price,
|
||||||
cur.avg_price AS actual_price,
|
cur.avg_price AS actual_price,
|
||||||
|
prev.product_name,
|
||||||
|
prev.description,
|
||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
m.hub_customer_id
|
m.hub_customer_id
|
||||||
FROM prev
|
FROM prev
|
||||||
@ -371,6 +489,11 @@ class DetectionService:
|
|||||||
|
|
||||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||||
continue
|
continue
|
||||||
|
if self._is_ignored_product_text(
|
||||||
|
row.get("product_name"),
|
||||||
|
row.get("description"),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
expected_price = row["expected_price"]
|
expected_price = row["expected_price"]
|
||||||
actual_price = row["actual_price"]
|
actual_price = row["actual_price"]
|
||||||
@ -383,6 +506,7 @@ class DetectionService:
|
|||||||
customer_id=hub_customer_id,
|
customer_id=hub_customer_id,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
product_number=row["product_key"],
|
product_number=row["product_key"],
|
||||||
|
product_name=row.get("product_name") or row.get("description"),
|
||||||
reference_period_start=current_start,
|
reference_period_start=current_start,
|
||||||
reference_period_end=current_end,
|
reference_period_end=current_end,
|
||||||
expected_price=expected_price,
|
expected_price=expected_price,
|
||||||
@ -473,10 +597,130 @@ class DetectionService:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _ignore_existing_issues(self) -> None:
|
||||||
|
ignored_terms = self._load_ignored_product_texts()
|
||||||
|
if not ignored_terms:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, product_name
|
||||||
|
FROM invoice_error_finder_issues
|
||||||
|
WHERE status IN ('open', 'investigating', 'approved_change', 'error_found', 'ready_to_invoice')
|
||||||
|
""",
|
||||||
|
(),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if not self._is_ignored_product_text(row.get("product_name")):
|
||||||
|
continue
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE invoice_error_finder_issues
|
||||||
|
SET status = 'ignored',
|
||||||
|
ignored_until = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_ignored_product_texts(self) -> List[str]:
|
||||||
|
if self._ignored_product_texts_cache is not None:
|
||||||
|
return self._ignored_product_texts_cache
|
||||||
|
|
||||||
|
setting = execute_query_single(
|
||||||
|
"SELECT value FROM settings WHERE key = %s",
|
||||||
|
("invoice_error_finder_ignored_product_texts",),
|
||||||
|
) or {}
|
||||||
|
raw_value = setting.get("value")
|
||||||
|
|
||||||
|
values: List[str] = []
|
||||||
|
if raw_value:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(str(raw_value))
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
values = [str(item) for item in parsed]
|
||||||
|
elif isinstance(parsed, str):
|
||||||
|
values = [parsed]
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
values = str(raw_value).replace(";", "\n").replace(",", "\n").splitlines()
|
||||||
|
|
||||||
|
combined = DEFAULT_IGNORED_PRODUCT_TEXTS + values
|
||||||
|
normalized: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for value in combined:
|
||||||
|
item = self._normalize_text(value)
|
||||||
|
if not item or item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
normalized.append(item)
|
||||||
|
|
||||||
|
self._ignored_product_texts_cache = normalized
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_text(value: Any) -> str:
|
||||||
|
return " ".join(str(value or "").strip().lower().split())
|
||||||
|
|
||||||
|
def _is_ignored_product_text(self, *values: Any) -> bool:
|
||||||
|
ignore_terms = self._load_ignored_product_texts()
|
||||||
|
if not ignore_terms:
|
||||||
|
ignore_terms = []
|
||||||
|
|
||||||
|
normalized_values = [
|
||||||
|
self._normalize_text(value)
|
||||||
|
for value in values
|
||||||
|
if self._normalize_text(value)
|
||||||
|
]
|
||||||
|
if not normalized_values:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for candidate in normalized_values:
|
||||||
|
for term in ignore_terms:
|
||||||
|
if term and term in candidate:
|
||||||
|
return True
|
||||||
|
for pattern in DEFAULT_IGNORED_PRODUCT_PATTERNS:
|
||||||
|
if pattern.search(candidate):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _resolve_stale_issues(self, reference_month: date) -> None:
|
||||||
|
period_start, period_end = self._month_bounds(reference_month)
|
||||||
|
active_statuses = ("open", "investigating", "approved_change", "error_found", "ready_to_invoice")
|
||||||
|
issue_types = ("missing_line", "open_order_not_invoiced", "quantity_drop", "price_change")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM invoice_error_finder_issues
|
||||||
|
WHERE reference_period_start = %s
|
||||||
|
AND reference_period_end = %s
|
||||||
|
AND issue_type = ANY(%s::text[])
|
||||||
|
AND status = ANY(%s::text[])
|
||||||
|
""",
|
||||||
|
(period_start, period_end, list(issue_types), list(active_statuses)),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
stale_ids = [row["id"] for row in rows if int(row["id"]) not in self._seen_issue_ids]
|
||||||
|
if not stale_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE invoice_error_finder_issues
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolved_at = COALESCE(resolved_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ANY(%s::int[])
|
||||||
|
""",
|
||||||
|
(stale_ids,),
|
||||||
|
)
|
||||||
|
|
||||||
def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
|
def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
|
||||||
"""Insert a new issue or update an existing open one."""
|
"""Insert a new issue or update an existing open one."""
|
||||||
issue_type = kwargs["issue_type"]
|
issue_type = kwargs["issue_type"]
|
||||||
customer_id = kwargs.get("customer_id")
|
customer_id = self._resolve_existing_customer_id(kwargs.get("customer_id"))
|
||||||
product_number = kwargs.get("product_number")
|
product_number = kwargs.get("product_number")
|
||||||
reference_period_start = kwargs.get("reference_period_start")
|
reference_period_start = kwargs.get("reference_period_start")
|
||||||
reference_period_end = kwargs.get("reference_period_end")
|
reference_period_end = kwargs.get("reference_period_end")
|
||||||
@ -513,6 +757,41 @@ class DetectionService:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if existing and existing.get("status") == "resolved":
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE invoice_error_finder_issues
|
||||||
|
SET status = %s,
|
||||||
|
resolved_at = NULL,
|
||||||
|
expected_quantity = COALESCE(%s, expected_quantity),
|
||||||
|
actual_quantity = COALESCE(%s, actual_quantity),
|
||||||
|
expected_price = COALESCE(%s, expected_price),
|
||||||
|
actual_price = COALESCE(%s, actual_price),
|
||||||
|
amount_impact = COALESCE(%s, amount_impact),
|
||||||
|
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||||
|
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||||
|
sales_order_number = COALESCE(%s, sales_order_number),
|
||||||
|
product_name = COALESCE(%s, product_name),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kwargs.get("status", "open"),
|
||||||
|
kwargs.get("expected_quantity"),
|
||||||
|
kwargs.get("actual_quantity"),
|
||||||
|
kwargs.get("expected_price"),
|
||||||
|
kwargs.get("actual_price"),
|
||||||
|
kwargs.get("amount_impact"),
|
||||||
|
kwargs.get("last_invoice_number"),
|
||||||
|
kwargs.get("last_invoice_date"),
|
||||||
|
kwargs.get("sales_order_number"),
|
||||||
|
kwargs.get("product_name"),
|
||||||
|
existing["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._seen_issue_ids.add(int(existing["id"]))
|
||||||
|
return existing["id"]
|
||||||
|
|
||||||
if existing and existing.get("status") not in {"ignored", "invoiced"}:
|
if existing and existing.get("status") not in {"ignored", "invoiced"}:
|
||||||
execute_query(
|
execute_query(
|
||||||
"""
|
"""
|
||||||
@ -525,6 +804,7 @@ class DetectionService:
|
|||||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||||
sales_order_number = COALESCE(%s, sales_order_number),
|
sales_order_number = COALESCE(%s, sales_order_number),
|
||||||
|
product_name = COALESCE(%s, product_name),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
@ -537,9 +817,11 @@ class DetectionService:
|
|||||||
kwargs.get("last_invoice_number"),
|
kwargs.get("last_invoice_number"),
|
||||||
kwargs.get("last_invoice_date"),
|
kwargs.get("last_invoice_date"),
|
||||||
kwargs.get("sales_order_number"),
|
kwargs.get("sales_order_number"),
|
||||||
|
kwargs.get("product_name"),
|
||||||
existing["id"],
|
existing["id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self._seen_issue_ids.add(int(existing["id"]))
|
||||||
return existing["id"]
|
return existing["id"]
|
||||||
|
|
||||||
if existing and existing.get("status") == "invoiced":
|
if existing and existing.get("status") == "invoiced":
|
||||||
@ -568,6 +850,7 @@ class DetectionService:
|
|||||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||||
sales_order_number = COALESCE(%s, sales_order_number),
|
sales_order_number = COALESCE(%s, sales_order_number),
|
||||||
|
product_name = COALESCE(%s, product_name),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
@ -581,9 +864,11 @@ class DetectionService:
|
|||||||
kwargs.get("last_invoice_number"),
|
kwargs.get("last_invoice_number"),
|
||||||
kwargs.get("last_invoice_date"),
|
kwargs.get("last_invoice_date"),
|
||||||
kwargs.get("sales_order_number"),
|
kwargs.get("sales_order_number"),
|
||||||
|
kwargs.get("product_name"),
|
||||||
existing["id"],
|
existing["id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self._seen_issue_ids.add(int(existing["id"]))
|
||||||
return existing["id"]
|
return existing["id"]
|
||||||
|
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
@ -624,15 +909,33 @@ class DetectionService:
|
|||||||
kwargs.get("notes"),
|
kwargs.get("notes"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if row and row.get("id") is not None:
|
||||||
|
self._seen_issue_ids.add(int(row["id"]))
|
||||||
return row["id"] if row else None
|
return row["id"] if row else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]:
|
def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]:
|
||||||
value = row.get("hub_customer_id") or row.get("customer_key")
|
value = row.get("hub_customer_id")
|
||||||
if isinstance(value, int):
|
if not isinstance(value, int):
|
||||||
return value
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE id = %s",
|
||||||
|
(value,),
|
||||||
|
)
|
||||||
|
return value if customer else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_existing_customer_id(value: Any) -> Optional[int]:
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE id = %s",
|
||||||
|
(value,),
|
||||||
|
)
|
||||||
|
return value if customer else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
|
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
|
||||||
if hub_customer_id:
|
if hub_customer_id:
|
||||||
|
|||||||
@ -100,7 +100,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-muted text-uppercase small mb-2">Klar til fakturering</h6>
|
<h6 class="text-muted text-uppercase small mb-2">Ordrekladder klar</h6>
|
||||||
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
|
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-success bg-opacity-10 p-2 rounded">
|
<div class="bg-success bg-opacity-10 p-2 rounded">
|
||||||
|
|||||||
@ -30,9 +30,10 @@
|
|||||||
<option value="investigating">Under undersøgelse</option>
|
<option value="investigating">Under undersøgelse</option>
|
||||||
<option value="approved_change">Godkendt ændring</option>
|
<option value="approved_change">Godkendt ændring</option>
|
||||||
<option value="error_found">Fejl fundet</option>
|
<option value="error_found">Fejl fundet</option>
|
||||||
<option value="ready_to_invoice">Klar til fakturering</option>
|
<option value="ready_to_invoice">Opret ordrekladde</option>
|
||||||
<option value="invoiced">Faktureret</option>
|
<option value="invoiced">Faktureret</option>
|
||||||
<option value="ignored">Ignoreret</option>
|
<option value="ignored">Ignoreret</option>
|
||||||
|
<option value="resolved">Løst</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-3">
|
<div class="col-12 col-md-3">
|
||||||
@ -99,12 +100,61 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
<span class="text-muted small" id="paginationInfo"></span>
|
<span class="text-muted small" id="paginationInfo"></span>
|
||||||
<div class="btn-group" id="paginationControls"></div>
|
<div class="btn-group" id="paginationControls"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="historyModalStatus" class="alert d-none mb-3"></div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3" id="historyModalActions">
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" id="historyApproveBtn" disabled>
|
||||||
|
<i class="bi bi-check2 me-1"></i>Godkend
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" id="historyInvestigatingBtn" disabled>
|
||||||
|
<i class="bi bi-search me-1"></i>Undersøgelse
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" id="historyReadyBtn" disabled>
|
||||||
|
<i class="bi bi-receipt me-1"></i>Opret ordrekladde
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" id="historyCreateSagBtn" disabled>
|
||||||
|
<i class="bi bi-folder-plus me-1"></i>Opret sag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Måned</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Antal linjer</th>
|
||||||
|
<th>Samlet mængde</th>
|
||||||
|
<th>Samlet beløb</th>
|
||||||
|
<th>Fakturaer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="historyModalBody">
|
||||||
|
<tr><td colspan="6" class="text-muted text-center py-4">Vælg en fejl for at se historik</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
const pageSize = 100;
|
const pageSize = 100;
|
||||||
|
let currentHistoryIssueId = null;
|
||||||
|
|
||||||
const issueTypeLabels = {
|
const issueTypeLabels = {
|
||||||
missing_line: 'Manglende varelinje',
|
missing_line: 'Manglende varelinje',
|
||||||
@ -119,9 +169,10 @@ const statusLabels = {
|
|||||||
investigating: 'Under undersøgelse',
|
investigating: 'Under undersøgelse',
|
||||||
approved_change: 'Godkendt ændring',
|
approved_change: 'Godkendt ændring',
|
||||||
error_found: 'Fejl fundet',
|
error_found: 'Fejl fundet',
|
||||||
ready_to_invoice: 'Klar til fakturering',
|
ready_to_invoice: 'Opret ordrekladde',
|
||||||
invoiced: 'Faktureret',
|
invoiced: 'Faktureret',
|
||||||
ignored: 'Ignoreret'
|
ignored: 'Ignoreret',
|
||||||
|
resolved: 'Løst'
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@ -143,6 +194,11 @@ function formatNumber(value) {
|
|||||||
return new Intl.NumberFormat('da-DK').format(value);
|
return new Intl.NumberFormat('da-DK').format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMonth(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('da-DK', { year: 'numeric', month: 'short' }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(status) {
|
function statusBadge(status) {
|
||||||
const map = {
|
const map = {
|
||||||
open: 'bg-danger',
|
open: 'bg-danger',
|
||||||
@ -151,7 +207,8 @@ function statusBadge(status) {
|
|||||||
error_found: 'bg-danger',
|
error_found: 'bg-danger',
|
||||||
ready_to_invoice: 'bg-success',
|
ready_to_invoice: 'bg-success',
|
||||||
invoiced: 'bg-secondary',
|
invoiced: 'bg-secondary',
|
||||||
ignored: 'bg-light text-dark'
|
ignored: 'bg-light text-dark',
|
||||||
|
resolved: 'bg-secondary-subtle text-dark'
|
||||||
};
|
};
|
||||||
const cls = map[status] || 'bg-light text-dark';
|
const cls = map[status] || 'bg-light text-dark';
|
||||||
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
|
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
|
||||||
@ -198,7 +255,7 @@ async function loadIssues() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
|
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
|
||||||
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
|
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
|
||||||
<td>${escapeHtml(issue.product_name || issue.product_number || '-')}</td>
|
<td>${escapeHtml(issue.resolved_product_name || issue.product_name || issue.product_number || '-')}</td>
|
||||||
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
|
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
|
||||||
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
|
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
|
||||||
<td>${issue.reference_period_start || '-'}</td>
|
<td>${issue.reference_period_start || '-'}</td>
|
||||||
@ -222,13 +279,16 @@ async function loadIssues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderActionButtons(issue) {
|
function renderActionButtons(issue) {
|
||||||
if (issue.status === 'ignored') return '<span class="text-muted small">Ignoreret</span>';
|
const historyButton = `<button class="btn btn-outline-dark" title="Se 13 måneder tilbage og 2 frem" onclick="showInvoiceHistory(${issue.id})"><i class="bi bi-clock-history"></i></button>`;
|
||||||
if (issue.status === 'invoiced') return '<span class="text-muted small">Faktureret</span>';
|
if (issue.status === 'ignored') return `${historyButton}<span class="text-muted small ms-2">Ignoreret</span>`;
|
||||||
|
if (issue.status === 'invoiced') return `${historyButton}<span class="text-muted small ms-2">Faktureret</span>`;
|
||||||
|
if (issue.status === 'resolved') return `${historyButton}<span class="text-muted small ms-2">Løst</span>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
${historyButton}
|
||||||
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
|
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
|
||||||
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
|
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
|
||||||
<button class="btn btn-outline-primary" title="Klar til fakturering" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
|
<button class="btn btn-outline-primary" title="Opret ordrekladde" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
|
||||||
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
|
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
|
||||||
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
|
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
|
||||||
`;
|
`;
|
||||||
@ -257,7 +317,8 @@ function goToPage(page) {
|
|||||||
loadIssues();
|
loadIssues();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(issueId, status) {
|
async function updateStatus(issueId, status, options = {}) {
|
||||||
|
const { target = 'page', successMessage = 'Status opdateret' } = options;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
|
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -265,14 +326,23 @@ async function updateStatus(issueId, status) {
|
|||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Opdatering fejlede');
|
if (!res.ok) throw new Error('Opdatering fejlede');
|
||||||
showStatus('Status opdateret', 'success');
|
if (target === 'history') {
|
||||||
|
showHistoryStatus(successMessage, 'success');
|
||||||
|
} else {
|
||||||
|
showStatus(successMessage, 'success');
|
||||||
|
}
|
||||||
loadIssues();
|
loadIssues();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (target === 'history') {
|
||||||
|
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||||
|
} else {
|
||||||
showStatus('Fejl: ' + err.message, 'danger');
|
showStatus('Fejl: ' + err.message, 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSag(issueId) {
|
async function createSag(issueId, options = {}) {
|
||||||
|
const { target = 'page' } = options;
|
||||||
const titel = prompt('Titel på sag:');
|
const titel = prompt('Titel på sag:');
|
||||||
if (!titel) return;
|
if (!titel) return;
|
||||||
try {
|
try {
|
||||||
@ -283,11 +353,19 @@ async function createSag(issueId) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
|
if (!res.ok) throw new Error(data.detail || 'Opret sag fejlede');
|
||||||
|
if (target === 'history') {
|
||||||
|
showHistoryStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
||||||
|
} else {
|
||||||
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
showStatus(`Sag #${data.sag_id} oprettet`, 'success');
|
||||||
|
}
|
||||||
loadIssues();
|
loadIssues();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (target === 'history') {
|
||||||
|
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||||
|
} else {
|
||||||
showStatus('Fejl: ' + err.message, 'danger');
|
showStatus('Fejl: ' + err.message, 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrdreDraft(issueId) {
|
async function createOrdreDraft(issueId) {
|
||||||
@ -328,6 +406,162 @@ function showStatus(message, type) {
|
|||||||
setTimeout(() => el.classList.add('d-none'), 4000);
|
setTimeout(() => el.classList.add('d-none'), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showHistoryStatus(message, type) {
|
||||||
|
const el = document.getElementById('historyModalStatus');
|
||||||
|
el.className = `alert alert-${type} mb-3`;
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHistoryActionState(enabled) {
|
||||||
|
['historyApproveBtn', 'historyInvestigatingBtn', 'historyReadyBtn', 'historyCreateSagBtn']
|
||||||
|
.forEach(id => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (btn) btn.disabled = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceLines(lines) {
|
||||||
|
if (!lines || lines.length === 0) {
|
||||||
|
return '<div class="text-muted small">Ingen linjer fundet</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="table-responsive mt-2">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Linje</th>
|
||||||
|
<th>Varenr</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Antal</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th>Beløb</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${lines.map(line => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatNumber(line.line_number)}</td>
|
||||||
|
<td>${escapeHtml(line.product_number || '-')}</td>
|
||||||
|
<td class="text-truncate" style="max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(line.description || line.product_name || '-')}">${escapeHtml(line.description || line.product_name || '-')}</td>
|
||||||
|
<td>${formatNumber(line.quantity)}</td>
|
||||||
|
<td>${formatCurrency(line.unit_price)}</td>
|
||||||
|
<td>${formatCurrency(line.line_net_amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceNotes(invoice) {
|
||||||
|
const parts = [invoice.heading, invoice.note_text].filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="border rounded bg-light p-2 mb-2 small">
|
||||||
|
${parts.map(part => `<div>${escapeHtml(part)}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceCard(invoice, monthKey, index) {
|
||||||
|
const collapseId = `invoice-lines-${monthKey}-${index}`;
|
||||||
|
return `
|
||||||
|
<div class="border rounded p-2 mb-2 bg-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<div class="text-truncate small" style="min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(`${invoice.invoice_number || '-'} (${invoice.invoice_date || '-'}) · ${invoice.heading || invoice.source_type || ''} · Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}`)}">
|
||||||
|
<strong>${escapeHtml(invoice.invoice_number || '-')}</strong>
|
||||||
|
<span class="text-muted">(${escapeHtml(invoice.invoice_date || '-')})</span>
|
||||||
|
<span class="text-muted">· ${escapeHtml(invoice.heading || invoice.source_type || '')}</span>
|
||||||
|
<span>· Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false">
|
||||||
|
Vis linjer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="${collapseId}">
|
||||||
|
${renderInvoiceNotes(invoice)}
|
||||||
|
${renderInvoiceLines(invoice.lines)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showInvoiceHistory(issueId) {
|
||||||
|
const body = document.getElementById('historyModalBody');
|
||||||
|
const meta = document.getElementById('historyModalMeta');
|
||||||
|
const status = document.getElementById('historyModalStatus');
|
||||||
|
currentHistoryIssueId = issueId;
|
||||||
|
setHistoryActionState(true);
|
||||||
|
status.classList.add('d-none');
|
||||||
|
meta.textContent = '';
|
||||||
|
body.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Indlæser historik...</td></tr>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/invoice-history`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente historik');
|
||||||
|
|
||||||
|
meta.textContent = `${data.customer_name || 'Ukendt kunde'} · ${data.product_name || data.product_number || '-'}`;
|
||||||
|
|
||||||
|
body.innerHTML = data.months.map(row => {
|
||||||
|
const stateBadge = row.line_count > 0
|
||||||
|
? '<span class="badge bg-success">Faktureret</span>'
|
||||||
|
: '<span class="badge bg-danger">Manglende</span>';
|
||||||
|
const monthBadge = row.is_reference_month
|
||||||
|
? ' <span class="badge bg-warning text-dark">Reference</span>'
|
||||||
|
: row.is_fallback_history
|
||||||
|
? ` <span class="badge bg-info text-dark">${escapeHtml(row.fallback_label || 'Seneste tidligere faktura')}</span>`
|
||||||
|
: '';
|
||||||
|
const invoiceList = row.invoices && row.invoices.length
|
||||||
|
? row.invoices.map((invoice, index) => renderInvoiceCard(invoice, row.month_start || 'month', index)).join('')
|
||||||
|
: '<span class="text-muted">Ingen faktura</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${row.is_reference_month ? 'table-warning' : row.is_fallback_history ? 'table-info' : ''}">
|
||||||
|
<td>${formatMonth(row.month_start)}${monthBadge}</td>
|
||||||
|
<td>${stateBadge}</td>
|
||||||
|
<td>${formatNumber(row.line_count)}</td>
|
||||||
|
<td>${formatNumber(row.total_quantity)}</td>
|
||||||
|
<td>${formatCurrency(row.total_amount)}</td>
|
||||||
|
<td>${invoiceList}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
body.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Kunne ikke hente historik</td></tr>';
|
||||||
|
showHistoryStatus(`Fejl: ${err.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('historyApproveBtn')?.addEventListener('click', function() {
|
||||||
|
if (!currentHistoryIssueId) return;
|
||||||
|
updateStatus(currentHistoryIssueId, 'approved_change', { target: 'history', successMessage: 'Fejlen er godkendt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('historyInvestigatingBtn')?.addEventListener('click', function() {
|
||||||
|
if (!currentHistoryIssueId) return;
|
||||||
|
updateStatus(currentHistoryIssueId, 'investigating', { target: 'history', successMessage: 'Fejlen er sat til undersøgelse' });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('historyReadyBtn')?.addEventListener('click', function() {
|
||||||
|
if (!currentHistoryIssueId) return;
|
||||||
|
updateStatus(currentHistoryIssueId, 'ready_to_invoice', { target: 'history', successMessage: 'Ordrekladde markeret som klar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('historyCreateSagBtn')?.addEventListener('click', function() {
|
||||||
|
if (!currentHistoryIssueId) return;
|
||||||
|
createSag(currentHistoryIssueId, { target: 'history' });
|
||||||
|
});
|
||||||
|
|
||||||
loadIssues();
|
loadIssues();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -40,7 +40,9 @@ from app.modules.locations.models.schemas import (
|
|||||||
Service, ServiceCreate, ServiceUpdate,
|
Service, ServiceCreate, ServiceUpdate,
|
||||||
Capacity, CapacityCreate, CapacityUpdate,
|
Capacity, CapacityCreate, CapacityUpdate,
|
||||||
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
||||||
LocationWizardCreateRequest, LocationWizardCreateResponse
|
LocationWizardCreateRequest, LocationWizardCreateResponse,
|
||||||
|
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate,
|
||||||
|
CrossFieldPortLabelsUpdate
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -164,15 +166,21 @@ async def create_location(request: Request):
|
|||||||
logger.warning("⚠️ Invalid location payload")
|
logger.warning("⚠️ Invalid location payload")
|
||||||
raise HTTPException(status_code=422, detail=e.errors())
|
raise HTTPException(status_code=422, detail=e.errors())
|
||||||
|
|
||||||
# Check for duplicate name
|
# Names only need to be unique within the same customer and hierarchy.
|
||||||
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
check_query = """
|
||||||
existing = execute_query(check_query, (data.name,))
|
SELECT id FROM locations_locations
|
||||||
|
WHERE lower(name) = lower(%s)
|
||||||
|
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||||
|
AND customer_id IS NOT DISTINCT FROM %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Location with name '{data.name}' already exists"
|
detail=f"Location with name '{data.name}' already exists under the same customer/location"
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.customer_id is not None:
|
if data.customer_id is not None:
|
||||||
@ -201,9 +209,9 @@ async def create_location(request: Request):
|
|||||||
INSERT INTO locations_locations (
|
INSERT INTO locations_locations (
|
||||||
name, location_type, parent_location_id, customer_id, address_street, address_city,
|
name, location_type, parent_location_id, customer_id, address_street, address_city,
|
||||||
address_postal_code, address_country, latitude, longitude,
|
address_postal_code, address_country, latitude, longitude,
|
||||||
phone, email, notes, is_active, created_at, updated_at
|
phone, email, notes, is_active, has_cross_field, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -221,7 +229,8 @@ async def create_location(request: Request):
|
|||||||
data.phone,
|
data.phone,
|
||||||
data.email,
|
data.email,
|
||||||
data.notes,
|
data.notes,
|
||||||
data.is_active
|
data.is_active,
|
||||||
|
data.has_cross_field if data.location_type == 'rum' else False
|
||||||
)
|
)
|
||||||
|
|
||||||
result = execute_query(insert_query, params)
|
result = execute_query(insert_query, params)
|
||||||
@ -426,32 +435,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
def _normalize_name(value: str) -> str:
|
def _normalize_name(value: str) -> str:
|
||||||
return (value or "").strip().lower()
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
def _name_exists(value: str) -> bool:
|
def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
|
||||||
normalized = _normalize_name(value)
|
normalized = _normalize_name(value)
|
||||||
if normalized in reserved_names:
|
scope_key = (normalized, parent_location_id, customer_id)
|
||||||
|
if scope_key in reserved_names:
|
||||||
return True
|
return True
|
||||||
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
check_query = """
|
||||||
existing = execute_query(check_query, (value,))
|
SELECT 1 FROM locations_locations
|
||||||
|
WHERE lower(name) = lower(%s)
|
||||||
|
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||||
|
AND customer_id IS NOT DISTINCT FROM %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
existing = execute_query(check_query, (value, parent_location_id, customer_id))
|
||||||
return bool(existing)
|
return bool(existing)
|
||||||
|
|
||||||
def _reserve_name(value: str) -> None:
|
def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
|
||||||
normalized = _normalize_name(value)
|
normalized = _normalize_name(value)
|
||||||
if normalized:
|
if normalized:
|
||||||
reserved_names.add(normalized)
|
reserved_names.add((normalized, parent_location_id, customer_id))
|
||||||
|
|
||||||
def _resolve_unique_name(base_name: str) -> str:
|
def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
|
||||||
if not auto_suffix:
|
if not auto_suffix:
|
||||||
_reserve_name(base_name)
|
_reserve_name(base_name, parent_location_id, customer_id)
|
||||||
return base_name
|
return base_name
|
||||||
base_name = base_name.strip()
|
base_name = base_name.strip()
|
||||||
if not _name_exists(base_name):
|
if not _name_exists(base_name, parent_location_id, customer_id):
|
||||||
_reserve_name(base_name)
|
_reserve_name(base_name, parent_location_id, customer_id)
|
||||||
return base_name
|
return base_name
|
||||||
suffix = 2
|
suffix = 2
|
||||||
while True:
|
while True:
|
||||||
candidate = f"{base_name} ({suffix})"
|
candidate = f"{base_name} ({suffix})"
|
||||||
if not _name_exists(candidate):
|
if not _name_exists(candidate, parent_location_id, customer_id):
|
||||||
_reserve_name(candidate)
|
_reserve_name(candidate, parent_location_id, customer_id)
|
||||||
return candidate
|
return candidate
|
||||||
suffix += 1
|
suffix += 1
|
||||||
|
|
||||||
@ -492,7 +508,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to create location")
|
raise HTTPException(status_code=500, detail="Failed to create location")
|
||||||
return Location(**result[0])
|
return Location(**result[0])
|
||||||
|
|
||||||
resolved_root_name = _resolve_unique_name(root.name)
|
resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
|
||||||
root_location = insert_location_record(
|
root_location = insert_location_record(
|
||||||
name=resolved_root_name,
|
name=resolved_root_name,
|
||||||
location_type=root.location_type,
|
location_type=root.location_type,
|
||||||
@ -522,7 +538,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
room_ids: List[int] = []
|
room_ids: List[int] = []
|
||||||
|
|
||||||
for floor in data.floors:
|
for floor in data.floors:
|
||||||
resolved_floor_name = _resolve_unique_name(floor.name)
|
resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
|
||||||
floor_location = insert_location_record(
|
floor_location = insert_location_record(
|
||||||
name=resolved_floor_name,
|
name=resolved_floor_name,
|
||||||
location_type=floor.location_type,
|
location_type=floor.location_type,
|
||||||
@ -548,7 +564,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
))
|
))
|
||||||
|
|
||||||
for room in floor.rooms:
|
for room in floor.rooms:
|
||||||
resolved_room_name = _resolve_unique_name(room.name)
|
resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
|
||||||
room_location = insert_location_record(
|
room_location = insert_location_record(
|
||||||
name=resolved_room_name,
|
name=resolved_room_name,
|
||||||
location_type=room.location_type,
|
location_type=room.location_type,
|
||||||
@ -594,6 +610,317 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
|||||||
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_field_port_insert_sql() -> str:
|
||||||
|
"""Generate physical labels in a stable order, e.g. 1A, 1B, 2A, 2B."""
|
||||||
|
return '''
|
||||||
|
INSERT INTO locations_cross_field_ports (cross_field_id, port_number, port_order)
|
||||||
|
SELECT %s,
|
||||||
|
CASE WHEN %s = 'paired'
|
||||||
|
THEN (%s + ((port_no + 1) / 2) - 1)::TEXT || CASE WHEN port_no %% 2 = 1 THEN 'A' ELSE 'B' END
|
||||||
|
ELSE (%s + port_no - 1)::TEXT
|
||||||
|
END,
|
||||||
|
port_no
|
||||||
|
FROM generate_series(%s, %s) AS port_no
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cross_field_layout(port_count: int, label_format: str) -> None:
|
||||||
|
if label_format == 'paired' and port_count % 2:
|
||||||
|
raise HTTPException(status_code=400, detail='Parrede A/B-porte kræver et lige antal porte')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/locations/cross-fields', response_model=List[CrossField])
|
||||||
|
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
|
||||||
|
where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE'
|
||||||
|
params: tuple = ()
|
||||||
|
if location_id:
|
||||||
|
where += ' AND cf.location_id = %s'
|
||||||
|
params = (location_id,)
|
||||||
|
fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.display_order, cf.id''', params) or []
|
||||||
|
for field in fields:
|
||||||
|
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or []
|
||||||
|
return [CrossField(**field) for field in fields]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/locations/cross-field-ports')
|
||||||
|
async def list_cross_field_ports():
|
||||||
|
return execute_query('''
|
||||||
|
SELECT p.id, p.port_number, cf.name AS cross_field_name,
|
||||||
|
l.name AS location_name, l.id AS location_id
|
||||||
|
FROM locations_cross_field_ports p
|
||||||
|
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
|
||||||
|
JOIN locations_locations l ON l.id = cf.location_id
|
||||||
|
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
||||||
|
WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL
|
||||||
|
ORDER BY l.name, cf.name, p.port_order
|
||||||
|
''') or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/locations/cross-fields', response_model=CrossField, status_code=201)
|
||||||
|
async def create_cross_field(data: CrossFieldCreate):
|
||||||
|
location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or []
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
|
||||||
|
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
|
||||||
|
raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt')
|
||||||
|
_validate_cross_field_layout(data.port_count, data.port_label_format)
|
||||||
|
try:
|
||||||
|
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, (SELECT COALESCE(MAX(display_order), 0) + 1 FROM locations_cross_fields WHERE location_id = %s)), %s)
|
||||||
|
RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.port_label_format, data.start_port_number, data.panel_row_size, data.display_order, data.location_id, data.notes)) or []
|
||||||
|
if not created:
|
||||||
|
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
|
||||||
|
field = created[0]
|
||||||
|
execute_query(_cross_field_port_insert_sql(), (field['id'], data.port_label_format, data.start_port_number, data.start_port_number, 1, data.port_count))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||||
|
raise
|
||||||
|
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or []
|
||||||
|
return CrossField(**field)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField)
|
||||||
|
async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate):
|
||||||
|
field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or []
|
||||||
|
if not field_rows:
|
||||||
|
raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet')
|
||||||
|
field = field_rows[0]
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
requested_ports = changes.pop('port_count', None)
|
||||||
|
if requested_ports is not None and requested_ports < field['port_count']:
|
||||||
|
raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt')
|
||||||
|
if changes:
|
||||||
|
assignments = ', '.join(f'{column} = %s' for column in changes)
|
||||||
|
try:
|
||||||
|
updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or []
|
||||||
|
field = updated[0]
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
|
||||||
|
raise
|
||||||
|
if requested_ports and requested_ports > field['port_count']:
|
||||||
|
start_number = field.get('start_port_number', 1)
|
||||||
|
execute_query(_cross_field_port_insert_sql(), (cross_field_id, field.get('port_label_format', 'numeric'), start_number, start_number, field['port_count'] + 1, requested_ports))
|
||||||
|
field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0]
|
||||||
|
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (cross_field_id,)) or []
|
||||||
|
return CrossField(**field)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch('/locations/cross-fields/{cross_field_id}/port-labels')
|
||||||
|
async def update_cross_field_port_labels(cross_field_id: int, data: CrossFieldPortLabelsUpdate):
|
||||||
|
"""Rename physical port labels without changing the linked outlet/port identity."""
|
||||||
|
existing = execute_query(
|
||||||
|
'''SELECT id FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''',
|
||||||
|
(cross_field_id,),
|
||||||
|
) or []
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail='Krydsfeltet eller dets porte blev ikke fundet')
|
||||||
|
|
||||||
|
submitted = {item.id: item.port_number.strip() for item in data.ports}
|
||||||
|
existing_ids = {row['id'] for row in existing}
|
||||||
|
if set(submitted) != existing_ids:
|
||||||
|
raise HTTPException(status_code=400, detail='Alle porte skal have en mærkning')
|
||||||
|
if any(not label for label in submitted.values()):
|
||||||
|
raise HTTPException(status_code=400, detail='Portmærkning må ikke være tom')
|
||||||
|
labels_lower = [label.casefold() for label in submitted.values()]
|
||||||
|
if len(labels_lower) != len(set(labels_lower)):
|
||||||
|
raise HTTPException(status_code=400, detail='Hver portmærkning skal være unik i panelet')
|
||||||
|
|
||||||
|
# Use temporary labels first, so labels can safely be swapped (e.g. 1A ↔ 1B).
|
||||||
|
placeholders = ', '.join(['%s'] * len(existing_ids))
|
||||||
|
execute_query(
|
||||||
|
f'''UPDATE locations_cross_field_ports SET port_number = '__tmp__' || id::TEXT
|
||||||
|
WHERE cross_field_id = %s AND id IN ({placeholders})''',
|
||||||
|
(cross_field_id, *existing_ids),
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
values_sql = ', '.join(['(%s::INTEGER, %s::VARCHAR)'] * len(submitted))
|
||||||
|
params = []
|
||||||
|
for port_id, label in submitted.items():
|
||||||
|
params.extend((port_id, label))
|
||||||
|
updated = execute_query(
|
||||||
|
f'''UPDATE locations_cross_field_ports AS p
|
||||||
|
SET port_number = incoming.port_number
|
||||||
|
FROM (VALUES {values_sql}) AS incoming(id, port_number)
|
||||||
|
WHERE p.id = incoming.id AND p.cross_field_id = %s
|
||||||
|
RETURNING p.id, p.port_number, p.port_order''',
|
||||||
|
tuple(params) + (cross_field_id,),
|
||||||
|
) or []
|
||||||
|
return {'updated': len(updated), 'ports': updated}
|
||||||
|
|
||||||
|
|
||||||
|
def _outlet_location(location_id: int) -> dict:
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(location_id,),
|
||||||
|
) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
|
||||||
|
location = dict(rows[0])
|
||||||
|
if location.get('location_type') not in _OUTLET_LOCATION_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum")
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
_OUTLET_SELECT = """
|
||||||
|
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
|
||||||
|
outlet_customer.name AS outlet_customer_name,
|
||||||
|
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
|
||||||
|
FROM locations_wall_outlets o
|
||||||
|
JOIN locations_locations l ON l.id = o.location_id
|
||||||
|
LEFT JOIN customers c ON c.id = l.customer_id
|
||||||
|
LEFT JOIN customers outlet_customer ON outlet_customer.id = o.customer_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
||||||
|
FROM locations_locations WHERE id = l.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id, parent.name, parent.parent_location_id,
|
||||||
|
parent.name || ' > ' || ancestors.hierarchy_path
|
||||||
|
FROM locations_locations parent
|
||||||
|
JOIN ancestors ON ancestors.parent_location_id = parent.id
|
||||||
|
)
|
||||||
|
SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1
|
||||||
|
) path ON TRUE
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/locations/outlets', response_model=List[WallOutlet])
|
||||||
|
async def list_wall_outlets(
|
||||||
|
q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1),
|
||||||
|
status: Optional[str] = Query(None), include_inactive: bool = Query(False),
|
||||||
|
):
|
||||||
|
where = ['o.deleted_at IS NULL']
|
||||||
|
params: List[Any] = []
|
||||||
|
if not include_inactive:
|
||||||
|
where.append('o.is_active = TRUE')
|
||||||
|
if location_id:
|
||||||
|
where.append('o.location_id = %s')
|
||||||
|
params.append(location_id)
|
||||||
|
if status:
|
||||||
|
where.append('o.status = %s')
|
||||||
|
params.append(status)
|
||||||
|
if q:
|
||||||
|
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
|
||||||
|
params.extend([f'%{q.strip()}%'] * 7)
|
||||||
|
rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or []
|
||||||
|
return [WallOutlet(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_switch_port_if_confirmed(
|
||||||
|
*, switch_hardware_id: Optional[int], switch_name: Optional[str], switch_port: Optional[str],
|
||||||
|
exclude_outlet_id: Optional[int], confirmed: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Guard the one-to-one physical switch-port assignment."""
|
||||||
|
if not switch_port or not (switch_hardware_id or switch_name):
|
||||||
|
return
|
||||||
|
where = ['deleted_at IS NULL', 'is_active = TRUE', 'switch_port = %s']
|
||||||
|
params: List[Any] = [switch_port]
|
||||||
|
if switch_hardware_id:
|
||||||
|
where.append('switch_hardware_id = %s')
|
||||||
|
params.append(switch_hardware_id)
|
||||||
|
else:
|
||||||
|
where.append('LOWER(COALESCE(switch_name, \'\')) = LOWER(%s)')
|
||||||
|
params.append(switch_name)
|
||||||
|
if exclude_outlet_id:
|
||||||
|
where.append('id <> %s')
|
||||||
|
params.append(exclude_outlet_id)
|
||||||
|
conflicts = execute_query(
|
||||||
|
f'''SELECT id, outlet_number FROM locations_wall_outlets WHERE {' AND '.join(where)}''',
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
if not conflicts:
|
||||||
|
return
|
||||||
|
if not confirmed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Switch-porten bruges allerede af vægstik {conflicts[0]['outlet_number']}. Bekræft overskrivning for at flytte forbindelsen.",
|
||||||
|
)
|
||||||
|
conflict_ids = tuple(row['id'] for row in conflicts)
|
||||||
|
placeholders = ', '.join(['%s'] * len(conflict_ids))
|
||||||
|
execute_query(
|
||||||
|
f'''UPDATE locations_wall_outlets
|
||||||
|
SET switch_hardware_id = NULL, switch_name = NULL, switch_port = NULL, updated_at = NOW()
|
||||||
|
WHERE id IN ({placeholders})''',
|
||||||
|
conflict_ids,
|
||||||
|
fetch=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
|
||||||
|
async def create_wall_outlet(data: WallOutletCreate):
|
||||||
|
_outlet_location(data.location_id)
|
||||||
|
_replace_switch_port_if_confirmed(
|
||||||
|
switch_hardware_id=data.switch_hardware_id,
|
||||||
|
switch_name=data.switch_name,
|
||||||
|
switch_port=data.switch_port,
|
||||||
|
exclude_outlet_id=None,
|
||||||
|
confirmed=data.replace_existing_switch_port,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
"""INSERT INTO locations_wall_outlets
|
||||||
|
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
||||||
|
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
|
||||||
|
) or []
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
|
||||||
|
raise
|
||||||
|
outlet_id = rows[0]['id']
|
||||||
|
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
|
||||||
|
return WallOutlet(**result[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
|
||||||
|
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
replace_existing_switch_port = changes.pop('replace_existing_switch_port', False)
|
||||||
|
if not changes:
|
||||||
|
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
|
||||||
|
if 'outlet_number' in changes:
|
||||||
|
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
|
||||||
|
current = execute_query(
|
||||||
|
'''SELECT switch_hardware_id, switch_name, switch_port
|
||||||
|
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
|
||||||
|
(outlet_id,),
|
||||||
|
) or []
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||||
|
_replace_switch_port_if_confirmed(
|
||||||
|
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
|
||||||
|
switch_name=changes.get('switch_name', current[0].get('switch_name')),
|
||||||
|
switch_port=changes.get('switch_port', current[0].get('switch_port')),
|
||||||
|
exclude_outlet_id=outlet_id,
|
||||||
|
confirmed=replace_existing_switch_port,
|
||||||
|
)
|
||||||
|
fields = ', '.join(f'{field} = %s' for field in changes)
|
||||||
|
try:
|
||||||
|
rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or []
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
|
||||||
|
raise
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||||
|
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
|
||||||
|
return WallOutlet(**result[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/locations/outlets/{outlet_id}')
|
||||||
|
async def delete_wall_outlet(outlet_id: int):
|
||||||
|
rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or []
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||||
|
return {'status': 'deleted', 'id': outlet_id}
|
||||||
|
|
||||||
@router.get("/locations/{id}", response_model=LocationDetail)
|
@router.get("/locations/{id}", response_model=LocationDetail)
|
||||||
async def get_location(id: int):
|
async def get_location(id: int):
|
||||||
"""
|
"""
|
||||||
@ -645,6 +972,12 @@ async def get_location(id: int):
|
|||||||
capacity_result = execute_query(capacity_query, (id,))
|
capacity_result = execute_query(capacity_query, (id,))
|
||||||
capacity = [dict(row) for row in capacity_result] if capacity_result else []
|
capacity = [dict(row) for row in capacity_result] if capacity_result else []
|
||||||
|
|
||||||
|
outlet_result = execute_query(
|
||||||
|
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
|
||||||
|
(id,),
|
||||||
|
)
|
||||||
|
wall_outlets = [dict(row) for row in (outlet_result or [])]
|
||||||
|
|
||||||
# Build hierarchy breadcrumb (ancestors from root to parent)
|
# Build hierarchy breadcrumb (ancestors from root to parent)
|
||||||
hierarchy_query = """
|
hierarchy_query = """
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
@ -693,7 +1026,8 @@ async def get_location(id: int):
|
|||||||
contacts=contacts,
|
contacts=contacts,
|
||||||
hours=hours,
|
hours=hours,
|
||||||
services=services,
|
services=services,
|
||||||
capacity=capacity
|
capacity=capacity,
|
||||||
|
wall_outlets=wall_outlets,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
|
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
|
||||||
@ -740,15 +1074,26 @@ async def update_location(id: int, data: LocationUpdate):
|
|||||||
|
|
||||||
old_location = Location(**existing[0])
|
old_location = Location(**existing[0])
|
||||||
|
|
||||||
# Check for duplicate name if name is being updated
|
# Check the resulting name/customer/parent scope, including when only
|
||||||
if data.name is not None and data.name != old_location.name:
|
# customer or parent is changed.
|
||||||
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL"
|
if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
|
||||||
dup_check = execute_query(dup_query, (data.name, id))
|
candidate_name = data.name if data.name is not None else old_location.name
|
||||||
|
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
|
||||||
|
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
|
||||||
|
dup_query = """
|
||||||
|
SELECT id FROM locations_locations
|
||||||
|
WHERE lower(name) = lower(%s)
|
||||||
|
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||||
|
AND customer_id IS NOT DISTINCT FROM %s
|
||||||
|
AND id != %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
|
||||||
if dup_check:
|
if dup_check:
|
||||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Location with name '{data.name}' already exists"
|
detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build UPDATE query with only provided fields
|
# Build UPDATE query with only provided fields
|
||||||
@ -770,7 +1115,8 @@ async def update_location(id: int, data: LocationUpdate):
|
|||||||
'phone': 'phone',
|
'phone': 'phone',
|
||||||
'email': 'email',
|
'email': 'email',
|
||||||
'notes': 'notes',
|
'notes': 'notes',
|
||||||
'is_active': 'is_active'
|
'is_active': 'is_active',
|
||||||
|
'has_cross_field': 'has_cross_field'
|
||||||
}
|
}
|
||||||
|
|
||||||
update_data = {}
|
update_data = {}
|
||||||
@ -792,6 +1138,30 @@ async def update_location(id: int, data: LocationUpdate):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="parent_location_id does not exist"
|
detail="parent_location_id does not exist"
|
||||||
)
|
)
|
||||||
|
descendant_check = execute_query(
|
||||||
|
"""
|
||||||
|
WITH RECURSIVE descendants AS (
|
||||||
|
SELECT id
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE parent_location_id = %s AND deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT l.id
|
||||||
|
FROM locations_locations l
|
||||||
|
JOIN descendants d ON l.parent_location_id = d.id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT id FROM descendants WHERE id = %s LIMIT 1
|
||||||
|
""",
|
||||||
|
(id, value),
|
||||||
|
)
|
||||||
|
if descendant_check:
|
||||||
|
logger.warning("⚠️ parent_location_id cannot reference a descendant")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="parent_location_id cannot reference a descendant"
|
||||||
|
)
|
||||||
if key == 'customer_id':
|
if key == 'customer_id':
|
||||||
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
||||||
customer = execute_query(customer_query, (value,))
|
customer = execute_query(customer_query, (value,))
|
||||||
@ -809,6 +1179,10 @@ async def update_location(id: int, data: LocationUpdate):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"location_type must be one of: {', '.join(allowed_types)}"
|
detail=f"location_type must be one of: {', '.join(allowed_types)}"
|
||||||
)
|
)
|
||||||
|
if key == 'has_cross_field' and value:
|
||||||
|
resulting_type = data.location_type or old_location.location_type
|
||||||
|
if resulting_type != 'rum':
|
||||||
|
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
|
||||||
update_parts.append(f"{db_column} = %s")
|
update_parts.append(f"{db_column} = %s")
|
||||||
params.append(value)
|
params.append(value)
|
||||||
update_data[key] = value
|
update_data[key] = value
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
||||||
from pathlib import Path as PathlibPath
|
from pathlib import Path as PathlibPath
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.database import execute_query, execute_update
|
from app.core.database import execute_query, execute_update
|
||||||
@ -57,6 +58,100 @@ LOCATION_TYPES = [
|
|||||||
{"value": "vehicle", "label": "Køretøj"},
|
{"value": "vehicle", "label": "Køretøj"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
LOCATION_TYPE_LABELS = {
|
||||||
|
"kompleks": "Kompleks",
|
||||||
|
"bygning": "Bygning",
|
||||||
|
"etage": "Etage",
|
||||||
|
"customer_site": "Kundesite",
|
||||||
|
"rum": "Rum",
|
||||||
|
"kantine": "Kantine",
|
||||||
|
"moedelokale": "Mødelokale",
|
||||||
|
"vehicle": "Køretøj",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_location_type_label(location_type: Optional[str]) -> str:
|
||||||
|
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
|
||||||
|
|
||||||
|
|
||||||
|
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
|
||||||
|
exclude_ids = []
|
||||||
|
if exclude_id is not None:
|
||||||
|
exclude_tree = execute_query(
|
||||||
|
"""
|
||||||
|
WITH RECURSIVE descendants AS (
|
||||||
|
SELECT id
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE id = %s
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT l.id
|
||||||
|
FROM locations_locations l
|
||||||
|
JOIN descendants d ON l.parent_location_id = d.id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT id FROM descendants
|
||||||
|
""",
|
||||||
|
(exclude_id,),
|
||||||
|
)
|
||||||
|
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
|
||||||
|
|
||||||
|
parent_locations = execute_query(
|
||||||
|
"""
|
||||||
|
WITH RECURSIVE location_tree AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
location_type,
|
||||||
|
parent_location_id,
|
||||||
|
customer_id,
|
||||||
|
is_active,
|
||||||
|
name::text AS hierarchy_path,
|
||||||
|
0 AS depth
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE deleted_at IS NULL AND parent_location_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.name,
|
||||||
|
l.location_type,
|
||||||
|
l.parent_location_id,
|
||||||
|
l.customer_id,
|
||||||
|
l.is_active,
|
||||||
|
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
|
||||||
|
lt.depth + 1 AS depth
|
||||||
|
FROM locations_locations l
|
||||||
|
JOIN location_tree lt ON l.parent_location_id = lt.id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
location_type,
|
||||||
|
parent_location_id,
|
||||||
|
customer_id,
|
||||||
|
is_active,
|
||||||
|
hierarchy_path,
|
||||||
|
depth
|
||||||
|
FROM location_tree
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY hierarchy_path
|
||||||
|
LIMIT 2000
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
choices = []
|
||||||
|
for row in parent_locations or []:
|
||||||
|
if row.get("id") in exclude_ids:
|
||||||
|
continue
|
||||||
|
row["type_label"] = get_location_type_label(row.get("location_type"))
|
||||||
|
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
|
||||||
|
choices.append(row)
|
||||||
|
return choices
|
||||||
|
|
||||||
|
|
||||||
def render_template(template_name: str, **context) -> str:
|
def render_template(template_name: str, **context) -> str:
|
||||||
"""
|
"""
|
||||||
@ -167,7 +262,7 @@ def list_locations_view(
|
|||||||
"""
|
"""
|
||||||
query_params.extend([limit, skip])
|
query_params.extend([limit, skip])
|
||||||
|
|
||||||
locations = execute_query(query, tuple(query_params))
|
locations = execute_query(query, tuple(query_params)) or []
|
||||||
|
|
||||||
def build_tree(items: list) -> list:
|
def build_tree(items: list) -> list:
|
||||||
nodes = {}
|
nodes = {}
|
||||||
@ -247,7 +342,10 @@ def list_locations_view(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/app/locations/create", response_class=HTMLResponse)
|
@router.get("/app/locations/create", response_class=HTMLResponse)
|
||||||
def create_location_view():
|
def create_location_view(
|
||||||
|
parent_location_id: Optional[int] = Query(None, gt=0),
|
||||||
|
customer_id: Optional[int] = Query(None, gt=0),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Render the location creation form.
|
Render the location creation form.
|
||||||
|
|
||||||
@ -268,14 +366,11 @@ def create_location_view():
|
|||||||
try:
|
try:
|
||||||
logger.info("🆕 Rendering create location form")
|
logger.info("🆕 Rendering create location form")
|
||||||
|
|
||||||
# Query parent locations
|
parent_locations = get_parent_location_choices() or []
|
||||||
parent_locations = execute_query("""
|
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
|
||||||
SELECT id, name, location_type
|
|
||||||
FROM locations_locations
|
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
|
||||||
WHERE deleted_at IS NULL AND is_active = true
|
customer_id = selected_parent.get("customer_id")
|
||||||
ORDER BY name
|
|
||||||
LIMIT 1000
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Query customers
|
# Query customers
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
@ -295,7 +390,10 @@ def create_location_view():
|
|||||||
cancel_url="/app/locations",
|
cancel_url="/app/locations",
|
||||||
location_types=LOCATION_TYPES,
|
location_types=LOCATION_TYPES,
|
||||||
parent_locations=parent_locations,
|
parent_locations=parent_locations,
|
||||||
customers=customers,
|
customers=customers or [],
|
||||||
|
selected_parent_id=parent_location_id,
|
||||||
|
selected_customer_id=customer_id,
|
||||||
|
selected_parent=selected_parent,
|
||||||
location=None, # No location data for create form
|
location=None, # No location data for create form
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -321,13 +419,7 @@ def location_wizard_view():
|
|||||||
try:
|
try:
|
||||||
logger.info("🧭 Rendering location wizard")
|
logger.info("🧭 Rendering location wizard")
|
||||||
|
|
||||||
parent_locations = execute_query("""
|
parent_locations = get_parent_location_choices()
|
||||||
SELECT id, name, location_type
|
|
||||||
FROM locations_locations
|
|
||||||
WHERE deleted_at IS NULL AND is_active = true
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT 1000
|
|
||||||
""")
|
|
||||||
|
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
SELECT id, name, email, phone
|
SELECT id, name, email, phone
|
||||||
@ -356,7 +448,47 @@ def location_wizard_view():
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 3. GET /app/locations/{id} - Detail view (HTML)
|
# 3. GET /app/locations/outlets - Wall outlet overview
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/app/locations/outlets", response_class=HTMLResponse)
|
||||||
|
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
|
||||||
|
try:
|
||||||
|
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
|
||||||
|
params = []
|
||||||
|
if status:
|
||||||
|
where.append("o.status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if q:
|
||||||
|
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
|
||||||
|
params.extend([f"%{q.strip()}%"] * 7)
|
||||||
|
outlets = execute_query(f"""
|
||||||
|
WITH RECURSIVE tree AS (
|
||||||
|
SELECT id, name, parent_location_id, name::text AS hierarchy_path
|
||||||
|
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
|
||||||
|
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
|
||||||
|
FROM locations_wall_outlets o
|
||||||
|
JOIN locations_locations l ON l.id = o.location_id
|
||||||
|
LEFT JOIN customers c ON c.id = l.customer_id
|
||||||
|
LEFT JOIN tree ON tree.id = l.id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY tree.hierarchy_path, o.outlet_number
|
||||||
|
""", tuple(params)) or []
|
||||||
|
return HTMLResponse(render_template(
|
||||||
|
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error rendering wall outlets overview: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4. GET /app/locations/{id} - Detail view (HTML)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@router.get("/app/locations/{id}", response_class=HTMLResponse)
|
@router.get("/app/locations/{id}", response_class=HTMLResponse)
|
||||||
@ -465,14 +597,96 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
|
|
||||||
hardware = execute_query(
|
hardware = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT id, asset_type, brand, model, serial_number, status
|
SELECT id, asset_type, brand, model, serial_number, status, hardware_specs, location_display_order
|
||||||
FROM hardware_assets
|
FROM hardware_assets
|
||||||
WHERE current_location_id = %s AND deleted_at IS NULL
|
WHERE current_location_id = %s AND deleted_at IS NULL
|
||||||
ORDER BY brand ASC, model ASC, serial_number ASC
|
ORDER BY location_display_order NULLS LAST, brand ASC, model ASC, serial_number ASC
|
||||||
""",
|
""",
|
||||||
(id,)
|
(id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
wall_outlets = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active
|
||||||
|
FROM locations_wall_outlets
|
||||||
|
WHERE location_id = %s AND deleted_at IS NULL
|
||||||
|
ORDER BY outlet_number
|
||||||
|
""",
|
||||||
|
(id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render the same physical port map directly under each switch on the location page.
|
||||||
|
hardware_link_map = {}
|
||||||
|
hardware_ids = [hw['id'] for hw in (hardware or [])]
|
||||||
|
if hardware_ids:
|
||||||
|
hardware_link_rows = execute_query(
|
||||||
|
"""SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port,
|
||||||
|
target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial,
|
||||||
|
source.brand AS source_brand, source.model AS source_model, source.serial_number AS source_serial
|
||||||
|
FROM hardware_network_links l
|
||||||
|
JOIN hardware_assets target ON target.id = l.target_hardware_id
|
||||||
|
JOIN hardware_assets source ON source.id = l.source_hardware_id
|
||||||
|
WHERE (l.source_hardware_id = ANY(%s) OR l.target_hardware_id = ANY(%s)) AND l.deleted_at IS NULL
|
||||||
|
ORDER BY l.id""",
|
||||||
|
(hardware_ids, hardware_ids),
|
||||||
|
) or []
|
||||||
|
for row in hardware_link_rows:
|
||||||
|
if row.get('source_port'):
|
||||||
|
hardware_link_map[(row['source_hardware_id'], str(row['source_port']))] = row
|
||||||
|
if row.get('target_port'):
|
||||||
|
reverse_row = dict(row)
|
||||||
|
reverse_row.update({
|
||||||
|
'target_hardware_id': row.get('source_hardware_id'),
|
||||||
|
'target_brand': row.get('source_brand'),
|
||||||
|
'target_model': row.get('source_model'),
|
||||||
|
'target_serial': row.get('source_serial'),
|
||||||
|
'target_port': row.get('source_port'),
|
||||||
|
})
|
||||||
|
hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row
|
||||||
|
for hw in hardware or []:
|
||||||
|
hw['switch_ports'] = []
|
||||||
|
if str(hw.get('asset_type') or '').lower() != 'netværk':
|
||||||
|
continue
|
||||||
|
specs = hw.get('hardware_specs') or {}
|
||||||
|
if isinstance(specs, str):
|
||||||
|
try:
|
||||||
|
specs = json.loads(specs)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
specs = {}
|
||||||
|
port_count = int((specs or {}).get('port_count') or 0)
|
||||||
|
linked = {
|
||||||
|
str(outlet.get('switch_port')): outlet
|
||||||
|
for outlet in (wall_outlets or [])
|
||||||
|
if outlet.get('switch_hardware_id') == hw.get('id') and outlet.get('switch_port')
|
||||||
|
}
|
||||||
|
hw['switch_ports'] = [
|
||||||
|
{
|
||||||
|
'port_number': str(port),
|
||||||
|
'outlet': linked.get(str(port)),
|
||||||
|
'hardware_link': hardware_link_map.get((hw['id'], str(port))),
|
||||||
|
}
|
||||||
|
for port in range(1, port_count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
cross_fields = execute_query(
|
||||||
|
"""SELECT id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes, is_active
|
||||||
|
FROM locations_cross_fields
|
||||||
|
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
|
||||||
|
ORDER BY display_order, id""",
|
||||||
|
(id,),
|
||||||
|
)
|
||||||
|
for cross_field in cross_fields or []:
|
||||||
|
cross_field["ports"] = execute_query(
|
||||||
|
"""SELECT p.id, p.port_number, p.port_order, p.is_active,
|
||||||
|
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
|
||||||
|
l.name AS outlet_location_name
|
||||||
|
FROM locations_cross_field_ports p
|
||||||
|
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
||||||
|
LEFT JOIN locations_locations l ON l.id = o.location_id
|
||||||
|
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
|
||||||
|
(cross_field["id"],),
|
||||||
|
) or []
|
||||||
|
|
||||||
audit_log = execute_query(
|
audit_log = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT id, location_id, event_type, user_id, changes, created_at
|
SELECT id, location_id, event_type, user_id, changes, created_at
|
||||||
@ -490,6 +704,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
location["services"] = services or []
|
location["services"] = services or []
|
||||||
location["capacity"] = capacity or []
|
location["capacity"] = capacity or []
|
||||||
location["hardware"] = hardware or []
|
location["hardware"] = hardware or []
|
||||||
|
location["wall_outlets"] = wall_outlets or []
|
||||||
|
location["cross_fields"] = cross_fields or []
|
||||||
location["audit_log"] = audit_log or []
|
location["audit_log"] = audit_log or []
|
||||||
|
|
||||||
# Query customers
|
# Query customers
|
||||||
@ -505,6 +721,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
||||||
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
||||||
|
|
||||||
|
customers = customers or []
|
||||||
|
|
||||||
# Render template with context
|
# Render template with context
|
||||||
html = render_template(
|
html = render_template(
|
||||||
"modules/locations/templates/detail.html",
|
"modules/locations/templates/detail.html",
|
||||||
@ -555,14 +773,11 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
|||||||
|
|
||||||
location = location[0] # Get first result
|
location = location[0] # Get first result
|
||||||
|
|
||||||
# Query parent locations (exclude self)
|
parent_locations = get_parent_location_choices(exclude_id=id) or []
|
||||||
parent_locations = execute_query("""
|
selected_parent = next(
|
||||||
SELECT id, name, location_type
|
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
|
||||||
FROM locations_locations
|
None,
|
||||||
WHERE is_active = true AND id != %s
|
)
|
||||||
ORDER BY name
|
|
||||||
LIMIT 1000
|
|
||||||
""", (id,))
|
|
||||||
|
|
||||||
# Query customers
|
# Query customers
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
@ -584,7 +799,8 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
|||||||
cancel_url=f"/app/locations/{id}",
|
cancel_url=f"/app/locations/{id}",
|
||||||
location_types=LOCATION_TYPES,
|
location_types=LOCATION_TYPES,
|
||||||
parent_locations=parent_locations,
|
parent_locations=parent_locations,
|
||||||
customers=customers,
|
customers=customers or [],
|
||||||
|
selected_parent=selected_parent,
|
||||||
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
|
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -625,6 +841,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
|||||||
latitude = %s,
|
latitude = %s,
|
||||||
longitude = %s,
|
longitude = %s,
|
||||||
notes = %s,
|
notes = %s,
|
||||||
|
has_cross_field = %s,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (
|
""", (
|
||||||
@ -642,6 +859,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
|||||||
float(form.get("latitude")) if form.get("latitude") else None,
|
float(form.get("latitude")) if form.get("latitude") else None,
|
||||||
float(form.get("longitude")) if form.get("longitude") else None,
|
float(form.get("longitude")) if form.get("longitude") else None,
|
||||||
form.get("notes"),
|
form.get("notes"),
|
||||||
|
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
|
||||||
id
|
id
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
class LocationBase(BaseModel):
|
class LocationBase(BaseModel):
|
||||||
"""Shared fields for location models"""
|
"""Shared fields for location models"""
|
||||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
|
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
|
||||||
location_type: str = Field(
|
location_type: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
||||||
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: bool = Field(True, description="Whether location is active")
|
is_active: bool = Field(True, description="Whether location is active")
|
||||||
|
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
|
||||||
|
|
||||||
@field_validator('location_type')
|
@field_validator('location_type')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
has_cross_field: Optional[bool] = None
|
||||||
|
|
||||||
@field_validator('location_type')
|
@field_validator('location_type')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -101,6 +103,126 @@ class Location(LocationBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NETWORK WALL OUTLET MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
|
||||||
|
|
||||||
|
|
||||||
|
class WallOutletBase(BaseModel):
|
||||||
|
location_id: int = Field(..., ge=1)
|
||||||
|
outlet_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
customer_id: Optional[int] = Field(None, ge=1)
|
||||||
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
|
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||||
|
patch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
||||||
|
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||||
|
switch_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
status: str = Field('unknown')
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
@field_validator('status')
|
||||||
|
@classmethod
|
||||||
|
def validate_outlet_status(cls, value):
|
||||||
|
if value not in OUTLET_STATUSES:
|
||||||
|
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WallOutletCreate(WallOutletBase):
|
||||||
|
replace_existing_switch_port: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WallOutletUpdate(BaseModel):
|
||||||
|
outlet_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
customer_id: Optional[int] = Field(None, ge=1)
|
||||||
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
|
patch_panel: Optional[str] = Field(None, max_length=255)
|
||||||
|
patch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
cross_field_port_id: Optional[int] = Field(None, ge=1)
|
||||||
|
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||||
|
switch_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
switch_port: Optional[str] = Field(None, max_length=100)
|
||||||
|
status: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
replace_existing_switch_port: bool = False
|
||||||
|
|
||||||
|
@field_validator('status')
|
||||||
|
@classmethod
|
||||||
|
def validate_outlet_status(cls, value):
|
||||||
|
if value is not None and value not in OUTLET_STATUSES:
|
||||||
|
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WallOutlet(WallOutletBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
location_name: Optional[str] = None
|
||||||
|
location_type: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
outlet_customer_name: Optional[str] = None
|
||||||
|
hierarchy_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrossFieldCreate(BaseModel):
|
||||||
|
location_id: int = Field(..., ge=1)
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
port_count: int = Field(..., ge=1, le=999)
|
||||||
|
port_label_format: str = Field(default='numeric', pattern='^(numeric|paired)$')
|
||||||
|
start_port_number: int = Field(default=1, ge=1, le=9999)
|
||||||
|
panel_row_size: int = Field(default=24, ge=1, le=48)
|
||||||
|
display_order: Optional[int] = Field(default=None, ge=1, le=9999)
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrossFieldUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
port_count: Optional[int] = Field(None, ge=1, le=999)
|
||||||
|
start_port_number: Optional[int] = Field(None, ge=1, le=9999)
|
||||||
|
panel_row_size: Optional[int] = Field(None, ge=1, le=48)
|
||||||
|
display_order: Optional[int] = Field(None, ge=1, le=9999)
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrossFieldPort(BaseModel):
|
||||||
|
id: int
|
||||||
|
port_number: str
|
||||||
|
port_order: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CrossFieldPortLabelUpdate(BaseModel):
|
||||||
|
id: int = Field(..., ge=1)
|
||||||
|
port_number: str = Field(..., min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossFieldPortLabelsUpdate(BaseModel):
|
||||||
|
ports: List[CrossFieldPortLabelUpdate] = Field(..., min_length=1, max_length=999)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossField(BaseModel):
|
||||||
|
id: int
|
||||||
|
location_id: int
|
||||||
|
name: str
|
||||||
|
port_count: int
|
||||||
|
port_label_format: str = 'numeric'
|
||||||
|
start_port_number: int = 1
|
||||||
|
display_order: int = 1
|
||||||
|
panel_row_size: int = 24
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
ports: List[CrossFieldPort] = []
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 2. CONTACT MODELS
|
# 2. CONTACT MODELS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -360,6 +482,7 @@ class LocationDetail(Location):
|
|||||||
hours: List[OperatingHours] = Field(default_factory=list)
|
hours: List[OperatingHours] = Field(default_factory=list)
|
||||||
services: List[Service] = Field(default_factory=list)
|
services: List[Service] = Field(default_factory=list)
|
||||||
capacity: List[Capacity] = Field(default_factory=list)
|
capacity: List[Capacity] = Field(default_factory=list)
|
||||||
|
wall_outlets: List[WallOutlet] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AuditLogEntry(BaseModel):
|
class AuditLogEntry(BaseModel):
|
||||||
|
|||||||
@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
{% block title %}Opret lokation - BMC Hub{% endblock %}
|
{% block title %}Opret lokation - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.relation-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-summary {
|
||||||
|
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgba(15, 76, 117, 0.05);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-summary.empty {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-path {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f3b53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
@ -57,19 +91,52 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relation-card mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
|
||||||
|
<div class="text-muted small">Vælg hurtigt, hvor lokationen skal ligge, og søg i hele træet.</div>
|
||||||
|
</div>
|
||||||
|
{% if selected_parent %}
|
||||||
|
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-eye me-2"></i>Åbn valgt parent
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
|
||||||
|
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, bygningsdel eller sti...">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
|
||||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||||
<option value="">Ingen (øverste niveau)</option>
|
<option value="">Ingen (øverste niveau)</option>
|
||||||
{% if parent_locations %}
|
{% if parent_locations %}
|
||||||
{% for parent in parent_locations %}
|
{% for parent in parent_locations %}
|
||||||
<option value="{{ parent.id }}">
|
<option
|
||||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
value="{{ parent.id }}"
|
||||||
|
data-path="{{ parent.hierarchy_path }}"
|
||||||
|
data-type="{{ parent.type_label }}"
|
||||||
|
data-customer-id="{{ parent.customer_id | default('') }}"
|
||||||
|
{% if selected_parent_id == parent.id %}selected{% endif %}>
|
||||||
|
{{ parent.display_name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
<div class="form-text">Bruges til hierarki, fx Kompleks → Bygning → Etage → Rum.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
|
||||||
|
{% if selected_parent %}
|
||||||
|
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||||
|
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
|
||||||
|
<div class="relation-meta">{{ selected_parent.type_label }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -78,11 +145,11 @@
|
|||||||
<option value="">Ingen</option>
|
<option value="">Ingen</option>
|
||||||
{% if customers %}
|
{% if customers %}
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
<option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Valgfri – kan knyttes til alle typer.</div>
|
<div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -91,6 +158,14 @@
|
|||||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crossFieldOption" hidden>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
|
||||||
|
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||||
|
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Section 2: Address -->
|
<!-- Section 2: Address -->
|
||||||
@ -185,12 +260,79 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
const notesField = document.getElementById('notes');
|
const notesField = document.getElementById('notes');
|
||||||
const charCount = document.getElementById('charCount');
|
const charCount = document.getElementById('charCount');
|
||||||
|
const parentLocationSelect = document.getElementById('parentLocation');
|
||||||
|
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||||
|
const customerSelect = document.getElementById('customerId');
|
||||||
|
const parentSummary = document.getElementById('parentSummary');
|
||||||
|
|
||||||
// Character counter for notes
|
// Character counter for notes
|
||||||
notesField.addEventListener('input', function() {
|
notesField.addEventListener('input', function() {
|
||||||
charCount.textContent = this.value.length;
|
charCount.textContent = this.value.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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 submission
|
||||||
form.addEventListener('submit', async function(e) {
|
form.addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -202,7 +344,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const data = {
|
const data = {
|
||||||
name: formData.get('name'),
|
name: formData.get('name'),
|
||||||
location_type: formData.get('location_type'),
|
location_type: formData.get('location_type'),
|
||||||
|
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||||
|
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||||
is_active: formData.get('is_active') === 'on',
|
is_active: formData.get('is_active') === 'on',
|
||||||
|
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||||
address_street: formData.get('address_street'),
|
address_street: formData.get('address_street'),
|
||||||
address_city: formData.get('address_city'),
|
address_city: formData.get('address_city'),
|
||||||
address_postal_code: formData.get('address_postal_code'),
|
address_postal_code: formData.get('address_postal_code'),
|
||||||
@ -239,6 +384,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
|
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const locationType = document.getElementById('locationType');
|
||||||
|
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||||
|
const hasCrossField = document.getElementById('hasCrossField');
|
||||||
|
const updateCrossFieldOption = () => {
|
||||||
|
const isRoom = locationType.value === 'rum';
|
||||||
|
crossFieldOption.hidden = !isRoom;
|
||||||
|
if (!isRoom) hasCrossField.checked = false;
|
||||||
|
};
|
||||||
|
locationType.addEventListener('change', updateCrossFieldOption);
|
||||||
|
updateCrossFieldOption();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
|
||||||
|
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
|
||||||
|
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
|
||||||
|
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
|
||||||
|
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
|
||||||
|
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
|
||||||
|
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
|
||||||
|
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||||||
|
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||||||
|
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
|
||||||
|
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
|
||||||
|
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
|
||||||
.locations-detail-page {
|
.locations-detail-page {
|
||||||
--loc-accent: var(--accent, #0f4c75);
|
--loc-accent: var(--accent, #0f4c75);
|
||||||
}
|
}
|
||||||
@ -247,6 +259,9 @@
|
|||||||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||||||
{{ type_label }}
|
{{ type_label }}
|
||||||
</span>
|
</span>
|
||||||
|
{% if location.has_cross_field %}
|
||||||
|
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
|
||||||
|
{% endif %}
|
||||||
{% if location.is_active %}
|
{% if location.is_active %}
|
||||||
<span class="case-status-chip open">
|
<span class="case-status-chip open">
|
||||||
<span class="case-status-dot"></span>Aktiv
|
<span class="case-status-dot"></span>Aktiv
|
||||||
@ -328,6 +343,13 @@
|
|||||||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
|
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
|
||||||
<i class="bi bi-diagram-3 me-2"></i>Relationer
|
<i class="bi bi-diagram-3 me-2"></i>Relationer
|
||||||
@ -715,9 +737,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
<div class="card-header bg-transparent border-bottom">
|
<div class="card-header bg-transparent border-bottom">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
<h5 class="card-title mb-0">Tilføj underlokation</h5>
|
<h5 class="card-title mb-0">Tilføj underlokation</h5>
|
||||||
|
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
|
||||||
<form action="/api/v1/locations" method="post" class="row g-2">
|
<form action="/api/v1/locations" method="post" class="row g-2">
|
||||||
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
|
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
|
||||||
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
|
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
|
||||||
@ -747,7 +775,7 @@
|
|||||||
<option value="">Ingen</option>
|
<option value="">Ingen</option>
|
||||||
{% if customers %}
|
{% if customers %}
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
@ -763,6 +791,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 mt-4">
|
||||||
|
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-0">Vægstik</h5>
|
||||||
|
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
|
||||||
|
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
|
||||||
|
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
|
||||||
|
{% elif location.wall_outlets %}
|
||||||
|
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
|
||||||
|
{% for outlet in location.wall_outlets %}
|
||||||
|
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 mt-4">
|
<div class="card border-0 mt-4">
|
||||||
<div class="card-header bg-transparent border-bottom">
|
<div class="card-header bg-transparent border-bottom">
|
||||||
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
||||||
@ -803,6 +857,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||||||
|
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
|
||||||
|
<div class="card border-0">
|
||||||
|
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
|
||||||
|
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
|
||||||
|
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for field in location.cross_fields %}
|
||||||
|
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
|
||||||
|
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
|
||||||
|
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Tab 7: Hardware -->
|
<!-- Tab 7: Hardware -->
|
||||||
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
|
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
@ -813,12 +884,14 @@
|
|||||||
{% if location.hardware %}
|
{% if location.hardware %}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for hw in location.hardware %}
|
{% for hw in location.hardware %}
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
<div class="list-group-item">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||||
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div>
|
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
|
||||||
<div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div>
|
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge bg-secondary">{{ hw.status }}</span>
|
{% if hw.switch_ports %}
|
||||||
|
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -976,6 +1049,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Wall outlet modal -->
|
||||||
|
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
|
||||||
|
<input type="hidden" id="crossFieldId">
|
||||||
|
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 48"></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Portmærkning</label><select class="form-select" id="crossFieldPortLabelFormat"><option value="numeric">1, 2, 3 …</option><option value="paired">1A, 1B, 2A, 2B …</option></select><div class="form-text">A/B-par kræver et lige antal porte, fx 48 porte = 1A–24B.</div></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Startnummer</label><input class="form-control" type="number" id="crossFieldStartPortNumber" min="1" max="9999" value="1"><div class="form-text">Næste panel kan fx starte ved 25, så det bliver 25A, 25B …</div></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Porte pr. række</label><input class="form-control" type="number" id="crossFieldPanelRowSize" min="1" max="48" value="24"><div class="form-text">Sæt fx 24 for samme brede panelopstilling som på billedet.</div></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Visningsrækkefølge</label><input class="form-control" type="number" id="crossFieldDisplayOrder" min="1" max="9999" value="1"><div class="form-text">Laveste nummer vises øverst. Ændr fx et panel til 1 og et andet til 2.</div></div>
|
||||||
|
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
|
||||||
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="crossFieldPortLabelsModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="crossFieldPortLabelsForm">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">Rediger portnumre: <span id="crossFieldPortLabelsName"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body"><p class="small text-muted">Ændr de fysiske mærkninger frit, fx <code>1A</code>, <code>Kontor-12</code> eller <code>Rack2-07</code>. Forbindelser bevares på den samme port.</p><div id="crossFieldPortLabelsList" class="row g-2"></div></div>
|
||||||
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem portnumre</button></div>
|
||||||
|
</form></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<form id="outletForm"><div class="modal-body">
|
||||||
|
<input type="hidden" id="outletId"><div class="row g-3">
|
||||||
|
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
|
||||||
|
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
|
||||||
|
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
|
||||||
|
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
|
||||||
|
</div>
|
||||||
|
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@ -1005,6 +1122,8 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
const locationId = '{{ location.id }}';
|
const locationId = '{{ location.id }}';
|
||||||
|
const locationHardware = {{ location.hardware | tojson }};
|
||||||
|
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||||
const existingContactIdInput = document.getElementById('existingContactId');
|
const existingContactIdInput = document.getElementById('existingContactId');
|
||||||
@ -1292,6 +1411,270 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const crossFieldButton = document.getElementById('addCrossFieldBtn');
|
||||||
|
const crossFieldModalElement = document.getElementById('crossFieldModal');
|
||||||
|
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
|
||||||
|
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldPortLabelFormat').disabled = false; document.getElementById('crossFieldStartPortNumber').disabled = false; document.getElementById('crossFieldStartPortNumber').value = 1; document.getElementById('crossFieldPanelRowSize').value = 24; document.getElementById('crossFieldDisplayOrder').value = 1; document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
|
||||||
|
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldPortLabelFormat').value = button.dataset.labelFormat || 'numeric'; document.getElementById('crossFieldStartPortNumber').value = button.dataset.startNumber || 1; document.getElementById('crossFieldPanelRowSize').value = button.dataset.rowSize || 24; document.getElementById('crossFieldDisplayOrder').value = button.dataset.displayOrder || 1; document.getElementById('crossFieldPortLabelFormat').disabled = true; document.getElementById('crossFieldStartPortNumber').disabled = true; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
|
||||||
|
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const crossFieldId = document.getElementById('crossFieldId').value;
|
||||||
|
const payload = {location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), panel_row_size: Number(document.getElementById('crossFieldPanelRowSize').value), display_order: Number(document.getElementById('crossFieldDisplayOrder').value), notes: document.getElementById('crossFieldNotes').value || null};
|
||||||
|
if (!crossFieldId) { payload.port_label_format = document.getElementById('crossFieldPortLabelFormat').value; payload.start_port_number = Number(document.getElementById('crossFieldStartPortNumber').value); }
|
||||||
|
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
|
||||||
|
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
|
||||||
|
});
|
||||||
|
const crossFieldPortLabelsModalElement = document.getElementById('crossFieldPortLabelsModal');
|
||||||
|
const crossFieldPortLabelsModal = crossFieldPortLabelsModalElement ? new bootstrap.Modal(crossFieldPortLabelsModalElement) : null;
|
||||||
|
let activeCrossFieldPortLabelsId = null;
|
||||||
|
document.querySelectorAll('.edit-port-labels-btn').forEach(button => button.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/locations/cross-fields?location_id=${locationId}`);
|
||||||
|
const fields = await response.json();
|
||||||
|
const field = Array.isArray(fields) ? fields.find(item => Number(item.id) === Number(button.dataset.id)) : null;
|
||||||
|
if (!response.ok || !field) throw new Error('Krydsfeltet kunne ikke indlæses');
|
||||||
|
activeCrossFieldPortLabelsId = field.id;
|
||||||
|
document.getElementById('crossFieldPortLabelsName').textContent = field.name;
|
||||||
|
const list = document.getElementById('crossFieldPortLabelsList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
field.ports.forEach(port => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'col-md-4';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'form-label small mb-1';
|
||||||
|
label.textContent = `Port ${port.port_number}`;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control form-control-sm cross-field-port-label-input';
|
||||||
|
input.maxLength = 20;
|
||||||
|
input.required = true;
|
||||||
|
input.value = port.port_number;
|
||||||
|
input.dataset.portId = port.id;
|
||||||
|
wrapper.append(label, input);
|
||||||
|
list.appendChild(wrapper);
|
||||||
|
});
|
||||||
|
crossFieldPortLabelsModal.show();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Kunne ikke indlæse portnumre');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
document.getElementById('crossFieldPortLabelsForm')?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeCrossFieldPortLabelsId) return;
|
||||||
|
const ports = Array.from(document.querySelectorAll('.cross-field-port-label-input')).map(input => ({id: Number(input.dataset.portId), port_number: input.value.trim()}));
|
||||||
|
const response = await fetch(`/api/v1/locations/cross-fields/${activeCrossFieldPortLabelsId}/port-labels`, {method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ports})});
|
||||||
|
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Portnumrene kunne ikke gemmes'); }
|
||||||
|
});
|
||||||
|
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
|
||||||
|
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
|
||||||
|
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
|
||||||
|
|
||||||
|
const outletModalElement = document.getElementById('outletModal');
|
||||||
|
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
|
||||||
|
const outletForm = document.getElementById('outletForm');
|
||||||
|
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
||||||
|
|
||||||
|
function switchDisplayName(hardware) {
|
||||||
|
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPortCount(hardware) {
|
||||||
|
let specs = hardware.hardware_specs || {};
|
||||||
|
if (typeof specs === 'string') {
|
||||||
|
try { specs = JSON.parse(specs); } catch (_) { specs = {}; }
|
||||||
|
}
|
||||||
|
const count = Number(specs?.port_count || specs?.ports || 0);
|
||||||
|
return Number.isInteger(count) && count > 0 ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSwitchHardwareId() {
|
||||||
|
const selectedName = document.getElementById('outletSwitch').value;
|
||||||
|
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
|
||||||
|
return match ? Number(match.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSwitchName(value) {
|
||||||
|
return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPortConflict(switchHardware, switchName, portNumber, currentOutletId = null) {
|
||||||
|
if (!portNumber) return null;
|
||||||
|
return (locationWallOutlets || []).find(outlet => {
|
||||||
|
if (currentOutletId && Number(outlet.id) === Number(currentOutletId)) return false;
|
||||||
|
if (String(outlet.switch_port || '') !== String(portNumber)) return false;
|
||||||
|
if (switchHardware?.id && outlet.switch_hardware_id) return Number(outlet.switch_hardware_id) === Number(switchHardware.id);
|
||||||
|
return normalizeSwitchName(outlet.switch_name) === normalizeSwitchName(switchName);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSwitchChoices(selectedName = '', selectedPort = '', currentOutletId = null) {
|
||||||
|
const switchInput = document.getElementById('outletSwitch');
|
||||||
|
const switchOptions = document.getElementById('outletSwitchOptions');
|
||||||
|
const portOptions = document.getElementById('outletSwitchPort');
|
||||||
|
const portHelp = document.getElementById('outletSwitchPortHelp');
|
||||||
|
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
||||||
|
switchOptions.innerHTML = '';
|
||||||
|
switches.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = switchDisplayName(item);
|
||||||
|
option.label = switchPortCount(item) ? `${switchPortCount(item)} porte` : 'Antal porte ikke angivet';
|
||||||
|
switchOptions.appendChild(option);
|
||||||
|
});
|
||||||
|
switchInput.value = selectedName || '';
|
||||||
|
|
||||||
|
const selectedSwitch = switches.find(item => switchDisplayName(item) === switchInput.value);
|
||||||
|
const count = selectedSwitch ? switchPortCount(selectedSwitch) : 0;
|
||||||
|
portOptions.innerHTML = '<option value="">Vælg port</option>';
|
||||||
|
if (count) {
|
||||||
|
for (let number = 1; number <= count; number += 1) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(number);
|
||||||
|
const conflict = switchPortConflict(selectedSwitch, switchInput.value, number, currentOutletId);
|
||||||
|
option.textContent = conflict
|
||||||
|
? `Port ${number} — OPTAGET: ${conflict.outlet_number} (${conflict.status || 'ukendt'})`
|
||||||
|
: `Port ${number} — ledig`;
|
||||||
|
portOptions.appendChild(option);
|
||||||
|
}
|
||||||
|
portHelp.textContent = `${count} porte på den valgte switch.`;
|
||||||
|
} else if (switchInput.value) {
|
||||||
|
portHelp.textContent = 'Ingen portliste på switchen — du kan skrive porten manuelt.';
|
||||||
|
} else {
|
||||||
|
portHelp.textContent = switches.length ? 'Vælg en switch for at se dens porte.' : 'Ingen registrerede switches på denne lokation endnu.';
|
||||||
|
}
|
||||||
|
document.getElementById('outletSwitchPort').value = selectedPort || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('outletSwitch')?.addEventListener('input', () => {
|
||||||
|
loadSwitchChoices(document.getElementById('outletSwitch').value, '', document.getElementById('outletId').value || null);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCrossFieldPorts() {
|
||||||
|
const select = document.getElementById('outletCrossFieldPort');
|
||||||
|
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
|
||||||
|
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOutletLocations() {
|
||||||
|
const select = document.getElementById('outletLocationId');
|
||||||
|
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
|
||||||
|
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
|
||||||
|
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
|
||||||
|
select.value = String(locationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOutletCustomers(selectedCustomerId = null) {
|
||||||
|
const select = document.getElementById('outletCustomerId');
|
||||||
|
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
|
||||||
|
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
|
||||||
|
(customers || []).forEach(customer => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(customer.id);
|
||||||
|
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
|
||||||
|
option.selected = String(customer.id) === String(selectedCustomerId || '');
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOutletModal(outlet = null, selectedPort = null) {
|
||||||
|
if (!outletModal) return;
|
||||||
|
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
|
||||||
|
document.getElementById('outletId').value = outlet?.id || '';
|
||||||
|
document.getElementById('outletNumber').value = outlet?.number || '';
|
||||||
|
document.getElementById('outletCategory').value = outlet?.category || '';
|
||||||
|
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
|
||||||
|
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||||||
|
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
||||||
|
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||||||
|
document.getElementById('outletNotes').value = outlet?.notes || '';
|
||||||
|
if (selectedPort) {
|
||||||
|
const portSelect = document.getElementById('outletCrossFieldPort');
|
||||||
|
portSelect.value = String(selectedPort.id);
|
||||||
|
if (portSelect.value !== String(selectedPort.id)) {
|
||||||
|
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
|
||||||
|
}
|
||||||
|
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
||||||
|
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
||||||
|
}
|
||||||
|
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||||||
|
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
|
||||||
|
outletModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
|
||||||
|
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
|
||||||
|
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
|
||||||
|
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
|
||||||
|
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
|
||||||
|
status: btn.dataset.status, notes: btn.dataset.notes
|
||||||
|
})));
|
||||||
|
|
||||||
|
outletForm?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const outletId = document.getElementById('outletId').value;
|
||||||
|
const payload = {
|
||||||
|
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), customer_id: document.getElementById('outletCustomerId').value ? Number(document.getElementById('outletCustomerId').value) : null, category: outletValue('outletCategory'),
|
||||||
|
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||||||
|
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
|
||||||
|
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
|
||||||
|
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
||||||
|
};
|
||||||
|
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
|
||||||
|
const conflict = switchPortConflict(selectedSwitch, payload.switch_name, payload.switch_port, outletId || null);
|
||||||
|
if (conflict) {
|
||||||
|
const message = `Switch-port ${payload.switch_port} er allerede registreret på vægstik ${conflict.outlet_number}. Vil du flytte forbindelsen til dette vægstik?`;
|
||||||
|
if (!confirm(message)) return;
|
||||||
|
payload.replace_existing_switch_port = true;
|
||||||
|
}
|
||||||
|
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
|
||||||
|
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (response.status === 409) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
if (confirm(`${error.detail || 'Porten er allerede i brug.'}\n\nVil du overskrive forbindelsen?`)) {
|
||||||
|
payload.replace_existing_switch_port = true;
|
||||||
|
const retry = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
|
||||||
|
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (retry.ok) { location.reload(); return; }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
alert(error.detail || 'Kunne ikke gemme vægstik');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
|
||||||
|
const outletId = document.getElementById('outletId').value;
|
||||||
|
if (!outletId || !confirm('Slet dette vægstik?')) return;
|
||||||
|
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else alert('Kunne ikke slette vægstik');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.save-hardware-order-btn').forEach(button => button.addEventListener('click', async () => {
|
||||||
|
const hardwareId = button.dataset.hardwareId;
|
||||||
|
const input = document.querySelector(`.hardware-display-order[data-hardware-id="${hardwareId}"]`);
|
||||||
|
const order = Number(input?.value);
|
||||||
|
if (!Number.isInteger(order) || order < 1) {
|
||||||
|
alert('Angiv et positivt heltal for rækkefølgen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.disabled = true;
|
||||||
|
const response = await fetch(`/api/v1/hardware/${hardwareId}`, {
|
||||||
|
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({location_display_order: order})
|
||||||
|
});
|
||||||
|
if (response.ok) location.reload();
|
||||||
|
else {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
alert(error.detail || 'Kunne ikke gemme rækkefølgen');
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
|
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.relation-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-summary {
|
||||||
|
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgba(15, 76, 117, 0.05);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-summary.empty {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-path {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f3b53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
@ -58,19 +92,52 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relation-card mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
|
||||||
|
<div class="text-muted small">Flyt lokationen ved at vælge en ny overordnet lokation.</div>
|
||||||
|
</div>
|
||||||
|
{% if selected_parent %}
|
||||||
|
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-eye me-2"></i>Åbn valgt parent
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
|
||||||
|
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, sti eller type...">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
|
||||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||||
<option value="">Ingen (øverste niveau)</option>
|
<option value="">Ingen (øverste niveau)</option>
|
||||||
{% if parent_locations %}
|
{% if parent_locations %}
|
||||||
{% for parent in parent_locations %}
|
{% for parent in parent_locations %}
|
||||||
<option value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}>
|
<option
|
||||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
value="{{ parent.id }}"
|
||||||
|
data-path="{{ parent.hierarchy_path }}"
|
||||||
|
data-type="{{ parent.type_label }}"
|
||||||
|
data-customer-id="{{ parent.customer_id | default('') }}"
|
||||||
|
{% if location.parent_location_id == parent.id %}selected{% endif %}>
|
||||||
|
{{ parent.display_name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
<div class="form-text">Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
|
||||||
|
{% if selected_parent %}
|
||||||
|
<div class="small text-muted mb-1">Nuværende overordnet lokation</div>
|
||||||
|
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
|
||||||
|
<div class="relation-meta">{{ selected_parent.type_label }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted">Lokationen ligger i topniveau.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -92,6 +159,13 @@
|
|||||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Section 2: Address -->
|
<!-- Section 2: Address -->
|
||||||
@ -130,7 +204,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk">
|
<input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@ -160,7 +234,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="notes" class="form-label">Noter og kommentarer</label>
|
<label for="notes" class="form-label">Noter og kommentarer</label>
|
||||||
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
|
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
|
||||||
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small>
|
<small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@ -216,12 +290,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const deleteModalElement = document.getElementById('deleteModal');
|
const deleteModalElement = document.getElementById('deleteModal');
|
||||||
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
|
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
|
||||||
const locationId = '{{ location.id }}';
|
const locationId = '{{ location.id }}';
|
||||||
|
const parentLocationSelect = document.getElementById('parentLocation');
|
||||||
|
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||||
|
const parentSummary = document.getElementById('parentSummary');
|
||||||
|
|
||||||
// Character counter for notes
|
// Character counter for notes
|
||||||
notesField.addEventListener('input', function() {
|
notesField.addEventListener('input', function() {
|
||||||
charCount.textContent = this.value.length;
|
charCount.textContent = this.value.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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 submission
|
||||||
form.addEventListener('submit', async function(e) {
|
form.addEventListener('submit', async function(e) {
|
||||||
if (form.dataset.noIntercept === 'true') {
|
if (form.dataset.noIntercept === 'true') {
|
||||||
@ -239,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||||
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||||
is_active: formData.get('is_active') === 'on',
|
is_active: formData.get('is_active') === 'on',
|
||||||
|
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||||
address_street: formData.get('address_street'),
|
address_street: formData.get('address_street'),
|
||||||
address_city: formData.get('address_city'),
|
address_city: formData.get('address_city'),
|
||||||
address_postal_code: formData.get('address_postal_code'),
|
address_postal_code: formData.get('address_postal_code'),
|
||||||
@ -275,6 +402,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const locationType = document.getElementById('locationType');
|
||||||
|
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||||
|
const hasCrossField = document.getElementById('hasCrossField');
|
||||||
|
const updateCrossFieldOption = () => {
|
||||||
|
const isRoom = locationType.value === 'rum';
|
||||||
|
crossFieldOption.hidden = !isRoom;
|
||||||
|
if (!isRoom) hasCrossField.checked = false;
|
||||||
|
};
|
||||||
|
locationType.addEventListener('change', updateCrossFieldOption);
|
||||||
|
updateCrossFieldOption();
|
||||||
|
|
||||||
// Delete location
|
// Delete location
|
||||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||||
fetch(`/api/v1/locations/${locationId}`, {
|
fetch(`/api/v1/locations/${locationId}`, {
|
||||||
|
|||||||
@ -6,81 +6,271 @@
|
|||||||
<style>
|
<style>
|
||||||
.locations-list-page {
|
.locations-list-page {
|
||||||
--loc-accent: #0f4c75;
|
--loc-accent: #0f4c75;
|
||||||
--loc-accent-soft: rgba(15, 76, 117, 0.08);
|
--loc-accent-soft: rgba(15, 76, 117, 0.06);
|
||||||
--loc-border: rgba(15, 76, 117, 0.16);
|
--loc-border: rgba(15, 76, 117, 0.12);
|
||||||
|
--loc-surface: #f7f9fc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .locations-hero {
|
.locations-list-page .locations-hero {
|
||||||
border: 1px solid var(--loc-border);
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
background:
|
background: var(--bg-card);
|
||||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
|
border-radius: 0.9rem;
|
||||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
|
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
|
||||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .locations-list-page .locations-hero {
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
|
|
||||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
|
|
||||||
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .stat-tile {
|
.locations-list-page .stat-tile {
|
||||||
background: var(--loc-accent-soft);
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||||
border: 1px solid var(--loc-border);
|
border: 1px solid rgba(15, 76, 117, 0.08);
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.8rem;
|
||||||
padding: 0.8rem 0.95rem;
|
padding: 0.75rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .section-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--loc-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .stat-tile .stat-value {
|
.locations-list-page .stat-tile .stat-value {
|
||||||
font-size: 1.15rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--loc-accent);
|
color: var(--loc-accent);
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
|
.locations-list-page .stat-tile .stat-note {
|
||||||
color: #8fd0ff;
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .table thead th {
|
.locations-list-page .table thead th {
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .location-row {
|
.locations-list-page .location-row {
|
||||||
transition: background-color 0.18s ease, transform 0.16s ease;
|
transition: background-color 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .location-row:hover {
|
.locations-list-page .location-row:hover {
|
||||||
background-color: rgba(52, 152, 219, 0.08);
|
background-color: rgba(15, 76, 117, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .toggle-row {
|
.locations-list-page .table > :not(caption) > * > * {
|
||||||
|
padding-top: 0.95rem;
|
||||||
|
padding-bottom: 0.95rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .toggle-row,
|
||||||
|
.locations-list-page .location-tree-spacer {
|
||||||
|
color: var(--loc-accent);
|
||||||
|
width: 1.2rem;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .filter-shell,
|
||||||
|
.locations-list-page .content-shell {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .filter-shell {
|
||||||
|
padding: 1.05rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .content-shell-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .content-shell-toolbar,
|
||||||
|
.locations-list-page .filter-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .content-shell-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .content-shell-subtitle {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .content-shell-subtitle span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-name-link {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-name-link:hover {
|
||||||
color: var(--loc-accent);
|
color: var(--loc-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-primary {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.7rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-indent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-name-block {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .location-meta-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 76, 117, 0.07);
|
||||||
|
color: #2d5670;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .type-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.32rem 0.62rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.28rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .status-pill.active {
|
||||||
|
background: rgba(25, 135, 84, 0.12);
|
||||||
|
color: #146c43;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .status-pill.inactive {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
.locations-list-page .shortcut-hint {
|
.locations-list-page .shortcut-hint {
|
||||||
border: 1px dashed var(--loc-border);
|
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
padding: 0.3rem 0.45rem;
|
padding: 0.3rem 0.45rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locations-list-page .hero-actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .list-status-note {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--loc-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .actions-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .actions-inline .btn {
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-list-page .empty-state-soft {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.locations-list-page {
|
.locations-list-page {
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locations-list-page .stat-tile {
|
.locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
|
||||||
padding: 0.65rem 0.75rem;
|
.locations-list-page .content-shell-header { align-items: flex-start; flex-direction: column; }
|
||||||
}
|
.locations-list-page .content-shell-toolbar,
|
||||||
|
.locations-list-page .filter-toolbar { align-items: flex-start; flex-direction: column; }
|
||||||
|
.locations-list-page .hero-actions { width: 100%; }
|
||||||
|
.locations-list-page .hero-actions .btn { flex: 1 1 auto; }
|
||||||
|
.locations-list-page .location-summary { gap: 0.55rem; }
|
||||||
|
.locations-list-page .location-name-link { font-size: 0.94rem; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -96,59 +286,83 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="row mb-4">
|
<div class="locations-hero p-3 p-lg-4 mb-4">
|
||||||
<div class="col-12">
|
|
||||||
<div class="locations-hero p-3 p-lg-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
<div class="section-label mb-2">
|
||||||
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
|
<i class="bi bi-diagram-3"></i>
|
||||||
|
Lokationsstruktur
|
||||||
|
</div>
|
||||||
|
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||||
|
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap hero-actions">
|
||||||
|
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||||
|
</a>
|
||||||
|
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
||||||
|
</a>
|
||||||
|
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-ethernet me-2"></i>Vægstik
|
||||||
|
</a>
|
||||||
|
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-2 mt-2">
|
<div class="row g-2 mt-2">
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="small text-muted">Total</div>
|
<div class="small text-muted">Total</div>
|
||||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||||
|
<div class="stat-note">Alle registrerede lokationer</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="small text-muted">Aktive</div>
|
<div class="small text-muted">Aktive</div>
|
||||||
<div class="stat-value" id="statActive">0</div>
|
<div class="stat-value" id="statActive">0</div>
|
||||||
|
<div class="stat-note">Klar til daglig brug</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="small text-muted">Inaktive</div>
|
<div class="small text-muted">Inaktive</div>
|
||||||
<div class="stat-value" id="statInactive">0</div>
|
<div class="stat-value" id="statInactive">0</div>
|
||||||
|
<div class="stat-note">Skjulte eller lukkede</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="small text-muted">Synlige nu</div>
|
<div class="small text-muted">Synlige nu</div>
|
||||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||||
</div>
|
<div class="stat-note">Efter søgning og foldning</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Card -->
|
<!-- Filter Card -->
|
||||||
<div class="card mb-4 border-0">
|
<div class="filter-shell mb-4">
|
||||||
<div class="card-body">
|
<div class="filter-toolbar mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="content-shell-title">Filtre</div>
|
||||||
|
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-status-note">
|
||||||
|
<i class="bi bi-lightning-charge"></i>
|
||||||
|
Hierarkiet kan foldes direkte i listen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
||||||
<div class="col-md-4">
|
<div class="col-lg-5">
|
||||||
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter navn, hierarki eller by...">
|
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4 col-lg-3">
|
||||||
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
||||||
<select class="form-select" id="locationTypeFilter" name="location_type">
|
<select class="form-select" id="locationTypeFilter" name="location_type">
|
||||||
<option value="">Alle typer</option>
|
<option value="">Alle typer</option>
|
||||||
@ -164,7 +378,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-2">
|
<div class="col-md-3 col-lg-2">
|
||||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||||
<select class="form-select" id="statusFilter" name="is_active">
|
<select class="form-select" id="statusFilter" name="is_active">
|
||||||
<option value="">Alle</option>
|
<option value="">Alle</option>
|
||||||
@ -173,44 +387,43 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-2 d-flex gap-2">
|
<div class="col-md-5 col-lg-2 d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
<i class="bi bi-funnel me-2"></i>Anvend filtre
|
<i class="bi bi-funnel me-2"></i>Filtrer
|
||||||
</button>
|
</button>
|
||||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toolbar Section -->
|
<!-- Main Content Section -->
|
||||||
<div class="row mb-3">
|
<div class="content-shell">
|
||||||
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="content-shell-header">
|
||||||
<div class="d-flex gap-2">
|
<div class="content-shell-toolbar w-100">
|
||||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
<div>
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
<div class="content-shell-title">Lokationsliste</div>
|
||||||
</a>
|
<div class="content-shell-subtitle">
|
||||||
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
{% if total %}
|
||||||
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
||||||
</a>
|
<span>med tydeligt hierarki og status</span>
|
||||||
|
{% else %}
|
||||||
|
Ingen lokationer endnu
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<span class="list-status-note">
|
||||||
|
<i class="bi bi-check2-circle"></i>
|
||||||
|
Klik kun på navn eller handlinger for at åbne
|
||||||
|
</span>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
||||||
<i class="bi bi-trash me-2"></i>Slet valgte
|
<i class="bi bi-trash me-2"></i>Slet valgte
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small">
|
|
||||||
{% if total %}
|
|
||||||
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
|
||||||
{% else %}
|
|
||||||
Ingen lokationer
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Section -->
|
|
||||||
<div class="card border-0">
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
{% if location_tree %}
|
{% if location_tree %}
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
@ -250,26 +463,44 @@
|
|||||||
'vehicle': '#8e44ad'
|
'vehicle': '#8e44ad'
|
||||||
}.get(node.location_type, '#6c757d') %}
|
}.get(node.location_type, '#6c757d') %}
|
||||||
|
|
||||||
|
{% set child_count = node.children|length if node.children else 0 %}
|
||||||
|
|
||||||
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
|
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
|
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;">
|
<div class="location-summary">
|
||||||
|
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
|
||||||
{% if node.children %}
|
{% if node.children %}
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 me-2 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
<button type="button" class="btn btn-link btn-sm p-0 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
||||||
<i class="bi bi-caret-right-fill"></i>
|
<i class="bi bi-caret-right-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted me-2">•</span>
|
<span class="location-tree-spacer text-muted">•</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500">
|
</div>
|
||||||
|
<div class="location-name-block">
|
||||||
|
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
|
||||||
{{ node.name }}
|
{{ node.name }}
|
||||||
</a>
|
</a>
|
||||||
|
<div class="location-meta">
|
||||||
|
{% if depth > 0 %}
|
||||||
|
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if child_count %}
|
||||||
|
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if node.address_city %}
|
||||||
|
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" style="background-color: {{ type_color }}; color: white;">
|
<span class="type-pill" style="background-color: {{ type_color }};">
|
||||||
{{ type_label }}
|
{{ type_label }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -278,20 +509,20 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if node.is_active %}
|
{% if node.is_active %}
|
||||||
<span class="badge bg-success">Aktiv</span>
|
<span class="status-pill active"><span>●</span>Aktiv</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Inaktiv</span>
|
<span class="status-pill inactive"><span>●</span>Inaktiv</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="actions-inline" role="group" aria-label="Handlinger">
|
||||||
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis">
|
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger">
|
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger delete-location-btn"
|
<button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
|
||||||
data-location-id="{{ node.id }}"
|
data-location-id="{{ node.id }}"
|
||||||
data-location-name="{{ node.name }}"
|
data-location-name="{{ node.name }}"
|
||||||
title="Slet">
|
title="Slet">
|
||||||
@ -315,6 +546,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
|
<div class="empty-state-soft">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -324,6 +556,7 @@
|
|||||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -608,12 +841,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
let currentDeleteId = null;
|
let currentDeleteId = null;
|
||||||
|
|
||||||
// Select all functionality
|
// Select all functionality
|
||||||
|
if (selectAllCheckbox) {
|
||||||
selectAllCheckbox.addEventListener('change', function() {
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
locationCheckboxes.forEach(checkbox => {
|
locationCheckboxes.forEach(checkbox => {
|
||||||
checkbox.checked = this.checked;
|
checkbox.checked = this.checked;
|
||||||
});
|
});
|
||||||
updateBulkDeleteButton();
|
updateBulkDeleteButton();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Individual checkbox functionality
|
// Individual checkbox functionality
|
||||||
locationCheckboxes.forEach(checkbox => {
|
locationCheckboxes.forEach(checkbox => {
|
||||||
@ -632,6 +867,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectAllCheckbox() {
|
function updateSelectAllCheckbox() {
|
||||||
|
if (!selectAllCheckbox) return;
|
||||||
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
|
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
|
||||||
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
|
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
|
||||||
selectAllCheckbox.checked = allChecked;
|
selectAllCheckbox.checked = allChecked;
|
||||||
@ -678,6 +914,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bulk delete
|
// Bulk delete
|
||||||
|
if (bulkDeleteBtn) {
|
||||||
bulkDeleteBtn.addEventListener('click', function() {
|
bulkDeleteBtn.addEventListener('click', function() {
|
||||||
const selectedIds = Array.from(locationCheckboxes)
|
const selectedIds = Array.from(locationCheckboxes)
|
||||||
.filter(cb => cb.checked)
|
.filter(cb => cb.checked)
|
||||||
@ -696,18 +933,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clickable rows
|
|
||||||
document.querySelectorAll('.location-row').forEach(row => {
|
|
||||||
row.addEventListener('click', function(e) {
|
|
||||||
// Don't navigate if clicking checkbox or action buttons
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.closest('.btn-group')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const link = this.querySelector('a');
|
|
||||||
if (link) link.click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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 import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
@ -310,6 +311,17 @@ class RewriteTextResponse(BaseModel):
|
|||||||
context: Optional[str] = None
|
context: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseCreateRewriteRequest(BaseModel):
|
||||||
|
title: str = Field(default="", max_length=500)
|
||||||
|
description: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseCreateRewriteResponse(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SagSendEmailRequest(BaseModel):
|
class SagSendEmailRequest(BaseModel):
|
||||||
to: List[str]
|
to: List[str]
|
||||||
subject: str = Field(..., min_length=1, max_length=998)
|
subject: str = Field(..., min_length=1, max_length=998)
|
||||||
@ -730,6 +742,19 @@ async def analyze_quick_create(request: QuickCreateRequest):
|
|||||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sag/rewrite-case-create", response_model=CaseCreateRewriteResponse)
|
||||||
|
async def rewrite_case_create(request: CaseCreateRewriteRequest):
|
||||||
|
"""Return a fact-preserving title and description suggestion for a new case."""
|
||||||
|
result = await ollama_service.rewrite_case_creation(request.title, request.description)
|
||||||
|
if not result or result.get("error"):
|
||||||
|
raise HTTPException(status_code=502, detail=(result or {}).get("error") or "Could not rewrite case")
|
||||||
|
return CaseCreateRewriteResponse(
|
||||||
|
title=result.get("title", ""),
|
||||||
|
description=result.get("description", ""),
|
||||||
|
model=result.get("model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
|
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
|
||||||
async def rewrite_sag_text(request: RewriteTextRequest):
|
async def rewrite_sag_text(request: RewriteTextRequest):
|
||||||
"""Rewrite case/email text using Ollama with configurable prompt."""
|
"""Rewrite case/email text using Ollama with configurable prompt."""
|
||||||
@ -903,7 +928,7 @@ async def list_all_sale_items(
|
|||||||
|
|
||||||
@router.post("/sag")
|
@router.post("/sag")
|
||||||
async def create_sag(data: dict):
|
async def create_sag(data: dict):
|
||||||
"""Create a new case."""
|
"""Create a case and its optional pipeline/order data atomically."""
|
||||||
try:
|
try:
|
||||||
if not data.get("titel"):
|
if not data.get("titel"):
|
||||||
raise HTTPException(status_code=400, detail="titel is required")
|
raise HTTPException(status_code=400, detail="titel is required")
|
||||||
@ -919,32 +944,118 @@ async def create_sag(data: dict):
|
|||||||
_validate_user_id(ansvarlig_bruger_id)
|
_validate_user_id(ansvarlig_bruger_id)
|
||||||
_validate_group_id(assigned_group_id)
|
_validate_group_id(assigned_group_id)
|
||||||
|
|
||||||
query = """
|
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||||
INSERT INTO sag_sager
|
pipeline = data.get("pipeline") if case_type == "pipeline" else None
|
||||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
order_items = data.get("order_items") if case_type == "ordre" else []
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
if pipeline is not None and not isinstance(pipeline, dict):
|
||||||
RETURNING *
|
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
|
||||||
"""
|
if not isinstance(order_items, list):
|
||||||
params = (
|
raise HTTPException(status_code=400, detail="order_items skal være en liste")
|
||||||
data.get("titel"),
|
|
||||||
data.get("beskrivelse", ""),
|
|
||||||
data.get("template_key") or data.get("type", "ticket"),
|
|
||||||
status,
|
|
||||||
data.get("customer_id"),
|
|
||||||
ansvarlig_bruger_id,
|
|
||||||
assigned_group_id,
|
|
||||||
data.get("created_by_user_id", 1),
|
|
||||||
deadline,
|
|
||||||
deferred_until,
|
|
||||||
data.get("deferred_until_case_id"),
|
|
||||||
data.get("deferred_until_status"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = execute_query(query, params)
|
conn = get_db_connection()
|
||||||
if result:
|
try:
|
||||||
logger.info("✅ Case created: %s", result[0]["id"])
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
return result[0]
|
pipeline_values = {"amount": None, "probability": None, "stage_id": None, "description": None}
|
||||||
|
if pipeline:
|
||||||
|
pipeline_values.update({key: pipeline.get(key) for key in pipeline_values})
|
||||||
|
if pipeline_values["amount"] not in (None, ""):
|
||||||
|
try:
|
||||||
|
pipeline_values["amount"] = float(pipeline_values["amount"])
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
||||||
|
else:
|
||||||
|
pipeline_values["amount"] = None
|
||||||
|
if pipeline_values["probability"] not in (None, ""):
|
||||||
|
try:
|
||||||
|
pipeline_values["probability"] = int(pipeline_values["probability"])
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være et helt tal") from exc
|
||||||
|
if not 0 <= pipeline_values["probability"] <= 100:
|
||||||
|
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være mellem 0 og 100")
|
||||||
|
else:
|
||||||
|
pipeline_values["probability"] = None
|
||||||
|
if pipeline_values["stage_id"] not in (None, ""):
|
||||||
|
try:
|
||||||
|
pipeline_values["stage_id"] = int(pipeline_values["stage_id"])
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage") from exc
|
||||||
|
cursor.execute("SELECT id FROM pipeline_stages WHERE id = %s", (pipeline_values["stage_id"],))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage")
|
||||||
|
else:
|
||||||
|
pipeline_values["stage_id"] = None
|
||||||
|
|
||||||
|
normalized_items = []
|
||||||
|
for item in order_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig ordrelinje")
|
||||||
|
description = str(item.get("description") or "").strip()
|
||||||
|
item_type = str(item.get("type") or "sale").lower()
|
||||||
|
if not description:
|
||||||
|
raise HTTPException(status_code=400, detail="Ordrelinjens beskrivelse er påkrævet")
|
||||||
|
if item_type not in ("sale", "purchase"):
|
||||||
|
raise HTTPException(status_code=400, detail="Ordrelinjetype skal være køb eller salg")
|
||||||
|
if item.get("amount") in (None, ""):
|
||||||
|
raise HTTPException(status_code=400, detail="Ordrelinjens beløb er påkrævet")
|
||||||
|
try:
|
||||||
|
normalized_items.append({
|
||||||
|
"type": item_type,
|
||||||
|
"description": description,
|
||||||
|
"quantity": float(item["quantity"]) if item.get("quantity") not in (None, "") else None,
|
||||||
|
"unit": item.get("unit") or None,
|
||||||
|
"unit_price": float(item["unit_price"]) if item.get("unit_price") not in (None, "") else None,
|
||||||
|
"amount": float(item["amount"]),
|
||||||
|
"currency": str(item.get("currency") or "DKK").upper(),
|
||||||
|
"status": str(item.get("status") or "draft").lower(),
|
||||||
|
"line_date": item.get("line_date") or None,
|
||||||
|
"external_ref": item.get("external_ref") or None,
|
||||||
|
"purchase_purpose": _normalize_purchase_purpose(item.get("purchase_purpose"), item_type),
|
||||||
|
})
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
||||||
|
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager
|
||||||
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
|
||||||
|
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
||||||
|
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||||
|
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||||
|
|
||||||
|
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||||
|
for item in normalized_items:
|
||||||
|
if has_purchase_columns:
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sag_salgsvarer
|
||||||
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, purchase_purpose)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"], item["purchase_purpose"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sag_salgsvarer
|
||||||
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("✅ Case created: %s", result["id"])
|
||||||
|
return dict(result)
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -162,6 +162,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="createForm" novalidate>
|
<form id="createForm" novalidate>
|
||||||
|
<div class="mb-4 p-3 rounded-3 border bg-light">
|
||||||
|
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||||
|
<select class="form-select form-select-lg" id="type" required></select>
|
||||||
|
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Section: Relations -->
|
<!-- Section: Relations -->
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
@ -206,15 +212,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<label for="beskrivelse" class="form-label">Beskrivelse</label>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label for="beskrivelse" class="form-label mb-0">Beskrivelse</label>
|
||||||
|
<button type="button" id="caseCreateRewriteBtn" class="btn btn-sm btn-outline-primary" title="Renskriv kun det, du allerede har skrevet">
|
||||||
|
<i class="bi bi-magic me-1"></i>AI renskriv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||||
<div class="form-text text-end" id="charCount">0 tegn</div>
|
<div class="d-flex justify-content-between form-text">
|
||||||
|
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
|
||||||
|
<span id="charCount">0 tegn</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
<!-- Section: Hardware & AnyDesk -->
|
<!-- Section: Hardware & AnyDesk -->
|
||||||
|
<section id="hardwareSection">
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@ -246,23 +261,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="pipelineSection" class="d-none">
|
||||||
|
<hr class="my-4 opacity-25">
|
||||||
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
|
||||||
|
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="orderSection" class="d-none">
|
||||||
|
<hr class="my-4 opacity-25">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="orderLines" class="vstack gap-3 mb-4"></div>
|
||||||
|
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
<!-- Section: Metadata -->
|
<!-- Section: Metadata -->
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
|
||||||
<select class="form-select" id="type" required>
|
|
||||||
<option value="ticket" selected>🎫 Ticket</option>
|
|
||||||
<option value="opgave">🧩 Opgave</option>
|
|
||||||
<option value="ordre">🧾 Ordre</option>
|
|
||||||
<option value="projekt">📁 Projekt</option>
|
|
||||||
<option value="service">🛠️ Service</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="åben" selected>🟢 Åben</option>
|
<option value="åben" selected>🟢 Åben</option>
|
||||||
@ -270,7 +300,7 @@
|
|||||||
<option value="lukket">🔴 Lukket</option>
|
<option value="lukket">🔴 Lukket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||||
<select class="form-select" id="ansvarlig_bruger_id">
|
<select class="form-select" id="ansvarlig_bruger_id">
|
||||||
<option value="">Ingen</option>
|
<option value="">Ingen</option>
|
||||||
@ -280,7 +310,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||||
<select class="form-select" id="assigned_group_id">
|
<select class="form-select" id="assigned_group_id">
|
||||||
<option value="">Ingen</option>
|
<option value="">Ingen</option>
|
||||||
@ -317,6 +347,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="caseCreateRewriteModalLabel"><i class="bi bi-magic me-2"></i>AI-forslag til sag</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
Gennemgå forslaget før du bruger det. AI må kun have rettet formulering og foreslået en titel ud fra din egen tekst.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="caseCreateSuggestedTitle" class="form-label">Foreslået titel</label>
|
||||||
|
<input id="caseCreateSuggestedTitle" class="form-control" type="text">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="caseCreateSuggestedDescription" class="form-label">Renskrevet beskrivelse</label>
|
||||||
|
<textarea id="caseCreateSuggestedDescription" class="form-control" rows="10"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" id="caseCreateApplyRewriteBtn" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Brug forslag</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let selectedCustomer = null;
|
let selectedCustomer = null;
|
||||||
let selectedContacts = {};
|
let selectedContacts = {};
|
||||||
@ -324,6 +382,7 @@
|
|||||||
let customerSearchTimeout;
|
let customerSearchTimeout;
|
||||||
let contactSearchTimeout;
|
let contactSearchTimeout;
|
||||||
let successAlertTimeout;
|
let successAlertTimeout;
|
||||||
|
let orderLineCounter = 0;
|
||||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
||||||
let topAlertLoadToken = 0;
|
let topAlertLoadToken = 0;
|
||||||
|
|
||||||
@ -421,6 +480,76 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- AI renskrivning ved oprettelse ---
|
||||||
|
// Forslaget bliver altid vist først; formularens titel og beskrivelse ændres
|
||||||
|
// først når brugeren aktivt vælger "Brug forslag".
|
||||||
|
async function requestCaseCreateRewrite() {
|
||||||
|
const descriptionInput = document.getElementById('beskrivelse');
|
||||||
|
const titleInput = document.getElementById('titel');
|
||||||
|
const button = document.getElementById('caseCreateRewriteBtn');
|
||||||
|
const source = (descriptionInput?.value || '').trim();
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
descriptionInput?.focus();
|
||||||
|
alert('Skriv en beskrivelse først.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalButton = button?.innerHTML || '';
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/sag/rewrite-case-create', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: titleInput?.value || '', description: source })
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = {
|
||||||
|
title: String(payload?.title || '').trim(),
|
||||||
|
description: String(payload?.description || '').trim()
|
||||||
|
};
|
||||||
|
if (!suggestion.description) {
|
||||||
|
throw new Error('AI returnerede ikke en beskrivelse');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('caseCreateSuggestedTitle').value = suggestion.title || titleInput?.value || '';
|
||||||
|
document.getElementById('caseCreateSuggestedDescription').value = suggestion.description;
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Case create rewrite failed:', error);
|
||||||
|
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalButton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('caseCreateRewriteBtn')?.addEventListener('click', requestCaseCreateRewrite);
|
||||||
|
document.getElementById('caseCreateApplyRewriteBtn')?.addEventListener('click', () => {
|
||||||
|
const suggestedTitle = document.getElementById('caseCreateSuggestedTitle').value.trim();
|
||||||
|
const suggestedDescription = document.getElementById('caseCreateSuggestedDescription').value.trim();
|
||||||
|
const titleInput = document.getElementById('titel');
|
||||||
|
const descriptionInput = document.getElementById('beskrivelse');
|
||||||
|
|
||||||
|
if (suggestedTitle) titleInput.value = suggestedTitle;
|
||||||
|
if (suggestedDescription) {
|
||||||
|
descriptionInput.value = suggestedDescription;
|
||||||
|
descriptionInput.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Search Logic ---
|
// --- Search Logic ---
|
||||||
function initializeSearch() {
|
function initializeSearch() {
|
||||||
// Customer Search
|
// Customer Search
|
||||||
@ -889,28 +1018,113 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caseTypeLabels = {
|
||||||
|
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
||||||
|
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateCaseTypeSections() {
|
||||||
|
const type = document.getElementById('type')?.value || 'ticket';
|
||||||
|
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
|
||||||
|
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
|
||||||
|
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
|
||||||
|
const help = document.getElementById('caseTypeHelp');
|
||||||
|
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
|
||||||
|
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||||
|
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||||
|
: 'Denne sagstype bruger kun de fælles sagsfelter.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrderLinesEmptyState() {
|
||||||
|
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
|
||||||
|
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrderLine(type = 'sale') {
|
||||||
|
const id = ++orderLineCounter;
|
||||||
|
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
|
||||||
|
const purpose = type === 'purchase' ? `
|
||||||
|
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
|
||||||
|
const container = document.getElementById('orderLines');
|
||||||
|
container.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
|
||||||
|
<input type="hidden" class="order-type" value="${type}">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
|
||||||
|
${purpose}
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
renderOrderLinesEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOrderLine(id) {
|
||||||
|
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
|
||||||
|
renderOrderLinesEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOrderItems() {
|
||||||
|
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
|
||||||
|
type: line.querySelector('.order-type').value,
|
||||||
|
description: line.querySelector('.order-description').value.trim(),
|
||||||
|
quantity: line.querySelector('.order-quantity').value || null,
|
||||||
|
unit: line.querySelector('.order-unit').value.trim() || null,
|
||||||
|
unit_price: line.querySelector('.order-unit-price').value || null,
|
||||||
|
amount: line.querySelector('.order-amount').value || null,
|
||||||
|
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
|
||||||
|
status: line.querySelector('.order-status').value,
|
||||||
|
external_ref: line.querySelector('.order-reference').value.trim() || null,
|
||||||
|
purchase_purpose: line.querySelector('.order-purpose')?.value || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPipelineStages() {
|
||||||
|
const select = document.getElementById('pipeline_stage_id');
|
||||||
|
if (!select) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const stages = await response.json();
|
||||||
|
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||||||
|
} catch (err) { console.error('Failed to load pipeline stages', err); }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCaseTypesSelect() {
|
async function loadCaseTypesSelect() {
|
||||||
const select = document.getElementById('type');
|
const select = document.getElementById('type');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/settings/case_types');
|
const [typesRes, profileRes] = await Promise.all([
|
||||||
if (!res.ok) return;
|
fetch('/api/v1/settings/case_types', { credentials: 'include' }),
|
||||||
const setting = await res.json();
|
fetch('/api/v1/auth/me/profile', { credentials: 'include' })
|
||||||
const types = JSON.parse(setting.value || '[]');
|
]);
|
||||||
if (!Array.isArray(types) || types.length === 0) return;
|
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||||
|
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||||
select.innerHTML = types
|
const configured = JSON.parse(setting.value || '[]');
|
||||||
.map((type) => `<option value="${type}">${type}</option>`)
|
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
||||||
.join('');
|
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||||
|
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||||
|
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||||
|
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load case types', err);
|
console.error('Failed to load case types', err);
|
||||||
|
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
||||||
}
|
}
|
||||||
|
updateCaseTypeSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
loadCaseTypesSelect();
|
loadCaseTypesSelect();
|
||||||
|
loadPipelineStages();
|
||||||
|
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||||
applyTelefoniPrefill();
|
applyTelefoniPrefill();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -993,6 +1207,18 @@
|
|||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (data.type === 'pipeline') {
|
||||||
|
data.pipeline = {
|
||||||
|
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
||||||
|
amount: document.getElementById('pipeline_amount').value || null,
|
||||||
|
probability: document.getElementById('pipeline_probability').value || null,
|
||||||
|
description: document.getElementById('pipeline_description').value || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.type === 'ordre') {
|
||||||
|
data.order_items = collectOrderItems();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/sag', {
|
const response = await fetch('/api/v1/sag', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,9 @@ router = APIRouter()
|
|||||||
async def list_opportunities(
|
async def list_opportunities(
|
||||||
q: Optional[str] = None,
|
q: Optional[str] = None,
|
||||||
stage: Optional[str] = None,
|
stage: Optional[str] = None,
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None,
|
||||||
|
customer_id: Optional[int] = Query(default=None),
|
||||||
|
contact_id: Optional[int] = Query(default=None),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List all 'pipeline' cases.
|
List all 'pipeline' cases.
|
||||||
@ -71,6 +73,33 @@ async def list_opportunities(
|
|||||||
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
|
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
|
||||||
params.extend([f"%{q}%", f"%{q}%"])
|
params.extend([f"%{q}%", f"%{q}%"])
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
query += """
|
||||||
|
AND (
|
||||||
|
s.customer_id = %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_kunder sk
|
||||||
|
WHERE sk.sag_id = s.id
|
||||||
|
AND sk.customer_id = %s
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params.extend([customer_id, customer_id])
|
||||||
|
|
||||||
|
if contact_id is not None:
|
||||||
|
query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_kontakter sk
|
||||||
|
WHERE sk.sag_id = s.id
|
||||||
|
AND sk.contact_id = %s
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params.append(contact_id)
|
||||||
|
|
||||||
if status and status != 'all':
|
if status and status != 'all':
|
||||||
if status == 'open':
|
if status == 'open':
|
||||||
query += " AND s.status = 'åben'"
|
query += " AND s.status = 'åben'"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app")
|
|||||||
@router.get("/opportunities", response_class=HTMLResponse)
|
@router.get("/opportunities", response_class=HTMLResponse)
|
||||||
async def opportunities_page(request: Request):
|
async def opportunities_page(request: Request):
|
||||||
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/opportunities/{opportunity_id}", include_in_schema=False)
|
||||||
|
async def opportunity_detail_redirect(opportunity_id: int):
|
||||||
|
return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307)
|
||||||
|
|||||||
@ -45,7 +45,8 @@ class EmailActivityLogger:
|
|||||||
log_id = execute_insert(
|
log_id = execute_insert(
|
||||||
"""INSERT INTO email_activity_log
|
"""INSERT INTO email_activity_log
|
||||||
(email_id, event_type, event_category, description, metadata, user_id, created_by)
|
(email_id, event_type, event_category, description, metadata, user_id, created_by)
|
||||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)""",
|
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||||
|
RETURNING id""",
|
||||||
(email_id, event_type, category, description, metadata_json, user_id, created_by)
|
(email_id, event_type, category, description, metadata_json, user_id, created_by)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -148,10 +148,10 @@ Din opgave er at renskrive en rå tekst til klart, professionelt og venligt dans
|
|||||||
Teksten kan være enten en e-mail eller en sagsbeskrivelse.
|
Teksten kan være enten en e-mail eller en sagsbeskrivelse.
|
||||||
|
|
||||||
Regler:
|
Regler:
|
||||||
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer.
|
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer uændret.
|
||||||
2. Ret stavefejl, tegnsætning og grammatik.
|
2. Ret kun stavefejl, tegnsætning, grammatik og tydelig formulering.
|
||||||
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information.
|
3. Tilføj, gæt eller udled ALDRIG nye oplysninger. Ændr heller ikke rækkefølge, betydning, ansvar, omfang eller tekniske detaljer.
|
||||||
4. Fjern fyldord, gentagelser og intern støj.
|
4. Fjern kun en gentagelse, hvis den er helt identisk; behold ellers hele indholdet.
|
||||||
5. Bevar tone og intention: neutral, serviceminded og professionel.
|
5. Bevar tone og intention: neutral, serviceminded og professionel.
|
||||||
6. Opfind aldrig nye oplysninger.
|
6. Opfind aldrig nye oplysninger.
|
||||||
7. Hvis input er e-mail: returner i formatet:
|
7. Hvis input er e-mail: returner i formatet:
|
||||||
@ -260,6 +260,100 @@ Output:
|
|||||||
logger.error("❌ Ollama text rewrite failed: %s", e)
|
logger.error("❌ Ollama text rewrite failed: %s", e)
|
||||||
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
|
return {"error": f"Ollama rewrite failed: {str(e)}", "confidence": 0.0}
|
||||||
|
|
||||||
|
async def rewrite_case_creation(self, title: str, description: str) -> Dict:
|
||||||
|
"""Create a conservative, structured rewrite for the new-case form.
|
||||||
|
|
||||||
|
This intentionally does not use the configurable generic rewrite prompt:
|
||||||
|
case creation needs a reliable title and must never make up case facts.
|
||||||
|
"""
|
||||||
|
clean_description = (description or "").strip()
|
||||||
|
if not clean_description:
|
||||||
|
return {"error": "Input text is empty"}
|
||||||
|
|
||||||
|
system_prompt = """Du renskriver sagsbeskrivelser for et IT-system.
|
||||||
|
|
||||||
|
Du skal returnere KUN gyldig JSON på præcis denne form:
|
||||||
|
{"title":"...", "description":"..."}
|
||||||
|
|
||||||
|
ABSOLUTTE REGLER FOR description:
|
||||||
|
- Bevar alle fakta, navne, tal, datoer, versioner, IP-adresser, tekniske termer og usikkerheder præcist.
|
||||||
|
- Ret kun stavning, tegnsætning, grammatik og åbenlyst uklare formuleringer.
|
||||||
|
- Tilføj, gæt, forklar eller udled aldrig noget, der ikke står i input.
|
||||||
|
- Fjern ikke detaljer. Bevar også ønsker, spørgsmål, fejl og forbehold.
|
||||||
|
- Brug korte afsnit eller punktopstilling kun når input allerede tydeligt indeholder flere separate punkter.
|
||||||
|
|
||||||
|
REGLER FOR title:
|
||||||
|
- Skriv en kort, konkret titel på 4-10 ord, der beskriver det faktiske arbejde eller problem i teksten.
|
||||||
|
- Brug de mest specifikke ord fra teksten, f.eks. produkt, funktion, fejl eller handling.
|
||||||
|
- Brug ikke tomme titler som "Support", "Henvendelse", "Problem" eller "Ny sag" alene.
|
||||||
|
- Opfind ikke kunde, produkt, årsag eller løsning. Hvis teksten ikke giver nok grundlag, behold den eksisterende titel (kun med stavning rettet).
|
||||||
|
"""
|
||||||
|
user_message = (
|
||||||
|
"Eksisterende titel (kan være tom):\n"
|
||||||
|
f"{(title or '').strip()}\n\n"
|
||||||
|
"Sagsbeskrivelse, som er eneste kilde til fakta:\n"
|
||||||
|
f"{clean_description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
model_normalized = (self.model or "").strip().lower()
|
||||||
|
use_chat_api = model_normalized.startswith("qwen")
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
if use_chat_api:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.endpoint}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": f"{system_prompt}\n\nBrugerinput:\n{user_message}",
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"options": {"temperature": 0.0, "top_p": 0.9, "num_predict": 1200},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return {"error": f"Ollama returned status {response.status_code}: {response.text[:300]}"}
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
if use_chat_api:
|
||||||
|
raw = str(((payload.get("message") or {}).get("content") or "")).strip()
|
||||||
|
else:
|
||||||
|
raw = str((payload or {}).get("response") or "").strip()
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip()
|
||||||
|
structured = json.loads(raw)
|
||||||
|
result_title = str(structured.get("title") or "").strip()
|
||||||
|
result_description = str(structured.get("description") or "").strip()
|
||||||
|
if not result_title or not result_description:
|
||||||
|
return {"error": "Ollama returned an incomplete case rewrite"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": result_title[:200],
|
||||||
|
"description": result_description,
|
||||||
|
"model": self.model,
|
||||||
|
}
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("⚠️ Ollama returned invalid case-create rewrite: %s", e)
|
||||||
|
return {"error": "Ollama returned an invalid case rewrite"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Ollama case-create rewrite failed: %s", e)
|
||||||
|
return {"error": f"Ollama rewrite failed: {str(e)}"}
|
||||||
|
|
||||||
async def extract_from_text(self, text: str) -> Dict:
|
async def extract_from_text(self, text: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Extract structured invoice data from text using Ollama
|
Extract structured invoice data from text using Ollama
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import json
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from app.services.economic_service import get_economic_service
|
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -23,9 +22,6 @@ logger = logging.getLogger(__name__)
|
|||||||
class SubscriptionMatrixService:
|
class SubscriptionMatrixService:
|
||||||
"""Generate billing matrix for customer subscriptions"""
|
"""Generate billing matrix for customer subscriptions"""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.economic_service = get_economic_service()
|
|
||||||
|
|
||||||
async def generate_billing_matrix(
|
async def generate_billing_matrix(
|
||||||
self,
|
self,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@ -87,13 +83,9 @@ class SubscriptionMatrixService:
|
|||||||
economic_customer_number = customer[0]['economic_customer_number']
|
economic_customer_number = customer[0]['economic_customer_number']
|
||||||
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
|
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
|
||||||
|
|
||||||
# Fetch invoices from e-conomic
|
# Fetch imported invoice snapshot from local invoice_error_finder tables
|
||||||
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
|
invoices = self._load_imported_invoices(str(economic_customer_number))
|
||||||
invoices = await self.economic_service.get_customer_invoices(
|
logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
|
||||||
economic_customer_number,
|
|
||||||
include_lines=True
|
|
||||||
)
|
|
||||||
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
|
|
||||||
|
|
||||||
if not invoices:
|
if not invoices:
|
||||||
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
|
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
|
||||||
@ -131,6 +123,125 @@ class SubscriptionMatrixService:
|
|||||||
"products": []
|
"products": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _load_imported_invoices(self, economic_customer_number: str) -> List[Dict]:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
WITH ranked_invoices AS (
|
||||||
|
SELECT
|
||||||
|
inv.id,
|
||||||
|
inv.source_invoice_number,
|
||||||
|
inv.source_type,
|
||||||
|
inv.invoice_date,
|
||||||
|
inv.due_date,
|
||||||
|
inv.net_amount,
|
||||||
|
inv.vat_amount,
|
||||||
|
inv.total_amount,
|
||||||
|
inv.currency,
|
||||||
|
inv.source_raw,
|
||||||
|
CASE inv.source_type
|
||||||
|
WHEN 'paid' THEN 1
|
||||||
|
WHEN 'booked' THEN 2
|
||||||
|
WHEN 'unpaid' THEN 3
|
||||||
|
WHEN 'draft' THEN 4
|
||||||
|
ELSE 9
|
||||||
|
END AS source_rank
|
||||||
|
FROM invoice_error_finder_economic_invoices inv
|
||||||
|
WHERE inv.customer_number = %s
|
||||||
|
),
|
||||||
|
selected_invoices AS (
|
||||||
|
SELECT DISTINCT ON (source_invoice_number)
|
||||||
|
id,
|
||||||
|
source_invoice_number,
|
||||||
|
source_type,
|
||||||
|
invoice_date,
|
||||||
|
due_date,
|
||||||
|
net_amount,
|
||||||
|
vat_amount,
|
||||||
|
total_amount,
|
||||||
|
currency,
|
||||||
|
source_raw
|
||||||
|
FROM ranked_invoices
|
||||||
|
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
si.id AS invoice_id,
|
||||||
|
si.source_invoice_number,
|
||||||
|
si.source_type,
|
||||||
|
si.invoice_date,
|
||||||
|
si.due_date,
|
||||||
|
si.net_amount,
|
||||||
|
si.vat_amount,
|
||||||
|
si.total_amount,
|
||||||
|
si.currency,
|
||||||
|
si.source_raw AS invoice_source_raw,
|
||||||
|
line.line_number,
|
||||||
|
line.product_number,
|
||||||
|
line.product_name,
|
||||||
|
line.description,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.line_net_amount,
|
||||||
|
line.source_raw AS line_source_raw
|
||||||
|
FROM selected_invoices si
|
||||||
|
LEFT JOIN invoice_error_finder_economic_invoice_lines line
|
||||||
|
ON line.invoice_id = si.id
|
||||||
|
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
|
||||||
|
""",
|
||||||
|
(economic_customer_number,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
invoices: List[Dict] = []
|
||||||
|
invoices_by_id: Dict[int, Dict] = {}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
invoice_id = row.get("invoice_id")
|
||||||
|
if invoice_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
invoice_source_raw = self._ensure_dict(row.get("invoice_source_raw"))
|
||||||
|
line_source_raw = self._ensure_dict(row.get("line_source_raw"))
|
||||||
|
|
||||||
|
if invoice_id not in invoices_by_id:
|
||||||
|
invoice_payload = {
|
||||||
|
"id": invoice_id,
|
||||||
|
"status": row.get("source_type"),
|
||||||
|
"bookedInvoiceNumber": row.get("source_invoice_number"),
|
||||||
|
"date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
|
||||||
|
"dueDate": row.get("due_date").isoformat() if row.get("due_date") else None,
|
||||||
|
"netAmount": float(row.get("net_amount") or 0),
|
||||||
|
"vatAmount": float(row.get("vat_amount") or 0),
|
||||||
|
"grossAmount": float(row.get("total_amount") or 0),
|
||||||
|
"currency": row.get("currency") or "DKK",
|
||||||
|
"notes": invoice_source_raw.get("notes"),
|
||||||
|
"heading": invoice_source_raw.get("heading"),
|
||||||
|
"description": invoice_source_raw.get("description"),
|
||||||
|
"text": invoice_source_raw.get("text"),
|
||||||
|
"subject": invoice_source_raw.get("subject"),
|
||||||
|
"otherReference": invoice_source_raw.get("otherReference"),
|
||||||
|
"orderNumberDb": invoice_source_raw.get("orderNumberDb"),
|
||||||
|
"lines": [],
|
||||||
|
}
|
||||||
|
invoices_by_id[invoice_id] = invoice_payload
|
||||||
|
invoices.append(invoice_payload)
|
||||||
|
|
||||||
|
if row.get("line_number") is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
invoices_by_id[invoice_id]["lines"].append({
|
||||||
|
"lineNumber": row.get("line_number"),
|
||||||
|
"description": row.get("description"),
|
||||||
|
"quantity": float(row.get("quantity") or 0),
|
||||||
|
"unitNetPrice": float(row.get("unit_price") or 0),
|
||||||
|
"totalNetAmount": float(row.get("line_net_amount") or 0),
|
||||||
|
"period": (line_source_raw.get("period") if isinstance(line_source_raw, dict) else None) or {},
|
||||||
|
"product": {
|
||||||
|
"productNumber": row.get("product_number"),
|
||||||
|
"name": row.get("product_name"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return invoices
|
||||||
|
|
||||||
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
|
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Group invoice lines by product number and aggregate by month
|
Group invoice lines by product number and aggregate by month
|
||||||
@ -378,6 +489,18 @@ class SubscriptionMatrixService:
|
|||||||
|
|
||||||
return products
|
return products
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_dict(value) -> Dict:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_month_range(num_months: int) -> List[str]:
|
def _generate_month_range(num_months: int) -> List[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -168,7 +168,7 @@ async def get_setting(key: str):
|
|||||||
seed_query,
|
seed_query,
|
||||||
(
|
(
|
||||||
"case_types",
|
"case_types",
|
||||||
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
|
||||||
"system",
|
"system",
|
||||||
"Sags-typer",
|
"Sags-typer",
|
||||||
"json",
|
"json",
|
||||||
@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
|
|||||||
return execute_migration(model)
|
return execute_migration(model)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
|
||||||
|
async def execute_missing_migrations_api():
|
||||||
|
"""Run schema-detected missing migrations via the API namespace."""
|
||||||
|
from app.settings.backend.views import execute_missing_migrations
|
||||||
|
|
||||||
|
return execute_missing_migrations()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/settings/sync-from-env", tags=["Settings"])
|
@router.post("/settings/sync-from-env", tags=["Settings"])
|
||||||
async def sync_settings_from_env():
|
async def sync_settings_from_env():
|
||||||
"""Sync settings from .env file into database (only updates empty values)"""
|
"""Sync settings from .env file into database (only updates empty values)"""
|
||||||
@ -992,4 +1000,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
|||||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||||
err = str(e) or e.__class__.__name__
|
err = str(e) or e.__class__.__name__
|
||||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Settings Frontend Views
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||||
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
return {"message": "Migration executed successfully"}
|
return {"message": "Migration executed successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
|
||||||
|
def execute_missing_migrations():
|
||||||
|
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
|
||||||
|
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
|
||||||
|
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
|
||||||
|
conn = get_db_connection()
|
||||||
|
logs = []
|
||||||
|
try:
|
||||||
|
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
|
||||||
|
candidates = []
|
||||||
|
for migration_file in files:
|
||||||
|
sql = migration_file.read_text(encoding="utf-8")
|
||||||
|
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
|
||||||
|
if status["status"] == "red":
|
||||||
|
candidates.append((migration_file, sql, status))
|
||||||
|
|
||||||
|
for migration_file, sql, status in candidates:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
logs.append({
|
||||||
|
"file_name": migration_file.name,
|
||||||
|
"status": "success",
|
||||||
|
"summary": status["summary"],
|
||||||
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
logs.append({
|
||||||
|
"file_name": migration_file.name,
|
||||||
|
"status": "failed",
|
||||||
|
"summary": str(exc).splitlines()[0],
|
||||||
|
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Manglende migrationer behandlet",
|
||||||
|
"checked": len(files),
|
||||||
|
"candidates": len(candidates),
|
||||||
|
"executed": sum(item["status"] == "success" for item in logs),
|
||||||
|
"failed": sum(item["status"] == "failed" for item in logs),
|
||||||
|
"logs": logs,
|
||||||
|
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|||||||
@ -69,6 +69,9 @@
|
|||||||
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
||||||
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
||||||
</button>
|
</button>
|
||||||
|
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
|
||||||
|
<i class="bi bi-play-fill me-1"></i>Kør manglende
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -356,5 +359,35 @@
|
|||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMissingMigrations() {
|
||||||
|
const button = document.getElementById('runMissingMigrationsBtn');
|
||||||
|
const feedback = document.getElementById('migrationFeedback');
|
||||||
|
button.disabled = true;
|
||||||
|
feedback.className = 'alert alert-info mt-3';
|
||||||
|
feedback.textContent = 'Tjekker og kører manglende migrationer...';
|
||||||
|
feedback.classList.remove('d-none');
|
||||||
|
try {
|
||||||
|
const urls = buildMigrationActionUrls('execute-missing');
|
||||||
|
let data = null;
|
||||||
|
let lastError = null;
|
||||||
|
for (const url of urls) {
|
||||||
|
const response = await fetch(url, {method: 'POST', credentials: 'include'});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (response.ok) { data = payload; break; }
|
||||||
|
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
|
||||||
|
lastError = payload.detail || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
|
||||||
|
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
|
||||||
|
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
|
||||||
|
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
|
||||||
|
} catch (error) {
|
||||||
|
feedback.className = 'alert alert-danger mt-3';
|
||||||
|
feedback.textContent = `Fejl: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -184,6 +184,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mt-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Faktura-fejl-finder</h5>
|
||||||
|
<p class="text-muted mb-0">Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="invoiceErrorFinderSettingsCard"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card p-4 mt-4">
|
<div class="card p-4 mt-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
@ -2374,7 +2384,8 @@ async function testTelefoniCall() {
|
|||||||
|
|
||||||
function renderDriftConnectors() {
|
function renderDriftConnectors() {
|
||||||
const container = document.getElementById('driftConnectorCards');
|
const container = document.getElementById('driftConnectorCards');
|
||||||
if (!container) return;
|
const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
|
||||||
|
if (!container && !invoiceErrorFinderContainer) return;
|
||||||
|
|
||||||
const connectors = [
|
const connectors = [
|
||||||
{
|
{
|
||||||
@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
|
|||||||
<span id="uispSaveStatus" class="small text-muted"></span>
|
<span id="uispSaveStatus" class="small text-muted"></span>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'invoice-error-finder',
|
||||||
|
title: 'Faktura-fejl-finder',
|
||||||
|
description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.',
|
||||||
|
badge: 'Okonomi',
|
||||||
|
body: `
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold">Ignorer varetekster</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
|
||||||
|
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 mt-4">
|
||||||
|
<button class="btn btn-primary" onclick="saveInvoiceErrorFinderSettings()">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem faktura-fejl-finder
|
||||||
|
</button>
|
||||||
|
<span id="invoiceErrorFinderSaveStatus" class="small text-muted"></span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
container.innerHTML = connectors.map(connector => `
|
const renderConnectorCard = (connector) => `
|
||||||
<div class="card border-0 bg-light p-4">
|
<div class="card border-0 bg-light p-4">
|
||||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
|
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
|
|||||||
</div>
|
</div>
|
||||||
${connector.body}
|
${connector.body}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = connectors
|
||||||
|
.filter(connector => connector.key !== 'invoice-error-finder')
|
||||||
|
.map(renderConnectorCard)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceErrorFinderContainer) {
|
||||||
|
const invoiceErrorFinderConnector = connectors.find(connector => connector.key === 'invoice-error-finder');
|
||||||
|
invoiceErrorFinderContainer.innerHTML = invoiceErrorFinderConnector
|
||||||
|
? renderConnectorCard(invoiceErrorFinderConnector)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@ -2477,6 +2527,7 @@ async function loadSettings() {
|
|||||||
renderDriftConnectors();
|
renderDriftConnectors();
|
||||||
await loadUptimeKumaSettings();
|
await loadUptimeKumaSettings();
|
||||||
await loadUISPSettings();
|
await loadUISPSettings();
|
||||||
|
await loadInvoiceErrorFinderSettings();
|
||||||
await loadLabelPrinterSettings();
|
await loadLabelPrinterSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
@ -2805,6 +2856,30 @@ async function loadUISPSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let driftBlacklistItems = [];
|
let driftBlacklistItems = [];
|
||||||
|
const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [
|
||||||
|
'faktureringsgebyr',
|
||||||
|
'gebyr',
|
||||||
|
'porto',
|
||||||
|
'fragt',
|
||||||
|
'fragtomkostning',
|
||||||
|
'forsendelse',
|
||||||
|
'shipping',
|
||||||
|
'levering',
|
||||||
|
'engangsydelse',
|
||||||
|
'engangsarbejde',
|
||||||
|
'oprettelse',
|
||||||
|
'opstartsgebyr',
|
||||||
|
'installation',
|
||||||
|
'installationsgebyr',
|
||||||
|
'timeforbrug',
|
||||||
|
'arbejdstid',
|
||||||
|
'konsulenttimer',
|
||||||
|
'supporttid',
|
||||||
|
'teknikertid',
|
||||||
|
'montørtimer',
|
||||||
|
'projektarbejde'
|
||||||
|
];
|
||||||
|
let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||||
|
|
||||||
function parseDriftBlacklistValue(rawValue) {
|
function parseDriftBlacklistValue(rawValue) {
|
||||||
const raw = String(rawValue || '').trim();
|
const raw = String(rawValue || '').trim();
|
||||||
@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) {
|
|||||||
renderDriftBlacklistList();
|
renderDriftBlacklistList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseInvoiceErrorFinderIgnoreValue(rawValue) {
|
||||||
|
const raw = String(rawValue || '').trim();
|
||||||
|
if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||||
|
|
||||||
|
let values = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
values = parsed;
|
||||||
|
} else if (typeof parsed === 'string') {
|
||||||
|
values = [parsed];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const cleaned = [];
|
||||||
|
[...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => {
|
||||||
|
const normalized = String(item || '').trim().toLowerCase();
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
cleaned.push(normalized);
|
||||||
|
});
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceErrorFinderIgnoreList() {
|
||||||
|
const list = document.getElementById('invoiceErrorFinderIgnoreList');
|
||||||
|
if (!list) return;
|
||||||
|
if (!invoiceErrorFinderIgnoreItems.length) {
|
||||||
|
list.innerHTML = '<span class="text-muted small">Ingen varetekster ignoreres endnu.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => {
|
||||||
|
const safe = String(item).replace(/</g, '<').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() {
|
async function saveUISPSettings() {
|
||||||
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
|
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
|
||||||
const apiToken = (document.getElementById('uispApiToken').value || '').trim();
|
const apiToken = (document.getElementById('uispApiToken').value || '').trim();
|
||||||
|
|||||||
@ -1704,13 +1704,13 @@ if (bmcOriginalFetch) {
|
|||||||
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||||
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openQuickCreateModal();
|
openNewCasePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
|
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openQuickCreateModal();
|
openNewCasePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC to close
|
// ESC to close
|
||||||
@ -1719,22 +1719,14 @@ if (bmcOriginalFetch) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// QuickCreate modal opener function
|
function openNewCasePage() {
|
||||||
function openQuickCreateModal() {
|
window.location.href = '/sag/new';
|
||||||
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
|
|
||||||
quickCreateModal.show();
|
|
||||||
setTimeout(() => {
|
|
||||||
const textInput = document.getElementById('quickCreateText');
|
|
||||||
if (textInput) {
|
|
||||||
textInput.focus();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuickCreate button click handler
|
// QuickCreate button click handler
|
||||||
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
|
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openQuickCreateModal();
|
openNewCasePage();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset search when modal is closed
|
// Reset search when modal is closed
|
||||||
@ -2257,9 +2249,6 @@ if (bmcOriginalFetch) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
|
||||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
|
||||||
|
|
||||||
<!-- Manual Help Modal -->
|
<!-- Manual Help Modal -->
|
||||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||||
|
|
||||||
@ -2305,6 +2294,11 @@ if (bmcOriginalFetch) {
|
|||||||
<label class="form-label fw-semibold">Mobilnummer</label>
|
<label class="form-label fw-semibold">Mobilnummer</label>
|
||||||
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
|
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold">Standard sagstype</label>
|
||||||
|
<select class="form-select" id="prof_default_case_type"></select>
|
||||||
|
<div class="form-text">Bruges som udgangspunkt på “Ny sag”.</div>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
|
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
|
||||||
@ -2673,10 +2667,27 @@ if (bmcOriginalFetch) {
|
|||||||
document.getElementById('prof_full_name').value = p.full_name || '';
|
document.getElementById('prof_full_name').value = p.full_name || '';
|
||||||
document.getElementById('prof_title').value = p.title || '';
|
document.getElementById('prof_title').value = p.title || '';
|
||||||
document.getElementById('prof_phone').value = p.phone || '';
|
document.getElementById('prof_phone').value = p.phone || '';
|
||||||
|
await loadProfileCaseTypes(p.default_case_type || 'ticket');
|
||||||
} catch (e) { console.error('Failed to load profile', e); }
|
} catch (e) { console.error('Failed to load profile', e); }
|
||||||
loadAnyDeskChips();
|
loadAnyDeskChips();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProfileCaseTypes(selectedType = 'ticket') {
|
||||||
|
const select = document.getElementById('prof_default_case_type');
|
||||||
|
if (!select) return;
|
||||||
|
const labels = { ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service' };
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/case_types', { credentials: 'include' });
|
||||||
|
const setting = res.ok ? await res.json() : { value: '[]' };
|
||||||
|
const configured = JSON.parse(setting.value || '[]');
|
||||||
|
const types = Array.isArray(configured) ? configured.map(value => String(value).toLowerCase()) : [];
|
||||||
|
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||||
|
const finalTypes = types.length ? [...new Set(types)] : Object.keys(labels);
|
||||||
|
select.innerHTML = finalTypes.map(type => `<option value="${type}">${labels[type] || type}</option>`).join('');
|
||||||
|
select.value = finalTypes.includes(selectedType) ? selectedType : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||||
|
} catch (e) { console.error('Failed to load profile case types', e); }
|
||||||
|
}
|
||||||
|
|
||||||
function buildInitials(name) {
|
function buildInitials(name) {
|
||||||
const clean = String(name || '').trim();
|
const clean = String(name || '').trim();
|
||||||
if (!clean) return 'BR';
|
if (!clean) return 'BR';
|
||||||
@ -2763,6 +2774,7 @@ if (bmcOriginalFetch) {
|
|||||||
full_name: document.getElementById('prof_full_name').value || null,
|
full_name: document.getElementById('prof_full_name').value || null,
|
||||||
title: document.getElementById('prof_title').value || null,
|
title: document.getElementById('prof_title').value || null,
|
||||||
phone: document.getElementById('prof_phone').value || null,
|
phone: document.getElementById('prof_phone').value || null,
|
||||||
|
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/me/profile', {
|
const res = await fetch('/api/v1/auth/me/profile', {
|
||||||
|
|||||||
@ -7,9 +7,9 @@ SYNC ARCHITECTURE - Field Ownership:
|
|||||||
|
|
||||||
E-CONOMIC owns and syncs:
|
E-CONOMIC owns and syncs:
|
||||||
- economic_customer_number (primary key from e-conomic)
|
- economic_customer_number (primary key from e-conomic)
|
||||||
- address, city, postal_code, country (physical address)
|
- name, phone, address, city, postal_code, country (company and physical address)
|
||||||
- email_domain, website (contact information)
|
- email_domain, website (contact information)
|
||||||
- cvr_number (used for matching only, not overwritten if already set)
|
- cvr_number (company metadata; may be shared by several customers)
|
||||||
|
|
||||||
vTIGER owns and syncs:
|
vTIGER owns and syncs:
|
||||||
- vtiger_id (primary key from vTiger)
|
- vtiger_id (primary key from vTiger)
|
||||||
@ -18,7 +18,7 @@ vTIGER owns and syncs:
|
|||||||
|
|
||||||
HUB owns (manual or first-sync only):
|
HUB owns (manual or first-sync only):
|
||||||
- name (can be synced initially but not overwritten)
|
- name (can be synced initially but not overwritten)
|
||||||
- cvr_number (used for matching, set once)
|
- cvr_number (informational; can be refreshed from e-conomic)
|
||||||
- Tags, notes, custom fields
|
- Tags, notes, custom fields
|
||||||
|
|
||||||
SYNC RULES:
|
SYNC RULES:
|
||||||
@ -163,6 +163,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
country = eco_customer.get('country', 'DK')
|
country = eco_customer.get('country', 'DK')
|
||||||
email = eco_customer.get('email', '')
|
email = eco_customer.get('email', '')
|
||||||
website = eco_customer.get('website', '')
|
website = eco_customer.get('website', '')
|
||||||
|
phone = eco_customer.get('phone') or eco_customer.get('telephone') or ''
|
||||||
|
|
||||||
if not customer_number or not name:
|
if not customer_number or not name:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
@ -192,7 +193,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
# Strict matching: ONLY match by economic_customer_number
|
# Strict matching: ONLY match by economic_customer_number
|
||||||
existing = execute_query(
|
existing = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, email_domain, address, city, postal_code, country, website
|
SELECT id, name, phone, cvr_number, email_domain, address, city, postal_code, country, website
|
||||||
FROM customers
|
FROM customers
|
||||||
WHERE economic_customer_number = %s
|
WHERE economic_customer_number = %s
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@ -221,6 +222,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
if existing:
|
if existing:
|
||||||
target_customer_id = existing[0]['id']
|
target_customer_id = existing[0]['id']
|
||||||
current_values = {
|
current_values = {
|
||||||
|
"name": existing[0].get("name"),
|
||||||
|
"phone": existing[0].get("phone"),
|
||||||
|
"cvr_number": existing[0].get("cvr_number"),
|
||||||
"email_domain": existing[0].get("email_domain"),
|
"email_domain": existing[0].get("email_domain"),
|
||||||
"address": existing[0].get("address"),
|
"address": existing[0].get("address"),
|
||||||
"city": existing[0].get("city"),
|
"city": existing[0].get("city"),
|
||||||
@ -229,6 +233,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
"website": existing[0].get("website"),
|
"website": existing[0].get("website"),
|
||||||
}
|
}
|
||||||
proposed_values = {
|
proposed_values = {
|
||||||
|
"name": name,
|
||||||
|
"phone": phone,
|
||||||
|
"cvr_number": cvr,
|
||||||
"email_domain": email_domain,
|
"email_domain": email_domain,
|
||||||
"address": address,
|
"address": address,
|
||||||
"city": city,
|
"city": city,
|
||||||
@ -252,6 +259,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
update_query = """
|
update_query = """
|
||||||
UPDATE customers SET
|
UPDATE customers SET
|
||||||
economic_customer_number = %s,
|
economic_customer_number = %s,
|
||||||
|
name = %s,
|
||||||
|
phone = %s,
|
||||||
|
cvr_number = %s,
|
||||||
email_domain = %s,
|
email_domain = %s,
|
||||||
address = %s,
|
address = %s,
|
||||||
city = %s,
|
city = %s,
|
||||||
@ -262,7 +272,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
execute_query(update_query, (
|
execute_query(update_query, (
|
||||||
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
|
customer_number, name, phone, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||||
))
|
))
|
||||||
logger.info(
|
logger.info(
|
||||||
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
||||||
@ -276,6 +286,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
else:
|
else:
|
||||||
would_create.append({
|
would_create.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"phone": phone,
|
||||||
"economic_customer_number": customer_number,
|
"economic_customer_number": customer_number,
|
||||||
"cvr_number": cvr,
|
"cvr_number": cvr,
|
||||||
"email_domain": email_domain,
|
"email_domain": email_domain,
|
||||||
@ -289,13 +300,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
|||||||
if apply_changes:
|
if apply_changes:
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO customers
|
INSERT INTO customers
|
||||||
(name, economic_customer_number, cvr_number, email_domain,
|
(name, phone, economic_customer_number, cvr_number, email_domain,
|
||||||
address, city, postal_code, country, website, last_synced_at)
|
address, city, postal_code, country, website, last_synced_at)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
result = execute_query(insert_query, (
|
result = execute_query(insert_query, (
|
||||||
name, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
name, phone, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
||||||
))
|
))
|
||||||
if result:
|
if result:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
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.';
|
||||||
@ -18,6 +18,16 @@ ALTER TABLE email_messages
|
|||||||
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
|
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
|
||||||
|
|
||||||
-- Cleanup duplicates before adding unique constraint/PK
|
-- Cleanup duplicates before adding unique constraint/PK
|
||||||
|
-- Old installations can contain links to emails or cases that have since
|
||||||
|
-- been deleted. Remove those orphan links before enforcing foreign keys.
|
||||||
|
DELETE FROM sag_emails se
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM email_messages em WHERE em.id = se.email_id
|
||||||
|
)
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM sag_sager s WHERE s.id = se.sag_id
|
||||||
|
);
|
||||||
|
|
||||||
WITH ranked AS (
|
WITH ranked AS (
|
||||||
SELECT ctid,
|
SELECT ctid,
|
||||||
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn
|
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn
|
||||||
|
|||||||
@ -108,7 +108,8 @@ CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
|||||||
'error_found',
|
'error_found',
|
||||||
'ready_to_invoice',
|
'ready_to_invoice',
|
||||||
'invoiced',
|
'invoiced',
|
||||||
'ignored'
|
'ignored',
|
||||||
|
'resolved'
|
||||||
)),
|
)),
|
||||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
customer_name VARCHAR(255),
|
customer_name VARCHAR(255),
|
||||||
|
|||||||
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;
|
||||||
39
migrations/220_locations_cross_field_panel_layout.sql
Normal file
39
migrations/220_locations_cross_field_panel_layout.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-- Support physical patch-panel layouts such as 1A, 1B … 24A, 24B.
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD COLUMN IF NOT EXISTS port_label_format VARCHAR(20) NOT NULL DEFAULT 'numeric',
|
||||||
|
ADD COLUMN IF NOT EXISTS panel_row_size INTEGER NOT NULL DEFAULT 24;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
DROP CONSTRAINT IF EXISTS locations_cross_fields_port_label_format_check;
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD CONSTRAINT locations_cross_fields_port_label_format_check
|
||||||
|
CHECK (port_label_format IN ('numeric', 'paired'));
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
DROP CONSTRAINT IF EXISTS locations_cross_fields_panel_row_size_check;
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD CONSTRAINT locations_cross_fields_panel_row_size_check
|
||||||
|
CHECK (panel_row_size BETWEEN 1 AND 48);
|
||||||
|
|
||||||
|
-- Port labels are physical labels, not necessarily numbers (for example 1A/1B).
|
||||||
|
ALTER TABLE locations_cross_field_ports
|
||||||
|
DROP CONSTRAINT IF EXISTS locations_cross_field_ports_port_number_check;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_field_ports
|
||||||
|
ALTER COLUMN port_number TYPE VARCHAR(20) USING port_number::VARCHAR;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_field_ports
|
||||||
|
ADD COLUMN IF NOT EXISTS port_order INTEGER;
|
||||||
|
|
||||||
|
UPDATE locations_cross_field_ports
|
||||||
|
SET port_order = CASE
|
||||||
|
WHEN port_number ~ '^[0-9]+$' THEN port_number::INTEGER
|
||||||
|
ELSE id
|
||||||
|
END
|
||||||
|
WHERE port_order IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_field_ports
|
||||||
|
ALTER COLUMN port_order SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_field_ports_field_order
|
||||||
|
ON locations_cross_field_ports(cross_field_id, port_order);
|
||||||
9
migrations/221_locations_cross_field_start_number.sql
Normal file
9
migrations/221_locations_cross_field_start_number.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-- A physical patch panel may continue the labelling from the preceding panel.
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD COLUMN IF NOT EXISTS start_port_number INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
DROP CONSTRAINT IF EXISTS locations_cross_fields_start_port_number_check;
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD CONSTRAINT locations_cross_fields_start_port_number_check
|
||||||
|
CHECK (start_port_number BETWEEN 1 AND 9999);
|
||||||
20
migrations/222_locations_cross_field_display_order.sql
Normal file
20
migrations/222_locations_cross_field_display_order.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Physical panels may be displayed in a different order than their names.
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ADD COLUMN IF NOT EXISTS display_order INTEGER;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY location_id ORDER BY name, id) AS row_number
|
||||||
|
FROM locations_cross_fields
|
||||||
|
WHERE display_order IS NULL
|
||||||
|
)
|
||||||
|
UPDATE locations_cross_fields cf
|
||||||
|
SET display_order = ordered.row_number
|
||||||
|
FROM ordered
|
||||||
|
WHERE cf.id = ordered.id;
|
||||||
|
|
||||||
|
ALTER TABLE locations_cross_fields
|
||||||
|
ALTER COLUMN display_order SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cross_fields_location_display_order
|
||||||
|
ON locations_cross_fields(location_id, display_order)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
8
migrations/223_wall_outlet_switch_hardware_link.sql
Normal file
8
migrations/223_wall_outlet_switch_hardware_link.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Link a wall outlet to the actual switch hardware, rather than only its display name.
|
||||||
|
ALTER TABLE locations_wall_outlets
|
||||||
|
ADD COLUMN IF NOT EXISTS switch_hardware_id INTEGER
|
||||||
|
REFERENCES hardware_assets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wall_outlets_switch_hardware_port
|
||||||
|
ON locations_wall_outlets(switch_hardware_id, switch_port)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
11
migrations/224_wall_outlet_optional_label_and_customer.sql
Normal file
11
migrations/224_wall_outlet_optional_label_and_customer.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- A patch/switch port can be registered before the wall-outlet label is known.
|
||||||
|
ALTER TABLE locations_wall_outlets
|
||||||
|
ALTER COLUMN outlet_number DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE locations_wall_outlets
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_id INTEGER
|
||||||
|
REFERENCES customers(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wall_outlets_customer_id
|
||||||
|
ON locations_wall_outlets(customer_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
20
migrations/225_hardware_network_links.sql
Normal file
20
migrations/225_hardware_network_links.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS hardware_network_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
source_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
|
||||||
|
source_port VARCHAR(100) NOT NULL,
|
||||||
|
target_hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
|
||||||
|
target_port VARCHAR(100),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
CHECK (source_hardware_id <> target_hardware_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hardware_network_links_source_port_active
|
||||||
|
ON hardware_network_links(source_hardware_id, source_port)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hardware_network_links_target_active
|
||||||
|
ON hardware_network_links(target_hardware_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
16
migrations/226_hardware_location_display_order.sql
Normal file
16
migrations/226_hardware_location_display_order.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
ALTER TABLE hardware_assets
|
||||||
|
ADD COLUMN IF NOT EXISTS location_display_order INTEGER;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY current_location_id ORDER BY brand, model, serial_number, id) AS row_number
|
||||||
|
FROM hardware_assets
|
||||||
|
WHERE current_location_id IS NOT NULL AND location_display_order IS NULL
|
||||||
|
)
|
||||||
|
UPDATE hardware_assets h
|
||||||
|
SET location_display_order = ordered.row_number
|
||||||
|
FROM ordered
|
||||||
|
WHERE h.id = ordered.id;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hardware_assets_location_display_order
|
||||||
|
ON hardware_assets(current_location_id, location_display_order)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
40
migrations/227_hardware_uisp_devices.sql
Normal file
40
migrations/227_hardware_uisp_devices.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- Cached UISP inventory and the one-to-one link to a hardware asset.
|
||||||
|
CREATE TABLE IF NOT EXISTS uisp_devices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
external_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255),
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
hostname VARCHAR(255),
|
||||||
|
mac_address VARCHAR(64),
|
||||||
|
serial_number VARCHAR(255),
|
||||||
|
vendor VARCHAR(255),
|
||||||
|
model VARCHAR(255),
|
||||||
|
platform VARCHAR(255),
|
||||||
|
device_type VARCHAR(100),
|
||||||
|
device_role VARCHAR(100),
|
||||||
|
ip_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
status VARCHAR(100),
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
device_link TEXT,
|
||||||
|
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uisp_devices_name ON uisp_devices(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uisp_devices_serial_number ON uisp_devices(serial_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uisp_devices_mac_address ON uisp_devices(mac_address);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hardware_uisp_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
|
||||||
|
uisp_device_id INTEGER NOT NULL REFERENCES uisp_devices(id) ON DELETE CASCADE,
|
||||||
|
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
linked_by_user_id INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (hardware_id),
|
||||||
|
UNIQUE (uisp_device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hardware_uisp_links_hardware ON hardware_uisp_links(hardware_id);
|
||||||
@ -182,6 +182,52 @@ def test_migration_wizard_v2_query_keeps_precise_segment_hits(monkeypatch):
|
|||||||
assert payload["documents"][0]["snippet_count"] == 1
|
assert payload["documents"][0]["snippet_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_wizard_v2_returns_full_text_for_selected_segment(monkeypatch):
|
||||||
|
from app.modules.internet_connections.backend import router as internet_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(internet_router, "execute_query_single", lambda query, params: {
|
||||||
|
"segment_id": 202,
|
||||||
|
"document_id": 102,
|
||||||
|
"block_index": 3,
|
||||||
|
"title": "StageOne uplink",
|
||||||
|
"original_filename": "karise.txt",
|
||||||
|
"content": "Hele den valgte tekstblok\nmed alle linjer.",
|
||||||
|
})
|
||||||
|
|
||||||
|
payload = asyncio.run(internet_router.get_customer_document_segment(202))
|
||||||
|
|
||||||
|
assert payload["title"] == "StageOne uplink"
|
||||||
|
assert payload["content"] == "Hele den valgte tekstblok\nmed alle linjer."
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_wizard_block_search_requires_all_words(monkeypatch):
|
||||||
|
from app.modules.internet_connections.backend import router as internet_router
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "FROM internet_connections_customer_documents" in query:
|
||||||
|
return [{
|
||||||
|
"id": 103, "customer_id": 77, "connection_id": None,
|
||||||
|
"original_filename": "sales.txt", "filename": "sales.txt",
|
||||||
|
"file_size": 1, "mime_type": "text/plain", "notes": None,
|
||||||
|
"created_at": None, "extracted_text": "Management network notes",
|
||||||
|
}]
|
||||||
|
if "FROM internet_connections_customer_document_segments" in query:
|
||||||
|
return [{
|
||||||
|
"id": 203, "document_id": 103, "block_index": 0,
|
||||||
|
"block_title": "Management", "content": "Management network notes",
|
||||||
|
"ip_addresses": [], "cidr_blocks": [], "references_json": [], "socket_numbers": [],
|
||||||
|
}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
|
||||||
|
monkeypatch.setattr(internet_router, "_ensure_document_segments", lambda document_id, extracted_text: 1)
|
||||||
|
|
||||||
|
payload = asyncio.run(internet_router._build_customer_document_hits(77, "Karise", "sales management"))
|
||||||
|
|
||||||
|
assert payload["segments"] == []
|
||||||
|
assert payload["documents"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
|
def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
|
||||||
from app.modules.internet_connections.backend import router as internet_router
|
from app.modules.internet_connections.backend import router as internet_router
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
@ -179,6 +180,109 @@ def test_detection_service_reopens_expired_ignored_issue(monkeypatch):
|
|||||||
assert "ignored_until = NULL" in updates[0][0]
|
assert "ignored_until = NULL" in updates[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_service_ignores_non_hub_customer_keys(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "SELECT id FROM customers" in query:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DetectionService()
|
||||||
|
customer_id = service._resolve_hub_customer_id(
|
||||||
|
{
|
||||||
|
"customer_key": 56283338,
|
||||||
|
"hub_customer_id": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert customer_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_service_upsert_nulls_unknown_customer_id(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||||
|
|
||||||
|
inserted = {}
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "SELECT id, status" in query and "FROM invoice_error_finder_issues" in query:
|
||||||
|
return None
|
||||||
|
if "SELECT id FROM customers" in query:
|
||||||
|
return None
|
||||||
|
if "INSERT INTO invoice_error_finder_issues" in query:
|
||||||
|
inserted["params"] = params
|
||||||
|
return {"id": 21}
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DetectionService()
|
||||||
|
issue_id = service._upsert_issue(
|
||||||
|
issue_type="missing_line",
|
||||||
|
customer_id=702222153,
|
||||||
|
customer_name="Unknown Mapping",
|
||||||
|
product_number="INET-1000",
|
||||||
|
reference_period_start=date(2026, 7, 1),
|
||||||
|
reference_period_end=date(2026, 7, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert issue_id == 21
|
||||||
|
assert inserted["params"][2] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||||
|
|
||||||
|
scanned_months = []
|
||||||
|
current_month = date.today().replace(day=1)
|
||||||
|
first_month = current_month - __import__("dateutil.relativedelta").relativedelta.relativedelta(months=3)
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "MIN(invoice_date)" in query and "MAX(invoice_date)" in query:
|
||||||
|
return {
|
||||||
|
"first_month": first_month,
|
||||||
|
"last_month": current_month,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_analyze_single_month(self, month):
|
||||||
|
scanned_months.append(month)
|
||||||
|
return {
|
||||||
|
"missing_line": 1,
|
||||||
|
"open_order_not_invoiced": 0,
|
||||||
|
"quantity_drop": 0,
|
||||||
|
"price_change": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DetectionService,
|
||||||
|
"_analyze_single_month",
|
||||||
|
fake_analyze_single_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DetectionService()
|
||||||
|
counts = service.analyze()
|
||||||
|
|
||||||
|
assert scanned_months == [
|
||||||
|
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=1),
|
||||||
|
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=2),
|
||||||
|
current_month,
|
||||||
|
]
|
||||||
|
assert counts["missing_line"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_list_issues_supports_unassigned_filter(monkeypatch):
|
def test_list_issues_supports_unassigned_filter(monkeypatch):
|
||||||
from app.modules.invoice_error_finder.backend.router import list_issues
|
from app.modules.invoice_error_finder.backend.router import list_issues
|
||||||
|
|
||||||
@ -211,3 +315,266 @@ def test_list_issues_supports_unassigned_filter(monkeypatch):
|
|||||||
assert payload["total"] == 0
|
assert payload["total"] == 0
|
||||||
assert "assigned_user_id IS NULL" in captured["count_query"]
|
assert "assigned_user_id IS NULL" in captured["count_query"]
|
||||||
assert "assigned_user_id IS NULL" in captured["list_query"]
|
assert "assigned_user_id IS NULL" in captured["list_query"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_returns_resolved_product_name(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.backend.router import list_issues
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "COUNT(*) AS c" in query:
|
||||||
|
return {"c": 1}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"customer_name": "Karise Anlæg & Byg A/S",
|
||||||
|
"product_number": "PRO563",
|
||||||
|
"product_name": None,
|
||||||
|
"resolved_product_name": "Fiberforbindelse 1/1 Gbit.",
|
||||||
|
"status": "open",
|
||||||
|
"issue_type": "missing_line",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
list_issues(
|
||||||
|
status=None,
|
||||||
|
issue_type=None,
|
||||||
|
customer_id=None,
|
||||||
|
assigned_user_id=None,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
current_user={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["items"][0]["resolved_product_name"] == "Fiberforbindelse 1/1 Gbit."
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "FROM invoice_error_finder_issues" in query:
|
||||||
|
return {
|
||||||
|
"id": 14,
|
||||||
|
"customer_id": 77,
|
||||||
|
"customer_name": "ACME",
|
||||||
|
"product_number": "INET-1000",
|
||||||
|
"product_name": "1/1 Gbit Internet",
|
||||||
|
"reference_period_start": date(2026, 7, 1),
|
||||||
|
"reference_period_end": date(2026, 7, 31),
|
||||||
|
}
|
||||||
|
if "FROM customers WHERE id = %s" in query:
|
||||||
|
return {
|
||||||
|
"id": 77,
|
||||||
|
"name": "ACME",
|
||||||
|
"economic_customer_number": 56283338,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "FROM month_window mw" in query:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 6, 1),
|
||||||
|
"line_count": 1,
|
||||||
|
"total_quantity": 1,
|
||||||
|
"total_amount": 999.0,
|
||||||
|
"invoice_numbers": ["18912"],
|
||||||
|
"invoice_dates": ["2026-06-03"],
|
||||||
|
"descriptions": ["1/1 Gbit Internet"],
|
||||||
|
"is_reference_month": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 7, 1),
|
||||||
|
"line_count": 0,
|
||||||
|
"total_quantity": 0,
|
||||||
|
"total_amount": 0,
|
||||||
|
"invoice_numbers": [],
|
||||||
|
"invoice_dates": [],
|
||||||
|
"descriptions": [],
|
||||||
|
"is_reference_month": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 8, 1),
|
||||||
|
"line_count": 1,
|
||||||
|
"total_quantity": 1,
|
||||||
|
"total_amount": 999.0,
|
||||||
|
"invoice_numbers": ["19001"],
|
||||||
|
"invoice_dates": ["2026-08-04"],
|
||||||
|
"descriptions": ["1/1 Gbit Internet"],
|
||||||
|
"is_reference_month": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if "WITH ranked_invoices AS" in query:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 6, 1),
|
||||||
|
"invoice_id": 101,
|
||||||
|
"source_invoice_number": "18912",
|
||||||
|
"invoice_date": date(2026, 6, 3),
|
||||||
|
"total_amount": 1248.75,
|
||||||
|
"net_amount": 999.0,
|
||||||
|
"vat_amount": 249.75,
|
||||||
|
"currency": "DKK",
|
||||||
|
"source_type": "booked",
|
||||||
|
"heading": "Periode June 2026",
|
||||||
|
"note_text": "Kundeperiode juni\nEkstra note",
|
||||||
|
"line_number": 1,
|
||||||
|
"product_number": "INET-1000",
|
||||||
|
"product_name": None,
|
||||||
|
"description": "1/1 Gbit Internet",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 999.0,
|
||||||
|
"line_net_amount": 999.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 6, 1),
|
||||||
|
"invoice_id": 101,
|
||||||
|
"source_invoice_number": "18912",
|
||||||
|
"invoice_date": date(2026, 6, 3),
|
||||||
|
"total_amount": 1248.75,
|
||||||
|
"net_amount": 999.0,
|
||||||
|
"vat_amount": 249.75,
|
||||||
|
"currency": "DKK",
|
||||||
|
"source_type": "booked",
|
||||||
|
"heading": "Periode June 2026",
|
||||||
|
"note_text": "Kundeperiode juni\nEkstra note",
|
||||||
|
"line_number": 2,
|
||||||
|
"product_number": "RTR-1",
|
||||||
|
"product_name": None,
|
||||||
|
"description": "Leje af router",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 249.0,
|
||||||
|
"line_net_amount": 249.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 8, 1),
|
||||||
|
"invoice_id": 102,
|
||||||
|
"source_invoice_number": "19001",
|
||||||
|
"invoice_date": date(2026, 8, 4),
|
||||||
|
"total_amount": 1248.75,
|
||||||
|
"net_amount": 999.0,
|
||||||
|
"vat_amount": 249.75,
|
||||||
|
"currency": "DKK",
|
||||||
|
"source_type": "booked",
|
||||||
|
"heading": "Periode August 2026",
|
||||||
|
"note_text": None,
|
||||||
|
"line_number": 1,
|
||||||
|
"product_number": "INET-1000",
|
||||||
|
"product_name": None,
|
||||||
|
"description": "1/1 Gbit Internet",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 999.0,
|
||||||
|
"line_net_amount": 999.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(get_issue_invoice_history(14, current_user={}))
|
||||||
|
|
||||||
|
assert payload["customer_name"] == "ACME"
|
||||||
|
assert payload["product_number"] == "INET-1000"
|
||||||
|
assert len(payload["months"]) == 3
|
||||||
|
assert payload["months"][1]["is_reference_month"] is True
|
||||||
|
assert payload["months"][1]["line_count"] == 0
|
||||||
|
assert payload["months"][0]["invoices"][0]["invoice_number"] == "18912"
|
||||||
|
assert payload["months"][0]["invoices"][0]["note_text"] == "Kundeperiode juni\nEkstra note"
|
||||||
|
assert len(payload["months"][0]["invoices"][0]["lines"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "FROM invoice_error_finder_issues" in query:
|
||||||
|
return {
|
||||||
|
"id": 15,
|
||||||
|
"customer_id": 77,
|
||||||
|
"customer_name": "ACME",
|
||||||
|
"product_number": "INET-1000",
|
||||||
|
"product_name": "1/1 Gbit Internet",
|
||||||
|
"reference_period_start": date(2026, 7, 1),
|
||||||
|
"reference_period_end": date(2026, 7, 31),
|
||||||
|
}
|
||||||
|
if "FROM customers WHERE id = %s" in query:
|
||||||
|
return {
|
||||||
|
"id": 77,
|
||||||
|
"name": "ACME",
|
||||||
|
"economic_customer_number": 56283338,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "FROM month_window mw" in query:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 7, 1),
|
||||||
|
"line_count": 2,
|
||||||
|
"total_quantity": 2,
|
||||||
|
"total_amount": 1444.0,
|
||||||
|
"invoice_numbers": ["20098", "20098"],
|
||||||
|
"invoice_dates": ["2026-03-13", "2026-03-13"],
|
||||||
|
"descriptions": ["Fiberforbindelse", "Fiberforbindelse"],
|
||||||
|
"is_reference_month": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if "WITH ranked_invoices AS" in query:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month_start": date(2026, 7, 1),
|
||||||
|
"invoice_id": 201,
|
||||||
|
"source_invoice_number": "20098",
|
||||||
|
"invoice_date": date(2026, 3, 13),
|
||||||
|
"total_amount": 902.5,
|
||||||
|
"net_amount": 722.0,
|
||||||
|
"vat_amount": 180.5,
|
||||||
|
"currency": "DKK",
|
||||||
|
"source_type": "paid",
|
||||||
|
"heading": None,
|
||||||
|
"note_text": None,
|
||||||
|
"line_number": 1,
|
||||||
|
"product_number": "INET-1000",
|
||||||
|
"product_name": None,
|
||||||
|
"description": "Fiberforbindelse",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 722.0,
|
||||||
|
"line_net_amount": 722.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(get_issue_invoice_history(15, current_user={}))
|
||||||
|
|
||||||
|
assert len(payload["months"][0]["invoices"]) == 1
|
||||||
|
assert payload["months"][0]["invoices"][0]["source_type"] == "paid"
|
||||||
|
|||||||
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