Compare commits
No commits in common. "main" and "v2.3.23" have entirely different histories.
@ -217,22 +217,6 @@ class UserProfileUpdate(BaseModel):
|
||||
phone: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
anydesk_id: Optional[str] = None
|
||||
default_case_type: Optional[str] = None
|
||||
|
||||
|
||||
def _allowed_case_types() -> list[str]:
|
||||
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
|
||||
try:
|
||||
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
||||
if rows:
|
||||
import json
|
||||
configured = json.loads(rows[0].get("value") or "[]")
|
||||
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
||||
if values:
|
||||
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
|
||||
except Exception:
|
||||
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
@router.get("/me/profile")
|
||||
@ -244,18 +228,7 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
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
|
||||
return dict(rows[0])
|
||||
|
||||
|
||||
@router.patch("/me/profile")
|
||||
@ -280,29 +253,12 @@ async def update_my_profile(
|
||||
fields.append("anydesk_id = %s")
|
||||
values.append(payload.anydesk_id.strip() or None)
|
||||
|
||||
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:
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
if fields:
|
||||
fields.append("updated_at = NOW()")
|
||||
values.append(current_user["id"])
|
||||
|
||||
execute_query(
|
||||
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
||||
tuple(values)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -99,51 +99,6 @@
|
||||
.status-processing { background-color: #6c757d; color: #fff; }
|
||||
.status-failed { background-color: var(--danger); color: #fff; }
|
||||
.status-completed { background-color: var(--success); color: #fff; }
|
||||
|
||||
.sync-report-card {
|
||||
border: 1px solid rgba(13, 110, 253, 0.14);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(13, 110, 253, 0.04), rgba(13, 110, 253, 0.015));
|
||||
}
|
||||
|
||||
.sync-report-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sync-report-metric {
|
||||
border: 1px solid rgba(13, 110, 253, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.sync-report-metric .label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.sync-report-metric .value {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sync-line-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-line-item {
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -2081,114 +2036,6 @@ function getFileStatusBadge(status) {
|
||||
return badges[status] || `<span class="badge bg-secondary">${status}</span>`;
|
||||
}
|
||||
|
||||
function renderSyncVerificationBadge(verification) {
|
||||
if (!verification) return '';
|
||||
if (verification.requires_manual_review) {
|
||||
return '<span class="badge bg-danger">Kræver manuel kontrol</span>';
|
||||
}
|
||||
if (verification.fully_synced) {
|
||||
return '<span class="badge bg-success">Alt er dækket</span>';
|
||||
}
|
||||
return '<span class="badge bg-secondary">Ingen internet-sync</span>';
|
||||
}
|
||||
|
||||
function renderSyncReport(syncReport) {
|
||||
if (!syncReport || syncReport.skipped) return '';
|
||||
|
||||
const verification = syncReport.verification || {};
|
||||
const skippedItems = Array.isArray(syncReport.skipped_items) ? syncReport.skipped_items : [];
|
||||
const lineAudit = Array.isArray(syncReport.line_audit) ? syncReport.line_audit : [];
|
||||
const syncedLines = lineAudit.filter((item) => item.status === 'synced');
|
||||
const ignoredLines = lineAudit.filter((item) => item.status === 'ignored');
|
||||
|
||||
const renderLine = (item, tone) => `
|
||||
<div class="sync-line-item">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Linje ${item.line_number || '-'} · ${escapeHtml(item.description || '-')}</div>
|
||||
<div class="small text-muted mt-1">
|
||||
${item.provider_reference ? `Ref: ${escapeHtml(item.provider_reference)} · ` : ''}
|
||||
${item.ip_address ? `IP/CIDR: ${escapeHtml(item.ip_address)} · ` : ''}
|
||||
${item.service_address ? `Adresse: ${escapeHtml(item.service_address)}` : ''}
|
||||
</div>
|
||||
${item.reason ? `<div class="small mt-1 text-${tone}">${escapeHtml(item.reason)}</div>` : ''}
|
||||
</div>
|
||||
<span class="badge bg-${tone}">${item.result ? escapeHtml(item.result) : escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="sync-report-card p-3 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">Internet-importkontrol</h5>
|
||||
<div class="small text-muted">Alle relevante linjer er gennemgået enkeltvis. Alt der springes over vises her med årsag.</div>
|
||||
</div>
|
||||
<div>${renderSyncVerificationBadge(verification)}</div>
|
||||
</div>
|
||||
|
||||
<div class="sync-report-grid mb-3">
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">Linjer i alt</span>
|
||||
<div class="value">${verification.total_lines || 0}</div>
|
||||
</div>
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">Relevante linjer</span>
|
||||
<div class="value">${verification.actionable_lines || 0}</div>
|
||||
</div>
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">Synkroniseret</span>
|
||||
<div class="value text-success">${verification.synced_actionable_lines || 0}</div>
|
||||
</div>
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">Sprunget over</span>
|
||||
<div class="value text-danger">${verification.skipped_actionable_lines || 0}</div>
|
||||
</div>
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">Forbindelser</span>
|
||||
<div class="value">${syncReport.connections_created || 0} ny · ${syncReport.connections_updated || 0} opdat.</div>
|
||||
</div>
|
||||
<div class="sync-report-metric">
|
||||
<span class="label">IP-ranges</span>
|
||||
<div class="value">${syncReport.ip_ranges_synced || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${skippedItems.length ? `
|
||||
<div class="alert alert-danger mb-3">
|
||||
<strong>${skippedItems.length} linjer blev sprunget over.</strong> De skal gennemgås manuelt før du kan være sikker på, at alt er oprettet.
|
||||
</div>
|
||||
<div class="sync-line-list mb-3">
|
||||
${skippedItems.map((item) => renderLine(item, 'danger')).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div class="alert alert-success mb-3">
|
||||
Ingen relevante linjer blev sprunget over.
|
||||
</div>
|
||||
`}
|
||||
|
||||
${syncedLines.length ? `
|
||||
<details class="mb-3">
|
||||
<summary class="fw-semibold">Vis synkroniserede linjer (${syncedLines.length})</summary>
|
||||
<div class="sync-line-list mt-2">
|
||||
${syncedLines.map((item) => renderLine(item, 'success')).join('')}
|
||||
</div>
|
||||
</details>
|
||||
` : ''}
|
||||
|
||||
${ignoredLines.length ? `
|
||||
<details>
|
||||
<summary class="fw-semibold">Vis linjer uden internet-handling (${ignoredLines.length})</summary>
|
||||
<div class="sync-line-list mt-2">
|
||||
${ignoredLines.map((item) => renderLine(item, 'secondary')).join('')}
|
||||
</div>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// NEW: Batch analyze all files
|
||||
async function batchAnalyzeAllFiles() {
|
||||
if (!confirm('Kør automatisk analyse på alle ubehandlede filer?\n\nDette kan tage flere minutter afhængigt af antal filer.\nSiden opdateres automatisk undervejs.')) {
|
||||
@ -2910,7 +2757,6 @@ async function reviewExtractedData(fileId) {
|
||||
|
||||
const ext = data.extraction;
|
||||
const lines = data.extraction_lines || [];
|
||||
const syncPreview = data.internet_sync_preview || null;
|
||||
|
||||
// Parse JSON if llm_response_json exists
|
||||
let aiData = null;
|
||||
@ -2994,8 +2840,6 @@ async function reviewExtractedData(fileId) {
|
||||
<pre class="mb-0 text-body" style="font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; line-height: 1.3;">${escapeHtml(data.pdf_text_preview)}</pre>
|
||||
</div>
|
||||
` : '<div class="alert alert-warning mt-3"><i class="bi bi-exclamation-triangle me-2"></i>PDF tekst ikke tilgængelig - prøv at genbehandle filen</div>'}
|
||||
|
||||
${renderSyncReport(syncPreview)}
|
||||
`;
|
||||
|
||||
document.getElementById('reviewModalContent').innerHTML = modalContent;
|
||||
@ -3344,11 +3188,7 @@ async function createInvoiceFromExtraction() {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const syncReport = result.internet_sync || null;
|
||||
const syncWarning = syncReport?.verification?.requires_manual_review
|
||||
? `\n\nADVARSEL: ${syncReport.verification.skipped_actionable_lines} internet-linjer blev sprunget over. Åbn review igen og kontroller dem.`
|
||||
: '';
|
||||
alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}${syncWarning}`);
|
||||
alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}`);
|
||||
|
||||
// Close modal and refresh
|
||||
const modalInstance = bootstrap.Modal.getInstance(modal);
|
||||
|
||||
@ -28,15 +28,8 @@ class ContactCreate(BaseModel):
|
||||
last_name: str = ""
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
company_id: Optional[int] = None
|
||||
company_ids: Optional[list[int]] = None
|
||||
is_primary: bool = False
|
||||
role: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
@ -227,33 +220,18 @@ async def create_contact(contact: ContactCreate):
|
||||
pass
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, true)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
contact_id = execute_insert(
|
||||
insert_query,
|
||||
(
|
||||
contact.first_name,
|
||||
contact.last_name,
|
||||
contact.email,
|
||||
contact.phone,
|
||||
contact.mobile,
|
||||
contact.title,
|
||||
contact.department,
|
||||
contact.is_active,
|
||||
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title)
|
||||
)
|
||||
)
|
||||
|
||||
company_ids = []
|
||||
if contact.company_ids:
|
||||
company_ids.extend(int(company_id) for company_id in contact.company_ids if company_id)
|
||||
if contact.company_id and contact.company_id not in company_ids:
|
||||
company_ids.append(int(contact.company_id))
|
||||
|
||||
# Link to company if provided
|
||||
for idx, company_id in enumerate(company_ids):
|
||||
if contact.company_id:
|
||||
try:
|
||||
link_query = """
|
||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||
@ -262,25 +240,9 @@ async def create_contact(contact: ContactCreate):
|
||||
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
||||
RETURNING id
|
||||
"""
|
||||
execute_insert(
|
||||
link_query,
|
||||
(
|
||||
contact_id,
|
||||
company_id,
|
||||
),
|
||||
)
|
||||
if idx > 0 or not contact.is_primary or contact.role:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE contact_companies
|
||||
SET is_primary = %s,
|
||||
role = COALESCE(%s, role)
|
||||
WHERE contact_id = %s AND customer_id = %s
|
||||
""",
|
||||
(idx == 0 and contact.is_primary, contact.role, contact_id, company_id),
|
||||
)
|
||||
execute_insert(link_query, (contact_id, contact.company_id))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to link new contact {contact_id} to company {company_id}: {e}")
|
||||
logger.error(f"Failed to link new contact {contact_id} to company {contact.company_id}: {e}")
|
||||
# Don't fail the whole request, just log it
|
||||
|
||||
return await get_contact(contact_id)
|
||||
|
||||
@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
|
||||
<td>${escapeHtml(stage)}</td>
|
||||
<td>${probability}</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
|
||||
<a class="btn btn-sm btn-outline-primary" href="/opportunities/${o.id}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@ -817,8 +817,6 @@ let lastLoadedQueryKey = '';
|
||||
let availableCompanies = [];
|
||||
let selectedCompanyIds = new Set();
|
||||
let currentContactsData = [];
|
||||
let pendingCreateModalCustomerId = null;
|
||||
let pendingCreateReturnTo = null;
|
||||
let currentSort = {
|
||||
key: 'name',
|
||||
direction: 'asc'
|
||||
@ -832,26 +830,12 @@ let visibleColumns = {
|
||||
|
||||
// Load contacts on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const preselectedCustomerId = Number(urlParams.get('customer_id'));
|
||||
const shouldOpenCreateModal = urlParams.get('create') === '1';
|
||||
pendingCreateReturnTo = urlParams.get('return_to') || null;
|
||||
pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0
|
||||
? preselectedCustomerId
|
||||
: null;
|
||||
|
||||
loadTablePreferences();
|
||||
applyColumnVisibility();
|
||||
updateSortIndicators();
|
||||
loadContacts();
|
||||
loadCompaniesForSelect();
|
||||
|
||||
if (shouldOpenCreateModal) {
|
||||
setTimeout(() => {
|
||||
showCreateContactModal();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const clearBtn = document.getElementById('searchClearBtn');
|
||||
|
||||
@ -931,7 +915,7 @@ function setFilter(filter) {
|
||||
loadContacts();
|
||||
}
|
||||
|
||||
async function loadContacts(force = false) {
|
||||
async function loadContacts() {
|
||||
const tbody = document.getElementById('contactsTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
||||
|
||||
@ -958,7 +942,7 @@ async function loadContacts(force = false) {
|
||||
}
|
||||
|
||||
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
|
||||
if (!force && queryKey === lastLoadedQueryKey) {
|
||||
if (queryKey === lastLoadedQueryKey) {
|
||||
return;
|
||||
}
|
||||
lastLoadedQueryKey = queryKey;
|
||||
@ -1355,9 +1339,6 @@ async function loadCompaniesForSelect() {
|
||||
availableCompanies = Array.isArray(data.customers)
|
||||
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
|
||||
: [];
|
||||
if (pendingCreateModalCustomerId && availableCompanies.some((c) => c.id === pendingCreateModalCustomerId)) {
|
||||
selectedCompanyIds.add(pendingCreateModalCustomerId);
|
||||
}
|
||||
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
|
||||
renderSelectedCompanies();
|
||||
} catch (error) {
|
||||
@ -1430,9 +1411,6 @@ function showCreateContactModal() {
|
||||
document.getElementById('createContactForm').reset();
|
||||
document.getElementById('isActiveInput').checked = true;
|
||||
selectedCompanyIds = new Set();
|
||||
if (pendingCreateModalCustomerId) {
|
||||
selectedCompanyIds.add(pendingCreateModalCustomerId);
|
||||
}
|
||||
const companySearchInput = document.getElementById('companySearchInput');
|
||||
if (companySearchInput) {
|
||||
companySearchInput.value = '';
|
||||
@ -1492,33 +1470,8 @@ async function createContact() {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
|
||||
modal.hide();
|
||||
|
||||
const createdContactId = Number(newContact?.id) || null;
|
||||
|
||||
if (pendingCreateReturnTo) {
|
||||
window.location.href = pendingCreateReturnTo;
|
||||
return;
|
||||
}
|
||||
|
||||
if (createdContactId) {
|
||||
window.location.href = `/contacts/${createdContactId}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload contact list
|
||||
lastLoadedQueryKey = '';
|
||||
currentPage = 0;
|
||||
searchQuery = '';
|
||||
document.getElementById('searchInput').value = '';
|
||||
toggleClearButton('');
|
||||
await loadContacts(true);
|
||||
|
||||
if (pendingCreateModalCustomerId) {
|
||||
const cleanUrl = new URL(window.location.href);
|
||||
cleanUrl.searchParams.delete('create');
|
||||
cleanUrl.searchParams.delete('return_to');
|
||||
window.history.replaceState({}, '', cleanUrl.toString());
|
||||
pendingCreateModalCustomerId = null;
|
||||
}
|
||||
await loadContacts();
|
||||
|
||||
// Show success message
|
||||
alert('Kontakt oprettet succesfuldt!');
|
||||
|
||||
@ -1220,145 +1220,6 @@ async def get_customer_contacts(customer_id: int):
|
||||
return rows or []
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/economic-invoices")
|
||||
async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)):
|
||||
"""Get imported e-conomic invoices for a customer from invoice_error_finder staging data."""
|
||||
customer = execute_query_single(
|
||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
economic_customer_number = customer.get("economic_customer_number")
|
||||
if not economic_customer_number:
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"economic_customer_number": None,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH ranked_invoices AS (
|
||||
SELECT
|
||||
inv.id,
|
||||
inv.source_invoice_number,
|
||||
inv.invoice_date,
|
||||
inv.due_date,
|
||||
inv.total_amount,
|
||||
inv.net_amount,
|
||||
inv.vat_amount,
|
||||
inv.currency,
|
||||
inv.source_type,
|
||||
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
|
||||
NULLIF(
|
||||
CONCAT_WS(
|
||||
E'\n',
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
|
||||
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
|
||||
),
|
||||
''
|
||||
) AS note_text,
|
||||
CASE inv.source_type
|
||||
WHEN 'paid' THEN 1
|
||||
WHEN 'booked' THEN 2
|
||||
WHEN 'unpaid' THEN 3
|
||||
WHEN 'draft' THEN 4
|
||||
ELSE 9
|
||||
END AS source_rank
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
WHERE inv.customer_number = %s
|
||||
),
|
||||
selected_invoices AS (
|
||||
SELECT DISTINCT ON (source_invoice_number)
|
||||
id,
|
||||
source_invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total_amount,
|
||||
net_amount,
|
||||
vat_amount,
|
||||
currency,
|
||||
source_type,
|
||||
heading,
|
||||
note_text
|
||||
FROM ranked_invoices
|
||||
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
|
||||
)
|
||||
SELECT
|
||||
si.id AS invoice_id,
|
||||
si.source_invoice_number,
|
||||
si.invoice_date,
|
||||
si.due_date,
|
||||
si.total_amount,
|
||||
si.net_amount,
|
||||
si.vat_amount,
|
||||
si.currency,
|
||||
si.source_type,
|
||||
si.heading,
|
||||
si.note_text,
|
||||
line.line_number,
|
||||
line.product_number,
|
||||
line.product_name,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.line_net_amount
|
||||
FROM selected_invoices si
|
||||
LEFT JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = si.id
|
||||
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(economic_customer_number, limit * 25),
|
||||
) or []
|
||||
|
||||
invoices: List[Dict[str, Any]] = []
|
||||
invoices_by_id: Dict[int, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
invoice_id = row.get("invoice_id")
|
||||
if invoice_id is None:
|
||||
continue
|
||||
if invoice_id not in invoices_by_id:
|
||||
payload = {
|
||||
"invoice_id": invoice_id,
|
||||
"invoice_number": row.get("source_invoice_number"),
|
||||
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
|
||||
"due_date": row["due_date"].isoformat() if row.get("due_date") else None,
|
||||
"total_amount": float(row.get("total_amount") or 0),
|
||||
"net_amount": float(row.get("net_amount") or 0),
|
||||
"vat_amount": float(row.get("vat_amount") or 0),
|
||||
"currency": row.get("currency") or "DKK",
|
||||
"source_type": row.get("source_type"),
|
||||
"heading": row.get("heading") or None,
|
||||
"note_text": row.get("note_text") or None,
|
||||
"lines": [],
|
||||
}
|
||||
invoices_by_id[invoice_id] = payload
|
||||
invoices.append(payload)
|
||||
if row.get("line_number") is not None:
|
||||
invoices_by_id[invoice_id]["lines"].append(
|
||||
{
|
||||
"line_number": int(row.get("line_number") or 0),
|
||||
"product_number": row.get("product_number"),
|
||||
"product_name": row.get("product_name"),
|
||||
"description": row.get("description"),
|
||||
"quantity": float(row.get("quantity") or 0),
|
||||
"unit_price": float(row.get("unit_price") or 0),
|
||||
"line_net_amount": float(row.get("line_net_amount") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"economic_customer_number": economic_customer_number,
|
||||
"items": invoices[:limit],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/kontakt")
|
||||
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
|
||||
"""Get unified contact communication history (calls + SMS) for all company contacts."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,84 +0,0 @@
|
||||
"""
|
||||
Scheduled sync job for Invoice Error Finder.
|
||||
Runs daily after subscription processing to import e-conomic and Simply data
|
||||
and re-run anomaly detection.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.modules.invoice_error_finder.services.economic_import_service import EconomicImportService
|
||||
from app.modules.invoice_error_finder.services.simply_import_service import SimplyImportService
|
||||
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_invoice_error_finder_sync() -> dict:
|
||||
"""
|
||||
Daily scheduled job:
|
||||
1. Import e-conomic invoices (last 13 months).
|
||||
2. Import open Simply CRM sales orders.
|
||||
3. Re-import Simply subscription staging via existing endpoint.
|
||||
4. Run anomaly detection for current month.
|
||||
"""
|
||||
logger.info("🔄 Starting scheduled Invoice Error Finder sync")
|
||||
|
||||
economic_result = {"records_imported": 0, "records_failed": 0}
|
||||
simply_result = {"records_imported": 0, "records_failed": 0}
|
||||
staging_result = {"records_imported": 0, "records_failed": 0}
|
||||
detection_counts = {}
|
||||
errors = []
|
||||
|
||||
try:
|
||||
economic_service = EconomicImportService()
|
||||
economic_result = await economic_service.import_invoices(
|
||||
triggered_by_user_id=None,
|
||||
is_scheduled=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("❌ Scheduled e-conomic import failed: %s", exc, exc_info=True)
|
||||
errors.append(f"economic: {exc}")
|
||||
|
||||
try:
|
||||
simply_service = SimplyImportService()
|
||||
simply_result = await simply_service.import_sales_orders(
|
||||
triggered_by_user_id=None,
|
||||
is_scheduled=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("❌ Scheduled Simply sales order import failed: %s", exc, exc_info=True)
|
||||
errors.append(f"simply: {exc}")
|
||||
|
||||
# Refresh Simply subscription staging by calling the existing import function directly
|
||||
try:
|
||||
from app.subscriptions.backend.router import import_simply_subscriptions_to_staging
|
||||
|
||||
staging_data = await import_simply_subscriptions_to_staging()
|
||||
staging_result = {
|
||||
"records_imported": staging_data.get("imported", 0),
|
||||
"records_failed": staging_data.get("errors", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("❌ Scheduled Simply subscription staging import failed: %s", exc, exc_info=True)
|
||||
errors.append(f"staging: {exc}")
|
||||
|
||||
try:
|
||||
detection_service = DetectionService()
|
||||
detection_counts = detection_service.analyze()
|
||||
except Exception as exc:
|
||||
logger.error("❌ Scheduled detection failed: %s", exc, exc_info=True)
|
||||
errors.append(f"detection: {exc}")
|
||||
|
||||
result = {
|
||||
"economic": economic_result,
|
||||
"simply": simply_result,
|
||||
"staging": staging_result,
|
||||
"detection": detection_counts,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
if errors:
|
||||
logger.warning("⚠️ Invoice Error Finder sync completed with errors: %s", errors)
|
||||
else:
|
||||
logger.info("✅ Invoice Error Finder sync completed successfully: %s", result)
|
||||
|
||||
return result
|
||||
@ -7,7 +7,6 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
@ -739,18 +738,6 @@ def _fetch_json_from_candidates(base_url: str, token: str, candidates: List[str]
|
||||
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]:
|
||||
labels: Dict[str, str] = {}
|
||||
if not raw.strip():
|
||||
@ -1081,112 +1068,6 @@ def _parse_uisp_payload(payload: Any) -> List[Dict[str, Any]]:
|
||||
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]]:
|
||||
items = _parse_uisp_payload(payload)
|
||||
events: List[Dict[str, Any]] = []
|
||||
@ -1402,21 +1283,6 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
|
||||
"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)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("⚠️ Drift UISP sync failed: %s", exc)
|
||||
@ -1447,8 +1313,6 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
|
||||
"source": source.get("name"),
|
||||
"mode": "live",
|
||||
"blacklisted_skipped": skipped,
|
||||
"cached_devices": cached_devices,
|
||||
"detailed_devices": detailed_devices,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||
@ -371,7 +370,7 @@ async def create_hardware(data: dict):
|
||||
try:
|
||||
query = """
|
||||
INSERT INTO hardware_assets (
|
||||
asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
|
||||
asset_type, brand, model, serial_number, customer_asset_id,
|
||||
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
||||
status, status_reason, warranty_until, end_of_life,
|
||||
anydesk_id, anydesk_link,
|
||||
@ -379,7 +378,7 @@ async def create_hardware(data: dict):
|
||||
rental_default_start_price, rental_default_freight_price,
|
||||
rental_default_preparation_price, rental_default_operations_monthly_price
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
@ -393,7 +392,6 @@ async def create_hardware(data: dict):
|
||||
data.get("model"),
|
||||
data.get("serial_number"),
|
||||
data.get("customer_asset_id"),
|
||||
data.get("current_location_id"),
|
||||
data.get("internal_asset_id"),
|
||||
data.get("notes"),
|
||||
data.get("current_owner_type", "bmc"),
|
||||
@ -498,202 +496,6 @@ async def get_hardware(hardware_id: int):
|
||||
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)
|
||||
async def update_hardware(hardware_id: int, data: dict):
|
||||
"""Update hardware asset."""
|
||||
@ -709,8 +511,7 @@ async def update_hardware(hardware_id: int, data: dict):
|
||||
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
||||
"eset_uuid", "hardware_specs", "eset_group",
|
||||
"rental_default_start_price", "rental_default_freight_price",
|
||||
"rental_default_preparation_price", "rental_default_operations_monthly_price",
|
||||
"location_display_order"
|
||||
"rental_default_preparation_price", "rental_default_operations_monthly_price"
|
||||
]
|
||||
|
||||
for field in allowed_fields:
|
||||
@ -1371,3 +1172,4 @@ async def list_eset_incidents(
|
||||
"""
|
||||
result = execute_query(query, (severity_list, limit))
|
||||
return result or []
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Any
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
@ -449,116 +447,6 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
|
||||
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
|
||||
if hardware.get('current_owner_customer_id'):
|
||||
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
|
||||
@ -594,19 +482,6 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
"""
|
||||
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
|
||||
attachment_query = """
|
||||
SELECT * FROM hardware_attachments
|
||||
@ -795,7 +670,6 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
"hardware": hardware,
|
||||
"ownership": ownership or [],
|
||||
"locations": locations or [],
|
||||
"current_location": current_location,
|
||||
"attachments": attachments or [],
|
||||
"cases": cases or [],
|
||||
"tags": tags or [],
|
||||
@ -805,11 +679,6 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
"owner_contacts": owner_contacts or [],
|
||||
"location_tree": location_tree or [],
|
||||
"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,
|
||||
"recent_rentals": recent_rentals or [],
|
||||
})
|
||||
|
||||
@ -54,19 +54,6 @@
|
||||
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 {
|
||||
position: relative;
|
||||
@ -236,8 +223,8 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Location (Current) -->
|
||||
{% set current_loc = current_location or (locations[0] if locations else None) %}
|
||||
{% if current_loc and (current_location or not current_loc.end_date) %}
|
||||
{% set current_loc = locations[0] if locations else None %}
|
||||
{% if current_loc and not current_loc.end_date %}
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Lokation:</span>
|
||||
<span>{{ current_loc.location_name }}</span>
|
||||
@ -422,11 +409,11 @@
|
||||
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if current_loc and (current_location or not current_loc.end_date) %}
|
||||
{% if current_loc and not current_loc.end_date %}
|
||||
<div class="text-center py-3">
|
||||
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
|
||||
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
|
||||
<p class="text-muted small mb-0">{% if current_loc.start_date %}Siden: {{ current_loc.start_date }}{% else %}Aktuel placering{% endif %}</p>
|
||||
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
|
||||
{% if current_loc.notes %}
|
||||
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
|
||||
{% endif %}
|
||||
@ -688,64 +675,6 @@
|
||||
</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 %}
|
||||
<div class="card mt-4 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
@ -1305,21 +1234,6 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -1337,131 +1251,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
|
||||
const sagId = Number(document.getElementById('quickRentSagId').value || 0);
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Internet connections module package."""
|
||||
@ -1,143 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
NETWORK_KINDS = {"internet_access", "ip_allocation"}
|
||||
|
||||
|
||||
def parse_product_attributes(raw: Any) -> Dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_speed_from_text(text: str) -> Dict[str, Optional[int]]:
|
||||
match = re.search(r"(\d+)\s*/\s*(\d+)\s*(?:mbit|mbps|gbit|gbps)?", text, re.IGNORECASE)
|
||||
if not match:
|
||||
return {"speed_mbps": None, "download_mbps": None, "upload_mbps": None}
|
||||
|
||||
download = int(match.group(1))
|
||||
upload = int(match.group(2))
|
||||
if re.search(r"(gbit|gbps)", text, re.IGNORECASE):
|
||||
download *= 1000
|
||||
upload *= 1000
|
||||
|
||||
return {
|
||||
"speed_mbps": max(download, upload),
|
||||
"download_mbps": download,
|
||||
"upload_mbps": upload,
|
||||
}
|
||||
|
||||
|
||||
def _parse_prefix_from_text(text: str) -> Optional[int]:
|
||||
match = re.search(r"/(\d{1,2})", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
prefix = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return prefix if 0 <= prefix <= 32 else None
|
||||
|
||||
|
||||
def build_network_product_profile(product: Dict[str, Any], fallback_text: Optional[str] = None) -> Dict[str, Any]:
|
||||
attributes = parse_product_attributes(product.get("attributes_json"))
|
||||
network = attributes.get("network") if isinstance(attributes.get("network"), dict) else {}
|
||||
|
||||
text = " ".join(
|
||||
part for part in [
|
||||
str(product.get("name") or "").strip(),
|
||||
str(product.get("product_name") or "").strip(),
|
||||
str(product.get("description") or "").strip(),
|
||||
str(fallback_text or "").strip(),
|
||||
]
|
||||
if part
|
||||
)
|
||||
lowered = text.lower()
|
||||
|
||||
kind = (
|
||||
network.get("kind")
|
||||
or attributes.get("network_kind")
|
||||
or product.get("network_kind")
|
||||
or product.get("type")
|
||||
)
|
||||
if kind not in NETWORK_KINDS:
|
||||
if "/3" in lowered and "ip" in lowered:
|
||||
kind = "ip_allocation"
|
||||
elif re.search(r"/\d{1,2}", lowered) and "ip" in lowered:
|
||||
kind = "ip_allocation"
|
||||
elif "bmcnet" in lowered or "internet" in lowered or "fiber" in lowered:
|
||||
kind = "internet_access"
|
||||
else:
|
||||
kind = None
|
||||
|
||||
speeds = _parse_speed_from_text(text)
|
||||
speed_mbps = network.get("speed_mbps") or attributes.get("speed_mbps") or speeds["speed_mbps"]
|
||||
download_mbps = network.get("download_mbps") or attributes.get("download_mbps") or speeds["download_mbps"]
|
||||
upload_mbps = network.get("upload_mbps") or attributes.get("upload_mbps") or speeds["upload_mbps"]
|
||||
ip_prefix_length = network.get("ip_prefix_length") or attributes.get("ip_prefix_length") or _parse_prefix_from_text(text)
|
||||
connection_type = network.get("connection_type") or attributes.get("connection_type")
|
||||
|
||||
return {
|
||||
"kind": kind,
|
||||
"is_network_product": kind in NETWORK_KINDS,
|
||||
"requires_provisioning": kind in NETWORK_KINDS,
|
||||
"speed_mbps": int(speed_mbps) if speed_mbps is not None else None,
|
||||
"download_mbps": int(download_mbps) if download_mbps is not None else None,
|
||||
"upload_mbps": int(upload_mbps) if upload_mbps is not None else None,
|
||||
"ip_prefix_length": int(ip_prefix_length) if ip_prefix_length is not None else None,
|
||||
"connection_type": connection_type,
|
||||
"attributes": attributes,
|
||||
}
|
||||
|
||||
|
||||
def summarize_subscription_network_requirements(line_items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
internet_items: List[Dict[str, Any]] = []
|
||||
ip_items: List[Dict[str, Any]] = []
|
||||
|
||||
for item in line_items or []:
|
||||
profile = build_network_product_profile(item, fallback_text=item.get("description"))
|
||||
if not profile["requires_provisioning"]:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"subscription_item_id": item.get("id"),
|
||||
"line_no": item.get("line_no"),
|
||||
"product_id": item.get("product_id"),
|
||||
"product_name": item.get("product_name") or item.get("description"),
|
||||
"description": item.get("description"),
|
||||
"quantity": item.get("quantity"),
|
||||
"unit_price": item.get("unit_price"),
|
||||
"line_total": item.get("line_total"),
|
||||
"network_kind": profile["kind"],
|
||||
"speed_mbps": profile["speed_mbps"],
|
||||
"download_mbps": profile["download_mbps"],
|
||||
"upload_mbps": profile["upload_mbps"],
|
||||
"ip_prefix_length": profile["ip_prefix_length"],
|
||||
"connection_type": profile["connection_type"],
|
||||
}
|
||||
if profile["kind"] == "internet_access":
|
||||
internet_items.append(entry)
|
||||
elif profile["kind"] == "ip_allocation":
|
||||
ip_items.append(entry)
|
||||
|
||||
primary_internet_item = internet_items[0] if internet_items else None
|
||||
return {
|
||||
"requires_provisioning": bool(internet_items or ip_items),
|
||||
"internet_items": internet_items,
|
||||
"ip_items": ip_items,
|
||||
"primary_internet_item": primary_internet_item,
|
||||
"required_ip_prefixes": [item["ip_prefix_length"] for item in ip_items if item.get("ip_prefix_length") is not None],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,33 +0,0 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
|
||||
@router.get("/economy/internet-connections", response_class=HTMLResponse)
|
||||
async def internet_connections_index(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"modules/internet_connections/templates/index.html",
|
||||
{"request": request, "title": "Internetforbindelser"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/economy/internet-connections/{connection_id}", response_class=HTMLResponse)
|
||||
async def internet_connection_detail(request: Request, connection_id: int):
|
||||
return templates.TemplateResponse(
|
||||
"modules/internet_connections/templates/detail.html",
|
||||
{"request": request, "title": "Forbindelsesdetaljer", "connection_id": connection_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data-migration/internet-wizard-v2", response_class=HTMLResponse)
|
||||
async def internet_connection_migration_wizard_v2(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"modules/internet_connections/templates/migration_wizard_v2.html",
|
||||
{"request": request, "title": "Internet Wizard v2"},
|
||||
)
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "internet_connections",
|
||||
"version": "0.1.0",
|
||||
"description": "Modul til administration af internetforbindelser, IP-adresser og priser",
|
||||
"author": "BMC Networks",
|
||||
"enabled": true,
|
||||
"dependencies": [],
|
||||
"table_prefix": "internet_connections_",
|
||||
"api_prefix": "/api/v1/internet-connections",
|
||||
"tags": ["Internetforbindelser"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,633 +0,0 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Internetforbindelser{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.internet-hero {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 34%),
|
||||
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
|
||||
border: 1px solid rgba(15, 76, 117, 0.14);
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
.internet-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
|
||||
}
|
||||
|
||||
.internet-kpi {
|
||||
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.internet-kpi-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.internet-kpi-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.internet-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 0.8fr 0.9fr auto auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.internet-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.internet-row td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.internet-tabs {
|
||||
display: inline-flex;
|
||||
padding: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 76, 117, 0.07);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.internet-tab {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.internet-tab.active {
|
||||
background: #0f4c75;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.internet-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
background: rgba(15, 76, 117, 0.08);
|
||||
color: #0f4c75;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.internet-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.internet-status.active {
|
||||
background: rgba(25, 135, 84, 0.12);
|
||||
color: #146c43;
|
||||
}
|
||||
|
||||
.internet-status.inactive {
|
||||
background: rgba(108, 117, 125, 0.14);
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.internet-status.pending,
|
||||
.internet-status.planned {
|
||||
background: rgba(255, 193, 7, 0.18);
|
||||
color: #997404;
|
||||
}
|
||||
|
||||
.internet-status.terminated,
|
||||
.internet-status.cancelled {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: #b02a37;
|
||||
}
|
||||
|
||||
.internet-mini {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.internet-quick-form {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 76, 117, 0.03);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.internet-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="internet-hero mb-4">
|
||||
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
|
||||
<div>
|
||||
<div class="small text-uppercase fw-semibold text-muted mb-2">Økonomi / Drift / Support</div>
|
||||
<h2 class="h3 mb-1">Internetforbindelser</h2>
|
||||
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="internet-tabs mt-3">
|
||||
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
|
||||
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
|
||||
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Forbindelser</div>
|
||||
<div class="internet-kpi-value" id="metricTotal">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Aktive</div>
|
||||
<div class="internet-kpi-value" id="metricActive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label" id="metricSharedLabel">Delte hoveder</div>
|
||||
<div class="internet-kpi-value" id="metricShared">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Dækningsbidrag</div>
|
||||
<div class="internet-kpi-value" id="metricMargin">0 kr.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="internet-panel p-4 mb-4 collapse" id="createConnectionBlock">
|
||||
<div class="internet-quick-form">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<div class="fw-semibold">Opret ny forbindelse</div>
|
||||
<div class="internet-mini">Bruges til fysiske og interne BMC-forbindelser.</div>
|
||||
</div>
|
||||
<div class="small text-muted" id="createConnectionFeedback" role="status"></div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-lg-3">
|
||||
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="text" class="form-control" id="connectionCircuitInput" placeholder="Kredsløb" />
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<input type="text" class="form-control" id="connectionAddressInput" placeholder="Installationsadresse" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select class="form-select" id="connectionAllocationInput">
|
||||
<option value="dedicated">Dedikeret</option>
|
||||
<option value="shared">Delt</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select class="form-select" id="connectionValueTypeInput" onchange="toggleCreateValueFields()">
|
||||
<option value="other">Anden værdi</option>
|
||||
<option value="subscription">Abonnement</option>
|
||||
<option value="bmc_networks">BMC Networks</option>
|
||||
<option value="delefiber">Delefiber</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-4" id="createValueLabelWrap">
|
||||
<input type="text" class="form-control" id="connectionValueLabelInput" placeholder="Værdi / klassifikation" value="Mangler klassifikation" />
|
||||
</div>
|
||||
<div class="col-lg-4 d-none" id="createSubscriptionWrap">
|
||||
<input type="text" class="form-control" id="connectionSubscriptionInput" list="subscriptionLookupList" placeholder="Abonnement">
|
||||
<input type="hidden" id="connectionSubscriptionIdInput" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="text" class="form-control" id="connectionTechnologyInput" placeholder="Teknologi" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionDownloadInput" placeholder="Download" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionUploadInput" placeholder="Upload" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionPurchaseInput" placeholder="Kost" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionSalesInput" placeholder="Salg" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select class="form-select" id="connectionStatusInput">
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="planned">Planlagt</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<textarea class="form-control" id="connectionNotesInput" rows="2" placeholder="Noter, SLA, overvågning, intern struktur..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-primary" type="button" onclick="submitConnectionForm()">Gem forbindelse</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="internet-panel p-4 mb-4">
|
||||
<div class="internet-toolbar mb-3">
|
||||
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
|
||||
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="">Alle statusser</option>
|
||||
<option value="active">Aktive</option>
|
||||
<option value="planned">Planlagte</option>
|
||||
<option value="inactive">Inaktive</option>
|
||||
<option value="terminated">Opsagte</option>
|
||||
</select>
|
||||
<select class="form-select" id="valueTypeFilter">
|
||||
<option value="">Alle værdier</option>
|
||||
<option value="subscription">Abonnement</option>
|
||||
<option value="bmc_networks">BMC Networks</option>
|
||||
<option value="delefiber">Delefiber</option>
|
||||
<option value="other">Anden</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">Anvend</button>
|
||||
<button class="btn btn-light border" type="button" onclick="resetFilters()">Nulstil</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="internet-mini" id="pageSummaryText">Indlæser forbindelser...</div>
|
||||
</div>
|
||||
<div class="col-xl-6 text-xl-end">
|
||||
<div class="internet-mini">Klik på en række for at åbne forbindelsesdetaljer.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Forbindelse</th>
|
||||
<th>Kunde</th>
|
||||
<th>Leverandør / kredsløb</th>
|
||||
<th>Hastighed / IP</th>
|
||||
<th>Økonomi</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="connectionsTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted py-4">Indlæser...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allConnections = [];
|
||||
let activeTab = 'all';
|
||||
let subscriptionOptions = [];
|
||||
|
||||
function formatDKK(value) {
|
||||
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatSpeed(item) {
|
||||
const down = Number(item.download_mbps || 0);
|
||||
const up = Number(item.upload_mbps || 0);
|
||||
const speed = Number(item.speed_mbps || 0);
|
||||
if (down || up) return `${down || 0}/${up || 0} Mbps`;
|
||||
if (speed) return `${speed} Mbps`;
|
||||
return '-';
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const value = String(status || 'inactive').toLowerCase();
|
||||
const labelMap = {
|
||||
active: 'Aktiv',
|
||||
planned: 'Planlagt',
|
||||
pending: 'Afventer',
|
||||
inactive: 'Inaktiv',
|
||||
terminated: 'Opsagt',
|
||||
cancelled: 'Annulleret',
|
||||
};
|
||||
return `<span class="internet-status ${value}">${labelMap[value] || value}</span>`;
|
||||
}
|
||||
|
||||
function allocationBadge(item) {
|
||||
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
|
||||
return `<span class="internet-tag">${label}</span>`;
|
||||
}
|
||||
|
||||
function valueBadge(item) {
|
||||
const label = item.value_type_label || 'Anden';
|
||||
return `<span class="internet-tag">${label}</span>`;
|
||||
}
|
||||
|
||||
function currentTabDescription() {
|
||||
if (activeTab === 'shared') return ' i delte hovedforbindelser';
|
||||
if (activeTab === 'bmcnet') return ' i BMCnet';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function safeJson(response, fallback) {
|
||||
if (!response.ok) return fallback;
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractErrorMessage(response, fallback) {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail.trim();
|
||||
if (Array.isArray(payload?.detail)) {
|
||||
const validation = payload.detail
|
||||
.map((item) => item?.msg || item?.message || '')
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
if (validation) return validation;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
if (text) return text;
|
||||
} catch (_) {}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadInternetPage() {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
const provider = document.getElementById('providerFilter').value.trim();
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const valueType = document.getElementById('valueTypeFilter').value;
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
if (provider) params.set('provider', provider);
|
||||
if (status) params.set('status', status);
|
||||
if (valueType) params.set('value_type', valueType);
|
||||
if (activeTab === 'shared') params.set('shared_only', 'true');
|
||||
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
|
||||
|
||||
try {
|
||||
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
|
||||
|
||||
const connections = await safeJson(connectionsResponse, []);
|
||||
allConnections = Array.isArray(connections) ? connections : [];
|
||||
const total = allConnections.length;
|
||||
const active = allConnections.filter((item) => item.status === 'active').length;
|
||||
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
|
||||
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
|
||||
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
|
||||
|
||||
document.getElementById('metricTotal').textContent = String(total);
|
||||
document.getElementById('metricActive').textContent = String(active);
|
||||
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
|
||||
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
|
||||
document.getElementById('metricMargin').textContent = formatDKK(margin);
|
||||
|
||||
renderConnections(allConnections);
|
||||
} catch (error) {
|
||||
document.getElementById('connectionsTableBody').innerHTML = '<tr><td colspan="7" class="text-danger py-4">Kunne ikke indlæse internetforbindelser.</td></tr>';
|
||||
document.getElementById('pageSummaryText').textContent = 'Indlæsning fejlede.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderConnections(connections) {
|
||||
const body = document.getElementById('connectionsTableBody');
|
||||
document.getElementById('pageSummaryText').textContent = `${connections.length} forbindelser vist${currentTabDescription()}`;
|
||||
|
||||
if (!connections.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="text-muted py-4">Ingen forbindelser matcher filtrene.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = connections.map((item) => `
|
||||
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
|
||||
<td>
|
||||
<div class="fw-semibold">${item.name || '-'}</div>
|
||||
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
|
||||
<div class="internet-mini">${item.address || '-'}</div>
|
||||
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''}
|
||||
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div>${item.customer_name || '-'}</div>
|
||||
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>${item.provider || '-'}</div>
|
||||
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div>
|
||||
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')}
|
||||
</td>
|
||||
<td>
|
||||
<div>${formatSpeed(item)}</div>
|
||||
<div class="internet-mini">Ranges ${Number(item.ip_range_count || 0)} · IP i brug: ${Number(item.in_use_ip_addresses || 0)} / ${Number(item.total_ip_addresses || 0)}</div>
|
||||
${item.is_shared_head ? `<div class="internet-mini">BMCnet-IP i brug: ${Number(item.bmcnet_child_ip_count || 0)}</div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div>${formatDKK(item.sales_price || 0)}</div>
|
||||
<div class="internet-mini">Kost ${formatDKK(item.monthly_cost || 0)} · DB ${formatDKK(item.margin_amount || 0)}</div>
|
||||
</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td class="text-end">
|
||||
<a href="/economy/internet-connections/${item.id}" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
Åbn
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function submitConnectionForm() {
|
||||
const feedback = document.getElementById('createConnectionFeedback');
|
||||
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
|
||||
const payload = {
|
||||
name: document.getElementById('connectionNameInput').value.trim(),
|
||||
provider: document.getElementById('connectionProviderInput').value.trim() || null,
|
||||
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
|
||||
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
|
||||
address: document.getElementById('connectionAddressInput').value.trim() || null,
|
||||
technology: document.getElementById('connectionTechnologyInput').value.trim() || null,
|
||||
download_mbps: Number(document.getElementById('connectionDownloadInput').value || 0) || null,
|
||||
upload_mbps: Number(document.getElementById('connectionUploadInput').value || 0) || null,
|
||||
monthly_cost: Number(document.getElementById('connectionPurchaseInput').value || 0),
|
||||
sales_price: Number(document.getElementById('connectionSalesInput').value || 0),
|
||||
status: document.getElementById('connectionStatusInput').value || 'active',
|
||||
notes: document.getElementById('connectionNotesInput').value.trim() || null,
|
||||
connection_type: 'fiber',
|
||||
allocation_model: document.getElementById('connectionAllocationInput').value || 'dedicated',
|
||||
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
|
||||
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
|
||||
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
feedback.textContent = 'Navn er påkrævet.';
|
||||
return;
|
||||
}
|
||||
if (!payload.address) {
|
||||
feedback.textContent = 'Adresse er påkrævet.';
|
||||
return;
|
||||
}
|
||||
if (payload.value_type === 'subscription' && !payload.subscription_id) {
|
||||
feedback.textContent = 'Vælg et gyldigt abonnement.';
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.textContent = 'Gemmer...';
|
||||
if (saveButton) saveButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/internet-connections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke gemme forbindelsen.');
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.textContent = 'Forbindelse oprettet.';
|
||||
[
|
||||
'connectionNameInput',
|
||||
'connectionProviderInput',
|
||||
'connectionCustomerIdInput',
|
||||
'connectionCircuitInput',
|
||||
'connectionAddressInput',
|
||||
'connectionTechnologyInput',
|
||||
'connectionDownloadInput',
|
||||
'connectionUploadInput',
|
||||
'connectionPurchaseInput',
|
||||
'connectionSalesInput',
|
||||
'connectionNotesInput',
|
||||
].forEach((id) => {
|
||||
const field = document.getElementById(id);
|
||||
if (field) field.value = '';
|
||||
});
|
||||
document.getElementById('connectionStatusInput').value = 'active';
|
||||
document.getElementById('connectionAllocationInput').value = 'dedicated';
|
||||
document.getElementById('connectionValueTypeInput').value = 'other';
|
||||
document.getElementById('connectionValueLabelInput').value = 'Mangler klassifikation';
|
||||
document.getElementById('connectionSubscriptionInput').value = '';
|
||||
document.getElementById('connectionSubscriptionIdInput').value = '';
|
||||
toggleCreateValueFields();
|
||||
await loadInternetPage();
|
||||
} catch (error) {
|
||||
feedback.textContent = error?.message || 'Netværksfejl under gem.';
|
||||
} finally {
|
||||
if (saveButton) saveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('providerFilter').value = '';
|
||||
document.getElementById('statusFilter').value = '';
|
||||
document.getElementById('valueTypeFilter').value = '';
|
||||
loadInternetPage();
|
||||
}
|
||||
|
||||
function setActiveTab(tab) {
|
||||
activeTab = tab;
|
||||
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
|
||||
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
|
||||
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
|
||||
loadInternetPage();
|
||||
}
|
||||
|
||||
function toggleCreateValueFields() {
|
||||
const valueType = document.getElementById('connectionValueTypeInput').value;
|
||||
document.getElementById('createValueLabelWrap').classList.toggle('d-none', valueType !== 'other');
|
||||
document.getElementById('createSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
|
||||
}
|
||||
|
||||
function buildSubscriptionLabel(item) {
|
||||
return `${item.id} · ${item.subscription_number || '-'} · ${item.product_name || '-'} · ${item.customer_name || '-'}`;
|
||||
}
|
||||
|
||||
function resolveSubscriptionId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
const idMatch = raw.match(/^(\d+)\b/);
|
||||
if (idMatch) return Number(idMatch[1]);
|
||||
const matched = subscriptionOptions.find((item) => buildSubscriptionLabel(item).toLowerCase() === raw.toLowerCase());
|
||||
return matched ? Number(matched.id) : null;
|
||||
}
|
||||
|
||||
async function loadSubscriptionOptions() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/internet-connections/subscription-options');
|
||||
subscriptionOptions = response.ok ? await response.json() : [];
|
||||
} catch (error) {
|
||||
subscriptionOptions = [];
|
||||
}
|
||||
document.getElementById('subscriptionLookupList').innerHTML = subscriptionOptions
|
||||
.map((item) => `<option value="${buildSubscriptionLabel(item)}"></option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') loadInternetPage();
|
||||
});
|
||||
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
|
||||
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
|
||||
});
|
||||
toggleCreateValueFields();
|
||||
await loadSubscriptionOptions();
|
||||
await loadInternetPage();
|
||||
});
|
||||
</script>
|
||||
<datalist id="subscriptionLookupList"></datalist>
|
||||
{% endblock %}
|
||||
@ -1,619 +0,0 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Internet Wizard v2{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.wiz-hero {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 76, 117, 0.16), transparent 36%),
|
||||
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 14px 34px rgba(15, 76, 117, 0.06);
|
||||
}
|
||||
|
||||
.wiz-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 14px 30px rgba(15, 76, 117, 0.05);
|
||||
}
|
||||
|
||||
.wiz-kpi {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
|
||||
}
|
||||
|
||||
.wiz-kpi-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wiz-kpi-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wiz-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wiz-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 76, 117, 0.03);
|
||||
}
|
||||
|
||||
.wiz-card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.wiz-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wiz-snippet {
|
||||
border-left: 3px solid rgba(15, 76, 117, 0.25);
|
||||
padding-left: 0.8rem;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wiz-upload-box {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.24);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 76, 117, 0.025);
|
||||
}
|
||||
|
||||
.wiz-empty {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wiz-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: rgba(15, 76, 117, 0.08);
|
||||
color: #0f4c75;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wiz-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.wiz-summary {
|
||||
min-height: 110px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wiz-segment-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wiz-segment-button:hover .wiz-card,
|
||||
.wiz-segment-button:focus-visible .wiz-card {
|
||||
border-color: rgba(15, 76, 117, 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.1);
|
||||
}
|
||||
|
||||
.wiz-full-block {
|
||||
white-space: pre-wrap;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.wiz-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="wiz-hero mb-4">
|
||||
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
|
||||
<div>
|
||||
<div class="small text-uppercase fw-semibold text-muted mb-2">Data migration / Internet</div>
|
||||
<h2 class="h3 mb-1">Internet Wizard v2</h2>
|
||||
<div class="text-muted">Midlertidig research-wizard til gamle kundetekster, internetnoter og leverandørfakturaer fra de sidste 18 måneder.</div>
|
||||
</div>
|
||||
<div class="wiz-pill">
|
||||
<i class="bi bi-magic"></i>
|
||||
Temp version
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-panel p-4 mb-4">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-5">
|
||||
<label class="form-label fw-semibold">Kunde</label>
|
||||
<input type="text" class="form-control" id="customerSearchInput" list="customerOptions" placeholder="Søg kunde og vælg fra listen">
|
||||
<datalist id="customerOptions"></datalist>
|
||||
<input type="hidden" id="selectedCustomerId">
|
||||
<div class="wiz-meta mt-2" id="selectedCustomerMeta">Ingen kunde valgt endnu.</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<label class="form-label fw-semibold">Søgning / fokus</label>
|
||||
<input type="text" class="form-control" id="queryInput" placeholder="fx fiber, MPLS, Lejrvej, public IP, gammel aftale">
|
||||
</div>
|
||||
<div class="col-lg-2 d-grid">
|
||||
<button class="btn btn-primary" type="button" onclick="loadWizardContext()">
|
||||
<i class="bi bi-search me-1"></i>Vis info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="wiz-kpi">
|
||||
<div class="wiz-kpi-label">Kundefiler</div>
|
||||
<div class="wiz-kpi-value" id="metricDocuments">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="wiz-kpi">
|
||||
<div class="wiz-kpi-label">Blokfund</div>
|
||||
<div class="wiz-kpi-value" id="metricSnippets">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="wiz-kpi">
|
||||
<div class="wiz-kpi-label">Internetfakturaer</div>
|
||||
<div class="wiz-kpi-value" id="metricInvoices">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="wiz-kpi">
|
||||
<div class="wiz-kpi-label">Status</div>
|
||||
<div class="wiz-kpi-value fs-6" id="wizardStatusText">Venter</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-grid mb-4">
|
||||
<div class="wiz-panel p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1">AI-overblik</h3>
|
||||
<div class="wiz-meta">Kort opsummering fra kundefiler og relevante internetfakturaer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-card wiz-summary" id="aiSummaryBox">Vælg en kunde for at hente kontekst.</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-panel p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1">Upload tekstfiler</h3>
|
||||
<div class="wiz-meta">Upload delte batch-filer med flere kunder. Systemet splitter dem i søgbare blokke med fx IP, CIDR, stiknr og referencer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-upload-box">
|
||||
<div class="mb-2">
|
||||
<input type="file" class="form-control" id="customerFileInput" accept=".txt,.csv,.log,.md">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<textarea class="form-control" id="customerFileNotes" rows="3" placeholder="Noter om filen eller kontekst"></textarea>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-outline-primary" type="button" onclick="uploadCustomerFile()">
|
||||
<i class="bi bi-upload me-1"></i>Upload fil
|
||||
</button>
|
||||
</div>
|
||||
<div class="wiz-meta mt-2" id="uploadStatus">Ingen upload kørt endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-6">
|
||||
<div class="wiz-panel p-4 h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1">Kundetekster</h3>
|
||||
<div class="wiz-meta">Uploadede filer og de bedste fund fra dem.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-list" id="documentList">
|
||||
<div class="wiz-empty">Ingen dokumenter endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<div class="wiz-panel p-4 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1">Præcise blokfund</h3>
|
||||
<div class="wiz-meta">Her finder du den konkrete tekstblok med fx IP, stiknr eller reference.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-list" id="segmentList">
|
||||
<div class="wiz-empty">Ingen blokfund endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-12">
|
||||
<div class="wiz-panel p-4 h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1">Relaterede fakturaer</h3>
|
||||
<div class="wiz-meta">De sidste 18 måneder med internet/bredbånd-relateret tekst.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-list" id="invoiceList">
|
||||
<div class="wiz-empty">Ingen fakturafund endnu.</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let customerSearchTimer = null;
|
||||
let customerSearchRequest = 0;
|
||||
let customerOptions = [];
|
||||
let localUploadedDocuments = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleDateString('da-DK');
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!size) return '0 B';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatMoney(value, currency = 'DKK') {
|
||||
return new Intl.NumberFormat('da-DK', {
|
||||
style: 'currency',
|
||||
currency: currency || 'DKK',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function setWizardStatus(text) {
|
||||
document.getElementById('wizardStatusText').textContent = text;
|
||||
}
|
||||
|
||||
function renderLocalDocumentsOnly(documents = []) {
|
||||
document.getElementById('metricDocuments').textContent = String(documents.length);
|
||||
document.getElementById('metricSnippets').textContent = '0';
|
||||
document.getElementById('metricInvoices').textContent = '0';
|
||||
document.getElementById('aiSummaryBox').textContent = 'Viser uploadede filer. Vælg kunde eller genindlæs siden efter backend-genstart for fuld kontekst.';
|
||||
|
||||
const documentList = document.getElementById('documentList');
|
||||
if (!documents.length) {
|
||||
documentList.innerHTML = '<div class="wiz-empty">Ingen dokumenter i det delte arkiv endnu.</div>';
|
||||
} else {
|
||||
documentList.innerHTML = documents.map(item => `
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.original_filename || item.filename || 'Uploadet fil')}</h3>
|
||||
<div class="wiz-meta">${item.created_at ? formatDate(item.created_at) + ' · ' : ''}${item.file_size ? formatBytes(item.file_size) + ' · ' : ''}${escapeHtml(item.mime_type || '-')}</div>
|
||||
</div>
|
||||
<span class="wiz-pill">${item.segment_count || 0} blokke</span>
|
||||
</div>
|
||||
${(item.notes ? `<div class="wiz-meta mt-2">${escapeHtml(item.notes)}</div>` : '')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('segmentList').innerHTML = '<div class="wiz-empty">Vælg kunde eller søg på IP/stiknr/reference for konkrete blokfund.</div>';
|
||||
document.getElementById('invoiceList').innerHTML = '<div class="wiz-empty">Vælg kunde for at se relaterede internetfakturaer.</div>';
|
||||
}
|
||||
|
||||
function renderCustomerOptions(items) {
|
||||
customerOptions = items || [];
|
||||
const list = document.getElementById('customerOptions');
|
||||
list.innerHTML = customerOptions.map(item => (
|
||||
`<option value="${escapeHtml(item.name)}" data-id="${item.id}"></option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function searchCustomers(query) {
|
||||
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(query)}&limit=15&is_active=true`);
|
||||
if (!response.ok) return [];
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload) ? payload : (payload.customers || payload.items || []);
|
||||
}
|
||||
|
||||
async function onCustomerInputChanged() {
|
||||
const input = document.getElementById('customerSearchInput');
|
||||
const query = input.value.trim();
|
||||
const requestId = ++customerSearchRequest;
|
||||
document.getElementById('selectedCustomerId').value = '';
|
||||
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
|
||||
|
||||
const exact = customerOptions.find(item => item.name === query);
|
||||
if (exact) {
|
||||
document.getElementById('selectedCustomerId').value = exact.id;
|
||||
document.getElementById('selectedCustomerMeta').textContent = `Kunde-ID ${exact.id} · ${exact.name}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < 2) return;
|
||||
const items = await searchCustomers(query);
|
||||
// Ignore a slower response from an earlier, shorter search phrase.
|
||||
if (requestId !== customerSearchRequest || input.value.trim() !== query) return;
|
||||
renderCustomerOptions(items);
|
||||
}
|
||||
|
||||
async function loadWizardContext() {
|
||||
const customerId = document.getElementById('selectedCustomerId').value;
|
||||
const query = document.getElementById('queryInput').value.trim();
|
||||
setWizardStatus('Henter');
|
||||
if (!customerId && !query) {
|
||||
try {
|
||||
const docRes = await fetch('/api/v1/internet-connections/customer-documents');
|
||||
if (docRes.ok) {
|
||||
const docPayload = await docRes.json();
|
||||
const docs = docPayload.documents || [];
|
||||
renderLocalDocumentsOnly(docs);
|
||||
setWizardStatus('Klar');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load shared document list', error);
|
||||
}
|
||||
if (localUploadedDocuments.length) {
|
||||
renderLocalDocumentsOnly(localUploadedDocuments);
|
||||
setWizardStatus('Klar');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (customerId) {
|
||||
params.set('customer_id', customerId);
|
||||
}
|
||||
if (query) {
|
||||
params.set('query', query);
|
||||
}
|
||||
const response = await fetch(`/api/v1/internet-connections/migration-wizard-v2/context?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
}
|
||||
const detail = JSON.stringify(payload.detail || '');
|
||||
if (!customerId && detail.includes('customer_id')) {
|
||||
if (localUploadedDocuments.length) {
|
||||
renderLocalDocumentsOnly(localUploadedDocuments);
|
||||
setWizardStatus('Klar');
|
||||
return;
|
||||
}
|
||||
setWizardStatus('Venter');
|
||||
return;
|
||||
}
|
||||
setWizardStatus('Fejl');
|
||||
const errorText = payload.detail ? JSON.stringify(payload.detail) : await response.text();
|
||||
alert(`Kunne ikke hente kontekst: ${errorText}`);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderWizardContext(payload);
|
||||
setWizardStatus('Klar');
|
||||
}
|
||||
|
||||
function renderWizardContext(payload) {
|
||||
const documents = payload.documents || [];
|
||||
const invoiceHits = payload.invoice_hits || [];
|
||||
const segmentHits = payload.segment_hits || [];
|
||||
const customer = payload.customer || null;
|
||||
|
||||
document.getElementById('metricDocuments').textContent = String(documents.length);
|
||||
document.getElementById('metricSnippets').textContent = String(segmentHits.length);
|
||||
document.getElementById('metricInvoices').textContent = String(invoiceHits.length);
|
||||
document.getElementById('aiSummaryBox').textContent = payload.ai_summary || (customer ? 'Ingen AI-opsummering endnu.' : 'Viser delt arkiv. Vælg kunde eller søg på IP/stiknr/reference for mere præcise fund.');
|
||||
document.getElementById('selectedCustomerMeta').textContent = customer
|
||||
? `Kunde-ID ${customer.id} · ${customer.name}`
|
||||
: 'Delt arkiv uden valgt kunde.';
|
||||
|
||||
const documentList = document.getElementById('documentList');
|
||||
if (!documents.length) {
|
||||
documentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen kundefiler fundet for denne kunde.' : 'Ingen dokumenter i det delte arkiv endnu.'}</div>`;
|
||||
} else {
|
||||
documentList.innerHTML = documents.map(item => `
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.original_filename)}</h3>
|
||||
<div class="wiz-meta">${formatDate(item.created_at)} · ${formatBytes(item.file_size)} · ${escapeHtml(item.mime_type || '-')}</div>
|
||||
</div>
|
||||
<span class="wiz-pill">${item.snippet_count || 0} fund</span>
|
||||
</div>
|
||||
${(item.notes ? `<div class="wiz-meta mt-2">${escapeHtml(item.notes)}</div>` : '')}
|
||||
${(item.snippets || []).map(snippet => `<div class="wiz-snippet">${escapeHtml(snippet)}</div>`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const segmentList = document.getElementById('segmentList');
|
||||
if (!segmentHits.length) {
|
||||
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
|
||||
} else {
|
||||
segmentList.innerHTML = segmentHits.map(item => `
|
||||
<button class="wiz-segment-button" type="button" onclick="openSegment(${Number(item.segment_id)})" aria-label="Vis hele tekstblokken: ${escapeHtml(item.title || 'Blok')}">
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || 'Blok')}</h3>
|
||||
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score} · Klik for hele blokken</div>
|
||||
</div>
|
||||
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
|
||||
</div>
|
||||
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
|
||||
<div class="wiz-meta mt-2">
|
||||
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
|
||||
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
|
||||
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
|
||||
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const invoiceList = document.getElementById('invoiceList');
|
||||
if (!invoiceHits.length) {
|
||||
invoiceList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen internetrelaterede fakturafund de sidste 18 måneder.' : 'Vælg kunde for at se relaterede internetfakturaer.'}</div>`;
|
||||
} else {
|
||||
invoiceList.innerHTML = invoiceHits.map(item => `
|
||||
<div class="wiz-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.vendor_name)} · ${escapeHtml(item.invoice_number || '-')}</h3>
|
||||
<div class="wiz-meta">${formatDate(item.invoice_date)} · ${formatMoney(item.total_amount, item.currency)}${item.directly_linked ? ' · koblet til kunde' : ''}</div>
|
||||
</div>
|
||||
<span class="wiz-pill">score ${item.score}</span>
|
||||
</div>
|
||||
${(item.source_filename ? `<div class="wiz-meta mt-2">Fil: ${escapeHtml(item.source_filename)}</div>` : '')}
|
||||
${(item.snippets || []).map(snippet => `<div class="wiz-snippet">${escapeHtml(snippet)}</div>`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function openSegment(segmentId) {
|
||||
const modalElement = document.getElementById('segmentModal');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||
document.getElementById('segmentModalTitle').textContent = 'Tekstblok';
|
||||
document.getElementById('segmentModalMeta').textContent = '';
|
||||
document.getElementById('segmentModalContent').textContent = 'Henter tekstblok…';
|
||||
modal.show();
|
||||
try {
|
||||
const response = await fetch(`/api/v1/internet-connections/customer-documents/segments/${segmentId}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || 'Kunne ikke hente tekstblokken');
|
||||
document.getElementById('segmentModalTitle').textContent = payload.title || 'Tekstblok';
|
||||
document.getElementById('segmentModalMeta').textContent = `${payload.original_filename || 'Tekstfil'} · blok ${Number(payload.block_index || 0) + 1}`;
|
||||
document.getElementById('segmentModalContent').textContent = payload.content || 'Blokken er tom.';
|
||||
} catch (error) {
|
||||
document.getElementById('segmentModalContent').textContent = error.message || 'Kunne ikke hente tekstblokken.';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCustomerFile() {
|
||||
const fileInput = document.getElementById('customerFileInput');
|
||||
const notes = document.getElementById('customerFileNotes').value.trim();
|
||||
if (!fileInput.files.length) {
|
||||
alert('Vælg en fil først.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const customerId = document.getElementById('selectedCustomerId').value;
|
||||
if (customerId) {
|
||||
formData.append('customer_id', customerId);
|
||||
}
|
||||
formData.append('notes', notes);
|
||||
formData.append('file', fileInput.files[0]);
|
||||
document.getElementById('uploadStatus').textContent = 'Uploader...';
|
||||
|
||||
const response = await fetch('/api/v1/internet-connections/customer-documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
document.getElementById('uploadStatus').textContent = 'Upload fejlede.';
|
||||
alert(payload.detail || 'Upload fejlede');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('uploadStatus').textContent = payload.message || `Uploadet: ${payload.filename || fileInput.files[0].name} · ${payload.segment_count || 0} blokke indekseret`;
|
||||
localUploadedDocuments.unshift({
|
||||
document_id: payload.document_id,
|
||||
original_filename: payload.filename || fileInput.files[0].name,
|
||||
mime_type: fileInput.files[0].type || 'text/plain',
|
||||
file_size: fileInput.files[0].size || 0,
|
||||
notes,
|
||||
segment_count: payload.segment_count || 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
fileInput.value = '';
|
||||
document.getElementById('customerFileNotes').value = '';
|
||||
await loadWizardContext();
|
||||
}
|
||||
|
||||
document.getElementById('customerSearchInput').addEventListener('input', () => {
|
||||
clearTimeout(customerSearchTimer);
|
||||
customerSearchTimer = setTimeout(onCustomerInputChanged, 250);
|
||||
});
|
||||
|
||||
document.getElementById('customerSearchInput').addEventListener('change', onCustomerInputChanged);
|
||||
document.getElementById('queryInput').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
loadWizardContext();
|
||||
}
|
||||
});
|
||||
|
||||
loadWizardContext();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1 +0,0 @@
|
||||
"""Invoice Error Finder module."""
|
||||
@ -1 +0,0 @@
|
||||
"""Invoice Error Finder backend."""
|
||||
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
"""Invoice Error Finder frontend."""
|
||||
@ -1,67 +0,0 @@
|
||||
"""
|
||||
Invoice Error Finder frontend views.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.auth_dependencies import require_permission
|
||||
from app.core.database import execute_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
|
||||
def _fetch_assignment_users() -> list:
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT user_id, COALESCE(full_name, username) AS display_name
|
||||
FROM users
|
||||
ORDER BY display_name
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
|
||||
def _fetch_customers() -> list:
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM customers
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY name
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
|
||||
@router.get("/invoice-error-finder", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
current_user: dict = Depends(require_permission("invoice_error_finder.view")),
|
||||
):
|
||||
return templates.TemplateResponse(
|
||||
"modules/invoice_error_finder/templates/dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invoice-error-finder/issues", response_class=HTMLResponse)
|
||||
async def issues_list(
|
||||
request: Request,
|
||||
current_user: dict = Depends(require_permission("invoice_error_finder.view")),
|
||||
):
|
||||
return templates.TemplateResponse(
|
||||
"modules/invoice_error_finder/templates/issues.html",
|
||||
{
|
||||
"request": request,
|
||||
"users": _fetch_assignment_users(),
|
||||
"customers": _fetch_customers(),
|
||||
},
|
||||
)
|
||||
@ -1,180 +0,0 @@
|
||||
-- Migration 001: Invoice Error Finder module
|
||||
-- Staging tables for e-conomic invoices/lines and Simply CRM sales orders,
|
||||
-- plus detected issues and import runs.
|
||||
|
||||
-- Import run log (one row per source/import attempt)
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_import_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_type VARCHAR(50) NOT NULL, -- 'economic_invoices' | 'simply_sales_orders'
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'partial', 'failed')),
|
||||
records_imported INTEGER NOT NULL DEFAULT 0,
|
||||
records_failed INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
triggered_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
is_scheduled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_import_runs_source
|
||||
ON invoice_error_finder_import_runs(source_type, started_at DESC);
|
||||
|
||||
-- e-conomic invoice headers
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE,
|
||||
source_invoice_number VARCHAR(80),
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'booked', -- booked | paid | draft | unpaid
|
||||
customer_number INTEGER,
|
||||
customer_name VARCHAR(255),
|
||||
invoice_date DATE,
|
||||
due_date DATE,
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
net_amount NUMERIC(14,2),
|
||||
vat_amount NUMERIC(14,2),
|
||||
total_amount NUMERIC(14,2),
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT uq_ief_economic_invoice_import UNIQUE (import_run_id, source_invoice_number, source_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_run
|
||||
ON invoice_error_finder_economic_invoices(import_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_customer
|
||||
ON invoice_error_finder_economic_invoices(customer_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_date
|
||||
ON invoice_error_finder_economic_invoices(invoice_date);
|
||||
|
||||
-- e-conomic invoice lines
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoice_lines (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoice_error_finder_economic_invoices(id) ON DELETE CASCADE,
|
||||
line_number INTEGER,
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
description TEXT,
|
||||
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
line_net_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_percentage NUMERIC(5,2) DEFAULT 0,
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_invoice
|
||||
ON invoice_error_finder_economic_invoice_lines(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_product
|
||||
ON invoice_error_finder_economic_invoice_lines(product_number);
|
||||
|
||||
-- Simply CRM open sales orders
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_simply_sales_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE,
|
||||
source_record_id VARCHAR(80) NOT NULL,
|
||||
salesorder_no VARCHAR(80),
|
||||
account_id VARCHAR(80),
|
||||
customer_name VARCHAR(255),
|
||||
customer_cvr VARCHAR(32),
|
||||
subject TEXT,
|
||||
status VARCHAR(50),
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
start_period DATE,
|
||||
end_period DATE,
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT uq_ief_simply_order_import UNIQUE (source_record_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_run
|
||||
ON invoice_error_finder_simply_sales_orders(import_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_account
|
||||
ON invoice_error_finder_simply_sales_orders(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_status
|
||||
ON invoice_error_finder_simply_sales_orders(status);
|
||||
|
||||
-- Detected issues / anomalies
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
issue_type VARCHAR(50) NOT NULL CHECK (issue_type IN (
|
||||
'missing_line',
|
||||
'open_order_not_invoiced',
|
||||
'quantity_drop',
|
||||
'price_change',
|
||||
'new_item_never_invoiced'
|
||||
)),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'open' CHECK (status IN (
|
||||
'open',
|
||||
'investigating',
|
||||
'approved_change',
|
||||
'error_found',
|
||||
'ready_to_invoice',
|
||||
'invoiced',
|
||||
'ignored',
|
||||
'resolved'
|
||||
)),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
customer_name VARCHAR(255),
|
||||
subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
|
||||
simply_order_id INTEGER REFERENCES invoice_error_finder_simply_sales_orders(id) ON DELETE SET NULL,
|
||||
simply_source_record_id VARCHAR(80),
|
||||
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
|
||||
reference_period_start DATE,
|
||||
reference_period_end DATE,
|
||||
|
||||
expected_quantity NUMERIC(14,4),
|
||||
actual_quantity NUMERIC(14,4),
|
||||
expected_price NUMERIC(14,4),
|
||||
actual_price NUMERIC(14,4),
|
||||
|
||||
last_invoice_number VARCHAR(80),
|
||||
last_invoice_date DATE,
|
||||
sales_order_number VARCHAR(80),
|
||||
|
||||
amount_impact NUMERIC(14,2),
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
|
||||
assigned_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
ignored_until DATE,
|
||||
resolved_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_type
|
||||
ON invoice_error_finder_issues(issue_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_status
|
||||
ON invoice_error_finder_issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_customer
|
||||
ON invoice_error_finder_issues(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_period
|
||||
ON invoice_error_finder_issues(reference_period_start, reference_period_end);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_assigned
|
||||
ON invoice_error_finder_issues(assigned_user_id)
|
||||
WHERE assigned_user_id IS NULL;
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE OR REPLACE FUNCTION update_ief_issues_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_ief_issues_updated_at ON invoice_error_finder_issues;
|
||||
CREATE TRIGGER trigger_ief_issues_updated_at
|
||||
BEFORE UPDATE ON invoice_error_finder_issues
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ief_issues_updated_at();
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "invoice_error_finder",
|
||||
"version": "1.0.0",
|
||||
"description": "Faktura-fejl-finder: sammenligner fakturaer fra e-conomic med abonnementer og salgsordrer fra Simply CRM for at finde manglende eller ændrede fakturalinjer.",
|
||||
"author": "BMC Networks",
|
||||
"enabled": true,
|
||||
"dependencies": ["sag"],
|
||||
"table_prefix": "invoice_error_finder_",
|
||||
"api_prefix": "/api/v1/invoice-error-finder",
|
||||
"tags": ["Invoice Error Finder", "Faktura", "Økonomi"],
|
||||
"config": {
|
||||
"safety_switches": {
|
||||
"read_only": false,
|
||||
"dry_run": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
"""Invoice Error Finder services."""
|
||||
@ -1,954 +0,0 @@
|
||||
"""
|
||||
Detection service for Invoice Error Finder.
|
||||
Compares imported e-conomic invoices with subscriptions / Simply orders and
|
||||
writes issues to invoice_error_finder_issues.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_IGNORED_PRODUCT_TEXTS = [
|
||||
"faktureringsgebyr",
|
||||
"gebyr",
|
||||
"porto",
|
||||
"fragt",
|
||||
"fragtomkostning",
|
||||
"forsendelse",
|
||||
"shipping",
|
||||
"levering",
|
||||
"engangsydelse",
|
||||
"engangsarbejde",
|
||||
"oprettelse",
|
||||
"opstartsgebyr",
|
||||
"installation",
|
||||
"installationsgebyr",
|
||||
"timeforbrug",
|
||||
"arbejdstid",
|
||||
"konsulenttimer",
|
||||
"supporttid",
|
||||
"teknikertid",
|
||||
"montørtimer",
|
||||
"projektarbejde",
|
||||
]
|
||||
|
||||
DEFAULT_IGNORED_PRODUCT_PATTERNS = [
|
||||
re.compile(r"\bcase\s*id\s*cc[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\bcase\s*id\b", re.IGNORECASE),
|
||||
re.compile(r"\bsag\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\bprojekt\s*(id|nr|nummer)?\s*[:#-]?\s*[\w-]+\b", re.IGNORECASE),
|
||||
re.compile(r"\b(?:timeforbrug|arbejdstid|konsulenttimer|supporttid|teknikertid|montørtimer)\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
class DetectionService:
|
||||
"""Detect invoice anomalies and write issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
quantity_drop_threshold: float = 0.10,
|
||||
open_order_days_threshold: int = 7,
|
||||
):
|
||||
self.quantity_drop_threshold = quantity_drop_threshold
|
||||
self.open_order_days_threshold = open_order_days_threshold
|
||||
self._ignored_product_texts_cache: Optional[List[str]] = None
|
||||
self._seen_issue_ids: set[int] = set()
|
||||
|
||||
def analyze(self, reference_month: Optional[date] = None) -> Dict[str, int]:
|
||||
"""
|
||||
Run detection rules for a specific month or sweep historical invoice months.
|
||||
Returns aggregated counts per issue_type.
|
||||
"""
|
||||
self._ignore_existing_issues()
|
||||
|
||||
if reference_month is not None:
|
||||
return self._analyze_single_month(reference_month)
|
||||
|
||||
totals = {
|
||||
"missing_line": 0,
|
||||
"open_order_not_invoiced": 0,
|
||||
"quantity_drop": 0,
|
||||
"price_change": 0,
|
||||
}
|
||||
|
||||
months = self._get_analysis_months()
|
||||
logger.info("🔍 Running historical invoice error detection for %s month(s)", len(months))
|
||||
|
||||
for month in months:
|
||||
month_counts = self._analyze_single_month(month)
|
||||
for key, value in month_counts.items():
|
||||
totals[key] = totals.get(key, 0) + int(value or 0)
|
||||
|
||||
logger.info("✅ Historical detection complete: %s", totals)
|
||||
return totals
|
||||
|
||||
def _analyze_single_month(self, reference_month: date) -> Dict[str, int]:
|
||||
previous_month = reference_month - relativedelta(months=1)
|
||||
self._seen_issue_ids = set()
|
||||
|
||||
logger.info("🔍 Running invoice error detection for %s", reference_month)
|
||||
|
||||
counts = {
|
||||
"missing_line": self._detect_missing_lines(reference_month, previous_month),
|
||||
"open_order_not_invoiced": self._detect_open_orders_not_invoiced(reference_month),
|
||||
"quantity_drop": self._detect_quantity_drops(reference_month, previous_month),
|
||||
"price_change": self._detect_price_changes(reference_month, previous_month),
|
||||
}
|
||||
|
||||
self._resolve_stale_issues(reference_month)
|
||||
|
||||
logger.info("✅ Detection complete for %s: %s", reference_month, counts)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _get_analysis_months() -> List[date]:
|
||||
current_month = date.today().replace(day=1)
|
||||
bounds = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
DATE_TRUNC('month', MIN(invoice_date))::date AS first_month,
|
||||
GREATEST(
|
||||
DATE_TRUNC('month', MAX(invoice_date))::date,
|
||||
DATE_TRUNC('month', CURRENT_DATE)::date
|
||||
) AS last_month
|
||||
FROM invoice_error_finder_economic_invoices
|
||||
"""
|
||||
) or {}
|
||||
|
||||
first_month = bounds.get("first_month")
|
||||
last_month = bounds.get("last_month") or current_month
|
||||
|
||||
if not first_month:
|
||||
return [current_month]
|
||||
|
||||
month = first_month + relativedelta(months=1)
|
||||
months: List[date] = []
|
||||
while month <= last_month:
|
||||
months.append(month)
|
||||
month += relativedelta(months=1)
|
||||
|
||||
return months or [last_month]
|
||||
|
||||
def _detect_missing_lines(self, current_month: date, previous_month: date) -> int:
|
||||
"""
|
||||
Products invoiced in previous month but missing in current month for same customer.
|
||||
"""
|
||||
current_start, current_end = self._month_bounds(current_month)
|
||||
previous_start, previous_end = self._month_bounds(previous_month)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH customer_map AS (
|
||||
SELECT DISTINCT ON (economic_customer_number)
|
||||
economic_customer_number,
|
||||
id AS hub_customer_id
|
||||
FROM customers
|
||||
WHERE economic_customer_number IS NOT NULL
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY economic_customer_number, id
|
||||
),
|
||||
previous_lines AS (
|
||||
SELECT
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
SUM(line.quantity) AS quantity,
|
||||
MAX(inv.invoice_date) AS last_invoice_date,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
LEFT JOIN customer_map m
|
||||
ON m.economic_customer_number = inv.customer_number
|
||||
WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s
|
||||
AND line.product_number IS NOT NULL
|
||||
GROUP BY customer_key, product_key
|
||||
),
|
||||
current_lines AS (
|
||||
SELECT
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
LEFT JOIN customer_map m
|
||||
ON m.economic_customer_number = inv.customer_number
|
||||
WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s
|
||||
AND line.product_number IS NOT NULL
|
||||
GROUP BY customer_key, product_key
|
||||
)
|
||||
SELECT
|
||||
prev.customer_key,
|
||||
prev.product_key,
|
||||
prev.quantity AS expected_quantity,
|
||||
prev.last_invoice_date,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m2.hub_customer_id
|
||||
FROM previous_lines prev
|
||||
LEFT JOIN current_lines cur
|
||||
ON cur.customer_key = prev.customer_key
|
||||
AND cur.product_key = prev.product_key
|
||||
LEFT JOIN customers c ON c.id = prev.customer_key
|
||||
LEFT JOIN customer_map m2
|
||||
ON m2.hub_customer_id = prev.customer_key
|
||||
WHERE cur.product_key IS NULL
|
||||
""",
|
||||
(previous_start, previous_end, current_start, current_end),
|
||||
) or []
|
||||
|
||||
created = 0
|
||||
for row in rows:
|
||||
hub_customer_id = self._resolve_hub_customer_id(row)
|
||||
customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name"))
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="missing_line",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_quantity=row.get("expected_quantity"),
|
||||
actual_quantity=0,
|
||||
last_invoice_date=row.get("last_invoice_date"),
|
||||
amount_impact=None,
|
||||
)
|
||||
if issue_id:
|
||||
created += 1
|
||||
|
||||
return created
|
||||
|
||||
def _detect_open_orders_not_invoiced(self, reference_month: date) -> int:
|
||||
"""Open Simply sales orders without a matching e-conomic invoice line."""
|
||||
month_start, month_end = self._month_bounds(reference_month)
|
||||
lookback_start = month_start - timedelta(days=self.open_order_days_threshold)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
so.id,
|
||||
so.source_record_id,
|
||||
so.salesorder_no,
|
||||
so.account_id,
|
||||
so.customer_name,
|
||||
so.subject,
|
||||
so.product_number,
|
||||
so.product_name,
|
||||
so.quantity,
|
||||
so.unit_price,
|
||||
so.total_amount,
|
||||
so.start_period,
|
||||
so.end_period,
|
||||
sss.hub_customer_id
|
||||
FROM invoice_error_finder_simply_sales_orders so
|
||||
LEFT JOIN simply_subscription_staging sss
|
||||
ON sss.source_account_id = so.account_id
|
||||
WHERE so.status IN ('Created', 'Approved', 'Delivered')
|
||||
AND COALESCE(so.source_record_id, '') NOT IN (
|
||||
SELECT COALESCE(simply_source_record_id, '')
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE issue_type = 'open_order_not_invoiced'
|
||||
AND (
|
||||
status = 'invoiced'
|
||||
OR (
|
||||
status = 'ignored'
|
||||
AND (ignored_until IS NULL OR ignored_until >= CURRENT_DATE)
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY so.id
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
created = 0
|
||||
for row in rows:
|
||||
hub_customer_id = row.get("hub_customer_id")
|
||||
customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name"))
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, reference_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("subject"),
|
||||
):
|
||||
continue
|
||||
|
||||
# Check if there is any e-conomic invoice line for this customer + product recently
|
||||
has_invoice = self._has_recent_invoice_for_product(
|
||||
hub_customer_id,
|
||||
row.get("product_number"),
|
||||
lookback_start,
|
||||
month_end,
|
||||
)
|
||||
if has_invoice:
|
||||
continue
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="open_order_not_invoiced",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
simply_order_id=row["id"],
|
||||
simply_source_record_id=row.get("source_record_id"),
|
||||
product_number=row.get("product_number"),
|
||||
product_name=row.get("product_name"),
|
||||
reference_period_start=month_start,
|
||||
reference_period_end=month_end,
|
||||
expected_quantity=row.get("quantity"),
|
||||
actual_quantity=0,
|
||||
sales_order_number=row.get("salesorder_no"),
|
||||
amount_impact=row.get("total_amount"),
|
||||
)
|
||||
if issue_id:
|
||||
created += 1
|
||||
|
||||
return created
|
||||
|
||||
def _detect_quantity_drops(self, current_month: date, previous_month: date) -> int:
|
||||
"""Flag products where invoiced quantity dropped more than threshold."""
|
||||
current_start, current_end = self._month_bounds(current_month)
|
||||
previous_start, previous_end = self._month_bounds(previous_month)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH customer_map AS (
|
||||
SELECT DISTINCT ON (economic_customer_number)
|
||||
economic_customer_number,
|
||||
id AS hub_customer_id
|
||||
FROM customers
|
||||
WHERE economic_customer_number IS NOT NULL
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY economic_customer_number, id
|
||||
),
|
||||
monthly_qty AS (
|
||||
SELECT
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||
SUM(line.quantity) AS quantity,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
LEFT JOIN customer_map m
|
||||
ON m.economic_customer_number = inv.customer_number
|
||||
WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s
|
||||
AND line.product_number IS NOT NULL
|
||||
GROUP BY customer_key, product_key, period
|
||||
),
|
||||
prev AS (
|
||||
SELECT customer_key, product_key, quantity, product_name, description FROM monthly_qty WHERE period = %s
|
||||
),
|
||||
cur AS (
|
||||
SELECT customer_key, product_key, quantity FROM monthly_qty WHERE period = %s
|
||||
)
|
||||
SELECT
|
||||
prev.customer_key,
|
||||
prev.product_key,
|
||||
prev.quantity AS expected_quantity,
|
||||
cur.quantity AS actual_quantity,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m.hub_customer_id
|
||||
FROM prev
|
||||
JOIN cur
|
||||
ON cur.customer_key = prev.customer_key
|
||||
AND cur.product_key = prev.product_key
|
||||
LEFT JOIN customers c ON c.id = prev.customer_key
|
||||
LEFT JOIN customer_map m ON m.hub_customer_id = prev.customer_key
|
||||
WHERE prev.quantity > 0
|
||||
AND cur.quantity < prev.quantity * (1 - %s)
|
||||
""",
|
||||
(
|
||||
previous_start,
|
||||
current_end,
|
||||
previous_start,
|
||||
current_start,
|
||||
self.quantity_drop_threshold,
|
||||
),
|
||||
) or []
|
||||
|
||||
created = 0
|
||||
for row in rows:
|
||||
hub_customer_id = self._resolve_hub_customer_id(row)
|
||||
customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name"))
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="quantity_drop",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_quantity=row["expected_quantity"],
|
||||
actual_quantity=row["actual_quantity"],
|
||||
amount_impact=None,
|
||||
)
|
||||
if issue_id:
|
||||
created += 1
|
||||
|
||||
return created
|
||||
|
||||
def _detect_price_changes(self, current_month: date, previous_month: date) -> int:
|
||||
"""Flag products where unit price changed between months."""
|
||||
current_start, current_end = self._month_bounds(current_month)
|
||||
previous_start, previous_end = self._month_bounds(previous_month)
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH customer_map AS (
|
||||
SELECT DISTINCT ON (economic_customer_number)
|
||||
economic_customer_number,
|
||||
id AS hub_customer_id
|
||||
FROM customers
|
||||
WHERE economic_customer_number IS NOT NULL
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY economic_customer_number, id
|
||||
),
|
||||
monthly_price AS (
|
||||
SELECT
|
||||
COALESCE(m.hub_customer_id, inv.customer_number) AS customer_key,
|
||||
LOWER(TRIM(COALESCE(line.product_number, ''))) AS product_key,
|
||||
DATE_TRUNC('month', inv.invoice_date)::date AS period,
|
||||
AVG(line.unit_price) AS avg_price,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.product_name, '')), '')) AS product_name,
|
||||
MAX(NULLIF(TRIM(COALESCE(line.description, '')), '')) AS description
|
||||
FROM invoice_error_finder_economic_invoices inv
|
||||
JOIN invoice_error_finder_economic_invoice_lines line
|
||||
ON line.invoice_id = inv.id
|
||||
LEFT JOIN customer_map m
|
||||
ON m.economic_customer_number = inv.customer_number
|
||||
WHERE inv.invoice_date >= %s AND inv.invoice_date <= %s
|
||||
AND line.product_number IS NOT NULL
|
||||
AND line.unit_price > 0
|
||||
GROUP BY customer_key, product_key, period
|
||||
),
|
||||
prev AS (
|
||||
SELECT customer_key, product_key, avg_price, product_name, description FROM monthly_price WHERE period = %s
|
||||
),
|
||||
cur AS (
|
||||
SELECT customer_key, product_key, avg_price FROM monthly_price WHERE period = %s
|
||||
)
|
||||
SELECT
|
||||
prev.customer_key,
|
||||
prev.product_key,
|
||||
prev.avg_price AS expected_price,
|
||||
cur.avg_price AS actual_price,
|
||||
prev.product_name,
|
||||
prev.description,
|
||||
c.name AS customer_name,
|
||||
m.hub_customer_id
|
||||
FROM prev
|
||||
JOIN cur
|
||||
ON cur.customer_key = prev.customer_key
|
||||
AND cur.product_key = prev.product_key
|
||||
LEFT JOIN customers c ON c.id = prev.customer_key
|
||||
LEFT JOIN customer_map m ON m.hub_customer_id = prev.customer_key
|
||||
WHERE ABS(cur.avg_price - prev.avg_price) > 0.001
|
||||
""",
|
||||
(
|
||||
previous_start,
|
||||
current_end,
|
||||
previous_start,
|
||||
current_start,
|
||||
),
|
||||
) or []
|
||||
|
||||
created = 0
|
||||
for row in rows:
|
||||
hub_customer_id = self._resolve_hub_customer_id(row)
|
||||
customer_name = self._resolve_customer_name(hub_customer_id, row.get("customer_name"))
|
||||
|
||||
if self._is_customer_closed_or_cancelled(hub_customer_id, current_month):
|
||||
continue
|
||||
if self._is_ignored_product_text(
|
||||
row.get("product_name"),
|
||||
row.get("description"),
|
||||
):
|
||||
continue
|
||||
|
||||
expected_price = row["expected_price"]
|
||||
actual_price = row["actual_price"]
|
||||
impact = None
|
||||
if expected_price and actual_price is not None:
|
||||
impact = actual_price - expected_price
|
||||
|
||||
issue_id = self._upsert_issue(
|
||||
issue_type="price_change",
|
||||
customer_id=hub_customer_id,
|
||||
customer_name=customer_name,
|
||||
product_number=row["product_key"],
|
||||
product_name=row.get("product_name") or row.get("description"),
|
||||
reference_period_start=current_start,
|
||||
reference_period_end=current_end,
|
||||
expected_price=expected_price,
|
||||
actual_price=actual_price,
|
||||
amount_impact=impact,
|
||||
)
|
||||
if issue_id:
|
||||
created += 1
|
||||
|
||||
return created
|
||||
|
||||
def _has_recent_invoice_for_product(
|
||||
self,
|
||||
customer_id: Optional[int],
|
||||
product_number: Optional[str],
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> bool:
|
||||
if not customer_id and not product_number:
|
||||
return False
|
||||
|
||||
customer_rows = execute_query(
|
||||
"SELECT economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
) or []
|
||||
economic_numbers = [str(r["economic_customer_number"]) for r in customer_rows if r.get("economic_customer_number")]
|
||||
|
||||
query = """
|
||||
SELECT 1
|
||||
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 >= %s AND inv.invoice_date <= %s
|
||||
"""
|
||||
params: List[Any] = [start_date, end_date]
|
||||
|
||||
if economic_numbers:
|
||||
query += " AND inv.customer_number = ANY(%s::int[])"
|
||||
params.append(economic_numbers)
|
||||
else:
|
||||
return False
|
||||
|
||||
if product_number:
|
||||
query += " AND LOWER(TRIM(line.product_number)) = LOWER(TRIM(%s))"
|
||||
params.append(product_number)
|
||||
|
||||
query += " LIMIT 1"
|
||||
result = execute_query(query, tuple(params))
|
||||
return bool(result)
|
||||
|
||||
def _is_customer_closed_or_cancelled(self, customer_id: Optional[int], reference_month: date) -> bool:
|
||||
if not customer_id:
|
||||
return False
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT deleted_at FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if customer and customer.get("deleted_at"):
|
||||
return True
|
||||
|
||||
has_subscriptions = execute_query(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sag_subscriptions
|
||||
WHERE customer_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(customer_id,),
|
||||
)
|
||||
if not has_subscriptions:
|
||||
return False
|
||||
|
||||
# Only treat customer as closed when subscription data explicitly shows no active coverage.
|
||||
active = execute_query(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sag_subscriptions
|
||||
WHERE customer_id = %s
|
||||
AND status = 'active'
|
||||
AND (end_date IS NULL OR end_date >= %s)
|
||||
LIMIT 1
|
||||
""",
|
||||
(customer_id, reference_month),
|
||||
)
|
||||
if not active:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _ignore_existing_issues(self) -> None:
|
||||
ignored_terms = self._load_ignored_product_texts()
|
||||
if not ignored_terms:
|
||||
return
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id, product_name
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE status IN ('open', 'investigating', 'approved_change', 'error_found', 'ready_to_invoice')
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
for row in rows:
|
||||
if not self._is_ignored_product_text(row.get("product_name")):
|
||||
continue
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = 'ignored',
|
||||
ignored_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
|
||||
def _load_ignored_product_texts(self) -> List[str]:
|
||||
if self._ignored_product_texts_cache is not None:
|
||||
return self._ignored_product_texts_cache
|
||||
|
||||
setting = execute_query_single(
|
||||
"SELECT value FROM settings WHERE key = %s",
|
||||
("invoice_error_finder_ignored_product_texts",),
|
||||
) or {}
|
||||
raw_value = setting.get("value")
|
||||
|
||||
values: List[str] = []
|
||||
if raw_value:
|
||||
try:
|
||||
parsed = json.loads(str(raw_value))
|
||||
if isinstance(parsed, list):
|
||||
values = [str(item) for item in parsed]
|
||||
elif isinstance(parsed, str):
|
||||
values = [parsed]
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
values = str(raw_value).replace(";", "\n").replace(",", "\n").splitlines()
|
||||
|
||||
combined = DEFAULT_IGNORED_PRODUCT_TEXTS + values
|
||||
normalized: List[str] = []
|
||||
seen = set()
|
||||
for value in combined:
|
||||
item = self._normalize_text(value)
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
self._ignored_product_texts_cache = normalized
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().lower().split())
|
||||
|
||||
def _is_ignored_product_text(self, *values: Any) -> bool:
|
||||
ignore_terms = self._load_ignored_product_texts()
|
||||
if not ignore_terms:
|
||||
ignore_terms = []
|
||||
|
||||
normalized_values = [
|
||||
self._normalize_text(value)
|
||||
for value in values
|
||||
if self._normalize_text(value)
|
||||
]
|
||||
if not normalized_values:
|
||||
return False
|
||||
|
||||
for candidate in normalized_values:
|
||||
for term in ignore_terms:
|
||||
if term and term in candidate:
|
||||
return True
|
||||
for pattern in DEFAULT_IGNORED_PRODUCT_PATTERNS:
|
||||
if pattern.search(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_stale_issues(self, reference_month: date) -> None:
|
||||
period_start, period_end = self._month_bounds(reference_month)
|
||||
active_statuses = ("open", "investigating", "approved_change", "error_found", "ready_to_invoice")
|
||||
issue_types = ("missing_line", "open_order_not_invoiced", "quantity_drop", "price_change")
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE reference_period_start = %s
|
||||
AND reference_period_end = %s
|
||||
AND issue_type = ANY(%s::text[])
|
||||
AND status = ANY(%s::text[])
|
||||
""",
|
||||
(period_start, period_end, list(issue_types), list(active_statuses)),
|
||||
) or []
|
||||
|
||||
stale_ids = [row["id"] for row in rows if int(row["id"]) not in self._seen_issue_ids]
|
||||
if not stale_ids:
|
||||
return
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = 'resolved',
|
||||
resolved_at = COALESCE(resolved_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY(%s::int[])
|
||||
""",
|
||||
(stale_ids,),
|
||||
)
|
||||
|
||||
def _upsert_issue(self, **kwargs: Any) -> Optional[int]:
|
||||
"""Insert a new issue or update an existing open one."""
|
||||
issue_type = kwargs["issue_type"]
|
||||
customer_id = self._resolve_existing_customer_id(kwargs.get("customer_id"))
|
||||
product_number = kwargs.get("product_number")
|
||||
reference_period_start = kwargs.get("reference_period_start")
|
||||
reference_period_end = kwargs.get("reference_period_end")
|
||||
simply_order_id = kwargs.get("simply_order_id")
|
||||
simply_source_record_id = kwargs.get("simply_source_record_id")
|
||||
|
||||
existing = execute_query_single(
|
||||
"""
|
||||
SELECT id, status
|
||||
FROM invoice_error_finder_issues
|
||||
WHERE issue_type = %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND COALESCE(product_number, '') = COALESCE(%s, '')
|
||||
AND reference_period_start = %s
|
||||
AND reference_period_end = %s
|
||||
AND (
|
||||
COALESCE(simply_source_record_id, '') = COALESCE(%s, '')
|
||||
OR (
|
||||
simply_source_record_id IS NULL
|
||||
AND COALESCE(simply_order_id, 0) = COALESCE(%s, 0)
|
||||
)
|
||||
)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
issue_type,
|
||||
customer_id,
|
||||
product_number,
|
||||
reference_period_start,
|
||||
reference_period_end,
|
||||
simply_source_record_id,
|
||||
simply_order_id,
|
||||
),
|
||||
)
|
||||
|
||||
if existing and existing.get("status") == "resolved":
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = %s,
|
||||
resolved_at = NULL,
|
||||
expected_quantity = COALESCE(%s, expected_quantity),
|
||||
actual_quantity = COALESCE(%s, actual_quantity),
|
||||
expected_price = COALESCE(%s, expected_price),
|
||||
actual_price = COALESCE(%s, actual_price),
|
||||
amount_impact = COALESCE(%s, amount_impact),
|
||||
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||
sales_order_number = COALESCE(%s, sales_order_number),
|
||||
product_name = COALESCE(%s, product_name),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
kwargs.get("status", "open"),
|
||||
kwargs.get("expected_quantity"),
|
||||
kwargs.get("actual_quantity"),
|
||||
kwargs.get("expected_price"),
|
||||
kwargs.get("actual_price"),
|
||||
kwargs.get("amount_impact"),
|
||||
kwargs.get("last_invoice_number"),
|
||||
kwargs.get("last_invoice_date"),
|
||||
kwargs.get("sales_order_number"),
|
||||
kwargs.get("product_name"),
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
self._seen_issue_ids.add(int(existing["id"]))
|
||||
return existing["id"]
|
||||
|
||||
if existing and existing.get("status") not in {"ignored", "invoiced"}:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET 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("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") == "invoiced":
|
||||
return None
|
||||
|
||||
if existing and existing.get("status") == "ignored":
|
||||
ignored_row = execute_query_single(
|
||||
"SELECT ignored_until FROM invoice_error_finder_issues WHERE id = %s",
|
||||
(existing["id"],),
|
||||
)
|
||||
ignored_until = (ignored_row or {}).get("ignored_until")
|
||||
if ignored_until is None or ignored_until >= date.today():
|
||||
return None
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_issues
|
||||
SET status = %s,
|
||||
ignored_until = NULL,
|
||||
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"]
|
||||
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_issues (
|
||||
issue_type, status, customer_id, customer_name, subscription_id,
|
||||
simply_order_id, simply_source_record_id, sag_id, product_number, product_name,
|
||||
reference_period_start, reference_period_end, expected_quantity,
|
||||
actual_quantity, expected_price, actual_price, last_invoice_number,
|
||||
last_invoice_date, sales_order_number, amount_impact, currency, notes
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
issue_type,
|
||||
kwargs.get("status", "open"),
|
||||
customer_id,
|
||||
kwargs.get("customer_name"),
|
||||
kwargs.get("subscription_id"),
|
||||
simply_order_id,
|
||||
simply_source_record_id,
|
||||
kwargs.get("sag_id"),
|
||||
product_number,
|
||||
kwargs.get("product_name"),
|
||||
reference_period_start,
|
||||
reference_period_end,
|
||||
kwargs.get("expected_quantity"),
|
||||
kwargs.get("actual_quantity"),
|
||||
kwargs.get("expected_price"),
|
||||
kwargs.get("actual_price"),
|
||||
kwargs.get("last_invoice_number"),
|
||||
kwargs.get("last_invoice_date"),
|
||||
kwargs.get("sales_order_number"),
|
||||
kwargs.get("amount_impact"),
|
||||
kwargs.get("currency", "DKK"),
|
||||
kwargs.get("notes"),
|
||||
),
|
||||
)
|
||||
if row and row.get("id") is not None:
|
||||
self._seen_issue_ids.add(int(row["id"]))
|
||||
return row["id"] if row else None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_hub_customer_id(row: Dict[str, Any]) -> Optional[int]:
|
||||
value = row.get("hub_customer_id")
|
||||
if not isinstance(value, int):
|
||||
return None
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT id FROM customers WHERE id = %s",
|
||||
(value,),
|
||||
)
|
||||
return value if customer else None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_existing_customer_id(value: Any) -> Optional[int]:
|
||||
if not isinstance(value, int):
|
||||
return None
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT id FROM customers WHERE id = %s",
|
||||
(value,),
|
||||
)
|
||||
return value if customer else None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_customer_name(hub_customer_id: Optional[int], fallback: Optional[str]) -> Optional[str]:
|
||||
if hub_customer_id:
|
||||
row = execute_query_single(
|
||||
"SELECT name FROM customers WHERE id = %s",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
if row:
|
||||
return row["name"]
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _month_bounds(month_date: date) -> tuple[date, date]:
|
||||
start = month_date.replace(day=1)
|
||||
end = (start + relativedelta(months=1)) - relativedelta(days=1)
|
||||
return start, end
|
||||
@ -1,290 +0,0 @@
|
||||
"""
|
||||
e-conomic import service for Invoice Error Finder.
|
||||
Fetches invoices and invoice lines from e-conomic and persists them locally
|
||||
for comparison with subscriptions and sales orders.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
import aiohttp
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EconomicImportService:
|
||||
"""Import e-conomic invoices/lines into invoice_error_finder staging tables."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = getattr(settings, "ECONOMIC_API_URL", "https://restapi.e-conomic.com")
|
||||
self.app_secret_token = getattr(settings, "ECONOMIC_APP_SECRET_TOKEN", None)
|
||||
self.agreement_grant_token = getattr(settings, "ECONOMIC_AGREEMENT_GRANT_TOKEN", None)
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
if not self.app_secret_token or not self.agreement_grant_token:
|
||||
raise ValueError("e-conomic credentials not configured")
|
||||
return {
|
||||
"X-AppSecretToken": self.app_secret_token,
|
||||
"X-AgreementGrantToken": self.agreement_grant_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def import_invoices(
|
||||
self,
|
||||
triggered_by_user_id: Optional[int] = None,
|
||||
is_scheduled: bool = False,
|
||||
months_back: int = 13,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch all e-conomic invoices (booked/drafts/paid/unpaid) for the last N months,
|
||||
fetch lines for each, and persist to staging tables.
|
||||
"""
|
||||
run_id = self._create_import_run("economic_invoices", triggered_by_user_id, is_scheduled)
|
||||
try:
|
||||
start_date = (datetime.now() - relativedelta(months=months_back)).replace(day=1).date()
|
||||
logger.info("📅 Importing e-conomic invoices from %s onwards", start_date)
|
||||
|
||||
endpoints = [
|
||||
("booked", f"{self.api_url}/invoices/booked"),
|
||||
("paid", f"{self.api_url}/invoices/paid"),
|
||||
("unpaid", f"{self.api_url}/invoices/unpaid"),
|
||||
("draft", f"{self.api_url}/invoices/drafts"),
|
||||
]
|
||||
|
||||
all_invoices: List[Dict[str, Any]] = []
|
||||
imported_count = 0
|
||||
failed_count = 0
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for source_type, endpoint in endpoints:
|
||||
try:
|
||||
page = 0
|
||||
while True:
|
||||
async with session.get(
|
||||
endpoint,
|
||||
params={"pagesize": 1000, "skippages": page},
|
||||
headers=self._headers(),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
logger.warning(
|
||||
"⚠️ e-conomic endpoint %s returned %s: %s",
|
||||
endpoint, response.status, error_text[:200]
|
||||
)
|
||||
break
|
||||
|
||||
data = await response.json()
|
||||
batch = data.get("collection", [])
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for inv in batch:
|
||||
inv["__source_type__"] = source_type
|
||||
all_invoices.append(inv)
|
||||
|
||||
if len(batch) < 1000:
|
||||
break
|
||||
page += 1
|
||||
except Exception as exc:
|
||||
logger.error("❌ Error fetching from %s: %s", endpoint, exc)
|
||||
|
||||
logger.info("📥 Fetched %s e-conomic invoice headers", len(all_invoices))
|
||||
|
||||
for inv in all_invoices:
|
||||
try:
|
||||
invoice_date_raw = inv.get("date")
|
||||
invoice_date = self._parse_date(invoice_date_raw)
|
||||
if invoice_date and invoice_date < start_date:
|
||||
continue
|
||||
|
||||
invoice_id = self._persist_invoice(run_id, inv)
|
||||
if invoice_id:
|
||||
lines = await self._fetch_invoice_lines(session, inv)
|
||||
self._persist_lines(invoice_id, lines)
|
||||
imported_count += 1
|
||||
except Exception as exc:
|
||||
logger.error("❌ Failed to import invoice %s: %s", inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber"), exc)
|
||||
failed_count += 1
|
||||
|
||||
final_status = "success" if failed_count == 0 else "partial"
|
||||
self._complete_import_run(run_id, final_status, imported_count, failed_count)
|
||||
logger.info(
|
||||
"✅ e-conomic import complete: %s imported, %s failed",
|
||||
imported_count,
|
||||
failed_count,
|
||||
)
|
||||
return {
|
||||
"import_run_id": run_id,
|
||||
"records_imported": imported_count,
|
||||
"records_failed": failed_count,
|
||||
}
|
||||
except Exception as exc:
|
||||
self._complete_import_run(run_id, "failed", 0, 0, str(exc))
|
||||
logger.error("❌ e-conomic import failed: %s", exc, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _fetch_invoice_lines(
|
||||
self, session: aiohttp.ClientSession, invoice: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch full invoice with lines using the self link or direct URL."""
|
||||
self_link = invoice.get("self")
|
||||
invoice_number = invoice.get("draftInvoiceNumber") or invoice.get("bookedInvoiceNumber")
|
||||
fetch_url = self_link or f"{self.api_url}/invoices/sales/{invoice_number}"
|
||||
|
||||
try:
|
||||
async with session.get(fetch_url, headers=self._headers()) as response:
|
||||
if response.status == 200:
|
||||
full = await response.json()
|
||||
return full.get("lines", [])
|
||||
except Exception as exc:
|
||||
logger.warning("⚠️ Could not fetch lines for invoice %s: %s", invoice_number, exc)
|
||||
|
||||
return invoice.get("lines", [])
|
||||
|
||||
def _persist_invoice(self, run_id: int, invoice: Dict[str, Any]) -> Optional[int]:
|
||||
customer = invoice.get("customer") or {}
|
||||
customer_number = customer.get("customerNumber")
|
||||
invoice_number = invoice.get("draftInvoiceNumber") or invoice.get("bookedInvoiceNumber")
|
||||
source_type = invoice.get("__source_type__", "booked")
|
||||
|
||||
if not invoice_number:
|
||||
return None
|
||||
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_economic_invoices (
|
||||
import_run_id, source_invoice_number, source_type, customer_number,
|
||||
customer_name, invoice_date, due_date, currency, net_amount,
|
||||
vat_amount, total_amount, source_raw
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (import_run_id, source_invoice_number, source_type)
|
||||
DO UPDATE SET
|
||||
customer_number = EXCLUDED.customer_number,
|
||||
customer_name = EXCLUDED.customer_name,
|
||||
invoice_date = EXCLUDED.invoice_date,
|
||||
due_date = EXCLUDED.due_date,
|
||||
currency = EXCLUDED.currency,
|
||||
net_amount = EXCLUDED.net_amount,
|
||||
vat_amount = EXCLUDED.vat_amount,
|
||||
total_amount = EXCLUDED.total_amount,
|
||||
source_raw = EXCLUDED.source_raw
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
str(invoice_number),
|
||||
source_type,
|
||||
customer_number,
|
||||
(customer.get("name") or invoice.get("customerName"))[:255] if (customer.get("name") or invoice.get("customerName")) else None,
|
||||
self._parse_date(invoice.get("date")),
|
||||
self._parse_date(invoice.get("dueDate")),
|
||||
(invoice.get("currency") or "DKK")[:10],
|
||||
self._parse_amount(invoice.get("netAmount")),
|
||||
self._parse_amount(invoice.get("vatAmount")),
|
||||
self._parse_amount(invoice.get("grossAmount")),
|
||||
json.dumps(invoice, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
return row["id"] if row else None
|
||||
|
||||
def _persist_lines(self, invoice_id: int, lines: List[Dict[str, Any]]) -> None:
|
||||
execute_query(
|
||||
"DELETE FROM invoice_error_finder_economic_invoice_lines WHERE invoice_id = %s",
|
||||
(invoice_id,),
|
||||
)
|
||||
|
||||
if not lines:
|
||||
return
|
||||
|
||||
for line in lines:
|
||||
product = line.get("product") or {}
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_economic_invoice_lines (
|
||||
invoice_id, line_number, product_number, product_name,
|
||||
description, quantity, unit_price, line_net_amount,
|
||||
discount_percentage, source_raw
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
invoice_id,
|
||||
line.get("lineNumber"),
|
||||
self._safe_str(product.get("productNumber"), 100),
|
||||
self._safe_str(product.get("name"), 500),
|
||||
line.get("description"),
|
||||
self._parse_amount(line.get("quantity"), default=0.0),
|
||||
self._parse_amount(line.get("unitNetPrice"), default=0.0),
|
||||
self._parse_amount(line.get("totalNetAmount"), default=0.0),
|
||||
self._parse_amount(line.get("discountPercentage"), default=0.0),
|
||||
json.dumps(line, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
|
||||
def _create_import_run(
|
||||
self, source_type: str, triggered_by_user_id: Optional[int], is_scheduled: bool
|
||||
) -> int:
|
||||
# Treat 0 / invalid user ids as None (shadowadmin has id 0 and is not in users table)
|
||||
user_id = triggered_by_user_id if triggered_by_user_id else None
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_import_runs
|
||||
(source_type, status, triggered_by_user_id, is_scheduled)
|
||||
VALUES (%s, 'running', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(source_type, user_id, is_scheduled),
|
||||
)
|
||||
return row["id"]
|
||||
|
||||
def _complete_import_run(
|
||||
self,
|
||||
run_id: int,
|
||||
status: str,
|
||||
records_imported: int,
|
||||
records_failed: int,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_import_runs
|
||||
SET status = %s,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
records_imported = %s,
|
||||
records_failed = %s,
|
||||
error_message = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(status, records_imported, records_failed, error_message, run_id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_amount(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_str(value: Any, max_length: int) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
return text[:max_length]
|
||||
@ -1,249 +0,0 @@
|
||||
"""
|
||||
Simply CRM import service for Invoice Error Finder.
|
||||
Fetches open sales orders from Simply CRM and persists them locally.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import json
|
||||
|
||||
from app.services.simplycrm_service import SimplyCRMService
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Simply SalesOrder statuses considered "open / not yet invoiced"
|
||||
OPEN_SOSTATUS = {"Created", "Approved", "Delivered"}
|
||||
|
||||
|
||||
class SimplyImportService:
|
||||
"""Import open Simply CRM sales orders into invoice_error_finder staging tables."""
|
||||
|
||||
async def import_sales_orders(
|
||||
self,
|
||||
triggered_by_user_id: Optional[int] = None,
|
||||
is_scheduled: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch all open SalesOrders from Simply CRM and persist them."""
|
||||
run_id = self._create_import_run("simply_sales_orders", triggered_by_user_id, is_scheduled)
|
||||
try:
|
||||
async with SimplyCRMService() as service:
|
||||
raw_orders = await self._fetch_all_open_orders(service)
|
||||
|
||||
logger.info("📥 Fetched %s open Simply CRM sales orders", len(raw_orders))
|
||||
|
||||
imported_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for raw in raw_orders:
|
||||
try:
|
||||
self._persist_order(run_id, raw)
|
||||
imported_count += 1
|
||||
except Exception as exc:
|
||||
logger.error("❌ Failed to persist Simply order %s: %s", raw.get("id"), exc)
|
||||
failed_count += 1
|
||||
|
||||
status = "success" if failed_count == 0 else "partial"
|
||||
self._complete_import_run(run_id, status, imported_count, failed_count)
|
||||
logger.info(
|
||||
"✅ Simply import complete: %s imported, %s failed",
|
||||
imported_count,
|
||||
failed_count,
|
||||
)
|
||||
return {
|
||||
"import_run_id": run_id,
|
||||
"records_imported": imported_count,
|
||||
"records_failed": failed_count,
|
||||
}
|
||||
except Exception as exc:
|
||||
self._complete_import_run(run_id, "failed", 0, 0, str(exc))
|
||||
logger.error("❌ Simply import failed: %s", exc, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _fetch_all_open_orders(self, service: SimplyCRMService) -> List[Dict[str, Any]]:
|
||||
"""Fetch all open sales orders with pagination."""
|
||||
all_records: List[Dict[str, Any]] = []
|
||||
offset = 0
|
||||
limit = 100
|
||||
seen_ids = set()
|
||||
|
||||
while True:
|
||||
# Simply webservice does not support IN in all versions; fetch batches and filter in code
|
||||
query = f"SELECT * FROM SalesOrder LIMIT {offset}, {limit};"
|
||||
batch = await service.query(query)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for record in batch:
|
||||
record_id = record.get("id")
|
||||
if record_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(record_id)
|
||||
|
||||
status = record.get("sostatus")
|
||||
if status in OPEN_SOSTATUS:
|
||||
all_records.append(record)
|
||||
|
||||
if len(batch) < limit:
|
||||
break
|
||||
offset += limit
|
||||
|
||||
return all_records
|
||||
|
||||
def _persist_order(self, run_id: int, raw: Dict[str, Any]) -> None:
|
||||
order_source_id = str(raw.get("id") or "")
|
||||
if not order_source_id:
|
||||
return
|
||||
|
||||
# Sales orders in Simply may have line items inline
|
||||
line_items = raw.get("LineItems") or []
|
||||
if isinstance(line_items, str):
|
||||
try:
|
||||
line_items = json.loads(line_items)
|
||||
except Exception:
|
||||
line_items = []
|
||||
|
||||
# If there are explicit line items, create one row per line. Otherwise one summary row from the header.
|
||||
rows_to_insert = []
|
||||
if line_items:
|
||||
for line in line_items:
|
||||
rows_to_insert.append({
|
||||
"product_number": self._extract_product_number(line),
|
||||
"product_name": line.get("productname") or line.get("comment"),
|
||||
"quantity": self._parse_amount(line.get("quantity")),
|
||||
"unit_price": self._parse_amount(line.get("listprice") or line.get("unit_price")),
|
||||
"total_amount": self._parse_amount(line.get("netprice") or line.get("total")),
|
||||
})
|
||||
else:
|
||||
rows_to_insert.append({
|
||||
"product_number": self._extract_product_number(raw),
|
||||
"product_name": raw.get("comment") or raw.get("subject"),
|
||||
"quantity": self._parse_amount(raw.get("quantity"), 0),
|
||||
"unit_price": self._parse_amount(raw.get("listprice"), 0),
|
||||
"total_amount": self._parse_amount(raw.get("hdnGrandTotal"), 0),
|
||||
})
|
||||
|
||||
for idx, row in enumerate(rows_to_insert):
|
||||
line_source_record_id = order_source_id if len(rows_to_insert) == 1 else f"{order_source_id}:{idx + 1}:{self._safe_str(row['product_number'] or 'line', 100)}"
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_simply_sales_orders (
|
||||
import_run_id, source_record_id, salesorder_no, account_id,
|
||||
customer_name, customer_cvr, subject, status, product_number,
|
||||
product_name, quantity, unit_price, total_amount, start_period,
|
||||
end_period, source_raw
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
ON CONFLICT (source_record_id)
|
||||
DO UPDATE SET
|
||||
import_run_id = EXCLUDED.import_run_id,
|
||||
salesorder_no = EXCLUDED.salesorder_no,
|
||||
account_id = EXCLUDED.account_id,
|
||||
customer_name = EXCLUDED.customer_name,
|
||||
customer_cvr = EXCLUDED.customer_cvr,
|
||||
subject = EXCLUDED.subject,
|
||||
status = EXCLUDED.status,
|
||||
product_number = EXCLUDED.product_number,
|
||||
product_name = EXCLUDED.product_name,
|
||||
quantity = EXCLUDED.quantity,
|
||||
unit_price = EXCLUDED.unit_price,
|
||||
total_amount = EXCLUDED.total_amount,
|
||||
start_period = EXCLUDED.start_period,
|
||||
end_period = EXCLUDED.end_period,
|
||||
source_raw = EXCLUDED.source_raw
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
line_source_record_id,
|
||||
self._safe_str(raw.get("salesorder_no"), 80),
|
||||
self._safe_str(raw.get("account_id"), 80),
|
||||
self._safe_str(raw.get("accountname") or raw.get("customer_name"), 255),
|
||||
self._safe_str(raw.get("siccode") or raw.get("vat_number"), 32),
|
||||
raw.get("subject"),
|
||||
raw.get("sostatus"),
|
||||
self._safe_str(row["product_number"], 100),
|
||||
self._safe_str(row["product_name"], 500),
|
||||
row["quantity"],
|
||||
row["unit_price"],
|
||||
row["total_amount"],
|
||||
self._parse_date(raw.get("start_period")),
|
||||
self._parse_date(raw.get("end_period")),
|
||||
json.dumps(raw, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_product_number(line: Dict[str, Any]) -> Optional[str]:
|
||||
product = line.get("productid") or {}
|
||||
if isinstance(product, dict):
|
||||
return product.get("productnumber") or product.get("product_no")
|
||||
# productid can also be a string like "14x842339"; strip the prefix and return the rest
|
||||
if isinstance(product, str):
|
||||
return product.split("x")[-1] if "x" in product else product
|
||||
return line.get("product_no") or line.get("productnumber")
|
||||
|
||||
def _create_import_run(
|
||||
self, source_type: str, triggered_by_user_id: Optional[int], is_scheduled: bool
|
||||
) -> int:
|
||||
# Treat 0 / invalid user ids as None (shadowadmin has id 0 and is not in users table)
|
||||
user_id = triggered_by_user_id if triggered_by_user_id else None
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO invoice_error_finder_import_runs
|
||||
(source_type, status, triggered_by_user_id, is_scheduled)
|
||||
VALUES (%s, 'running', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(source_type, user_id, is_scheduled),
|
||||
)
|
||||
return row["id"]
|
||||
|
||||
def _complete_import_run(
|
||||
self,
|
||||
run_id: int,
|
||||
status: str,
|
||||
records_imported: int,
|
||||
records_failed: int,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE invoice_error_finder_import_runs
|
||||
SET status = %s,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
records_imported = %s,
|
||||
records_failed = %s,
|
||||
error_message = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(status, records_imported, records_failed, error_message, run_id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
from datetime import datetime
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_amount(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_str(value: Any, max_length: int) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)[:max_length]
|
||||
@ -1,256 +0,0 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Faktura-fejl-finder - BMC Hub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">🔍 Faktura-fejl-finder</h1>
|
||||
<p class="text-muted mb-0">Find abonnementer, varer og salgsordrer som burde være faktureret, men ikke er blevet det.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" onclick="importEconomic()">
|
||||
<i class="bi bi-cloud-download me-1"></i>Importér e-conomic
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" onclick="importSimply()">
|
||||
<i class="bi bi-cloud-download me-1"></i>Importér Simply
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="runAnalysis()">
|
||||
<i class="bi bi-search me-1"></i>Kør analyse
|
||||
</button>
|
||||
<a href="/invoice-error-finder/issues" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-list-ul me-1"></i>Fejlliste
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="importStatus" class="alert d-none mb-4"></div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Manglende varelinjer</h6>
|
||||
<h2 class="mb-0" id="missingLineCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-danger bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-file-x text-danger fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?issue_type=missing_line" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Åbne ordrer uden faktura</h6>
|
||||
<h2 class="mb-0" id="openOrderCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-cart-x text-warning fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?issue_type=open_order_not_invoiced" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Antalsfald</h6>
|
||||
<h2 class="mb-0" id="quantityDropCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-graph-down-arrow text-info fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?issue_type=quantity_drop" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Prisændringer</h6>
|
||||
<h2 class="mb-0" id="priceChangeCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-currency-exchange text-primary fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?issue_type=price_change" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Ordrekladder klar</h6>
|
||||
<h2 class="mb-0" id="readyToInvoiceCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-check-circle text-success fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?status=ready_to_invoice" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Fejl uden ansvarlig</h6>
|
||||
<h2 class="mb-0" id="noOwnerCount">-</h2>
|
||||
</div>
|
||||
<div class="bg-secondary bg-opacity-10 p-2 rounded">
|
||||
<i class="bi bi-person-x text-secondary fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/invoice-error-finder/issues?assigned_user_id=null" class="stretched-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="text-muted text-uppercase small mb-2">Seneste importkørsler</h6>
|
||||
<ul class="list-unstyled mb-0 small" id="lastImportRuns">
|
||||
<li class="text-muted">Indlæser...</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-light p-2 rounded">
|
||||
<i class="bi bi-clock-history text-muted fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/invoice-error-finder/dashboard');
|
||||
if (!res.ok) {
|
||||
let detail = 'Kunne ikke hente dashboard';
|
||||
try {
|
||||
const payload = await res.json();
|
||||
if (res.status === 403) {
|
||||
detail = 'Du mangler adgang til Faktura-fejl-finder';
|
||||
} else if (payload?.detail) {
|
||||
detail = payload.detail;
|
||||
}
|
||||
} catch (error) {
|
||||
if (res.status === 403) {
|
||||
detail = 'Du mangler adgang til Faktura-fejl-finder';
|
||||
}
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('missingLineCount').textContent = data.missing_line?.count ?? 0;
|
||||
document.getElementById('openOrderCount').textContent = data.open_order_not_invoiced?.count ?? 0;
|
||||
document.getElementById('quantityDropCount').textContent = data.quantity_drop?.count ?? 0;
|
||||
document.getElementById('priceChangeCount').textContent = data.price_change?.count ?? 0;
|
||||
document.getElementById('readyToInvoiceCount').textContent = data.ready_to_invoice?.count ?? 0;
|
||||
document.getElementById('noOwnerCount').textContent = data.no_owner?.count ?? 0;
|
||||
|
||||
const runsList = document.getElementById('lastImportRuns');
|
||||
if (data.last_import_runs && data.last_import_runs.length > 0) {
|
||||
runsList.innerHTML = data.last_import_runs.map(run => {
|
||||
const date = new Date(run.started_at).toLocaleString('da-DK');
|
||||
const icon = run.status === 'success' ? '✅' : run.status === 'partial' ? '⚠️' : '❌';
|
||||
return `<li>${icon} ${run.source_type}: ${run.records_imported} importeret (${date})</li>`;
|
||||
}).join('');
|
||||
} else {
|
||||
runsList.innerHTML = '<li class="text-muted">Ingen importer endnu</li>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showStatus('Fejl ved indlæsning af dashboard: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function importEconomic() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/invoice-error-finder/import/economic', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Import fejlede');
|
||||
showStatus(`e-conomic import færdig: ${data.records_imported} importeret, ${data.records_failed} fejlede.`, 'success');
|
||||
await loadDashboard();
|
||||
} catch (err) {
|
||||
showStatus('e-conomic import fejlede: ' + err.message, 'danger');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importSimply() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/invoice-error-finder/import/simply', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Import fejlede');
|
||||
showStatus(`Simply import færdig: ${data.records_imported} importeret, ${data.records_failed} fejlede.`, 'success');
|
||||
await loadDashboard();
|
||||
} catch (err) {
|
||||
showStatus('Simply import fejlede: ' + err.message, 'danger');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/invoice-error-finder/analyze', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Analyse fejlede');
|
||||
const counts = Object.entries(data.counts || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(', ');
|
||||
showStatus(`Analyse færdig for ${data.reference_month}. ${counts}`, 'success');
|
||||
await loadDashboard();
|
||||
} catch (err) {
|
||||
showStatus('Analyse fejlede: ' + err.message, 'danger');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const el = document.getElementById('importStatus');
|
||||
el.className = `alert alert-${type} mb-4`;
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
document.querySelectorAll('button').forEach(btn => btn.disabled = loading);
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,567 +0,0 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Faktura-fejl-finder - Fejlliste - BMC Hub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">📋 Faktura-fejl-liste</h1>
|
||||
<p class="text-muted mb-0">Gennemgå, godkend og håndter registrerede fakturaafvigelser.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/invoice-error-finder" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Tilbage til dashboard
|
||||
</a>
|
||||
<button class="btn btn-primary" onclick="loadIssues()">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select id="filterStatus" class="form-select" onchange="loadIssues()">
|
||||
<option value="">Alle</option>
|
||||
<option value="open" selected>Åben</option>
|
||||
<option value="investigating">Under undersøgelse</option>
|
||||
<option value="approved_change">Godkendt ændring</option>
|
||||
<option value="error_found">Fejl fundet</option>
|
||||
<option value="ready_to_invoice">Opret ordrekladde</option>
|
||||
<option value="invoiced">Faktureret</option>
|
||||
<option value="ignored">Ignoreret</option>
|
||||
<option value="resolved">Løst</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">Fejltype</label>
|
||||
<select id="filterType" class="form-select" onchange="loadIssues()">
|
||||
<option value="">Alle</option>
|
||||
<option value="missing_line">Manglende varelinje</option>
|
||||
<option value="open_order_not_invoiced">Åben salgsordre ikke faktureret</option>
|
||||
<option value="quantity_drop">Antalsfald</option>
|
||||
<option value="price_change">Prisændring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">Kunde</label>
|
||||
<select id="filterCustomer" class="form-select" onchange="loadIssues()">
|
||||
<option value="">Alle</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">Ansvarlig</label>
|
||||
<select id="filterAssigned" class="form-select" onchange="loadIssues()">
|
||||
<option value="">Alle</option>
|
||||
<option value="null">Ikke tildelt</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="issuesStatus" class="alert d-none mb-4"></div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Fejltype</th>
|
||||
<th>Vare</th>
|
||||
<th>Forventet</th>
|
||||
<th>Faktisk</th>
|
||||
<th>Periode</th>
|
||||
<th>Beløb/impact</th>
|
||||
<th>Status</th>
|
||||
<th>Ansvarlig</th>
|
||||
<th style="min-width: 220px;">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issuesBody">
|
||||
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<span class="text-muted small" id="paginationInfo"></span>
|
||||
<div class="btn-group" id="paginationControls"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable" style="max-width: 90vw; width: 90vw;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title mb-1">Fakturahistorik</h5>
|
||||
<div class="text-muted small" id="historyModalMeta"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="historyModalStatus" class="alert d-none mb-3"></div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="historyModalActions">
|
||||
<button type="button" class="btn btn-outline-success btn-sm" id="historyApproveBtn" disabled>
|
||||
<i class="bi bi-check2 me-1"></i>Godkend
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm" id="historyInvestigatingBtn" disabled>
|
||||
<i class="bi bi-search me-1"></i>Undersøgelse
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="historyReadyBtn" disabled>
|
||||
<i class="bi bi-receipt me-1"></i>Opret ordrekladde
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm" id="historyCreateSagBtn" disabled>
|
||||
<i class="bi bi-folder-plus me-1"></i>Opret sag
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Måned</th>
|
||||
<th>Status</th>
|
||||
<th>Antal linjer</th>
|
||||
<th>Samlet mængde</th>
|
||||
<th>Samlet beløb</th>
|
||||
<th>Fakturaer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyModalBody">
|
||||
<tr><td colspan="6" class="text-muted text-center py-4">Vælg en fejl for at se historik</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentOffset = 0;
|
||||
const pageSize = 100;
|
||||
let currentHistoryIssueId = null;
|
||||
|
||||
const issueTypeLabels = {
|
||||
missing_line: 'Manglende varelinje',
|
||||
open_order_not_invoiced: 'Åben salgsordre ikke faktureret',
|
||||
quantity_drop: 'Antalsfald',
|
||||
price_change: 'Prisændring',
|
||||
new_item_never_invoiced: 'Ny vare aldrig faktureret'
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
open: 'Åben',
|
||||
investigating: 'Under undersøgelse',
|
||||
approved_change: 'Godkendt ændring',
|
||||
error_found: 'Fejl fundet',
|
||||
ready_to_invoice: 'Opret ordrekladde',
|
||||
invoiced: 'Faktureret',
|
||||
ignored: 'Ignoreret',
|
||||
resolved: 'Løst'
|
||||
};
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null) return '';
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
if (value == null) return '-';
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(value);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value == null) return '-';
|
||||
return new Intl.NumberFormat('da-DK').format(value);
|
||||
}
|
||||
|
||||
function formatMonth(value) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat('da-DK', { year: 'numeric', month: 'short' }).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
open: 'bg-danger',
|
||||
investigating: 'bg-warning text-dark',
|
||||
approved_change: 'bg-info text-dark',
|
||||
error_found: 'bg-danger',
|
||||
ready_to_invoice: 'bg-success',
|
||||
invoiced: 'bg-secondary',
|
||||
ignored: 'bg-light text-dark',
|
||||
resolved: 'bg-secondary-subtle text-dark'
|
||||
};
|
||||
const cls = map[status] || 'bg-light text-dark';
|
||||
return `<span class="badge ${cls}">${statusLabels[status] || status}</span>`;
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', pageSize);
|
||||
params.set('offset', currentOffset);
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
if (status) params.set('status', status);
|
||||
|
||||
const type = document.getElementById('filterType').value;
|
||||
if (type) params.set('issue_type', type);
|
||||
|
||||
const customer = document.getElementById('filterCustomer').value;
|
||||
if (customer) params.set('customer_id', customer);
|
||||
|
||||
const assigned = document.getElementById('filterAssigned').value;
|
||||
if (assigned === 'null') {
|
||||
params.set('assigned_user_id', 'null');
|
||||
} else if (assigned) {
|
||||
params.set('assigned_user_id', assigned);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadIssues() {
|
||||
const body = document.getElementById('issuesBody');
|
||||
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
|
||||
|
||||
try {
|
||||
const params = buildQueryParams();
|
||||
const res = await fetch('/api/v1/invoice-error-finder/issues?' + params.toString());
|
||||
if (!res.ok) throw new Error('Kunne ikke hente fejlliste');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.items.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen fejl fundet</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = data.items.map(issue => `
|
||||
<tr>
|
||||
<td>${escapeHtml(issue.customer_name || 'Ukendt kunde')}</td>
|
||||
<td>${issueTypeLabels[issue.issue_type] || issue.issue_type}</td>
|
||||
<td>${escapeHtml(issue.resolved_product_name || issue.product_name || issue.product_number || '-')}</td>
|
||||
<td>${formatNumber(issue.expected_quantity ?? issue.expected_price)}</td>
|
||||
<td>${formatNumber(issue.actual_quantity ?? issue.actual_price)}</td>
|
||||
<td>${issue.reference_period_start || '-'}</td>
|
||||
<td>${formatCurrency(issue.amount_impact)}</td>
|
||||
<td>${statusBadge(issue.status)}</td>
|
||||
<td>${escapeHtml(issue.assigned_user_name || '-')}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
${renderActionButtons(issue)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('paginationInfo').textContent = `Viser ${data.items.length} af ${data.total} fejl`;
|
||||
renderPagination(data.total);
|
||||
} catch (err) {
|
||||
body.innerHTML = `<tr><td colspan="10" class="text-danger text-center py-4">Fejl: ${escapeHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderActionButtons(issue) {
|
||||
const historyButton = `<button class="btn btn-outline-dark" title="Se 13 måneder tilbage og 2 frem" onclick="showInvoiceHistory(${issue.id})"><i class="bi bi-clock-history"></i></button>`;
|
||||
if (issue.status === 'ignored') return `${historyButton}<span class="text-muted small ms-2">Ignoreret</span>`;
|
||||
if (issue.status === 'invoiced') return `${historyButton}<span class="text-muted small ms-2">Faktureret</span>`;
|
||||
if (issue.status === 'resolved') return `${historyButton}<span class="text-muted small ms-2">Løst</span>`;
|
||||
|
||||
return `
|
||||
${historyButton}
|
||||
<button class="btn btn-outline-success" title="Godkend" onclick="updateStatus(${issue.id}, 'approved_change')"><i class="bi bi-check"></i></button>
|
||||
<button class="btn btn-outline-warning" title="Under undersøgelse" onclick="updateStatus(${issue.id}, 'investigating')"><i class="bi bi-search"></i></button>
|
||||
<button class="btn btn-outline-primary" title="Opret ordrekladde" onclick="createOrdreDraft(${issue.id})"><i class="bi bi-receipt"></i></button>
|
||||
<button class="btn btn-outline-info" title="Opret sag" onclick="createSag(${issue.id})"><i class="bi bi-folder-plus"></i></button>
|
||||
<button class="btn btn-outline-secondary" title="Ignorér" onclick="ignoreIssue(${issue.id})"><i class="bi bi-eye-slash"></i></button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPagination(total) {
|
||||
const controls = document.getElementById('paginationControls');
|
||||
const pages = Math.ceil(total / pageSize);
|
||||
if (pages <= 1) {
|
||||
controls.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(currentOffset / pageSize);
|
||||
let html = `<button class="btn btn-outline-secondary" ${currentOffset === 0 ? 'disabled' : ''} onclick="goToPage(0)">«</button>`;
|
||||
for (let i = 0; i < pages; i++) {
|
||||
const active = i === currentPage ? 'active' : '';
|
||||
html += `<button class="btn btn-outline-secondary ${active}" onclick="goToPage(${i})">${i + 1}</button>`;
|
||||
}
|
||||
html += `<button class="btn btn-outline-secondary" ${currentOffset + pageSize >= total ? 'disabled' : ''} onclick="goToPage(${pages - 1})">»</button>`;
|
||||
controls.innerHTML = html;
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
currentOffset = page * pageSize;
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
async function updateStatus(issueId, status, options = {}) {
|
||||
const { target = 'page', successMessage = 'Status opdateret' } = options;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
if (!res.ok) throw new Error('Opdatering fejlede');
|
||||
if (target === 'history') {
|
||||
showHistoryStatus(successMessage, 'success');
|
||||
} else {
|
||||
showStatus(successMessage, 'success');
|
||||
}
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
if (target === 'history') {
|
||||
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||
} else {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createSag(issueId, options = {}) {
|
||||
const { target = 'page' } = options;
|
||||
const titel = prompt('Titel på sag:');
|
||||
if (!titel) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-sag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ titel })
|
||||
});
|
||||
const data = await res.json();
|
||||
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');
|
||||
}
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
if (target === 'history') {
|
||||
showHistoryStatus('Fejl: ' + err.message, 'danger');
|
||||
} else {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrdreDraft(issueId) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/create-ordre-draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Opret kladde fejlede');
|
||||
showStatus(`Ordrekladde #${data.draft_id} oprettet`, 'success');
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function ignoreIssue(issueId) {
|
||||
if (!confirm('Ignorér denne fejl?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/ignore`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) throw new Error('Ignorér fejlede');
|
||||
showStatus('Fejl ignoreret', 'success');
|
||||
loadIssues();
|
||||
} catch (err) {
|
||||
showStatus('Fejl: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const el = document.getElementById('issuesStatus');
|
||||
el.className = `alert alert-${type} mb-4`;
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
setTimeout(() => el.classList.add('d-none'), 4000);
|
||||
}
|
||||
|
||||
function showHistoryStatus(message, type) {
|
||||
const el = document.getElementById('historyModalStatus');
|
||||
el.className = `alert alert-${type} mb-3`;
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function setHistoryActionState(enabled) {
|
||||
['historyApproveBtn', 'historyInvestigatingBtn', 'historyReadyBtn', 'historyCreateSagBtn']
|
||||
.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoiceLines(lines) {
|
||||
if (!lines || lines.length === 0) {
|
||||
return '<div class="text-muted small">Ingen linjer fundet</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Linje</th>
|
||||
<th>Varenr</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Antal</th>
|
||||
<th>Pris</th>
|
||||
<th>Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${lines.map(line => `
|
||||
<tr>
|
||||
<td>${formatNumber(line.line_number)}</td>
|
||||
<td>${escapeHtml(line.product_number || '-')}</td>
|
||||
<td class="text-truncate" style="max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(line.description || line.product_name || '-')}">${escapeHtml(line.description || line.product_name || '-')}</td>
|
||||
<td>${formatNumber(line.quantity)}</td>
|
||||
<td>${formatCurrency(line.unit_price)}</td>
|
||||
<td>${formatCurrency(line.line_net_amount)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInvoiceNotes(invoice) {
|
||||
const parts = [invoice.heading, invoice.note_text].filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="border rounded bg-light p-2 mb-2 small">
|
||||
${parts.map(part => `<div>${escapeHtml(part)}</div>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInvoiceCard(invoice, monthKey, index) {
|
||||
const collapseId = `invoice-lines-${monthKey}-${index}`;
|
||||
return `
|
||||
<div class="border rounded p-2 mb-2 bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="text-truncate small" style="min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(`${invoice.invoice_number || '-'} (${invoice.invoice_date || '-'}) · ${invoice.heading || invoice.source_type || ''} · Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}`)}">
|
||||
<strong>${escapeHtml(invoice.invoice_number || '-')}</strong>
|
||||
<span class="text-muted">(${escapeHtml(invoice.invoice_date || '-')})</span>
|
||||
<span class="text-muted">· ${escapeHtml(invoice.heading || invoice.source_type || '')}</span>
|
||||
<span>· Netto: ${formatCurrency(invoice.net_amount)} · Moms: ${formatCurrency(invoice.vat_amount)} · Total: ${formatCurrency(invoice.total_amount)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false">
|
||||
Vis linjer
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="${collapseId}">
|
||||
${renderInvoiceNotes(invoice)}
|
||||
${renderInvoiceLines(invoice.lines)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showInvoiceHistory(issueId) {
|
||||
const body = document.getElementById('historyModalBody');
|
||||
const meta = document.getElementById('historyModalMeta');
|
||||
const status = document.getElementById('historyModalStatus');
|
||||
currentHistoryIssueId = issueId;
|
||||
setHistoryActionState(true);
|
||||
status.classList.add('d-none');
|
||||
meta.textContent = '';
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Indlæser historik...</td></tr>';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/invoice-error-finder/issues/${issueId}/invoice-history`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente historik');
|
||||
|
||||
meta.textContent = `${data.customer_name || 'Ukendt kunde'} · ${data.product_name || data.product_number || '-'}`;
|
||||
|
||||
body.innerHTML = data.months.map(row => {
|
||||
const stateBadge = row.line_count > 0
|
||||
? '<span class="badge bg-success">Faktureret</span>'
|
||||
: '<span class="badge bg-danger">Manglende</span>';
|
||||
const monthBadge = row.is_reference_month
|
||||
? ' <span class="badge bg-warning text-dark">Reference</span>'
|
||||
: row.is_fallback_history
|
||||
? ` <span class="badge bg-info text-dark">${escapeHtml(row.fallback_label || 'Seneste tidligere faktura')}</span>`
|
||||
: '';
|
||||
const invoiceList = row.invoices && row.invoices.length
|
||||
? row.invoices.map((invoice, index) => renderInvoiceCard(invoice, row.month_start || 'month', index)).join('')
|
||||
: '<span class="text-muted">Ingen faktura</span>';
|
||||
|
||||
return `
|
||||
<tr class="${row.is_reference_month ? 'table-warning' : row.is_fallback_history ? 'table-info' : ''}">
|
||||
<td>${formatMonth(row.month_start)}${monthBadge}</td>
|
||||
<td>${stateBadge}</td>
|
||||
<td>${formatNumber(row.line_count)}</td>
|
||||
<td>${formatNumber(row.total_quantity)}</td>
|
||||
<td>${formatCurrency(row.total_amount)}</td>
|
||||
<td>${invoiceList}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
body.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Kunne ikke hente historik</td></tr>';
|
||||
showHistoryStatus(`Fejl: ${err.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('historyApproveBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'approved_change', { target: 'history', successMessage: 'Fejlen er godkendt' });
|
||||
});
|
||||
|
||||
document.getElementById('historyInvestigatingBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'investigating', { target: 'history', successMessage: 'Fejlen er sat til undersøgelse' });
|
||||
});
|
||||
|
||||
document.getElementById('historyReadyBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
updateStatus(currentHistoryIssueId, 'ready_to_invoice', { target: 'history', successMessage: 'Ordrekladde markeret som klar' });
|
||||
});
|
||||
|
||||
document.getElementById('historyCreateSagBtn')?.addEventListener('click', function() {
|
||||
if (!currentHistoryIssueId) return;
|
||||
createSag(currentHistoryIssueId, { target: 'history' });
|
||||
});
|
||||
|
||||
loadIssues();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -40,9 +40,7 @@ from app.modules.locations.models.schemas import (
|
||||
Service, ServiceCreate, ServiceUpdate,
|
||||
Capacity, CapacityCreate, CapacityUpdate,
|
||||
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
||||
LocationWizardCreateRequest, LocationWizardCreateResponse,
|
||||
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate,
|
||||
CrossFieldPortLabelsUpdate
|
||||
LocationWizardCreateRequest, LocationWizardCreateResponse
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@ -166,21 +164,15 @@ async def create_location(request: Request):
|
||||
logger.warning("⚠️ Invalid location payload")
|
||||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
# Names only need to be unique within the same customer and hierarchy.
|
||||
check_query = """
|
||||
SELECT id FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
|
||||
# Check for duplicate name
|
||||
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
||||
existing = execute_query(check_query, (data.name,))
|
||||
|
||||
if existing:
|
||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location with name '{data.name}' already exists under the same customer/location"
|
||||
detail=f"Location with name '{data.name}' already exists"
|
||||
)
|
||||
|
||||
if data.customer_id is not None:
|
||||
@ -209,9 +201,9 @@ async def create_location(request: Request):
|
||||
INSERT INTO locations_locations (
|
||||
name, location_type, parent_location_id, customer_id, address_street, address_city,
|
||||
address_postal_code, address_country, latitude, longitude,
|
||||
phone, email, notes, is_active, has_cross_field, created_at, updated_at
|
||||
phone, email, notes, is_active, created_at, updated_at
|
||||
)
|
||||
VALUES (%s, %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, NOW(), NOW())
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
@ -229,8 +221,7 @@ async def create_location(request: Request):
|
||||
data.phone,
|
||||
data.email,
|
||||
data.notes,
|
||||
data.is_active,
|
||||
data.has_cross_field if data.location_type == 'rum' else False
|
||||
data.is_active
|
||||
)
|
||||
|
||||
result = execute_query(insert_query, params)
|
||||
@ -435,39 +426,32 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
def _normalize_name(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
|
||||
def _name_exists(value: str) -> bool:
|
||||
normalized = _normalize_name(value)
|
||||
scope_key = (normalized, parent_location_id, customer_id)
|
||||
if scope_key in reserved_names:
|
||||
if normalized in reserved_names:
|
||||
return True
|
||||
check_query = """
|
||||
SELECT 1 FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
existing = execute_query(check_query, (value, parent_location_id, customer_id))
|
||||
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
||||
existing = execute_query(check_query, (value,))
|
||||
return bool(existing)
|
||||
|
||||
def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
|
||||
def _reserve_name(value: str) -> None:
|
||||
normalized = _normalize_name(value)
|
||||
if normalized:
|
||||
reserved_names.add((normalized, parent_location_id, customer_id))
|
||||
reserved_names.add(normalized)
|
||||
|
||||
def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
|
||||
def _resolve_unique_name(base_name: str) -> str:
|
||||
if not auto_suffix:
|
||||
_reserve_name(base_name, parent_location_id, customer_id)
|
||||
_reserve_name(base_name)
|
||||
return base_name
|
||||
base_name = base_name.strip()
|
||||
if not _name_exists(base_name, parent_location_id, customer_id):
|
||||
_reserve_name(base_name, parent_location_id, customer_id)
|
||||
if not _name_exists(base_name):
|
||||
_reserve_name(base_name)
|
||||
return base_name
|
||||
suffix = 2
|
||||
while True:
|
||||
candidate = f"{base_name} ({suffix})"
|
||||
if not _name_exists(candidate, parent_location_id, customer_id):
|
||||
_reserve_name(candidate, parent_location_id, customer_id)
|
||||
if not _name_exists(candidate):
|
||||
_reserve_name(candidate)
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
@ -508,7 +492,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
raise HTTPException(status_code=500, detail="Failed to create location")
|
||||
return Location(**result[0])
|
||||
|
||||
resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
|
||||
resolved_root_name = _resolve_unique_name(root.name)
|
||||
root_location = insert_location_record(
|
||||
name=resolved_root_name,
|
||||
location_type=root.location_type,
|
||||
@ -538,7 +522,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
room_ids: List[int] = []
|
||||
|
||||
for floor in data.floors:
|
||||
resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
|
||||
resolved_floor_name = _resolve_unique_name(floor.name)
|
||||
floor_location = insert_location_record(
|
||||
name=resolved_floor_name,
|
||||
location_type=floor.location_type,
|
||||
@ -564,7 +548,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
))
|
||||
|
||||
for room in floor.rooms:
|
||||
resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
|
||||
resolved_room_name = _resolve_unique_name(room.name)
|
||||
room_location = insert_location_record(
|
||||
name=resolved_room_name,
|
||||
location_type=room.location_type,
|
||||
@ -610,317 +594,6 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
||||
# ============================================================================
|
||||
|
||||
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
|
||||
|
||||
|
||||
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)
|
||||
async def get_location(id: int):
|
||||
"""
|
||||
@ -972,12 +645,6 @@ async def get_location(id: int):
|
||||
capacity_result = execute_query(capacity_query, (id,))
|
||||
capacity = [dict(row) for row in capacity_result] if capacity_result else []
|
||||
|
||||
outlet_result = execute_query(
|
||||
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
|
||||
(id,),
|
||||
)
|
||||
wall_outlets = [dict(row) for row in (outlet_result or [])]
|
||||
|
||||
# Build hierarchy breadcrumb (ancestors from root to parent)
|
||||
hierarchy_query = """
|
||||
WITH RECURSIVE ancestors AS (
|
||||
@ -1026,8 +693,7 @@ async def get_location(id: int):
|
||||
contacts=contacts,
|
||||
hours=hours,
|
||||
services=services,
|
||||
capacity=capacity,
|
||||
wall_outlets=wall_outlets,
|
||||
capacity=capacity
|
||||
)
|
||||
|
||||
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
|
||||
@ -1074,26 +740,15 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
|
||||
old_location = Location(**existing[0])
|
||||
|
||||
# Check the resulting name/customer/parent scope, including when only
|
||||
# customer or parent is changed.
|
||||
if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
|
||||
candidate_name = data.name if data.name is not None else old_location.name
|
||||
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
|
||||
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
|
||||
dup_query = """
|
||||
SELECT id FROM locations_locations
|
||||
WHERE lower(name) = lower(%s)
|
||||
AND parent_location_id IS NOT DISTINCT FROM %s
|
||||
AND customer_id IS NOT DISTINCT FROM %s
|
||||
AND id != %s
|
||||
AND deleted_at IS NULL
|
||||
"""
|
||||
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
|
||||
# Check for duplicate name if name is being updated
|
||||
if data.name is not None and data.name != old_location.name:
|
||||
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL"
|
||||
dup_check = execute_query(dup_query, (data.name, id))
|
||||
if dup_check:
|
||||
logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
|
||||
logger.warning(f"⚠️ Duplicate location name: {data.name}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
|
||||
detail=f"Location with name '{data.name}' already exists"
|
||||
)
|
||||
|
||||
# Build UPDATE query with only provided fields
|
||||
@ -1115,8 +770,7 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
'phone': 'phone',
|
||||
'email': 'email',
|
||||
'notes': 'notes',
|
||||
'is_active': 'is_active',
|
||||
'has_cross_field': 'has_cross_field'
|
||||
'is_active': 'is_active'
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@ -1138,30 +792,6 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
status_code=400,
|
||||
detail="parent_location_id does not exist"
|
||||
)
|
||||
descendant_check = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id
|
||||
FROM locations_locations
|
||||
WHERE parent_location_id = %s AND deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.id
|
||||
FROM locations_locations l
|
||||
JOIN descendants d ON l.parent_location_id = d.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM descendants WHERE id = %s LIMIT 1
|
||||
""",
|
||||
(id, value),
|
||||
)
|
||||
if descendant_check:
|
||||
logger.warning("⚠️ parent_location_id cannot reference a descendant")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="parent_location_id cannot reference a descendant"
|
||||
)
|
||||
if key == 'customer_id':
|
||||
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
||||
customer = execute_query(customer_query, (value,))
|
||||
@ -1179,10 +809,6 @@ async def update_location(id: int, data: LocationUpdate):
|
||||
status_code=400,
|
||||
detail=f"location_type must be one of: {', '.join(allowed_types)}"
|
||||
)
|
||||
if key == 'has_cross_field' and value:
|
||||
resulting_type = data.location_type or old_location.location_type
|
||||
if resulting_type != 'rum':
|
||||
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
|
||||
update_parts.append(f"{db_column} = %s")
|
||||
params.append(value)
|
||||
update_data[key] = value
|
||||
|
||||
@ -21,7 +21,6 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
||||
from pathlib import Path as PathlibPath
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.core.database import execute_query, execute_update
|
||||
@ -58,100 +57,6 @@ LOCATION_TYPES = [
|
||||
{"value": "vehicle", "label": "Køretøj"},
|
||||
]
|
||||
|
||||
LOCATION_TYPE_LABELS = {
|
||||
"kompleks": "Kompleks",
|
||||
"bygning": "Bygning",
|
||||
"etage": "Etage",
|
||||
"customer_site": "Kundesite",
|
||||
"rum": "Rum",
|
||||
"kantine": "Kantine",
|
||||
"moedelokale": "Mødelokale",
|
||||
"vehicle": "Køretøj",
|
||||
}
|
||||
|
||||
|
||||
def get_location_type_label(location_type: Optional[str]) -> str:
|
||||
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
|
||||
|
||||
|
||||
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
|
||||
exclude_ids = []
|
||||
if exclude_id is not None:
|
||||
exclude_tree = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id
|
||||
FROM locations_locations
|
||||
WHERE id = %s
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.id
|
||||
FROM locations_locations l
|
||||
JOIN descendants d ON l.parent_location_id = d.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
""",
|
||||
(exclude_id,),
|
||||
)
|
||||
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
|
||||
|
||||
parent_locations = execute_query(
|
||||
"""
|
||||
WITH RECURSIVE location_tree AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
location_type,
|
||||
parent_location_id,
|
||||
customer_id,
|
||||
is_active,
|
||||
name::text AS hierarchy_path,
|
||||
0 AS depth
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND parent_location_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
l.id,
|
||||
l.name,
|
||||
l.location_type,
|
||||
l.parent_location_id,
|
||||
l.customer_id,
|
||||
l.is_active,
|
||||
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
|
||||
lt.depth + 1 AS depth
|
||||
FROM locations_locations l
|
||||
JOIN location_tree lt ON l.parent_location_id = lt.id
|
||||
WHERE l.deleted_at IS NULL
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
location_type,
|
||||
parent_location_id,
|
||||
customer_id,
|
||||
is_active,
|
||||
hierarchy_path,
|
||||
depth
|
||||
FROM location_tree
|
||||
WHERE is_active = true
|
||||
ORDER BY hierarchy_path
|
||||
LIMIT 2000
|
||||
"""
|
||||
)
|
||||
|
||||
choices = []
|
||||
for row in parent_locations or []:
|
||||
if row.get("id") in exclude_ids:
|
||||
continue
|
||||
row["type_label"] = get_location_type_label(row.get("location_type"))
|
||||
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
|
||||
choices.append(row)
|
||||
return choices
|
||||
|
||||
|
||||
def render_template(template_name: str, **context) -> str:
|
||||
"""
|
||||
@ -262,7 +167,7 @@ def list_locations_view(
|
||||
"""
|
||||
query_params.extend([limit, skip])
|
||||
|
||||
locations = execute_query(query, tuple(query_params)) or []
|
||||
locations = execute_query(query, tuple(query_params))
|
||||
|
||||
def build_tree(items: list) -> list:
|
||||
nodes = {}
|
||||
@ -342,10 +247,7 @@ def list_locations_view(
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/app/locations/create", response_class=HTMLResponse)
|
||||
def create_location_view(
|
||||
parent_location_id: Optional[int] = Query(None, gt=0),
|
||||
customer_id: Optional[int] = Query(None, gt=0),
|
||||
):
|
||||
def create_location_view():
|
||||
"""
|
||||
Render the location creation form.
|
||||
|
||||
@ -366,11 +268,14 @@ def create_location_view(
|
||||
try:
|
||||
logger.info("🆕 Rendering create location form")
|
||||
|
||||
parent_locations = get_parent_location_choices() or []
|
||||
selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
|
||||
|
||||
if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
|
||||
customer_id = selected_parent.get("customer_id")
|
||||
# Query parent locations
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
@ -390,10 +295,7 @@ def create_location_view(
|
||||
cancel_url="/app/locations",
|
||||
location_types=LOCATION_TYPES,
|
||||
parent_locations=parent_locations,
|
||||
customers=customers or [],
|
||||
selected_parent_id=parent_location_id,
|
||||
selected_customer_id=customer_id,
|
||||
selected_parent=selected_parent,
|
||||
customers=customers,
|
||||
location=None, # No location data for create form
|
||||
)
|
||||
|
||||
@ -419,7 +321,13 @@ def location_wizard_view():
|
||||
try:
|
||||
logger.info("🧭 Rendering location wizard")
|
||||
|
||||
parent_locations = get_parent_location_choices()
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
|
||||
customers = execute_query("""
|
||||
SELECT id, name, email, phone
|
||||
@ -448,47 +356,7 @@ def location_wizard_view():
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)
|
||||
# 3. GET /app/locations/{id} - Detail view (HTML)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/app/locations/{id}", response_class=HTMLResponse)
|
||||
@ -597,96 +465,14 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
|
||||
hardware = execute_query(
|
||||
"""
|
||||
SELECT id, asset_type, brand, model, serial_number, status, hardware_specs, location_display_order
|
||||
SELECT id, asset_type, brand, model, serial_number, status
|
||||
FROM hardware_assets
|
||||
WHERE current_location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY location_display_order NULLS LAST, brand ASC, model ASC, serial_number ASC
|
||||
ORDER BY brand ASC, model ASC, serial_number ASC
|
||||
""",
|
||||
(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(
|
||||
"""
|
||||
SELECT id, location_id, event_type, user_id, changes, created_at
|
||||
@ -704,8 +490,6 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
location["services"] = services or []
|
||||
location["capacity"] = capacity or []
|
||||
location["hardware"] = hardware or []
|
||||
location["wall_outlets"] = wall_outlets or []
|
||||
location["cross_fields"] = cross_fields or []
|
||||
location["audit_log"] = audit_log or []
|
||||
|
||||
# Query customers
|
||||
@ -721,8 +505,6 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
|
||||
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
|
||||
|
||||
customers = customers or []
|
||||
|
||||
# Render template with context
|
||||
html = render_template(
|
||||
"modules/locations/templates/detail.html",
|
||||
@ -773,11 +555,14 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
||||
|
||||
location = location[0] # Get first result
|
||||
|
||||
parent_locations = get_parent_location_choices(exclude_id=id) or []
|
||||
selected_parent = next(
|
||||
(row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
|
||||
None,
|
||||
)
|
||||
# Query parent locations (exclude self)
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE is_active = true AND id != %s
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""", (id,))
|
||||
|
||||
# Query customers
|
||||
customers = execute_query("""
|
||||
@ -799,8 +584,7 @@ def edit_location_view(id: int = Path(..., gt=0)):
|
||||
cancel_url=f"/app/locations/{id}",
|
||||
location_types=LOCATION_TYPES,
|
||||
parent_locations=parent_locations,
|
||||
customers=customers or [],
|
||||
selected_parent=selected_parent,
|
||||
customers=customers,
|
||||
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
|
||||
)
|
||||
|
||||
@ -841,7 +625,6 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
||||
latitude = %s,
|
||||
longitude = %s,
|
||||
notes = %s,
|
||||
has_cross_field = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (
|
||||
@ -859,7 +642,6 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
|
||||
float(form.get("latitude")) if form.get("latitude") else None,
|
||||
float(form.get("longitude")) if form.get("longitude") else None,
|
||||
form.get("notes"),
|
||||
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
|
||||
id
|
||||
))
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ from decimal import Decimal
|
||||
|
||||
class LocationBase(BaseModel):
|
||||
"""Shared fields for location models"""
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
|
||||
location_type: str = Field(
|
||||
...,
|
||||
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
||||
@ -40,7 +40,6 @@ class LocationBase(BaseModel):
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = Field(True, description="Whether location is active")
|
||||
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
|
||||
|
||||
@field_validator('location_type')
|
||||
@classmethod
|
||||
@ -76,7 +75,6 @@ class LocationUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
has_cross_field: Optional[bool] = None
|
||||
|
||||
@field_validator('location_type')
|
||||
@classmethod
|
||||
@ -103,126 +101,6 @@ class Location(LocationBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NETWORK WALL OUTLET MODELS
|
||||
# ============================================================================
|
||||
|
||||
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
|
||||
|
||||
|
||||
class WallOutletBase(BaseModel):
|
||||
location_id: int = Field(..., ge=1)
|
||||
outlet_number: 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
|
||||
# ============================================================================
|
||||
@ -482,7 +360,6 @@ class LocationDetail(Location):
|
||||
hours: List[OperatingHours] = Field(default_factory=list)
|
||||
services: List[Service] = Field(default_factory=list)
|
||||
capacity: List[Capacity] = Field(default_factory=list)
|
||||
wall_outlets: List[WallOutlet] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
|
||||
@ -2,40 +2,6 @@
|
||||
|
||||
{% block title %}Opret lokation - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.relation-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.relation-summary {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.relation-summary.empty {
|
||||
background: #f8fafc;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.relation-path {
|
||||
font-weight: 600;
|
||||
color: #1f3b53;
|
||||
}
|
||||
|
||||
.relation-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
@ -91,52 +57,19 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||
<option value="">Ingen (øverste niveau)</option>
|
||||
{% if parent_locations %}
|
||||
{% for parent in parent_locations %}
|
||||
<option
|
||||
value="{{ parent.id }}"
|
||||
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 value="{{ parent.id }}">
|
||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<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 class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -145,11 +78,11 @@
|
||||
<option value="">Ingen</option>
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
|
||||
<div class="form-text">Valgfri – kan knyttes til alle typer.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -158,14 +91,6 @@
|
||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="crossFieldOption" hidden>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
|
||||
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section 2: Address -->
|
||||
@ -260,79 +185,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const notesField = document.getElementById('notes');
|
||||
const charCount = document.getElementById('charCount');
|
||||
const parentLocationSelect = document.getElementById('parentLocation');
|
||||
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||
const customerSelect = document.getElementById('customerId');
|
||||
const parentSummary = document.getElementById('parentSummary');
|
||||
|
||||
// Character counter for notes
|
||||
notesField.addEventListener('input', function() {
|
||||
charCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function updateParentSummary() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const path = selectedOption?.dataset?.path || '';
|
||||
const type = selectedOption?.dataset?.type || '';
|
||||
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
parentSummary.classList.add('empty');
|
||||
parentSummary.innerHTML = '<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
parentSummary.classList.remove('empty');
|
||||
parentSummary.innerHTML = `
|
||||
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||
<div class="relation-path">${escapeHtml(path)}</div>
|
||||
<div class="relation-meta">${escapeHtml(type)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterParentLocations() {
|
||||
const query = (parentLocationSearch.value || '').trim().toLowerCase();
|
||||
Array.from(parentLocationSelect.options).forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
|
||||
option.hidden = query ? !haystack.includes(query) : false;
|
||||
});
|
||||
}
|
||||
|
||||
if (parentLocationSearch) {
|
||||
parentLocationSearch.addEventListener('input', filterParentLocations);
|
||||
}
|
||||
|
||||
if (parentLocationSelect) {
|
||||
parentLocationSelect.addEventListener('change', function() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const parentCustomerId = selectedOption?.dataset?.customerId;
|
||||
if ((!customerSelect.value || customerSelect.dataset.autofilled === 'true') && parentCustomerId) {
|
||||
customerSelect.value = parentCustomerId;
|
||||
customerSelect.dataset.autofilled = 'true';
|
||||
}
|
||||
updateParentSummary();
|
||||
});
|
||||
updateParentSummary();
|
||||
}
|
||||
|
||||
if (customerSelect) {
|
||||
customerSelect.addEventListener('change', function() {
|
||||
customerSelect.dataset.autofilled = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@ -344,10 +202,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
location_type: formData.get('location_type'),
|
||||
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||
is_active: formData.get('is_active') === 'on',
|
||||
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||
address_street: formData.get('address_street'),
|
||||
address_city: formData.get('address_city'),
|
||||
address_postal_code: formData.get('address_postal_code'),
|
||||
@ -384,17 +239,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
|
||||
}
|
||||
});
|
||||
|
||||
const locationType = document.getElementById('locationType');
|
||||
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||
const hasCrossField = document.getElementById('hasCrossField');
|
||||
const updateCrossFieldOption = () => {
|
||||
const isRoom = locationType.value === 'rum';
|
||||
crossFieldOption.hidden = !isRoom;
|
||||
if (!isRoom) hasCrossField.checked = false;
|
||||
};
|
||||
locationType.addEventListener('change', updateCrossFieldOption);
|
||||
updateCrossFieldOption();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -4,18 +4,6 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
|
||||
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
|
||||
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
|
||||
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
|
||||
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
|
||||
.patch-port.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 {
|
||||
--loc-accent: var(--accent, #0f4c75);
|
||||
}
|
||||
@ -259,9 +247,6 @@
|
||||
<span class="case-type-chip" style="--tcolor: {{ type_color }};">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
{% if location.has_cross_field %}
|
||||
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
|
||||
{% endif %}
|
||||
{% if location.is_active %}
|
||||
<span class="case-status-chip open">
|
||||
<span class="case-status-dot"></span>Aktiv
|
||||
@ -343,13 +328,6 @@
|
||||
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{% if location.location_type == 'rum' and location.has_cross_field %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Relationer
|
||||
@ -737,15 +715,9 @@
|
||||
</div>
|
||||
<div class="card border-0">
|
||||
<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>
|
||||
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
|
||||
<form action="/api/v1/locations" method="post" class="row g-2">
|
||||
<input type="hidden" name="parent_location_id" value="{{ location.id }}">
|
||||
<input type="hidden" name="redirect_to" value="/app/locations/{id}">
|
||||
@ -775,7 +747,7 @@
|
||||
<option value="">Ingen</option>
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
@ -791,32 +763,6 @@
|
||||
</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-header bg-transparent border-bottom">
|
||||
<h5 class="card-title mb-0">Hierarki (træ)</h5>
|
||||
@ -857,23 +803,6 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
|
||||
<div class="card border-0">
|
||||
@ -884,14 +813,12 @@
|
||||
{% if location.hardware %}
|
||||
<div class="list-group">
|
||||
{% for hw in location.hardware %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center gap-3">
|
||||
<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="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 class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div>
|
||||
<div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<span class="badge bg-secondary">{{ hw.status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -1049,50 +976,6 @@
|
||||
</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 -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@ -1122,8 +1005,6 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
const locationId = '{{ location.id }}';
|
||||
const locationHardware = {{ location.hardware | tojson }};
|
||||
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||
const existingContactIdInput = document.getElementById('existingContactId');
|
||||
@ -1411,270 +1292,6 @@ 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>
|
||||
{% endblock %}
|
||||
|
||||
@ -2,40 +2,6 @@
|
||||
|
||||
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.relation-card {
|
||||
border: 1px solid rgba(15, 76, 117, 0.1);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.relation-summary {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.22);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.relation-summary.empty {
|
||||
background: #f8fafc;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.relation-path {
|
||||
font-weight: 600;
|
||||
color: #1f3b53;
|
||||
}
|
||||
|
||||
.relation-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
@ -92,52 +58,19 @@
|
||||
</select>
|
||||
</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">
|
||||
<label for="parentLocation" class="form-label">Overordnet lokation</label>
|
||||
<select class="form-select" id="parentLocation" name="parent_location_id">
|
||||
<option value="">Ingen (øverste niveau)</option>
|
||||
{% if parent_locations %}
|
||||
{% for parent in parent_locations %}
|
||||
<option
|
||||
value="{{ parent.id }}"
|
||||
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 value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}>
|
||||
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="form-text">Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.</div>
|
||||
</div>
|
||||
|
||||
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
|
||||
{% if selected_parent %}
|
||||
<div class="small text-muted mb-1">Nuværende overordnet lokation</div>
|
||||
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
|
||||
<div class="relation-meta">{{ selected_parent.type_label }}</div>
|
||||
{% else %}
|
||||
<div class="small text-muted">Lokationen ligger i topniveau.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -159,13 +92,6 @@
|
||||
<label class="form-check-label" for="isActive">Lokation er aktiv</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
|
||||
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section 2: Address -->
|
||||
@ -204,7 +130,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -234,7 +160,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Noter og kommentarer</label>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
|
||||
<small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
|
||||
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -290,64 +216,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteModalElement = document.getElementById('deleteModal');
|
||||
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
|
||||
const locationId = '{{ location.id }}';
|
||||
const parentLocationSelect = document.getElementById('parentLocation');
|
||||
const parentLocationSearch = document.getElementById('parentLocationSearch');
|
||||
const parentSummary = document.getElementById('parentSummary');
|
||||
|
||||
// Character counter for notes
|
||||
notesField.addEventListener('input', function() {
|
||||
charCount.textContent = this.value.length;
|
||||
});
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function updateParentSummary() {
|
||||
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
|
||||
const path = selectedOption?.dataset?.path || '';
|
||||
const type = selectedOption?.dataset?.type || '';
|
||||
|
||||
if (!selectedOption || !selectedOption.value) {
|
||||
parentSummary.classList.add('empty');
|
||||
parentSummary.innerHTML = '<div class="small text-muted">Lokationen ligger i topniveau.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
parentSummary.classList.remove('empty');
|
||||
parentSummary.innerHTML = `
|
||||
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
|
||||
<div class="relation-path">${escapeHtml(path)}</div>
|
||||
<div class="relation-meta">${escapeHtml(type)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterParentLocations() {
|
||||
const query = (parentLocationSearch.value || '').trim().toLowerCase();
|
||||
Array.from(parentLocationSelect.options).forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
|
||||
option.hidden = query ? !haystack.includes(query) : false;
|
||||
});
|
||||
}
|
||||
|
||||
if (parentLocationSearch) {
|
||||
parentLocationSearch.addEventListener('input', filterParentLocations);
|
||||
}
|
||||
|
||||
if (parentLocationSelect) {
|
||||
parentLocationSelect.addEventListener('change', updateParentSummary);
|
||||
updateParentSummary();
|
||||
}
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
if (form.dataset.noIntercept === 'true') {
|
||||
@ -365,7 +239,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
|
||||
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
|
||||
is_active: formData.get('is_active') === 'on',
|
||||
has_cross_field: formData.get('has_cross_field') === 'on',
|
||||
address_street: formData.get('address_street'),
|
||||
address_city: formData.get('address_city'),
|
||||
address_postal_code: formData.get('address_postal_code'),
|
||||
@ -402,17 +275,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
const locationType = document.getElementById('locationType');
|
||||
const crossFieldOption = document.getElementById('crossFieldOption');
|
||||
const hasCrossField = document.getElementById('hasCrossField');
|
||||
const updateCrossFieldOption = () => {
|
||||
const isRoom = locationType.value === 'rum';
|
||||
crossFieldOption.hidden = !isRoom;
|
||||
if (!isRoom) hasCrossField.checked = false;
|
||||
};
|
||||
locationType.addEventListener('change', updateCrossFieldOption);
|
||||
updateCrossFieldOption();
|
||||
|
||||
// Delete location
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||
fetch(`/api/v1/locations/${locationId}`, {
|
||||
|
||||
@ -6,271 +6,81 @@
|
||||
<style>
|
||||
.locations-list-page {
|
||||
--loc-accent: #0f4c75;
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.06);
|
||||
--loc-border: rgba(15, 76, 117, 0.12);
|
||||
--loc-surface: #f7f9fc;
|
||||
--loc-accent-soft: rgba(15, 76, 117, 0.08);
|
||||
--loc-border: rgba(15, 76, 117, 0.16);
|
||||
}
|
||||
|
||||
.locations-list-page .locations-hero {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--bg-card);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
|
||||
border: 1px solid var(--loc-border);
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .locations-list-page .locations-hero {
|
||||
background:
|
||||
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
|
||||
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
|
||||
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
|
||||
border: 1px solid rgba(15, 76, 117, 0.08);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.locations-list-page .section-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--loc-accent);
|
||||
background: var(--loc-accent-soft);
|
||||
border: 1px solid var(--loc-border);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile .stat-value {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--loc-accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile .stat-note {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
[data-theme="dark"] .locations-list-page .stat-tile .stat-value {
|
||||
color: #8fd0ff;
|
||||
}
|
||||
|
||||
.locations-list-page .table thead th {
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row {
|
||||
transition: background-color 0.16s ease;
|
||||
transition: background-color 0.18s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.locations-list-page .location-row:hover {
|
||||
background-color: rgba(15, 76, 117, 0.03);
|
||||
background-color: rgba(52, 152, 219, 0.08);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.locations-list-page .toggle-row {
|
||||
color: var(--loc-accent);
|
||||
width: 1.2rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
.locations-list-page .filter-shell,
|
||||
.locations-list-page .content-shell {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
|
||||
}
|
||||
|
||||
.locations-list-page .filter-shell {
|
||||
padding: 1.05rem 1.1rem;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-toolbar,
|
||||
.locations-list-page .filter-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-title {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-subtitle {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .content-shell-subtitle span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-link {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-link:hover {
|
||||
color: var(--loc-accent);
|
||||
}
|
||||
|
||||
.locations-list-page .location-primary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-indent {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.locations-list-page .location-name-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locations-list-page .location-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.locations-list-page .location-meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 76, 117, 0.07);
|
||||
color: #2d5670;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.32rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill.active {
|
||||
background: rgba(25, 135, 84, 0.12);
|
||||
color: #146c43;
|
||||
}
|
||||
|
||||
.locations-list-page .status-pill.inactive {
|
||||
background: rgba(108, 117, 125, 0.12);
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.locations-list-page .shortcut-hint {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||
border: 1px dashed var(--loc-border);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.locations-list-page .hero-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.locations-list-page .list-status-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--loc-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-list-page .actions-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.locations-list-page .actions-inline .btn {
|
||||
border-radius: 0.7rem;
|
||||
}
|
||||
|
||||
.locations-list-page .empty-state-soft {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.locations-list-page {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
|
||||
.locations-list-page .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; }
|
||||
.locations-list-page .stat-tile {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -286,83 +96,59 @@
|
||||
</nav>
|
||||
|
||||
<!-- Header Section -->
|
||||
<div class="locations-hero p-3 p-lg-4 mb-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="locations-hero p-3 p-lg-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="section-label mb-2">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
Lokationsstruktur
|
||||
</div>
|
||||
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
|
||||
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
|
||||
</div>
|
||||
<div 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>
|
||||
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p>
|
||||
</div>
|
||||
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
|
||||
</div>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="stat-value" id="statTotal">{{ total or 0 }}</div>
|
||||
<div class="stat-note">Alle registrerede lokationer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Aktive</div>
|
||||
<div class="stat-value" id="statActive">0</div>
|
||||
<div class="stat-note">Klar til daglig brug</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Inaktive</div>
|
||||
<div class="stat-value" id="statInactive">0</div>
|
||||
<div class="stat-note">Skjulte eller lukkede</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="stat-tile">
|
||||
<div class="small text-muted">Synlige nu</div>
|
||||
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
|
||||
<div class="stat-note">Efter søgning og foldning</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Card -->
|
||||
<div class="filter-shell mb-4">
|
||||
<div class="filter-toolbar mb-3">
|
||||
<div>
|
||||
<div class="content-shell-title">Filtre</div>
|
||||
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
|
||||
</div>
|
||||
<div class="list-status-note">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
Hierarkiet kan foldes direkte i listen
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4 border-0">
|
||||
<div class="card-body">
|
||||
<form id="filterForm" method="get" class="row g-3 align-items-end">
|
||||
<div class="col-lg-5">
|
||||
<div class="col-md-4">
|
||||
<label for="locationSearch" class="form-label small text-muted">Søg</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
|
||||
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter navn, hierarki eller by...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="col-md-4">
|
||||
<label for="locationTypeFilter" class="form-label small text-muted">Type</label>
|
||||
<select class="form-select" id="locationTypeFilter" name="location_type">
|
||||
<option value="">Alle typer</option>
|
||||
@ -378,7 +164,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-lg-2">
|
||||
<div class="col-md-2">
|
||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||
<select class="form-select" id="statusFilter" name="is_active">
|
||||
<option value="">Alle</option>
|
||||
@ -387,43 +173,44 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5 col-lg-2 d-flex gap-2">
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-funnel me-2"></i>Filtrer
|
||||
<i class="bi bi-funnel me-2"></i>Anvend filtre
|
||||
</button>
|
||||
<a href="/app/locations" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Section -->
|
||||
<div class="content-shell">
|
||||
<div class="content-shell-header">
|
||||
<div class="content-shell-toolbar w-100">
|
||||
<div>
|
||||
<div class="content-shell-title">Lokationsliste</div>
|
||||
<div class="content-shell-subtitle">
|
||||
{% if total %}
|
||||
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
|
||||
<span>med tydeligt hierarki og status</span>
|
||||
{% else %}
|
||||
Ingen lokationer endnu
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="list-status-note">
|
||||
<i class="bi bi-check2-circle"></i>
|
||||
Klik kun på navn eller handlinger for at åbne
|
||||
</span>
|
||||
<!-- Toolbar Section -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||
</a>
|
||||
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
||||
<i class="bi bi-trash me-2"></i>Slet valgte
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Main Content Section -->
|
||||
<div class="card border-0">
|
||||
<div class="table-responsive">
|
||||
{% if location_tree %}
|
||||
<table class="table table-hover mb-0">
|
||||
@ -463,44 +250,26 @@
|
||||
'vehicle': '#8e44ad'
|
||||
}.get(node.location_type, '#6c757d') %}
|
||||
|
||||
{% set child_count = node.children|length if node.children else 0 %}
|
||||
|
||||
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="location-summary">
|
||||
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
|
||||
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;">
|
||||
{% if node.children %}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 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 me-2 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="location-tree-spacer text-muted">•</span>
|
||||
<span class="text-muted me-2">•</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="location-name-block">
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
|
||||
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500">
|
||||
{{ node.name }}
|
||||
</a>
|
||||
<div class="location-meta">
|
||||
{% if depth > 0 %}
|
||||
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
|
||||
{% endif %}
|
||||
{% if child_count %}
|
||||
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
|
||||
{% endif %}
|
||||
{% if node.address_city %}
|
||||
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
|
||||
{% endif %}
|
||||
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-pill" style="background-color: {{ type_color }};">
|
||||
<span class="badge" style="background-color: {{ type_color }}; color: white;">
|
||||
{{ type_label }}
|
||||
</span>
|
||||
</td>
|
||||
@ -509,20 +278,20 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if node.is_active %}
|
||||
<span class="status-pill active"><span>●</span>Aktiv</span>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="status-pill inactive"><span>●</span>Inaktiv</span>
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions-inline" role="group" aria-label="Handlinger">
|
||||
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
|
||||
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
|
||||
<button type="button" class="btn btn-outline-danger delete-location-btn"
|
||||
data-location-id="{{ node.id }}"
|
||||
data-location-name="{{ node.name }}"
|
||||
title="Slet">
|
||||
@ -546,7 +315,6 @@
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-5">
|
||||
<div class="empty-state-soft">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
|
||||
</div>
|
||||
@ -556,7 +324,6 @@
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -841,14 +608,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let currentDeleteId = null;
|
||||
|
||||
// Select all functionality
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
locationCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkbox functionality
|
||||
locationCheckboxes.forEach(checkbox => {
|
||||
@ -867,7 +632,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
if (!selectAllCheckbox) return;
|
||||
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
|
||||
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
@ -914,7 +678,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Bulk delete
|
||||
if (bulkDeleteBtn) {
|
||||
bulkDeleteBtn.addEventListener('click', function() {
|
||||
const selectedIds = Array.from(locationCheckboxes)
|
||||
.filter(cb => cb.checked)
|
||||
@ -933,8 +696,18 @@ 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>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
{% 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,8 +14,7 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||
from app.core.config import settings
|
||||
from app.services.email_service import EmailService
|
||||
@ -311,17 +310,6 @@ class RewriteTextResponse(BaseModel):
|
||||
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):
|
||||
to: List[str]
|
||||
subject: str = Field(..., min_length=1, max_length=998)
|
||||
@ -742,19 +730,6 @@ async def analyze_quick_create(request: QuickCreateRequest):
|
||||
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)
|
||||
async def rewrite_sag_text(request: RewriteTextRequest):
|
||||
"""Rewrite case/email text using Ollama with configurable prompt."""
|
||||
@ -928,7 +903,7 @@ async def list_all_sale_items(
|
||||
|
||||
@router.post("/sag")
|
||||
async def create_sag(data: dict):
|
||||
"""Create a case and its optional pipeline/order data atomically."""
|
||||
"""Create a new case."""
|
||||
try:
|
||||
if not data.get("titel"):
|
||||
raise HTTPException(status_code=400, detail="titel is required")
|
||||
@ -944,118 +919,32 @@ async def create_sag(data: dict):
|
||||
_validate_user_id(ansvarlig_bruger_id)
|
||||
_validate_group_id(assigned_group_id)
|
||||
|
||||
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||
pipeline = data.get("pipeline") if case_type == "pipeline" else None
|
||||
order_items = data.get("order_items") if case_type == "ordre" else []
|
||||
if pipeline is not None and not isinstance(pipeline, dict):
|
||||
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
|
||||
if not isinstance(order_items, list):
|
||||
raise HTTPException(status_code=400, detail="order_items skal være en liste")
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
pipeline_values = {"amount": None, "probability": None, "stage_id": None, "description": None}
|
||||
if pipeline:
|
||||
pipeline_values.update({key: pipeline.get(key) for key in pipeline_values})
|
||||
if pipeline_values["amount"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["amount"] = float(pipeline_values["amount"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
||||
else:
|
||||
pipeline_values["amount"] = None
|
||||
if pipeline_values["probability"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["probability"] = int(pipeline_values["probability"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være et helt tal") from exc
|
||||
if not 0 <= pipeline_values["probability"] <= 100:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være mellem 0 og 100")
|
||||
else:
|
||||
pipeline_values["probability"] = None
|
||||
if pipeline_values["stage_id"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["stage_id"] = int(pipeline_values["stage_id"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage") from exc
|
||||
cursor.execute("SELECT id FROM pipeline_stages WHERE id = %s", (pipeline_values["stage_id"],))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage")
|
||||
else:
|
||||
pipeline_values["stage_id"] = None
|
||||
|
||||
normalized_items = []
|
||||
for item in order_items:
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinje")
|
||||
description = str(item.get("description") or "").strip()
|
||||
item_type = str(item.get("type") or "sale").lower()
|
||||
if not description:
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens beskrivelse er påkrævet")
|
||||
if item_type not in ("sale", "purchase"):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjetype skal være køb eller salg")
|
||||
if item.get("amount") in (None, ""):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens beløb er påkrævet")
|
||||
try:
|
||||
normalized_items.append({
|
||||
"type": item_type,
|
||||
"description": description,
|
||||
"quantity": float(item["quantity"]) if item.get("quantity") not in (None, "") else None,
|
||||
"unit": item.get("unit") or None,
|
||||
"unit_price": float(item["unit_price"]) if item.get("unit_price") not in (None, "") else None,
|
||||
"amount": float(item["amount"]),
|
||||
"currency": str(item.get("currency") or "DKK").upper(),
|
||||
"status": str(item.get("status") or "draft").lower(),
|
||||
"line_date": item.get("line_date") or None,
|
||||
"external_ref": item.get("external_ref") or None,
|
||||
"purchase_purpose": _normalize_purchase_purpose(item.get("purchase_purpose"), item_type),
|
||||
})
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
||||
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO sag_sager
|
||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
|
||||
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)
|
||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(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"]),
|
||||
"""
|
||||
params = (
|
||||
data.get("titel"),
|
||||
data.get("beskrivelse", ""),
|
||||
data.get("template_key") or data.get("type", "ticket"),
|
||||
status,
|
||||
data.get("customer_id"),
|
||||
ansvarlig_bruger_id,
|
||||
assigned_group_id,
|
||||
data.get("created_by_user_id", 1),
|
||||
deadline,
|
||||
deferred_until,
|
||||
data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
for item in normalized_items:
|
||||
if has_purchase_columns:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, purchase_purpose)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"], item["purchase_purpose"]),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("✅ Case created: %s", result["id"])
|
||||
return dict(result)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
result = execute_query(query, params)
|
||||
if result:
|
||||
logger.info("✅ Case created: %s", result[0]["id"])
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@ -162,12 +162,6 @@
|
||||
</div>
|
||||
|
||||
<form id="createForm" novalidate>
|
||||
<div class="mb-4 p-3 rounded-3 border bg-light">
|
||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||
<select class="form-select form-select-lg" id="type" required></select>
|
||||
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Relations -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
@ -212,24 +206,15 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<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>
|
||||
<label for="beskrivelse" class="form-label">Beskrivelse</label>
|
||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||
<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 class="form-text text-end" id="charCount">0 tegn</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Hardware & AnyDesk -->
|
||||
<section id="hardwareSection">
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12">
|
||||
@ -261,38 +246,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pipelineSection" class="d-none">
|
||||
<hr class="my-4 opacity-25">
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
|
||||
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
|
||||
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="orderSection" class="d-none">
|
||||
<hr class="my-4 opacity-25">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="orderLines" class="vstack gap-3 mb-4"></div>
|
||||
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Metadata -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="type" required>
|
||||
<option value="ticket" selected>🎫 Ticket</option>
|
||||
<option value="opgave">🧩 Opgave</option>
|
||||
<option value="ordre">🧾 Ordre</option>
|
||||
<option value="projekt">📁 Projekt</option>
|
||||
<option value="service">🛠️ Service</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="status" required>
|
||||
<option value="åben" selected>🟢 Åben</option>
|
||||
@ -300,7 +270,7 @@
|
||||
<option value="lukket">🔴 Lukket</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||
<select class="form-select" id="ansvarlig_bruger_id">
|
||||
<option value="">Ingen</option>
|
||||
@ -310,7 +280,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||
<select class="form-select" id="assigned_group_id">
|
||||
<option value="">Ingen</option>
|
||||
@ -347,34 +317,6 @@
|
||||
</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>
|
||||
let selectedCustomer = null;
|
||||
let selectedContacts = {};
|
||||
@ -382,7 +324,6 @@
|
||||
let customerSearchTimeout;
|
||||
let contactSearchTimeout;
|
||||
let successAlertTimeout;
|
||||
let orderLineCounter = 0;
|
||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
||||
let topAlertLoadToken = 0;
|
||||
|
||||
@ -480,76 +421,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
function initializeSearch() {
|
||||
// Customer Search
|
||||
@ -1018,113 +889,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
const caseTypeLabels = {
|
||||
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
|
||||
};
|
||||
|
||||
function updateCaseTypeSections() {
|
||||
const type = document.getElementById('type')?.value || 'ticket';
|
||||
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
|
||||
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
|
||||
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
|
||||
const help = document.getElementById('caseTypeHelp');
|
||||
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
|
||||
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||
: 'Denne sagstype bruger kun de fælles sagsfelter.';
|
||||
}
|
||||
|
||||
function renderOrderLinesEmptyState() {
|
||||
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
|
||||
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
|
||||
}
|
||||
|
||||
function addOrderLine(type = 'sale') {
|
||||
const id = ++orderLineCounter;
|
||||
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
|
||||
const purpose = type === 'purchase' ? `
|
||||
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
|
||||
const container = document.getElementById('orderLines');
|
||||
container.insertAdjacentHTML('beforeend', `
|
||||
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
|
||||
<input type="hidden" class="order-type" value="${type}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
|
||||
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
|
||||
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
|
||||
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
|
||||
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
|
||||
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
|
||||
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
|
||||
${purpose}
|
||||
</div>
|
||||
</div>`);
|
||||
renderOrderLinesEmptyState();
|
||||
}
|
||||
|
||||
function removeOrderLine(id) {
|
||||
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
|
||||
renderOrderLinesEmptyState();
|
||||
}
|
||||
|
||||
function collectOrderItems() {
|
||||
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
|
||||
type: line.querySelector('.order-type').value,
|
||||
description: line.querySelector('.order-description').value.trim(),
|
||||
quantity: line.querySelector('.order-quantity').value || null,
|
||||
unit: line.querySelector('.order-unit').value.trim() || null,
|
||||
unit_price: line.querySelector('.order-unit-price').value || null,
|
||||
amount: line.querySelector('.order-amount').value || null,
|
||||
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
|
||||
status: line.querySelector('.order-status').value,
|
||||
external_ref: line.querySelector('.order-reference').value.trim() || null,
|
||||
purchase_purpose: line.querySelector('.order-purpose')?.value || null
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPipelineStages() {
|
||||
const select = document.getElementById('pipeline_stage_id');
|
||||
if (!select) return;
|
||||
try {
|
||||
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const stages = await response.json();
|
||||
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||||
} catch (err) { console.error('Failed to load pipeline stages', err); }
|
||||
}
|
||||
|
||||
async function loadCaseTypesSelect() {
|
||||
const select = document.getElementById('type');
|
||||
if (!select) return;
|
||||
try {
|
||||
const [typesRes, profileRes] = await Promise.all([
|
||||
fetch('/api/v1/settings/case_types', { credentials: 'include' }),
|
||||
fetch('/api/v1/auth/me/profile', { credentials: 'include' })
|
||||
]);
|
||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
const res = await fetch('/api/v1/settings/case_types');
|
||||
if (!res.ok) return;
|
||||
const setting = await res.json();
|
||||
const types = JSON.parse(setting.value || '[]');
|
||||
if (!Array.isArray(types) || types.length === 0) return;
|
||||
|
||||
select.innerHTML = types
|
||||
.map((type) => `<option value="${type}">${type}</option>`)
|
||||
.join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load case types', err);
|
||||
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
||||
}
|
||||
updateCaseTypeSections();
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeSearch();
|
||||
loadCaseTypesSelect();
|
||||
loadPipelineStages();
|
||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||
applyTelefoniPrefill();
|
||||
});
|
||||
|
||||
@ -1207,18 +993,6 @@
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
if (data.type === 'pipeline') {
|
||||
data.pipeline = {
|
||||
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
||||
amount: document.getElementById('pipeline_amount').value || null,
|
||||
probability: document.getElementById('pipeline_probability').value || null,
|
||||
description: document.getElementById('pipeline_description').value || null
|
||||
};
|
||||
}
|
||||
if (data.type === 'ordre') {
|
||||
data.order_items = collectOrderItems();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/sag', {
|
||||
method: 'POST',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -8451,40 +8451,6 @@
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
|
||||
</div>
|
||||
<div id="subscriptionProvisioningStatus" class="alert alert-light border d-none mb-3"></div>
|
||||
<div id="subscriptionProvisioningInline" class="card border-primary-subtle bg-light d-none mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<div class="fw-semibold">Netværksprovisionering</div>
|
||||
<div class="small text-muted">Vælg BMC-hovedforbindelse og reserver IP-range på kladden, før aktivering.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subscriptionProvisioningInlineAlert" class="alert alert-info d-none mb-3"></div>
|
||||
<div id="subscriptionProvisioningInlineCurrent" class="alert alert-secondary d-none mb-3"></div>
|
||||
<div id="subscriptionProvisioningInlineCompact" class="d-none mb-2"></div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">BMC hovedforbindelse *</label>
|
||||
<select class="form-select" id="subscriptionProvisioningInlineHeadSelect"></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Internetprodukt</label>
|
||||
<select class="form-select" id="subscriptionProvisioningInlineInternetItem"></select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Adresse</label>
|
||||
<div class="form-control bg-white" id="subscriptionProvisioningInlineAddress">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subscriptionProvisioningInlineRanges"></div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="button" class="btn btn-primary" id="subscriptionProvisioningInlineSaveBtn" onclick="saveSubscriptionProvisioningInline()">
|
||||
<i class="bi bi-check2-circle me-1"></i>Gem provisioning
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
|
||||
</div>
|
||||
|
||||
@ -8554,15 +8520,9 @@
|
||||
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div id="subscriptionCreateHint" class="alert alert-info d-none mb-3"></div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary" id="subscriptionCreateButton" onclick="createSubscription()">
|
||||
<i class="bi bi-plus-circle me-1"></i><span id="subscriptionCreateButtonLabel">Opret abonnement</span>
|
||||
<button type="button" class="btn btn-primary" onclick="createSubscription()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret abonnement
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="subscriptionCancelEditButton" onclick="cancelSubscriptionEdit()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Annuller redigering
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -8617,26 +8577,6 @@
|
||||
<label class="form-label">Kort beskrivelse</label>
|
||||
<input type="text" class="form-control" id="subscriptionProductDescription">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Netværksprodukt</label>
|
||||
<select class="form-select" id="subscriptionProductNetworkKind">
|
||||
<option value="">Ingen provisioning</option>
|
||||
<option value="internet_access">Internet adgang</option>
|
||||
<option value="ip_allocation">IP-allokering</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Download Mbps</label>
|
||||
<input type="number" class="form-control" id="subscriptionProductDownloadMbps" min="1" step="1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Upload Mbps</label>
|
||||
<input type="number" class="form-control" id="subscriptionProductUploadMbps" min="1" step="1">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">IP prefix</label>
|
||||
<input type="number" class="form-control" id="subscriptionProductIpPrefix" min="1" max="32" placeholder="fx 30 for /30">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -16421,8 +16361,6 @@
|
||||
let currentSubscription = null;
|
||||
let subscriptionProducts = [];
|
||||
let lastCreatedSubscriptionProductId = null;
|
||||
let subscriptionProvisioningData = null;
|
||||
let subscriptionEditMode = false;
|
||||
|
||||
function formatSubscriptionInterval(interval) {
|
||||
const map = {
|
||||
@ -16504,81 +16442,6 @@
|
||||
}
|
||||
populateSubscriptionProductSelects();
|
||||
updateSubscriptionLineTotals();
|
||||
refreshSubscriptionCreateState();
|
||||
}
|
||||
|
||||
function renderSubscriptionEditForm(subscription) {
|
||||
const empty = document.getElementById('subscriptionEmpty');
|
||||
const form = document.getElementById('subscriptionCreateForm');
|
||||
const details = document.getElementById('subscriptionDetails');
|
||||
if (empty) empty.classList.add('d-none');
|
||||
if (form) form.classList.remove('d-none');
|
||||
if (details) details.classList.add('d-none');
|
||||
|
||||
document.getElementById('subscriptionIntervalInput').value = subscription.billing_interval || 'monthly';
|
||||
document.getElementById('subscriptionBillingDayInput').value = subscription.billing_day || 1;
|
||||
document.getElementById('subscriptionStartDateInput').value = subscription.start_date || '';
|
||||
document.getElementById('subscriptionNotesInput').value = subscription.notes || '';
|
||||
|
||||
const body = document.getElementById('subscriptionLineItemsBody');
|
||||
if (!body) return;
|
||||
const items = subscription.line_items || [];
|
||||
body.innerHTML = items.map(item => `
|
||||
<tr>
|
||||
<td>
|
||||
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
||||
<option value="">Vælg produkt</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse" value="${(item.description || '').replace(/"/g, '"')}"></td>
|
||||
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="${item.quantity ?? 1}" oninput="updateSubscriptionLineTotals()"></td>
|
||||
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="${item.unit_price ?? 0}" oninput="updateSubscriptionLineTotals()"></td>
|
||||
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || `
|
||||
<tr>
|
||||
<td>
|
||||
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
||||
<option value="">Vælg produkt</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
|
||||
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
||||
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
||||
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
populateSubscriptionProductSelects();
|
||||
Array.from(body.querySelectorAll('tr')).forEach((row, index) => {
|
||||
const select = row.querySelector('.subscriptionProductSelect');
|
||||
const item = items[index];
|
||||
if (select && item?.product_id) {
|
||||
select.value = String(item.product_id);
|
||||
}
|
||||
});
|
||||
updateSubscriptionLineTotals();
|
||||
refreshSubscriptionCreateState();
|
||||
}
|
||||
|
||||
function startSubscriptionEdit() {
|
||||
if (!currentSubscription) return;
|
||||
subscriptionEditMode = true;
|
||||
renderSubscriptionEditForm(currentSubscription);
|
||||
}
|
||||
|
||||
function cancelSubscriptionEdit() {
|
||||
subscriptionEditMode = false;
|
||||
if (currentSubscription?.id) {
|
||||
renderSubscription(currentSubscription);
|
||||
} else {
|
||||
showSubscriptionCreateForm();
|
||||
}
|
||||
}
|
||||
|
||||
function populateSubscriptionProductSelects() {
|
||||
@ -16592,7 +16455,6 @@
|
||||
option.textContent = product.name;
|
||||
option.dataset.salesPrice = product.sales_price ?? '';
|
||||
option.dataset.description = product.short_description ?? '';
|
||||
option.dataset.attributes = JSON.stringify(product.attributes_json ?? {});
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (currentValue) {
|
||||
@ -16604,51 +16466,6 @@
|
||||
lastCreatedSubscriptionProductId = null;
|
||||
}
|
||||
|
||||
function selectedSubscriptionNeedsProvisioning() {
|
||||
const body = document.getElementById('subscriptionLineItemsBody');
|
||||
if (!body) return false;
|
||||
return Array.from(body.querySelectorAll('.subscriptionProductSelect')).some(select => {
|
||||
if (!select.value) return false;
|
||||
const option = select.options[select.selectedIndex];
|
||||
if (!option?.dataset?.attributes) return false;
|
||||
try {
|
||||
const attrs = JSON.parse(option.dataset.attributes);
|
||||
const kind = attrs?.network?.kind;
|
||||
return kind === 'internet_access' || kind === 'ip_allocation';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSubscriptionCreateState() {
|
||||
const hint = document.getElementById('subscriptionCreateHint');
|
||||
const label = document.getElementById('subscriptionCreateButtonLabel');
|
||||
const cancelButton = document.getElementById('subscriptionCancelEditButton');
|
||||
const needsProvisioning = selectedSubscriptionNeedsProvisioning();
|
||||
if (hint) {
|
||||
if (needsProvisioning) {
|
||||
hint.classList.remove('d-none');
|
||||
hint.textContent = subscriptionEditMode
|
||||
? "Denne kladde kræver netværksprovisionering. Gem kladden og vælg BMC-adresse og IP-range i boksen under abonnementet."
|
||||
: "Dette abonnement kræver netværksprovisionering. Efter oprettelse bruger du boksen under abonnementet til at vælge BMC-adresse og IP-range.";
|
||||
} else {
|
||||
hint.classList.add('d-none');
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
if (label) {
|
||||
if (subscriptionEditMode) {
|
||||
label.textContent = needsProvisioning ? "Gem kladde og vælg IP'er" : 'Gem kladde';
|
||||
} else {
|
||||
label.textContent = needsProvisioning ? "Opret abonnement og vælg IP'er" : 'Opret abonnement';
|
||||
}
|
||||
}
|
||||
if (cancelButton) {
|
||||
cancelButton.classList.toggle('d-none', !subscriptionEditMode);
|
||||
}
|
||||
}
|
||||
|
||||
function applySubscriptionProduct(select) {
|
||||
const row = select.closest('tr');
|
||||
if (!row) return;
|
||||
@ -16667,7 +16484,6 @@
|
||||
unitPriceInput.value = salesPrice;
|
||||
}
|
||||
updateSubscriptionLineTotals();
|
||||
refreshSubscriptionCreateState();
|
||||
}
|
||||
|
||||
function addSubscriptionLine() {
|
||||
@ -16691,7 +16507,6 @@
|
||||
body.appendChild(row);
|
||||
populateSubscriptionProductSelects();
|
||||
updateSubscriptionLineTotals();
|
||||
refreshSubscriptionCreateState();
|
||||
}
|
||||
|
||||
function removeSubscriptionLine(button) {
|
||||
@ -16706,7 +16521,6 @@
|
||||
row.remove();
|
||||
}
|
||||
updateSubscriptionLineTotals();
|
||||
refreshSubscriptionCreateState();
|
||||
}
|
||||
|
||||
function updateSubscriptionLineTotals() {
|
||||
@ -16780,28 +16594,13 @@
|
||||
}
|
||||
|
||||
async function createSubscriptionProduct() {
|
||||
const networkKind = document.getElementById('subscriptionProductNetworkKind').value || null;
|
||||
const downloadMbps = parseInt(document.getElementById('subscriptionProductDownloadMbps').value || '', 10);
|
||||
const uploadMbps = parseInt(document.getElementById('subscriptionProductUploadMbps').value || '', 10);
|
||||
const ipPrefix = parseInt(document.getElementById('subscriptionProductIpPrefix').value || '', 10);
|
||||
const attributes = {};
|
||||
if (networkKind) {
|
||||
attributes.network = {
|
||||
kind: networkKind,
|
||||
download_mbps: Number.isFinite(downloadMbps) ? downloadMbps : null,
|
||||
upload_mbps: Number.isFinite(uploadMbps) ? uploadMbps : null,
|
||||
speed_mbps: Number.isFinite(downloadMbps) && Number.isFinite(uploadMbps) ? Math.max(downloadMbps, uploadMbps) : null,
|
||||
ip_prefix_length: Number.isFinite(ipPrefix) ? ipPrefix : null
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
name: document.getElementById('subscriptionProductName').value.trim(),
|
||||
type: document.getElementById('subscriptionProductType').value.trim() || null,
|
||||
status: document.getElementById('subscriptionProductStatus').value,
|
||||
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
|
||||
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
|
||||
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null,
|
||||
attributes_json: networkKind ? attributes : null
|
||||
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
@ -16828,398 +16627,8 @@
|
||||
updateSubscriptionLineTotals();
|
||||
}
|
||||
|
||||
function renderSubscriptionProvisioningStatus(subscription) {
|
||||
const statusEl = document.getElementById('subscriptionProvisioningStatus');
|
||||
if (!statusEl) return;
|
||||
const provisioning = subscription?.network_provisioning;
|
||||
if (!provisioning?.requires_provisioning) {
|
||||
statusEl.classList.add('d-none');
|
||||
statusEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.classList.remove('d-none');
|
||||
const internetCount = provisioning.internet_items?.length || 0;
|
||||
const ipCount = provisioning.ip_items?.length || 0;
|
||||
const stateText = provisioning.is_provisioned
|
||||
? `Provisioneret paa forbindelse #${provisioning.existing_connection_id}`
|
||||
: 'Mangler provisioning';
|
||||
statusEl.innerHTML = `
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-center">
|
||||
<div>
|
||||
<strong>Netværksflow</strong><br>
|
||||
<span class="text-muted">${stateText}. Internetlinjer: ${internetCount}. IP-produkter: ${ipCount}.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function parseProvisioningRangeSelection(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
const [rangeIdRaw, requestedCidrRaw] = raw.split('|');
|
||||
const rangeId = parseInt(rangeIdRaw || '', 10);
|
||||
if (!Number.isFinite(rangeId)) return null;
|
||||
return {
|
||||
range_id: rangeId,
|
||||
requested_cidr: requestedCidrRaw ? requestedCidrRaw.trim() : null
|
||||
};
|
||||
}
|
||||
|
||||
function cidrPrefixLength(cidr) {
|
||||
const raw = String(cidr || '').trim();
|
||||
const match = raw.match(/\/(\d{1,2})$/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
function ipToInt(ip) {
|
||||
return String(ip || '').split('.').reduce((acc, octet) => {
|
||||
const value = parseInt(octet, 10);
|
||||
return (acc << 8) + (Number.isFinite(value) ? value : 0);
|
||||
}, 0) >>> 0;
|
||||
}
|
||||
|
||||
function intToIp(intValue) {
|
||||
return [
|
||||
(intValue >>> 24) & 255,
|
||||
(intValue >>> 16) & 255,
|
||||
(intValue >>> 8) & 255,
|
||||
intValue & 255
|
||||
].join('.');
|
||||
}
|
||||
|
||||
function subnetCandidatesFromRange(range, requiredPrefixes) {
|
||||
if (!requiredPrefixes?.length) return [];
|
||||
if (range.customer_id != null) return [];
|
||||
if (!range.is_fully_available) return [];
|
||||
const cidr = String(range.cidr || '').trim();
|
||||
const [baseIp] = cidr.split('/');
|
||||
const sourcePrefix = cidrPrefixLength(cidr);
|
||||
if (!baseIp || !Number.isFinite(sourcePrefix)) return [];
|
||||
|
||||
const baseInt = ipToInt(baseIp);
|
||||
const sourceSize = 2 ** (32 - sourcePrefix);
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
for (const prefix of requiredPrefixes) {
|
||||
const requestedPrefix = Number(prefix);
|
||||
if (!Number.isFinite(requestedPrefix) || requestedPrefix < sourcePrefix) continue;
|
||||
|
||||
if (requestedPrefix === sourcePrefix) {
|
||||
const key = `${range.id}|${cidr}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push({
|
||||
...range,
|
||||
range_id: Number(range.id),
|
||||
source_range_id: Number(range.id),
|
||||
source_cidr: cidr,
|
||||
requested_cidr: cidr,
|
||||
is_derived_candidate: false
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const subnetSize = 2 ** (32 - requestedPrefix);
|
||||
for (let offset = 0; offset < sourceSize; offset += subnetSize) {
|
||||
const requestedCidr = `${intToIp((baseInt + offset) >>> 0)}/${requestedPrefix}`;
|
||||
const key = `${range.id}|${requestedCidr}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const usableHosts = subnetSize > 1 ? Math.max(subnetSize - 2, 0) : subnetSize;
|
||||
results.push({
|
||||
...range,
|
||||
id: `${range.id}:${requestedCidr}`,
|
||||
range_id: Number(range.id),
|
||||
source_range_id: Number(range.id),
|
||||
source_cidr: cidr,
|
||||
requested_cidr: requestedCidr,
|
||||
cidr: requestedCidr,
|
||||
name: `${range.name || 'Range'} -> ${requestedCidr}`,
|
||||
prefix_length: requestedPrefix,
|
||||
total_hosts: subnetSize,
|
||||
usable_hosts: usableHosts,
|
||||
total_addresses: usableHosts,
|
||||
available_addresses: usableHosts,
|
||||
reserved_addresses: 0,
|
||||
in_use_addresses: 0,
|
||||
used_addresses: 0,
|
||||
is_fully_available: true,
|
||||
is_derived_candidate: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function loadAllSharedProvisioningHeads(requiredPrefixes) {
|
||||
const res = await fetch('/api/v1/internet-connections?shared_only=true');
|
||||
if (!res.ok) return [];
|
||||
const heads = await res.json();
|
||||
const sharedHeads = Array.isArray(heads)
|
||||
? heads.filter(head => head && String(head.allocation_model || '').toLowerCase() === 'shared' && !head.parent_id)
|
||||
: [];
|
||||
|
||||
const enriched = await Promise.all(sharedHeads.map(async (head) => {
|
||||
try {
|
||||
const rangesRes = await fetch(`/api/v1/internet-connections/${head.id}/ip-ranges`);
|
||||
const ranges = rangesRes.ok ? await rangesRes.json() : [];
|
||||
const availableMatchingRanges = Array.isArray(ranges)
|
||||
? ranges.flatMap(range => subnetCandidatesFromRange(range, requiredPrefixes || []))
|
||||
: [];
|
||||
return {
|
||||
...head,
|
||||
available_matching_ranges: availableMatchingRanges,
|
||||
available_matching_range_count: availableMatchingRanges.length
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
...head,
|
||||
available_matching_ranges: [],
|
||||
available_matching_range_count: 0
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
return enriched.sort((a, b) => String(a.address || a.name || '').localeCompare(String(b.address || b.name || ''), 'da'));
|
||||
}
|
||||
|
||||
function renderSubscriptionProvisioningInline(data, forcedHeadId = null) {
|
||||
const panel = document.getElementById('subscriptionProvisioningInline');
|
||||
const alertEl = document.getElementById('subscriptionProvisioningInlineAlert');
|
||||
const currentEl = document.getElementById('subscriptionProvisioningInlineCurrent');
|
||||
const compactEl = document.getElementById('subscriptionProvisioningInlineCompact');
|
||||
const headSelect = document.getElementById('subscriptionProvisioningInlineHeadSelect');
|
||||
const internetSelect = document.getElementById('subscriptionProvisioningInlineInternetItem');
|
||||
const addressEl = document.getElementById('subscriptionProvisioningInlineAddress');
|
||||
const rangesWrap = document.getElementById('subscriptionProvisioningInlineRanges');
|
||||
const saveBtn = document.getElementById('subscriptionProvisioningInlineSaveBtn');
|
||||
if (!panel || !alertEl || !currentEl || !compactEl || !headSelect || !internetSelect || !addressEl || !rangesWrap || !saveBtn) return;
|
||||
|
||||
const provisioning = data?.network_provisioning;
|
||||
if (!provisioning?.requires_provisioning) {
|
||||
panel.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
panel.classList.remove('d-none');
|
||||
|
||||
const heads = data.shared_heads || [];
|
||||
const currentConnection = data.existing_connection;
|
||||
const currentAllocatedRanges = Array.isArray(data.current_allocated_ranges) ? data.current_allocated_ranges : [];
|
||||
const isProvisioned = Boolean(provisioning?.is_provisioned && currentConnection);
|
||||
alertEl.classList.remove('d-none', 'alert-danger');
|
||||
alertEl.classList.add('alert-info');
|
||||
alertEl.textContent = isProvisioned
|
||||
? 'Denne kladde er allerede provisioneret. Den valgte hovedforbindelse og IP-allokering er låst her.'
|
||||
: 'Vælg den delte BMC-hovedforbindelse og et ledigt range til IP-produktet.';
|
||||
|
||||
if (currentConnection) {
|
||||
const currentRanges = currentAllocatedRanges.map(range => range.cidr).join(', ') || 'Ingen IP-ranges';
|
||||
currentEl.classList.remove('d-none');
|
||||
currentEl.innerHTML = `<strong>Eksisterende provisionering:</strong> #${currentConnection.id} · ${currentConnection.name || '-'} · ${currentRanges}`;
|
||||
} else {
|
||||
currentEl.classList.add('d-none');
|
||||
currentEl.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!heads.length) {
|
||||
alertEl.classList.remove('alert-info');
|
||||
alertEl.classList.add('alert-danger');
|
||||
alertEl.textContent = 'Ingen delte BMC-hovedforbindelser med ledig kapacitet matcher abonnementet endnu.';
|
||||
headSelect.innerHTML = '<option value="">Ingen ledige hovedforbindelser</option>';
|
||||
internetSelect.innerHTML = '<option value="">Ingen internetlinje</option>';
|
||||
addressEl.textContent = '-';
|
||||
rangesWrap.innerHTML = '<div class="alert alert-light border mb-0">Der findes ingen ledige ranges i den størrelse abonnementet kræver.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
headSelect.innerHTML = heads.map(head => `
|
||||
<option value="${head.id}">
|
||||
${head.name || 'Hovedforbindelse'} · ${head.address || 'Ingen adresse'}${head.available_matching_range_count ? ` · ${head.available_matching_range_count} ledige ranges` : ''}
|
||||
</option>
|
||||
`).join('');
|
||||
|
||||
const internetItems = provisioning.internet_items || [];
|
||||
const selectedInternetId = provisioning.primary_internet_item?.subscription_item_id || internetItems[0]?.subscription_item_id || '';
|
||||
if (internetItems.length) {
|
||||
internetSelect.innerHTML = internetItems.map(item => `
|
||||
<option value="${item.subscription_item_id}" ${String(item.subscription_item_id) === String(selectedInternetId) ? 'selected' : ''}>
|
||||
${item.description || item.product_name}
|
||||
</option>
|
||||
`).join('');
|
||||
} else {
|
||||
internetSelect.innerHTML = '<option value="">Ingen dedikeret internetlinje</option>';
|
||||
}
|
||||
|
||||
const desiredHeadId = forcedHeadId ?? headSelect.value;
|
||||
const selectedHead = heads.find(head => String(head.id) === String(desiredHeadId)) || heads[0];
|
||||
if (selectedHead) {
|
||||
headSelect.value = String(selectedHead.id);
|
||||
addressEl.textContent = selectedHead.address || '-';
|
||||
}
|
||||
if (isProvisioned && currentConnection?.parent_id) {
|
||||
headSelect.value = String(currentConnection.parent_id);
|
||||
}
|
||||
|
||||
const ipItems = provisioning.ip_items || [];
|
||||
const ranges = selectedHead?.available_matching_ranges || [];
|
||||
const remainingCurrentRanges = [...currentAllocatedRanges];
|
||||
rangesWrap.innerHTML = ipItems.map(item => {
|
||||
let currentRange = null;
|
||||
if (remainingCurrentRanges.length) {
|
||||
const matchingIndex = remainingCurrentRanges.findIndex((range) => (
|
||||
!item.ip_prefix_length || Number(range.prefix_length) === Number(item.ip_prefix_length)
|
||||
));
|
||||
if (matchingIndex >= 0) {
|
||||
currentRange = remainingCurrentRanges.splice(matchingIndex, 1)[0];
|
||||
} else {
|
||||
currentRange = remainingCurrentRanges.shift();
|
||||
}
|
||||
}
|
||||
const matchingOptions = ranges
|
||||
.filter(range => !item.ip_prefix_length || Number(range.prefix_length) === Number(item.ip_prefix_length));
|
||||
const currentValue = currentRange ? `${currentRange.id}|${currentRange.cidr}` : '';
|
||||
return `
|
||||
<div class="card border-0 bg-white mb-2">
|
||||
<div class="card-body py-3">
|
||||
<div class="fw-semibold mb-1">${item.description || item.product_name}</div>
|
||||
<div class="small text-muted mb-2">Kræver /${item.ip_prefix_length || '?'}.</div>
|
||||
<select class="form-select subscriptionProvisioningInlineRangeSelect" data-item-id="${item.subscription_item_id}" ${isProvisioned ? 'disabled' : ''}>
|
||||
<option value="">${isProvisioned ? 'Ingen range valgt' : 'Vælg ledigt range'}</option>
|
||||
${currentRange ? `<option value="${currentValue}" selected>${currentRange.cidr} · Allerede valgt</option>` : ''}
|
||||
${matchingOptions
|
||||
.filter(range => `${range.range_id || range.id}|${range.requested_cidr || range.cidr}` !== currentValue)
|
||||
.map(range => `<option value="${range.range_id || range.id}|${range.requested_cidr || range.cidr}">${range.cidr} · ${range.name || 'Range'} · ${range.available_addresses} ledige IP'er${range.is_derived_candidate ? ' · delblok' : ''}</option>`)
|
||||
.join('')}
|
||||
</select>
|
||||
${isProvisioned
|
||||
? '<div class="small text-muted mt-2">IP-range er allerede reserveret på abonnementet.</div>'
|
||||
: (matchingOptions.length ? '' : '<div class="small text-danger mt-2">Ingen ledige ranges i den størrelse på denne hovedforbindelse.</div>')}
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
if (isProvisioned) {
|
||||
const compactHead = selectedHead?.name || 'Ukendt hovedforbindelse';
|
||||
const compactProduct = currentConnection?.name || internetItems.find(item => String(item.subscription_item_id) === String(selectedInternetId))?.description || '-';
|
||||
const compactRanges = currentAllocatedRanges.map(range => range.cidr).join(', ') || 'Ingen IP-range';
|
||||
compactEl.classList.remove('d-none');
|
||||
compactEl.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 border rounded-3 bg-white px-3 py-2">
|
||||
<div class="text-truncate">
|
||||
<span class="fw-semibold">Provisionering:</span>
|
||||
<span>#${currentConnection.id}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span>${compactHead}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span>${compactProduct}</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span>${compactRanges}</span>
|
||||
</div>
|
||||
<a href="/economy/internet-connections/${currentConnection.id}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Rediger forbindelse">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
compactEl.classList.add('d-none');
|
||||
compactEl.innerHTML = '';
|
||||
}
|
||||
|
||||
headSelect.disabled = isProvisioned;
|
||||
internetSelect.disabled = isProvisioned;
|
||||
saveBtn.classList.toggle('d-none', isProvisioned);
|
||||
alertEl.classList.toggle('d-none', isProvisioned);
|
||||
currentEl.classList.toggle('d-none', isProvisioned);
|
||||
headSelect.closest('.row')?.classList.toggle('d-none', isProvisioned);
|
||||
rangesWrap.classList.toggle('d-none', isProvisioned);
|
||||
headSelect.onchange = isProvisioned ? null : () => renderSubscriptionProvisioningInline(data, headSelect.value);
|
||||
}
|
||||
|
||||
async function loadSubscriptionProvisioningInline() {
|
||||
if (!currentSubscription?.id) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provisioning`);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
subscriptionProvisioningData = await res.json();
|
||||
const requiredPrefixes = subscriptionProvisioningData?.network_provisioning?.required_ip_prefixes || [];
|
||||
const allHeads = await loadAllSharedProvisioningHeads(requiredPrefixes);
|
||||
if (allHeads.length) {
|
||||
const byId = new Map();
|
||||
(allHeads || []).forEach(head => byId.set(Number(head.id), head));
|
||||
(subscriptionProvisioningData.shared_heads || []).forEach(head => {
|
||||
const existing = byId.get(Number(head.id)) || {};
|
||||
byId.set(Number(head.id), {
|
||||
...existing,
|
||||
...head,
|
||||
available_matching_ranges: head.available_matching_ranges || existing.available_matching_ranges || [],
|
||||
available_matching_range_count: head.available_matching_range_count ?? existing.available_matching_range_count ?? 0
|
||||
});
|
||||
});
|
||||
subscriptionProvisioningData.shared_heads = Array.from(byId.values());
|
||||
}
|
||||
renderSubscriptionProvisioningInline(subscriptionProvisioningData);
|
||||
} catch (e) {
|
||||
console.error('Error loading inline provisioning:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSubscriptionProvisioningInline() {
|
||||
if (!currentSubscription?.id || !subscriptionProvisioningData) return;
|
||||
if (subscriptionProvisioningData?.network_provisioning?.is_provisioned) {
|
||||
alert('Denne provisionering er allerede gemt. Opret eller rediger forbindelsen direkte, hvis den skal ændres.');
|
||||
return;
|
||||
}
|
||||
const headSelect = document.getElementById('subscriptionProvisioningInlineHeadSelect');
|
||||
const internetSelect = document.getElementById('subscriptionProvisioningInlineInternetItem');
|
||||
const rangeSelects = Array.from(document.querySelectorAll('.subscriptionProvisioningInlineRangeSelect'));
|
||||
const sharedConnectionId = parseInt(headSelect?.value || '', 10);
|
||||
if (!Number.isFinite(sharedConnectionId)) {
|
||||
alert('Vælg en BMC hovedforbindelse');
|
||||
return;
|
||||
}
|
||||
|
||||
const ipAllocations = [];
|
||||
for (const select of rangeSelects) {
|
||||
const subscriptionItemId = parseInt(select.dataset.itemId || '', 10);
|
||||
const parsedRange = parseProvisioningRangeSelection(select.value);
|
||||
if (!Number.isFinite(subscriptionItemId) || !parsedRange) {
|
||||
alert('Vælg et ledigt range til alle IP-produktlinjer');
|
||||
return;
|
||||
}
|
||||
ipAllocations.push({
|
||||
subscription_item_id: subscriptionItemId,
|
||||
range_id: parsedRange.range_id,
|
||||
requested_cidr: parsedRange.requested_cidr
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
shared_connection_id: sharedConnectionId,
|
||||
internet_item_id: internetSelect?.value ? parseInt(internetSelect.value, 10) : null,
|
||||
ip_allocations: ipAllocations
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || 'Kunne ikke gemme provisioning');
|
||||
}
|
||||
await loadSubscriptionForCase();
|
||||
} catch (e) {
|
||||
alert(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscription(subscription) {
|
||||
currentSubscription = subscription;
|
||||
subscriptionEditMode = false;
|
||||
const empty = document.getElementById('subscriptionEmpty');
|
||||
const form = document.getElementById('subscriptionCreateForm');
|
||||
const details = document.getElementById('subscriptionDetails');
|
||||
@ -17276,21 +16685,10 @@
|
||||
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
|
||||
}
|
||||
|
||||
renderSubscriptionProvisioningStatus(subscription);
|
||||
if (subscription?.network_provisioning?.requires_provisioning) {
|
||||
loadSubscriptionProvisioningInline();
|
||||
} else {
|
||||
const panel = document.getElementById('subscriptionProvisioningInline');
|
||||
if (panel) panel.classList.add('d-none');
|
||||
}
|
||||
|
||||
const actions = document.getElementById('subscriptionActions');
|
||||
if (!actions) return;
|
||||
|
||||
const buttons = [];
|
||||
if (subscription.status === 'draft') {
|
||||
buttons.push(`<button class="btn btn-sm btn-outline-secondary" onclick="startSubscriptionEdit()"><i class="bi bi-pencil-square me-1"></i>Rediger kladde</button>`);
|
||||
}
|
||||
if (subscription.status === 'draft' || subscription.status === 'paused') {
|
||||
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
|
||||
}
|
||||
@ -17350,26 +16748,17 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const isEditing = subscriptionEditMode && currentSubscription?.id;
|
||||
const url = isEditing
|
||||
? `/api/v1/sag-subscriptions/${currentSubscription.id}`
|
||||
: '/api/v1/sag-subscriptions';
|
||||
const method = isEditing ? 'PATCH' : 'POST';
|
||||
const payload = {
|
||||
const res = await fetch('/api/v1/sag-subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sag_id: subscriptionCaseId,
|
||||
billing_interval: billingInterval,
|
||||
billing_day: billingDay,
|
||||
start_date: startDate,
|
||||
notes: notes || null,
|
||||
line_items: lineItems
|
||||
};
|
||||
if (!isEditing) {
|
||||
payload.sag_id = subscriptionCaseId;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@ -17379,9 +16768,6 @@
|
||||
|
||||
const subscription = await res.json();
|
||||
renderSubscription(subscription);
|
||||
if (subscription?.requires_network_provisioning) {
|
||||
await loadSubscriptionProvisioningInline();
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message || e);
|
||||
}
|
||||
|
||||
@ -34,50 +34,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_sms_recipient_from_contact(number: Optional[str], contact_id: Optional[int]) -> Optional[str]:
|
||||
raw_number = str(number or "").strip()
|
||||
if not raw_number or not contact_id:
|
||||
return raw_number or None
|
||||
|
||||
contact = execute_query_single(
|
||||
"""
|
||||
SELECT id, phone, mobile
|
||||
FROM contacts
|
||||
WHERE id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(contact_id,),
|
||||
)
|
||||
if not contact:
|
||||
return raw_number
|
||||
|
||||
requested_normalized = normalize_e164(raw_number) or raw_number
|
||||
requested_digits = digits_only(requested_normalized)
|
||||
requested_suffix = requested_digits[-8:] if len(requested_digits) >= 8 else None
|
||||
|
||||
candidates: list[str] = []
|
||||
for candidate_raw in (contact.get("mobile"), contact.get("phone")):
|
||||
candidate_normalized = normalize_e164(candidate_raw)
|
||||
if candidate_normalized and candidate_normalized not in candidates:
|
||||
candidates.append(candidate_normalized)
|
||||
|
||||
if not candidates:
|
||||
return raw_number
|
||||
|
||||
if requested_normalized in candidates:
|
||||
return requested_normalized
|
||||
|
||||
# If UI has reduced an international number to a Danish-looking 8-digit suffix,
|
||||
# prefer a stored international contact number that ends with the same suffix.
|
||||
if requested_suffix and (len(requested_digits) == 8 or requested_digits.startswith("45")):
|
||||
for candidate in candidates:
|
||||
candidate_digits = digits_only(candidate)
|
||||
if candidate_digits.endswith(requested_suffix) and not candidate_digits.startswith("45"):
|
||||
return candidate
|
||||
|
||||
return raw_number
|
||||
|
||||
|
||||
@router.post("/sms/send")
|
||||
async def send_sms(payload: SmsSendRequest, request: Request):
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
@ -92,8 +48,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
|
||||
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
|
||||
|
||||
try:
|
||||
recipient_number = _resolve_sms_recipient_from_contact(payload.to, contact_id)
|
||||
result = SmsService.send_sms(recipient_number, payload.message, payload.sender)
|
||||
result = SmsService.send_sms(payload.to, payload.message, payload.sender)
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
|
||||
@ -102,7 +57,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
|
||||
(
|
||||
contact_id,
|
||||
user_id,
|
||||
result.get("recipient") or recipient_number or payload.to,
|
||||
result.get("recipient") or payload.to,
|
||||
payload.sender or settings.SMS_SENDER,
|
||||
payload.message,
|
||||
"sent",
|
||||
@ -745,7 +700,9 @@ async def list_calls(
|
||||
t.bruger_id,
|
||||
t.direction,
|
||||
t.ekstern_nummer,
|
||||
NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(t.ekstern_nummer), '')
|
||||
) AS display_number,
|
||||
t.intern_extension,
|
||||
t.kontakt_id,
|
||||
t.sag_id,
|
||||
@ -787,14 +744,14 @@ async def list_calls(
|
||||
repaired = normalize_external_number(display_raw)
|
||||
if repaired:
|
||||
row["display_number"] = repaired
|
||||
|
||||
# Legacy mission call history (read-only rows) for environments where
|
||||
# some historical calls were stored before telefoni_opkald was populated.
|
||||
# Do not use this as an all-or-nothing fallback; merge it so mixed datasets
|
||||
# still show every visible call on the telefoni page.
|
||||
if user_id is not None:
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
# Fallback: legacy mission call history (read-only rows) for environments
|
||||
# where historical calls were stored before telefoni_opkald was populated.
|
||||
if user_id is not None:
|
||||
return []
|
||||
|
||||
legacy_where = []
|
||||
legacy_params = []
|
||||
if date_from:
|
||||
@ -846,34 +803,9 @@ async def list_calls(
|
||||
except Exception:
|
||||
legacy_rows = []
|
||||
|
||||
if not legacy_rows:
|
||||
return rows
|
||||
|
||||
existing_callids = {
|
||||
str(row.get("callid") or "").strip()
|
||||
for row in rows
|
||||
if str(row.get("callid") or "").strip()
|
||||
}
|
||||
|
||||
merged_rows = list(rows)
|
||||
for legacy_row in legacy_rows:
|
||||
legacy_callid = str(legacy_row.get("callid") or "").strip()
|
||||
if legacy_callid and legacy_callid in existing_callids:
|
||||
continue
|
||||
display_raw = legacy_row.get("display_number") or legacy_row.get("ekstern_nummer")
|
||||
repaired = normalize_external_number(display_raw)
|
||||
if repaired:
|
||||
legacy_row["display_number"] = repaired
|
||||
merged_rows.append(legacy_row)
|
||||
|
||||
merged_rows.sort(
|
||||
key=lambda row: row.get("started_at") or row.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
paged_rows = merged_rows[offset : offset + limit]
|
||||
if without_case:
|
||||
return [r for r in paged_rows if not r.get("sag_id")]
|
||||
return paged_rows
|
||||
return [r for r in legacy_rows if not r.get("sag_id")]
|
||||
return legacy_rows
|
||||
|
||||
|
||||
@router.patch("/telefoni/calls/{call_id}")
|
||||
|
||||
@ -20,7 +20,9 @@ async def telefoni_log_page(request: Request):
|
||||
SELECT
|
||||
t.id,
|
||||
t.direction,
|
||||
NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(t.ekstern_nummer), '')
|
||||
) AS display_number,
|
||||
t.started_at,
|
||||
t.duration_sec,
|
||||
t.ended_at,
|
||||
|
||||
@ -271,11 +271,14 @@ function normalizeDisplayNumber(value) {
|
||||
if (digits.length >= 10 && digits.slice(0, 2) === '45') {
|
||||
return `+${digits.slice(0, 10)}`;
|
||||
}
|
||||
if (digits.length >= 8 && /[2-9]/.test(digits.charAt(0))) {
|
||||
return `+45${digits.slice(0, 8)}`;
|
||||
}
|
||||
if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
|
||||
return `+${digits.slice(-10)}`;
|
||||
}
|
||||
if (digits.length >= 9) {
|
||||
return `+${digits.slice(-15)}`;
|
||||
if (digits.length >= 8) {
|
||||
return `+45${digits.slice(-8)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,7 +286,7 @@ function normalizeDisplayNumber(value) {
|
||||
return digits.length >= 9 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
if (digits.length === 8) return raw;
|
||||
if (digits.length === 8) return `+45${digits}`;
|
||||
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
|
||||
if (digits.length >= 9) return `+${digits}`;
|
||||
return raw;
|
||||
@ -1212,6 +1215,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('filterTo').addEventListener('change', loadCalls);
|
||||
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
|
||||
|
||||
// Keep SSR rows on first paint when they exist; avoid replacing visible data
|
||||
// with an empty state due to transient API/auth/cache issues in production.
|
||||
if (hasExistingCallRows(telefoniRows)) {
|
||||
console.warn('Telefoni: springer initial auto-refresh over, SSR-rækker vises');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCalls({ preserveOnEmpty: true, skipLoadingState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -16,9 +16,7 @@ router = APIRouter()
|
||||
async def list_opportunities(
|
||||
q: Optional[str] = None,
|
||||
stage: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
customer_id: Optional[int] = Query(default=None),
|
||||
contact_id: Optional[int] = Query(default=None),
|
||||
status: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
List all 'pipeline' cases.
|
||||
@ -73,33 +71,6 @@ async def list_opportunities(
|
||||
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
|
||||
if customer_id is not None:
|
||||
query += """
|
||||
AND (
|
||||
s.customer_id = %s
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM sag_kunder sk
|
||||
WHERE sk.sag_id = s.id
|
||||
AND sk.customer_id = %s
|
||||
AND sk.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
"""
|
||||
params.extend([customer_id, customer_id])
|
||||
|
||||
if contact_id is not None:
|
||||
query += """
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sag_kontakter sk
|
||||
WHERE sk.sag_id = s.id
|
||||
AND sk.contact_id = %s
|
||||
AND sk.deleted_at IS NULL
|
||||
)
|
||||
"""
|
||||
params.append(contact_id)
|
||||
|
||||
if status and status != 'all':
|
||||
if status == 'open':
|
||||
query += " AND s.status = 'åben'"
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -10,8 +9,3 @@ templates = Jinja2Templates(directory="app")
|
||||
@router.get("/opportunities", response_class=HTMLResponse)
|
||||
async def opportunities_page(request: Request):
|
||||
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/opportunities/{opportunity_id}", include_in_schema=False)
|
||||
async def opportunity_detail_redirect(opportunity_id: int):
|
||||
return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307)
|
||||
|
||||
@ -668,7 +668,6 @@ async def list_products(
|
||||
serial_number_required,
|
||||
asset_required,
|
||||
rental_asset_enabled,
|
||||
attributes_json,
|
||||
image_url
|
||||
FROM products
|
||||
{where_clause}
|
||||
|
||||
@ -45,8 +45,7 @@ class EmailActivityLogger:
|
||||
log_id = execute_insert(
|
||||
"""INSERT INTO email_activity_log
|
||||
(email_id, event_type, event_category, description, metadata, user_id, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
RETURNING id""",
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)""",
|
||||
(email_id, event_type, category, description, metadata_json, user_id, created_by)
|
||||
)
|
||||
|
||||
|
||||
@ -60,299 +60,6 @@ class Invoice2DataService:
|
||||
logger.warning("⚠️ No template matched")
|
||||
return None
|
||||
|
||||
def _parse_amount(self, value: Any, decimal_separator: str = ",", thousands_separator: str = ".") -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
cleaned = re.sub(r"\s+", "", str(value).strip())
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
if thousands_separator in cleaned and decimal_separator in cleaned:
|
||||
cleaned = cleaned.replace(thousands_separator, "").replace(decimal_separator, ".")
|
||||
elif thousands_separator in cleaned:
|
||||
cleaned = cleaned.replace(thousands_separator, "")
|
||||
elif decimal_separator == "," and "," in cleaned:
|
||||
cleaned = cleaned.replace(",", ".")
|
||||
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _parse_date_value(self, value: Any, date_formats: Optional[List[str]] = None) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
normalized = raw
|
||||
replacements = {
|
||||
"januar": "January",
|
||||
"februar": "February",
|
||||
"marts": "March",
|
||||
"april": "April",
|
||||
"maj": "May",
|
||||
"juni": "June",
|
||||
"juli": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"oktober": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
}
|
||||
for da_name, en_name in replacements.items():
|
||||
normalized = re.sub(rf"\b{da_name}\b", en_name, normalized, flags=re.IGNORECASE)
|
||||
|
||||
candidates = date_formats or [
|
||||
"%d.%m.%Y",
|
||||
"%d-%m-%Y",
|
||||
"%d/%m-%Y",
|
||||
"%d. %B %Y",
|
||||
"%d. %B %Y.",
|
||||
"%d. %B %Y",
|
||||
"%d. %B %Y",
|
||||
]
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return datetime.strptime(normalized, candidate).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _is_globalconnect_noise_line(self, line: str) -> bool:
|
||||
compact = re.sub(r"\s+", " ", str(line or "")).strip()
|
||||
if not compact:
|
||||
return True
|
||||
if len(compact) > 140:
|
||||
return True
|
||||
noise_patterns = (
|
||||
r"Skandinaviska Enskilda Banken",
|
||||
r"\bSWIFT-kode\b",
|
||||
r"\bIBAN\b",
|
||||
r"www\.globalconnect\.dk",
|
||||
r"CMsupport@globalconnect\.dk",
|
||||
r"\+45\s*77\s*30\s*30\s*00",
|
||||
r"Faktura\s+BMC Denmark ApS",
|
||||
r"\bBeskrivelse\s+Antal\s+Enhed\s+Enhedspris\s+Beløb\b",
|
||||
r"\bI alt DKK\b",
|
||||
r"\b25%\s+moms\b",
|
||||
r"\bSE/CVR-nr\.?\b",
|
||||
r"\bPBS-nummer\b",
|
||||
r"\bBS Kundenr\.?\b",
|
||||
r"\bDeb\. grp\. nr\.?\b",
|
||||
r"\bBetalingsbetingelser\b",
|
||||
r"\bEfter forfald beregnes rente\b",
|
||||
r"\bTeleydelser uden moms\b",
|
||||
r"\bAdministrations gebyr\b",
|
||||
)
|
||||
return any(re.search(pattern, compact, re.IGNORECASE) for pattern in noise_patterns)
|
||||
|
||||
def _extract_globalconnect(self, text: str, template_name: str, template: Dict[str, Any]) -> Dict[str, Any]:
|
||||
options = template.get("options", {})
|
||||
extracted: Dict[str, Any] = {
|
||||
"template": template_name,
|
||||
"issuer": template.get("issuer"),
|
||||
"country": template.get("country"),
|
||||
"currency": options.get("currency", "DKK"),
|
||||
}
|
||||
|
||||
invoice_number_match = re.search(r"(?:Fakturanr\.?|Kreditnotanr\.?)\s*(\d+)", text, re.IGNORECASE)
|
||||
if invoice_number_match:
|
||||
extracted["invoice_number"] = int(invoice_number_match.group(1))
|
||||
|
||||
if re.search(r"\bKreditnota\b|\bKreditnotanr\.?\b", text, re.IGNORECASE):
|
||||
extracted["document_type"] = "credit_note"
|
||||
|
||||
customer_reference_match = re.search(r"Kundenr\.?\s*([A-Z0-9-]+)", text, re.IGNORECASE)
|
||||
if customer_reference_match:
|
||||
extracted["customer_reference"] = customer_reference_match.group(1).strip()
|
||||
|
||||
invoice_date_match = re.search(r"Bilagsdato\s+([^\n]+)", text, re.IGNORECASE)
|
||||
if invoice_date_match:
|
||||
parsed = self._parse_date_value(invoice_date_match.group(1), ["%d. %B %Y"])
|
||||
if parsed:
|
||||
extracted["invoice_date"] = parsed
|
||||
|
||||
due_date_match = re.search(r"Forfaldsdato\s+([^\n]+)", text, re.IGNORECASE)
|
||||
if due_date_match:
|
||||
parsed = self._parse_date_value(due_date_match.group(1), ["%d. %B %Y"])
|
||||
if parsed:
|
||||
extracted["due_date"] = parsed
|
||||
|
||||
untaxed_match = re.search(r"I\s+alt\s+DKK\s+ekskl\.\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
|
||||
if untaxed_match:
|
||||
extracted["amount_untaxed"] = self._parse_amount(untaxed_match.group(1))
|
||||
|
||||
vat_match = re.search(r"25%\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
|
||||
if vat_match:
|
||||
extracted["vat_amount"] = self._parse_amount(vat_match.group(1))
|
||||
|
||||
total_match = re.search(r"I\s+alt\s+DKK\s+inkl\.\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
|
||||
if total_match:
|
||||
extracted["amount_total"] = self._parse_amount(total_match.group(1))
|
||||
|
||||
cvr_matches = [match.group(1) for match in re.finditer(r"SE/CVR-nr\.\s+(\d{8})", text, re.IGNORECASE)]
|
||||
vendor_cvrs = [cvr for cvr in cvr_matches if cvr != "29522790"]
|
||||
if vendor_cvrs:
|
||||
extracted["vendor_vat"] = vendor_cvrs[0]
|
||||
|
||||
lines: List[Dict[str, Any]] = []
|
||||
current_context: Dict[str, Any] = {}
|
||||
pending_street: Optional[str] = None
|
||||
pending_line_for_continuation: Optional[Dict[str, Any]] = None
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = re.sub(r"\s+", " ", raw_line).strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
contract_match = re.match(r"Kontrakt:\s*(.+)$", line, re.IGNORECASE)
|
||||
if contract_match:
|
||||
current_context["contract_number"] = contract_match.group(1).strip()
|
||||
pending_line_for_continuation = None
|
||||
continue
|
||||
|
||||
vedr_match = re.match(r"Vedr:\s*(.+)$", line, re.IGNORECASE)
|
||||
if vedr_match:
|
||||
provider_reference = vedr_match.group(1).strip()
|
||||
current_context["provider_reference"] = provider_reference
|
||||
current_context["circuit_id"] = provider_reference
|
||||
pending_line_for_continuation = None
|
||||
continue
|
||||
|
||||
customer_match = re.match(r"Slutkunde:\s*(.+)$", line, re.IGNORECASE)
|
||||
if customer_match:
|
||||
current_context["end_customer_name"] = customer_match.group(1).strip()
|
||||
pending_line_for_continuation = None
|
||||
continue
|
||||
|
||||
period_match = re.match(r"Periode:\s*(\d{2}-\d{2}-\d{4})\s*-\s*(\d{2}-\d{2}-\d{4})", line, re.IGNORECASE)
|
||||
if period_match:
|
||||
current_context["period_start"] = self._parse_date_value(period_match.group(1), ["%d-%m-%Y"])
|
||||
current_context["period_end"] = self._parse_date_value(period_match.group(2), ["%d-%m-%Y"])
|
||||
pending_line_for_continuation = None
|
||||
continue
|
||||
|
||||
cidr_match = re.match(r"(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})(?:\s+\(([^)]+)\))?$", line)
|
||||
if cidr_match:
|
||||
cidr = cidr_match.group(1)
|
||||
reference = cidr_match.group(2).strip() if cidr_match.group(2) else None
|
||||
if pending_line_for_continuation and "ip" in str(pending_line_for_continuation.get("description") or "").lower():
|
||||
pending_line_for_continuation["ip_address"] = cidr
|
||||
if reference:
|
||||
pending_line_for_continuation["provider_reference"] = reference
|
||||
pending_line_for_continuation["circuit_id"] = reference
|
||||
current_context["ip_address"] = cidr
|
||||
if reference:
|
||||
current_context["provider_reference"] = reference
|
||||
current_context["circuit_id"] = reference
|
||||
continue
|
||||
|
||||
reference_match = re.match(r"((?:NKA|EB|DSL-)[A-Z0-9-]+)$", line, re.IGNORECASE)
|
||||
if reference_match:
|
||||
reference = reference_match.group(1).strip()
|
||||
current_context["provider_reference"] = reference
|
||||
current_context["circuit_id"] = reference
|
||||
if pending_line_for_continuation and not pending_line_for_continuation.get("provider_reference"):
|
||||
pending_line_for_continuation["provider_reference"] = reference
|
||||
pending_line_for_continuation["circuit_id"] = reference
|
||||
pending_street = None
|
||||
continue
|
||||
|
||||
street_only_match = re.match(r"(.+?\d+[A-ZÆØÅa-zæøå]?)$", line)
|
||||
if street_only_match and not re.search(r"(?:Fakturanr|Bilagsdato|Forfaldsdato|SE/CVR|Kundenr|Kontrakt|Vedr|Slutkunde|Periode)", line, re.IGNORECASE):
|
||||
postal_hint = re.search(r"\b\d{4}\b", line)
|
||||
if not postal_hint and not re.search(r"\b(?:Gbps|Mbps|Kbps|Måneder|Måned|Stk|pcs)\b", line, re.IGNORECASE):
|
||||
pending_street = street_only_match.group(1).strip()
|
||||
continue
|
||||
|
||||
city_line_match = re.match(r"(\d{4})\s+([A-ZÆØÅa-zæøå].+)$", line)
|
||||
if city_line_match and pending_street:
|
||||
postal_code = city_line_match.group(1).strip()
|
||||
city = city_line_match.group(2).strip()
|
||||
current_context["location_street"] = pending_street
|
||||
current_context["location_zip"] = postal_code
|
||||
current_context["location_city"] = city
|
||||
current_context["service_address"] = f"{pending_street}, {postal_code} {city}"
|
||||
if pending_line_for_continuation and not pending_line_for_continuation.get("service_address"):
|
||||
pending_line_for_continuation["location_street"] = pending_street
|
||||
pending_line_for_continuation["location_zip"] = postal_code
|
||||
pending_line_for_continuation["location_city"] = city
|
||||
pending_line_for_continuation["service_address"] = f"{pending_street}, {postal_code} {city}"
|
||||
pending_street = None
|
||||
continue
|
||||
|
||||
address_match = re.match(r"(.+?)\s+(\d{4})\s+([A-ZÆØÅa-zæøå].+)$", line)
|
||||
if address_match and not re.search(r"(?:Fakturanr|Bilagsdato|Forfaldsdato|SE/CVR)", line, re.IGNORECASE):
|
||||
street = address_match.group(1).strip()
|
||||
postal_code = address_match.group(2).strip()
|
||||
city = address_match.group(3).strip()
|
||||
current_context["location_street"] = street
|
||||
current_context["location_zip"] = postal_code
|
||||
current_context["location_city"] = city
|
||||
current_context["service_address"] = f"{street}, {postal_code} {city}"
|
||||
if pending_line_for_continuation and not pending_line_for_continuation.get("service_address"):
|
||||
pending_line_for_continuation["location_street"] = street
|
||||
pending_line_for_continuation["location_zip"] = postal_code
|
||||
pending_line_for_continuation["location_city"] = city
|
||||
pending_line_for_continuation["service_address"] = f"{street}, {postal_code} {city}"
|
||||
pending_street = None
|
||||
continue
|
||||
|
||||
line_match = re.match(
|
||||
r"(.+?)\s+(\d+(?:[.,]\d+)?)\s+(Måneder|Måned|Stk\.?|Stk|pcs\.?)\s+([\d.]+,\d{2})\s+([\d.]+,\d{2})$",
|
||||
line,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not line_match:
|
||||
if pending_line_for_continuation and not self._is_globalconnect_noise_line(line) and not re.search(
|
||||
r"(?:I alt DKK|25% moms|SE/CVR|Kundenr\.?|Fakturanr\.?|Bilagsdato|Forfaldsdato)",
|
||||
line,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
existing = str(pending_line_for_continuation.get("description") or "").strip()
|
||||
if line.lower() not in existing.lower():
|
||||
combined = f"{existing} {line}".strip()
|
||||
pending_line_for_continuation["description"] = combined[:250].strip()
|
||||
continue
|
||||
|
||||
description = line_match.group(1).strip()
|
||||
quantity = self._parse_amount(line_match.group(2))
|
||||
unit = line_match.group(3).strip()
|
||||
unit_price = self._parse_amount(line_match.group(4))
|
||||
line_total = self._parse_amount(line_match.group(5))
|
||||
|
||||
line_data: Dict[str, Any] = {
|
||||
"line_number": len(lines) + 1,
|
||||
"description": description,
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"unit_price": unit_price,
|
||||
"line_total": line_total,
|
||||
"customer_reference": extracted.get("customer_reference"),
|
||||
}
|
||||
line_data.update(current_context)
|
||||
lines.append(line_data)
|
||||
pending_line_for_continuation = line_data
|
||||
pending_street = None
|
||||
|
||||
if "ip_address" in current_context:
|
||||
current_context.pop("ip_address", None)
|
||||
|
||||
if lines:
|
||||
extracted["lines"] = lines
|
||||
|
||||
self._validate_amounts(extracted)
|
||||
return extracted
|
||||
|
||||
def extract_with_template(self, text: str, template_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract invoice data using specific template
|
||||
@ -361,9 +68,6 @@ class Invoice2DataService:
|
||||
raise ValueError(f"Template not found: {template_name}")
|
||||
|
||||
template = self.templates[template_name]
|
||||
if template_name == "dk.globalconnect":
|
||||
return self._extract_globalconnect(text, template_name, template)
|
||||
|
||||
fields = template.get('fields', {})
|
||||
options = template.get('options', {})
|
||||
|
||||
@ -409,14 +113,49 @@ class Invoice2DataService:
|
||||
|
||||
# Convert type
|
||||
if field_type == 'float':
|
||||
# Handle Danish number format (1.234,56 → 1234.56)
|
||||
# OR (148,587.98 → 148587.98) - handle both formats
|
||||
decimal_sep = options.get('decimal_separator', ',')
|
||||
thousands_sep = options.get('thousands_separator', '.')
|
||||
value = self._parse_amount(value, decimal_sep, thousands_sep)
|
||||
|
||||
# Remove all whitespace first (pdf extraction may split numbers across lines)
|
||||
value = re.sub(r'\s+', '', value)
|
||||
|
||||
# If both separators are present, we can determine the format
|
||||
# Danish: 148.587,98 (thousands=., decimal=,)
|
||||
# English: 148,587.98 (thousands=, decimal=.)
|
||||
if thousands_sep in value and decimal_sep in value:
|
||||
# Remove thousands separator, then convert decimal separator to .
|
||||
value = value.replace(thousands_sep, '').replace(decimal_sep, '.')
|
||||
elif thousands_sep in value:
|
||||
# Only thousands separator present - just remove it
|
||||
value = value.replace(thousands_sep, '')
|
||||
elif decimal_sep in value and decimal_sep == ',':
|
||||
# Only decimal separator and it's Danish comma - convert to .
|
||||
value = value.replace(',', '.')
|
||||
|
||||
value = float(value)
|
||||
elif field_type == 'int':
|
||||
value = int(value)
|
||||
elif field_type == 'date':
|
||||
# Try to parse Danish dates
|
||||
date_formats = options.get('date_formats', ['%B %d, %Y', '%d-%m-%Y'])
|
||||
value = self._parse_date_value(value, date_formats) or value
|
||||
|
||||
# Danish month names
|
||||
value = value.replace('januar', 'January').replace('februar', 'February')
|
||||
value = value.replace('marts', 'March').replace('april', 'April')
|
||||
value = value.replace('maj', 'May').replace('juni', 'June')
|
||||
value = value.replace('juli', 'July').replace('august', 'August')
|
||||
value = value.replace('september', 'September').replace('oktober', 'October')
|
||||
value = value.replace('november', 'November').replace('december', 'December')
|
||||
|
||||
for date_format in date_formats:
|
||||
try:
|
||||
parsed_date = datetime.strptime(value, date_format)
|
||||
value = parsed_date.strftime('%Y-%m-%d')
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
extracted[field_name] = value
|
||||
logger.debug(f" ✓ {field_name}: {value}")
|
||||
@ -657,17 +396,6 @@ class Invoice2DataService:
|
||||
if vat_amount is not None:
|
||||
subtotal = total_amount - vat_amount
|
||||
|
||||
validation_details = {
|
||||
'line_sum': round(line_sum, 2),
|
||||
'subtotal': round(subtotal, 2),
|
||||
'difference': round(abs(line_sum - subtotal), 2),
|
||||
'subtotal_matches': abs(line_sum - subtotal) <= 1.0,
|
||||
'vat_amount': round(float(vat_amount), 2) if vat_amount is not None else None,
|
||||
'vat_expected': None,
|
||||
'vat_difference': None,
|
||||
'vat_matches': None,
|
||||
}
|
||||
|
||||
# Check if line sum matches subtotal (allow 1 DKK difference for rounding)
|
||||
if abs(line_sum - subtotal) > 1.0:
|
||||
logger.warning(f"⚠️ Amount validation: Line sum {line_sum:.2f} != subtotal {subtotal:.2f} (diff: {abs(line_sum - subtotal):.2f})")
|
||||
@ -678,17 +406,12 @@ class Invoice2DataService:
|
||||
# Check VAT calculation (25%)
|
||||
if vat_amount is not None:
|
||||
expected_vat = subtotal * 0.25
|
||||
validation_details['vat_expected'] = round(expected_vat, 2)
|
||||
validation_details['vat_difference'] = round(abs(vat_amount - expected_vat), 2)
|
||||
validation_details['vat_matches'] = abs(vat_amount - expected_vat) <= 1.0
|
||||
if abs(vat_amount - expected_vat) > 1.0:
|
||||
logger.warning(f"⚠️ VAT validation: VAT {vat_amount:.2f} != 25% of {subtotal:.2f} ({expected_vat:.2f})")
|
||||
extracted['_vat_warning'] = f"Moms ({vat_amount:.2f}) passer ikke med 25% af subtotal ({expected_vat:.2f})"
|
||||
else:
|
||||
logger.info(f"✅ VAT validation: 25% VAT calculation correct ({vat_amount:.2f})")
|
||||
|
||||
extracted['_validation_details'] = validation_details
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Amount validation failed: {e}")
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
Regler:
|
||||
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer uændret.
|
||||
2. Ret kun stavefejl, tegnsætning, grammatik og tydelig formulering.
|
||||
3. Tilføj, gæt eller udled ALDRIG nye oplysninger. Ændr heller ikke rækkefølge, betydning, ansvar, omfang eller tekniske detaljer.
|
||||
4. Fjern kun en gentagelse, hvis den er helt identisk; behold ellers hele indholdet.
|
||||
1. Bevar ALLE fakta, navne, datoer, beløb, ticket/sags-ID og tekniske termer.
|
||||
2. Ret stavefejl, tegnsætning og grammatik.
|
||||
3. Gør teksten kortere og mere præcis, men uden at fjerne vigtig information.
|
||||
4. Fjern fyldord, gentagelser og intern støj.
|
||||
5. Bevar tone og intention: neutral, serviceminded og professionel.
|
||||
6. Opfind aldrig nye oplysninger.
|
||||
7. Hvis input er e-mail: returner i formatet:
|
||||
@ -260,100 +260,6 @@ Output:
|
||||
logger.error("❌ Ollama text rewrite failed: %s", e)
|
||||
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:
|
||||
"""
|
||||
Extract structured invoice data from text using Ollama
|
||||
|
||||
@ -14,6 +14,7 @@ import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict
|
||||
from app.services.economic_service import get_economic_service
|
||||
from app.core.database import execute_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -22,6 +23,9 @@ logger = logging.getLogger(__name__)
|
||||
class SubscriptionMatrixService:
|
||||
"""Generate billing matrix for customer subscriptions"""
|
||||
|
||||
def __init__(self):
|
||||
self.economic_service = get_economic_service()
|
||||
|
||||
async def generate_billing_matrix(
|
||||
self,
|
||||
customer_id: int,
|
||||
@ -83,9 +87,13 @@ class SubscriptionMatrixService:
|
||||
economic_customer_number = customer[0]['economic_customer_number']
|
||||
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
|
||||
|
||||
# Fetch imported invoice snapshot from local invoice_error_finder tables
|
||||
invoices = self._load_imported_invoices(str(economic_customer_number))
|
||||
logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
|
||||
# Fetch invoices from e-conomic
|
||||
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
|
||||
invoices = await self.economic_service.get_customer_invoices(
|
||||
economic_customer_number,
|
||||
include_lines=True
|
||||
)
|
||||
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
|
||||
|
||||
if not invoices:
|
||||
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
|
||||
@ -123,125 +131,6 @@ class SubscriptionMatrixService:
|
||||
"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]:
|
||||
"""
|
||||
Group invoice lines by product number and aggregate by month
|
||||
@ -489,18 +378,6 @@ class SubscriptionMatrixService:
|
||||
|
||||
return products
|
||||
|
||||
@staticmethod
|
||||
def _ensure_dict(value) -> Dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _generate_month_range(num_months: int) -> List[str]:
|
||||
"""
|
||||
|
||||
@ -168,7 +168,7 @@ async def get_setting(key: str):
|
||||
seed_query,
|
||||
(
|
||||
"case_types",
|
||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
|
||||
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||
"system",
|
||||
"Sags-typer",
|
||||
"json",
|
||||
@ -416,14 +416,6 @@ async def execute_migration_api(payload: dict):
|
||||
return execute_migration(model)
|
||||
|
||||
|
||||
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
|
||||
async def execute_missing_migrations_api():
|
||||
"""Run schema-detected missing migrations via the API namespace."""
|
||||
from app.settings.backend.views import execute_missing_migrations
|
||||
|
||||
return execute_missing_migrations()
|
||||
|
||||
|
||||
@router.post("/settings/sync-from-env", tags=["Settings"])
|
||||
async def sync_settings_from_env():
|
||||
"""Sync settings from .env file into database (only updates empty values)"""
|
||||
@ -1000,3 +992,4 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ Settings Frontend Views
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import time
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
@ -415,55 +414,3 @@ def execute_migration(payload: MigrationExecution):
|
||||
release_db_connection(conn)
|
||||
|
||||
return {"message": "Migration executed successfully"}
|
||||
|
||||
|
||||
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
|
||||
def execute_missing_migrations():
|
||||
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
|
||||
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
|
||||
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
|
||||
conn = get_db_connection()
|
||||
logs = []
|
||||
try:
|
||||
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
|
||||
candidates = []
|
||||
for migration_file in files:
|
||||
sql = migration_file.read_text(encoding="utf-8")
|
||||
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
|
||||
if status["status"] == "red":
|
||||
candidates.append((migration_file, sql, status))
|
||||
|
||||
for migration_file, sql, status in candidates:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
conn.commit()
|
||||
logs.append({
|
||||
"file_name": migration_file.name,
|
||||
"status": "success",
|
||||
"summary": status["summary"],
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
})
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logs.append({
|
||||
"file_name": migration_file.name,
|
||||
"status": "failed",
|
||||
"summary": str(exc).splitlines()[0],
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
})
|
||||
|
||||
return {
|
||||
"message": "Manglende migrationer behandlet",
|
||||
"checked": len(files),
|
||||
"candidates": len(candidates),
|
||||
"executed": sum(item["status"] == "success" for item in logs),
|
||||
"failed": sum(item["status"] == "failed" for item in logs),
|
||||
"logs": logs,
|
||||
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
|
||||
@ -69,9 +69,6 @@
|
||||
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
|
||||
<i class="bi bi-check2-circle me-1"></i>Tjek status
|
||||
</button>
|
||||
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
|
||||
<i class="bi bi-play-fill me-1"></i>Kør manglende
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@ -359,35 +356,5 @@
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMissingMigrations() {
|
||||
const button = document.getElementById('runMissingMigrationsBtn');
|
||||
const feedback = document.getElementById('migrationFeedback');
|
||||
button.disabled = true;
|
||||
feedback.className = 'alert alert-info mt-3';
|
||||
feedback.textContent = 'Tjekker og kører manglende migrationer...';
|
||||
feedback.classList.remove('d-none');
|
||||
try {
|
||||
const urls = buildMigrationActionUrls('execute-missing');
|
||||
let data = null;
|
||||
let lastError = null;
|
||||
for (const url of urls) {
|
||||
const response = await fetch(url, {method: 'POST', credentials: 'include'});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.ok) { data = payload; break; }
|
||||
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
|
||||
lastError = payload.detail || `HTTP ${response.status}`;
|
||||
}
|
||||
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
|
||||
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
|
||||
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
|
||||
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
|
||||
} catch (error) {
|
||||
feedback.className = 'alert alert-danger mt-3';
|
||||
feedback.textContent = `Fejl: ${error.message}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -184,16 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
|
||||
<div>
|
||||
<h5 class="mb-1 fw-bold">Faktura-fejl-finder</h5>
|
||||
<p class="text-muted mb-0">Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="invoiceErrorFinderSettingsCard"></div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
@ -2384,8 +2374,7 @@ async function testTelefoniCall() {
|
||||
|
||||
function renderDriftConnectors() {
|
||||
const container = document.getElementById('driftConnectorCards');
|
||||
const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
|
||||
if (!container && !invoiceErrorFinderContainer) return;
|
||||
if (!container) return;
|
||||
|
||||
const connectors = [
|
||||
{
|
||||
@ -2451,35 +2440,10 @@ function renderDriftConnectors() {
|
||||
<span id="uispSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
key: 'invoice-error-finder',
|
||||
title: 'Faktura-fejl-finder',
|
||||
description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.',
|
||||
badge: 'Okonomi',
|
||||
body: `
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Ignorer varetekster</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
|
||||
</div>
|
||||
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
|
||||
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 mt-4">
|
||||
<button class="btn btn-primary" onclick="saveInvoiceErrorFinderSettings()">
|
||||
<i class="bi bi-save me-2"></i>Gem faktura-fejl-finder
|
||||
</button>
|
||||
<span id="invoiceErrorFinderSaveStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
const renderConnectorCard = (connector) => `
|
||||
container.innerHTML = connectors.map(connector => `
|
||||
<div class="card border-0 bg-light p-4">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
@ -2490,21 +2454,7 @@ function renderDriftConnectors() {
|
||||
</div>
|
||||
${connector.body}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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)
|
||||
: '';
|
||||
}
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
@ -2527,7 +2477,6 @@ async function loadSettings() {
|
||||
renderDriftConnectors();
|
||||
await loadUptimeKumaSettings();
|
||||
await loadUISPSettings();
|
||||
await loadInvoiceErrorFinderSettings();
|
||||
await loadLabelPrinterSettings();
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
@ -2856,30 +2805,6 @@ async function loadUISPSettings() {
|
||||
}
|
||||
|
||||
let driftBlacklistItems = [];
|
||||
const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [
|
||||
'faktureringsgebyr',
|
||||
'gebyr',
|
||||
'porto',
|
||||
'fragt',
|
||||
'fragtomkostning',
|
||||
'forsendelse',
|
||||
'shipping',
|
||||
'levering',
|
||||
'engangsydelse',
|
||||
'engangsarbejde',
|
||||
'oprettelse',
|
||||
'opstartsgebyr',
|
||||
'installation',
|
||||
'installationsgebyr',
|
||||
'timeforbrug',
|
||||
'arbejdstid',
|
||||
'konsulenttimer',
|
||||
'supporttid',
|
||||
'teknikertid',
|
||||
'montørtimer',
|
||||
'projektarbejde'
|
||||
];
|
||||
let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
|
||||
function parseDriftBlacklistValue(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
@ -2937,131 +2862,6 @@ function removeDriftBlacklistItem(item) {
|
||||
renderDriftBlacklistList();
|
||||
}
|
||||
|
||||
function parseInvoiceErrorFinderIgnoreValue(rawValue) {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
|
||||
let values = [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed;
|
||||
} else if (typeof parsed === 'string') {
|
||||
values = [parsed];
|
||||
}
|
||||
} catch (e) {
|
||||
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const cleaned = [];
|
||||
[...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => {
|
||||
const normalized = String(item || '').trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
cleaned.push(normalized);
|
||||
});
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function renderInvoiceErrorFinderIgnoreList() {
|
||||
const list = document.getElementById('invoiceErrorFinderIgnoreList');
|
||||
if (!list) return;
|
||||
if (!invoiceErrorFinderIgnoreItems.length) {
|
||||
list.innerHTML = '<span class="text-muted small">Ingen varetekster ignoreres endnu.</span>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => {
|
||||
const safe = String(item).replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<span class="badge text-bg-dark">${safe} <button type="button" class="btn btn-sm btn-link text-white p-0 ms-1" onclick="removeInvoiceErrorFinderIgnoreItem(${JSON.stringify(item)})" title="Fjern">×</button></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addInvoiceErrorFinderIgnoreItem() {
|
||||
const input = document.getElementById('invoiceErrorFinderIgnoreInput');
|
||||
if (!input) return;
|
||||
const value = String(input.value || '').trim().toLowerCase();
|
||||
if (!value) return;
|
||||
if (!invoiceErrorFinderIgnoreItems.includes(value)) {
|
||||
invoiceErrorFinderIgnoreItems.push(value);
|
||||
}
|
||||
input.value = '';
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
|
||||
function removeInvoiceErrorFinderIgnoreItem(item) {
|
||||
invoiceErrorFinderIgnoreItems = invoiceErrorFinderIgnoreItems.filter(v => v !== item);
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
|
||||
async function loadInvoiceErrorFinderSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await response.json();
|
||||
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(setting?.value || '[]');
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
} catch (e) {
|
||||
console.warn('Invoice Error Finder settings load failed:', e);
|
||||
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInvoiceErrorFinderSettings() {
|
||||
const statusEl = document.getElementById('invoiceErrorFinderSaveStatus');
|
||||
statusEl.textContent = 'Gemmer...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
const value = JSON.stringify(parseInvoiceErrorFinderIgnoreValue(invoiceErrorFinderIgnoreItems));
|
||||
const payload = {
|
||||
key: 'invoice_error_finder_ignored_product_texts',
|
||||
value,
|
||||
category: 'finance',
|
||||
description: 'JSON array of invoice product texts/descriptions ignored by Invoice Error Finder',
|
||||
value_type: 'string',
|
||||
is_public: false
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
response = await fetch('/api/v1/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme ignore-listen'));
|
||||
}
|
||||
|
||||
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(value);
|
||||
renderInvoiceErrorFinderIgnoreList();
|
||||
statusEl.textContent = '✅ Gemt';
|
||||
statusEl.className = 'small text-success';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
showNotification('Ignore-liste gemt', 'success');
|
||||
} catch (error) {
|
||||
statusEl.textContent = '❌ Kunne ikke gemme';
|
||||
statusEl.className = 'small text-danger';
|
||||
showNotification(error.message || 'Kunne ikke gemme ignore-listen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUISPSettings() {
|
||||
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
|
||||
const apiToken = (document.getElementById('uispApiToken').value || '').trim();
|
||||
|
||||
@ -1000,10 +1000,6 @@
|
||||
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||
<li data-menu-key="menu-okonomi-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Kontrol</h6></li>
|
||||
<li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@ -1019,7 +1015,6 @@
|
||||
<li data-menu-key="menu-datamigration-employee-log"><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
|
||||
<li data-menu-key="menu-datamigration-service-contract-wizard"><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li data-menu-key="menu-datamigration-service-contract-report"><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
|
||||
<li data-menu-key="menu-datamigration-internet-wizard-v2"><a class="dropdown-item py-2" href="/data-migration/internet-wizard-v2"><i class="bi bi-hdd-network me-2"></i>Internet Wizard v2</a></li>
|
||||
<li data-menu-key="menu-datamigration-orders"><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
</ul>
|
||||
@ -1432,7 +1427,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.4"></script>
|
||||
<script src="/static/js/sms.js?v=1.1"></script>
|
||||
<script src="/static/js/sms.js?v=1.0"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.43"></script>
|
||||
<script>
|
||||
@ -1704,13 +1699,13 @@ if (bmcOriginalFetch) {
|
||||
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
||||
e.preventDefault();
|
||||
openNewCasePage();
|
||||
openQuickCreateModal();
|
||||
}
|
||||
|
||||
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
openNewCasePage();
|
||||
openQuickCreateModal();
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
@ -1719,14 +1714,22 @@ if (bmcOriginalFetch) {
|
||||
}
|
||||
});
|
||||
|
||||
function openNewCasePage() {
|
||||
window.location.href = '/sag/new';
|
||||
// QuickCreate modal opener function
|
||||
function openQuickCreateModal() {
|
||||
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
|
||||
quickCreateModal.show();
|
||||
setTimeout(() => {
|
||||
const textInput = document.getElementById('quickCreateText');
|
||||
if (textInput) {
|
||||
textInput.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// QuickCreate button click handler
|
||||
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openNewCasePage();
|
||||
openQuickCreateModal();
|
||||
});
|
||||
|
||||
// Reset search when modal is closed
|
||||
@ -2249,6 +2252,9 @@ if (bmcOriginalFetch) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
||||
|
||||
<!-- Manual Help Modal -->
|
||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||
|
||||
@ -2294,11 +2300,6 @@ if (bmcOriginalFetch) {
|
||||
<label class="form-label fw-semibold">Mobilnummer</label>
|
||||
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Standard sagstype</label>
|
||||
<select class="form-select" id="prof_default_case_type"></select>
|
||||
<div class="form-text">Bruges som udgangspunkt på “Ny sag”.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
|
||||
@ -2438,7 +2439,6 @@ if (bmcOriginalFetch) {
|
||||
{ key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' },
|
||||
{ key: 'menu-datamigration-service-contract-wizard', label: 'Data migration: Servicekontrakt Migration' },
|
||||
{ key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' },
|
||||
{ key: 'menu-datamigration-internet-wizard-v2', label: 'Data migration: Internet Wizard v2' },
|
||||
{ key: 'menu-datamigration-orders', label: 'Data migration: Ordrer' },
|
||||
{ key: 'menu-datamigration-customers', label: 'Data migration: Kunder' },
|
||||
];
|
||||
@ -2667,27 +2667,10 @@ if (bmcOriginalFetch) {
|
||||
document.getElementById('prof_full_name').value = p.full_name || '';
|
||||
document.getElementById('prof_title').value = p.title || '';
|
||||
document.getElementById('prof_phone').value = p.phone || '';
|
||||
await loadProfileCaseTypes(p.default_case_type || 'ticket');
|
||||
} catch (e) { console.error('Failed to load profile', e); }
|
||||
loadAnyDeskChips();
|
||||
}
|
||||
|
||||
async function loadProfileCaseTypes(selectedType = 'ticket') {
|
||||
const select = document.getElementById('prof_default_case_type');
|
||||
if (!select) return;
|
||||
const labels = { ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service' };
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/case_types', { credentials: 'include' });
|
||||
const setting = res.ok ? await res.json() : { value: '[]' };
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(value => String(value).toLowerCase()) : [];
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(labels);
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${labels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(selectedType) ? selectedType : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
} catch (e) { console.error('Failed to load profile case types', e); }
|
||||
}
|
||||
|
||||
function buildInitials(name) {
|
||||
const clean = String(name || '').trim();
|
||||
if (!clean) return 'BR';
|
||||
@ -2774,7 +2757,6 @@ if (bmcOriginalFetch) {
|
||||
full_name: document.getElementById('prof_full_name').value || null,
|
||||
title: document.getElementById('prof_title').value || null,
|
||||
phone: document.getElementById('prof_phone').value || null,
|
||||
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me/profile', {
|
||||
|
||||
@ -14,7 +14,6 @@ from datetime import datetime, date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi import Request
|
||||
from app.services.simplycrm_service import SimplyCRMService
|
||||
from app.modules.internet_connections.backend.provisioning_utils import summarize_subscription_network_requirements
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -27,108 +26,6 @@ ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
|
||||
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"}
|
||||
|
||||
|
||||
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.line_no,
|
||||
i.product_id,
|
||||
p.name AS product_name,
|
||||
p.type AS product_type,
|
||||
p.attributes_json,
|
||||
i.description,
|
||||
i.quantity,
|
||||
i.unit_price,
|
||||
i.line_total,
|
||||
i.period_from,
|
||||
i.period_to,
|
||||
i.requires_serial_number,
|
||||
i.serial_number,
|
||||
i.billing_blocked,
|
||||
i.billing_block_reason
|
||||
FROM sag_subscription_items i
|
||||
LEFT JOIN products p ON p.id = i.product_id
|
||||
WHERE i.subscription_id = %s
|
||||
ORDER BY i.line_no ASC, i.id ASC
|
||||
""",
|
||||
(subscription_id,),
|
||||
) or []
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _attach_network_provisioning(subscription: Dict[str, Any]) -> Dict[str, Any]:
|
||||
line_items = subscription.get("line_items") or []
|
||||
provisioning = summarize_subscription_network_requirements(line_items)
|
||||
existing_connection = execute_query_single(
|
||||
"""
|
||||
SELECT id, parent_id
|
||||
FROM internet_connections_connections
|
||||
WHERE subscription_id = %s
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(subscription.get("id"),),
|
||||
)
|
||||
provisioning["existing_connection_id"] = existing_connection.get("id") if existing_connection else None
|
||||
provisioning["is_provisioned"] = bool(existing_connection)
|
||||
subscription["requires_network_provisioning"] = provisioning["requires_provisioning"]
|
||||
subscription["network_provisioning"] = provisioning
|
||||
return subscription
|
||||
|
||||
|
||||
def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
|
||||
subscription = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
s.id,
|
||||
s.subscription_number,
|
||||
s.sag_id,
|
||||
sg.titel AS sag_title,
|
||||
s.customer_id,
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_direction,
|
||||
s.advance_months,
|
||||
s.first_full_period_start,
|
||||
s.billing_day,
|
||||
s.price,
|
||||
s.start_date,
|
||||
s.end_date,
|
||||
s.next_invoice_date,
|
||||
s.period_start,
|
||||
s.binding_months,
|
||||
s.binding_start_date,
|
||||
s.binding_end_date,
|
||||
s.binding_group_key,
|
||||
s.notice_period_days,
|
||||
s.billing_blocked,
|
||||
s.billing_block_reason,
|
||||
s.invoice_merge_key,
|
||||
s.price_change_case_id,
|
||||
s.renewal_case_id,
|
||||
s.status,
|
||||
s.notes,
|
||||
s.cancelled_at,
|
||||
s.cancellation_reason,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_subscriptions s
|
||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(subscription_id,),
|
||||
)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
subscription = dict(subscription)
|
||||
subscription["line_items"] = _load_subscription_line_items(subscription_id)
|
||||
return _attach_network_provisioning(subscription)
|
||||
|
||||
|
||||
def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
|
||||
if status == "approved":
|
||||
return "approved"
|
||||
@ -342,8 +239,26 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
|
||||
if allow_missing:
|
||||
return {"subscription": None, "line_items": []}
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
|
||||
return _attach_network_provisioning(subscription)
|
||||
items = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.line_no,
|
||||
i.product_id,
|
||||
p.name AS product_name,
|
||||
i.description,
|
||||
i.quantity,
|
||||
i.unit_price,
|
||||
i.line_total
|
||||
FROM sag_subscription_items i
|
||||
LEFT JOIN products p ON p.id = i.product_id
|
||||
WHERE i.subscription_id = %s
|
||||
ORDER BY i.line_no ASC, i.id ASC
|
||||
""",
|
||||
(subscription["id"],)
|
||||
)
|
||||
subscription["line_items"] = items or []
|
||||
return subscription
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -664,8 +579,8 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
|
||||
conn.commit()
|
||||
|
||||
subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
|
||||
return _attach_network_provisioning(dict(subscription))
|
||||
subscription["line_items"] = cleaned_items
|
||||
return subscription
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
except HTTPException:
|
||||
@ -679,7 +594,78 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
async def get_subscription(subscription_id: int):
|
||||
"""Get a single subscription by ID with all details."""
|
||||
try:
|
||||
return _load_subscription_with_context(subscription_id)
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.subscription_number,
|
||||
s.sag_id,
|
||||
sg.titel AS sag_title,
|
||||
s.customer_id,
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_direction,
|
||||
s.advance_months,
|
||||
s.first_full_period_start,
|
||||
s.billing_day,
|
||||
s.price,
|
||||
s.start_date,
|
||||
s.end_date,
|
||||
s.next_invoice_date,
|
||||
s.period_start,
|
||||
s.binding_months,
|
||||
s.binding_start_date,
|
||||
s.binding_end_date,
|
||||
s.binding_group_key,
|
||||
s.notice_period_days,
|
||||
s.billing_blocked,
|
||||
s.billing_block_reason,
|
||||
s.invoice_merge_key,
|
||||
s.price_change_case_id,
|
||||
s.renewal_case_id,
|
||||
s.status,
|
||||
s.notes,
|
||||
s.cancelled_at,
|
||||
s.cancellation_reason,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_subscriptions s
|
||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.id = %s
|
||||
"""
|
||||
subscription = execute_query_single(query, (subscription_id,))
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
|
||||
# Get line items
|
||||
items = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.line_no,
|
||||
i.product_id,
|
||||
i.asset_id,
|
||||
p.name AS product_name,
|
||||
i.description,
|
||||
i.quantity,
|
||||
i.unit_price,
|
||||
i.line_total,
|
||||
i.period_from,
|
||||
i.period_to,
|
||||
i.requires_serial_number,
|
||||
i.serial_number,
|
||||
i.billing_blocked,
|
||||
i.billing_block_reason
|
||||
FROM sag_subscription_items i
|
||||
LEFT JOIN products p ON p.id = i.product_id
|
||||
WHERE i.subscription_id = %s
|
||||
ORDER BY i.line_no ASC, i.id ASC
|
||||
""",
|
||||
(subscription_id,)
|
||||
)
|
||||
subscription["line_items"] = items or []
|
||||
return subscription
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -700,45 +686,6 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
|
||||
# Extract line_items before processing other fields
|
||||
line_items = payload.pop("line_items", None)
|
||||
normalized_line_items = None
|
||||
if line_items is not None:
|
||||
normalized_line_items = []
|
||||
total_price = 0.0
|
||||
first_description = None
|
||||
for item in line_items:
|
||||
description = (item.get("description", "") or "").strip()
|
||||
quantity = float(item.get("quantity", 0) or 0)
|
||||
unit_price = float(item.get("unit_price", 0) or 0)
|
||||
if not description or quantity <= 0:
|
||||
continue
|
||||
line_total = quantity * unit_price
|
||||
total_price += line_total
|
||||
if first_description is None:
|
||||
first_description = description
|
||||
normalized_line_items.append({
|
||||
"description": description,
|
||||
"quantity": quantity,
|
||||
"unit_price": unit_price,
|
||||
"line_total": line_total,
|
||||
"product_id": item.get("product_id"),
|
||||
"asset_id": item.get("asset_id"),
|
||||
"period_from": item.get("period_from"),
|
||||
"period_to": item.get("period_to"),
|
||||
"price_type": item.get("price_type", "manual"),
|
||||
"custom_price_override": bool(item.get("custom_price_override")),
|
||||
"requires_serial_number": bool(item.get("requires_serial_number")),
|
||||
"serial_number": item.get("serial_number"),
|
||||
"billing_blocked": bool(item.get("billing_blocked")),
|
||||
"billing_block_reason": item.get("billing_block_reason"),
|
||||
})
|
||||
if not normalized_line_items:
|
||||
raise HTTPException(status_code=400, detail="line_items must contain at least one valid line")
|
||||
payload["price"] = total_price
|
||||
payload["product_name"] = (
|
||||
f"{first_description} (+{len(normalized_line_items) - 1})"
|
||||
if len(normalized_line_items) > 1
|
||||
else first_description
|
||||
)
|
||||
|
||||
# Build dynamic update query
|
||||
allowed_fields = {
|
||||
@ -782,7 +729,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
result = cursor.fetchone()
|
||||
|
||||
# Update line items if provided
|
||||
if normalized_line_items is not None:
|
||||
if line_items is not None:
|
||||
# Delete existing line items
|
||||
cursor.execute(
|
||||
"DELETE FROM sag_subscription_items WHERE subscription_id = %s",
|
||||
@ -790,7 +737,16 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
)
|
||||
|
||||
# Insert new line items
|
||||
for idx, item in enumerate(normalized_line_items, start=1):
|
||||
for idx, item in enumerate(line_items, start=1):
|
||||
description = item.get("description", "").strip()
|
||||
quantity = float(item.get("quantity", 0))
|
||||
unit_price = float(item.get("unit_price", 0))
|
||||
|
||||
if not description or quantity <= 0:
|
||||
continue
|
||||
|
||||
line_total = quantity * unit_price
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_subscription_items (
|
||||
@ -803,8 +759,8 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
subscription_id, idx, item["description"],
|
||||
item["quantity"], item["unit_price"], item["line_total"],
|
||||
subscription_id, idx, description,
|
||||
quantity, unit_price, line_total,
|
||||
item.get("product_id"),
|
||||
item.get("asset_id"),
|
||||
item.get("period_from"),
|
||||
@ -819,7 +775,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return _load_subscription_with_context(subscription_id)
|
||||
return result
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
except HTTPException:
|
||||
@ -846,7 +802,7 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
|
||||
result = execute_query(query, (status, subscription_id))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return _load_subscription_with_context(subscription_id)
|
||||
return result[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@ -7,9 +7,9 @@ SYNC ARCHITECTURE - Field Ownership:
|
||||
|
||||
E-CONOMIC owns and syncs:
|
||||
- economic_customer_number (primary key from e-conomic)
|
||||
- name, phone, address, city, postal_code, country (company and physical address)
|
||||
- address, city, postal_code, country (physical address)
|
||||
- email_domain, website (contact information)
|
||||
- cvr_number (company metadata; may be shared by several customers)
|
||||
- cvr_number (used for matching only, not overwritten if already set)
|
||||
|
||||
vTIGER owns and syncs:
|
||||
- vtiger_id (primary key from vTiger)
|
||||
@ -18,7 +18,7 @@ vTIGER owns and syncs:
|
||||
|
||||
HUB owns (manual or first-sync only):
|
||||
- name (can be synced initially but not overwritten)
|
||||
- cvr_number (informational; can be refreshed from e-conomic)
|
||||
- cvr_number (used for matching, set once)
|
||||
- Tags, notes, custom fields
|
||||
|
||||
SYNC RULES:
|
||||
@ -163,7 +163,6 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
country = eco_customer.get('country', 'DK')
|
||||
email = eco_customer.get('email', '')
|
||||
website = eco_customer.get('website', '')
|
||||
phone = eco_customer.get('phone') or eco_customer.get('telephone') or ''
|
||||
|
||||
if not customer_number or not name:
|
||||
skipped_count += 1
|
||||
@ -193,7 +192,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
# Strict matching: ONLY match by economic_customer_number
|
||||
existing = execute_query(
|
||||
"""
|
||||
SELECT id, name, phone, cvr_number, email_domain, address, city, postal_code, country, website
|
||||
SELECT id, name, email_domain, address, city, postal_code, country, website
|
||||
FROM customers
|
||||
WHERE economic_customer_number = %s
|
||||
ORDER BY id
|
||||
@ -222,9 +221,6 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
if existing:
|
||||
target_customer_id = existing[0]['id']
|
||||
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"),
|
||||
"address": existing[0].get("address"),
|
||||
"city": existing[0].get("city"),
|
||||
@ -233,9 +229,6 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
"website": existing[0].get("website"),
|
||||
}
|
||||
proposed_values = {
|
||||
"name": name,
|
||||
"phone": phone,
|
||||
"cvr_number": cvr,
|
||||
"email_domain": email_domain,
|
||||
"address": address,
|
||||
"city": city,
|
||||
@ -259,9 +252,6 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
update_query = """
|
||||
UPDATE customers SET
|
||||
economic_customer_number = %s,
|
||||
name = %s,
|
||||
phone = %s,
|
||||
cvr_number = %s,
|
||||
email_domain = %s,
|
||||
address = %s,
|
||||
city = %s,
|
||||
@ -272,7 +262,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_query(update_query, (
|
||||
customer_number, name, phone, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||
))
|
||||
logger.info(
|
||||
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
||||
@ -286,7 +276,6 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
else:
|
||||
would_create.append({
|
||||
"name": name,
|
||||
"phone": phone,
|
||||
"economic_customer_number": customer_number,
|
||||
"cvr_number": cvr,
|
||||
"email_domain": email_domain,
|
||||
@ -300,13 +289,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
|
||||
if apply_changes:
|
||||
insert_query = """
|
||||
INSERT INTO customers
|
||||
(name, phone, economic_customer_number, cvr_number, email_domain,
|
||||
(name, economic_customer_number, cvr_number, email_domain,
|
||||
address, city, postal_code, country, website, last_synced_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
result = execute_query(insert_query, (
|
||||
name, phone, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
||||
name, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
||||
))
|
||||
if result:
|
||||
logger.info(
|
||||
|
||||
23
main.py
23
main.py
@ -145,10 +145,6 @@ from app.modules.task_templates.backend import router as task_templates_api
|
||||
from app.modules.drift.backend import router as drift_api
|
||||
from app.modules.drift.frontend import views as drift_views
|
||||
from app.modules.drift.backend.router import run_uptime_kuma_sync
|
||||
from app.modules.internet_connections.backend import router as internet_connections_api
|
||||
from app.modules.internet_connections.frontend import views as internet_connections_views
|
||||
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
|
||||
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
|
||||
from app.bug_reports.backend import router as bug_reports_api
|
||||
|
||||
# Configure logging
|
||||
@ -285,19 +281,6 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
logger.info("✅ Drift Uptime Kuma sync job scheduled (every 120 seconds)")
|
||||
|
||||
# Register Invoice Error Finder scheduled sync job (daily at 05:00)
|
||||
from app.jobs.invoice_error_finder_sync import run_invoice_error_finder_sync
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=run_invoice_error_finder_sync,
|
||||
trigger=CronTrigger(hour=5, minute=0),
|
||||
id='invoice_error_finder_sync',
|
||||
name='Invoice Error Finder Sync',
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)")
|
||||
|
||||
logger.info("✅ System initialized successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
@ -384,8 +367,6 @@ async def auth_middleware(request: Request, call_next):
|
||||
or any(path.startswith(prefix) for prefix in public_prefixes)
|
||||
or path.startswith("/static")
|
||||
or path.startswith("/docs")
|
||||
or path.startswith("/api/v1/internet-connections")
|
||||
or path.startswith("/economy/internet-connections")
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
@ -498,8 +479,6 @@ app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
|
||||
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
|
||||
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.backend import router as links_api
|
||||
@ -537,8 +516,6 @@ app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
app.include_router(manual_views.router, tags=["Frontend"])
|
||||
app.include_router(drift_views.router, tags=["Frontend"])
|
||||
app.include_router(internet_connections_views.router, tags=["Frontend"])
|
||||
app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
-- 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.';
|
||||
@ -1,58 +0,0 @@
|
||||
-- Migration 1007: Invoice Error Finder module fixes and permissions
|
||||
|
||||
-- Add stable Simply CRM source record id to issues so detection survives import-run re-imports
|
||||
ALTER TABLE invoice_error_finder_issues
|
||||
ADD COLUMN IF NOT EXISTS simply_source_record_id VARCHAR(80);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_simply_source
|
||||
ON invoice_error_finder_issues(simply_source_record_id);
|
||||
|
||||
-- Module permissions
|
||||
INSERT INTO permissions (code, description, category) VALUES
|
||||
('invoice_error_finder.view', 'View invoice error finder dashboard and issues', 'invoice_error_finder'),
|
||||
('invoice_error_finder.run_import', 'Trigger invoice/error data imports', 'invoice_error_finder'),
|
||||
('invoice_error_finder.analyze', 'Run invoice error detection analysis', 'invoice_error_finder'),
|
||||
('invoice_error_finder.update_status', 'Update issue status and assignee', 'invoice_error_finder'),
|
||||
('invoice_error_finder.create_sag', 'Create/link sag from invoice error issue', 'invoice_error_finder'),
|
||||
('invoice_error_finder.create_ordre_draft', 'Create ordre draft from invoice error issue', 'invoice_error_finder'),
|
||||
('invoice_error_finder.ignore', 'Ignore invoice error issues', 'invoice_error_finder'),
|
||||
('invoice_error_finder.admin', 'Administer invoice error finder settings', 'invoice_error_finder')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Assign permissions to groups
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Administrators'
|
||||
AND p.category = 'invoice_error_finder'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Managers'
|
||||
AND p.category = 'invoice_error_finder'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Technicians'
|
||||
AND p.code IN (
|
||||
'invoice_error_finder.view',
|
||||
'invoice_error_finder.update_status',
|
||||
'invoice_error_finder.create_sag',
|
||||
'invoice_error_finder.create_ordre_draft'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
CROSS JOIN permissions p
|
||||
WHERE g.name = 'Viewers'
|
||||
AND p.code = 'invoice_error_finder.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -18,16 +18,6 @@ ALTER TABLE email_messages
|
||||
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
|
||||
|
||||
-- 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 (
|
||||
SELECT ctid,
|
||||
ROW_NUMBER() OVER (PARTITION BY sag_id, email_id ORDER BY created_at NULLS LAST, ctid) AS rn
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_connections (
|
||||
id SERIAL PRIMARY KEY,
|
||||
parent_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
connection_type VARCHAR(50) NOT NULL DEFAULT 'fiber',
|
||||
provider VARCHAR(255),
|
||||
circuit_number VARCHAR(255),
|
||||
customer_id INTEGER,
|
||||
address VARCHAR(500),
|
||||
speed_mbps INTEGER,
|
||||
upload_mbps INTEGER,
|
||||
download_mbps INTEGER,
|
||||
technology VARCHAR(100),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
monthly_cost NUMERIC(12,2) DEFAULT 0,
|
||||
sales_price NUMERIC(12,2) DEFAULT 0,
|
||||
monitoring_url TEXT,
|
||||
contract_start DATE,
|
||||
contract_end DATE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_ip_ranges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cidr VARCHAR(32) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_ip_addresses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
range_id INTEGER REFERENCES internet_connections_ip_ranges(id) ON DELETE CASCADE,
|
||||
ip_address VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'available',
|
||||
assigned_to VARCHAR(255),
|
||||
assigned_type VARCHAR(50),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
details JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_pricing (
|
||||
id SERIAL PRIMARY KEY,
|
||||
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
|
||||
effective_from DATE NOT NULL,
|
||||
purchase_price NUMERIC(12,2) DEFAULT 0,
|
||||
sales_price NUMERIC(12,2) DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_parent_id ON internet_connections_connections(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_status ON internet_connections_connections(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_ranges_connection_id ON internet_connections_ip_ranges(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_range_id ON internet_connections_ip_addresses(range_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_history_connection_id ON internet_connections_history(connection_id);
|
||||
@ -1,43 +0,0 @@
|
||||
-- Migration 198: GlobalConnect extraction context + richer IPAM relations
|
||||
|
||||
ALTER TABLE extraction_lines
|
||||
ADD COLUMN IF NOT EXISTS provider_reference VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS customer_reference VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS circuit_id VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS end_customer_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS period_start DATE,
|
||||
ADD COLUMN IF NOT EXISTS period_end DATE,
|
||||
ADD COLUMN IF NOT EXISTS service_address TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_extraction_lines_provider_reference ON extraction_lines(provider_reference);
|
||||
CREATE INDEX IF NOT EXISTS idx_extraction_lines_circuit_id ON extraction_lines(circuit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_extraction_lines_end_customer_name ON extraction_lines(end_customer_name);
|
||||
|
||||
COMMENT ON COLUMN extraction_lines.provider_reference IS 'Provider reference from invoice context (e.g. DSL-EB722388, NKA020902)';
|
||||
COMMENT ON COLUMN extraction_lines.customer_reference IS 'Customer account/reference from supplier invoice';
|
||||
COMMENT ON COLUMN extraction_lines.circuit_id IS 'Circuit or service identifier for the billed line';
|
||||
COMMENT ON COLUMN extraction_lines.end_customer_name IS 'Named end customer from invoice context';
|
||||
COMMENT ON COLUMN extraction_lines.period_start IS 'Billing period start date for the extracted line';
|
||||
COMMENT ON COLUMN extraction_lines.period_end IS 'Billing period end date for the extracted line';
|
||||
COMMENT ON COLUMN extraction_lines.service_address IS 'Full service address captured from invoice context';
|
||||
|
||||
ALTER TABLE internet_connections_ip_ranges
|
||||
ADD COLUMN IF NOT EXISTS provider_reference VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS contract_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS service_address VARCHAR(500),
|
||||
ADD COLUMN IF NOT EXISTS monthly_cost NUMERIC(12,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS sales_price NUMERIC(12,2) DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_ranges_customer_id
|
||||
ON internet_connections_ip_ranges(customer_id);
|
||||
|
||||
ALTER TABLE internet_connections_ip_addresses
|
||||
ADD COLUMN IF NOT EXISTS assigned_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS assigned_connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_assigned_customer_id
|
||||
ON internet_connections_ip_addresses(assigned_customer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_assigned_connection_id
|
||||
ON internet_connections_ip_addresses(assigned_connection_id);
|
||||
@ -1,52 +0,0 @@
|
||||
ALTER TABLE internet_connections_connections
|
||||
ADD COLUMN IF NOT EXISTS allocation_model VARCHAR(20) NOT NULL DEFAULT 'dedicated',
|
||||
ADD COLUMN IF NOT EXISTS value_type VARCHAR(30) NOT NULL DEFAULT 'other',
|
||||
ADD COLUMN IF NOT EXISTS value_label VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL;
|
||||
|
||||
UPDATE internet_connections_connections
|
||||
SET allocation_model = 'shared',
|
||||
value_type = 'bmc_networks',
|
||||
value_label = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE deleted_at IS NULL
|
||||
AND customer_id IN (
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND LOWER(name) = 'bmc networks'
|
||||
);
|
||||
|
||||
UPDATE internet_connections_connections
|
||||
SET allocation_model = COALESCE(NULLIF(allocation_model, ''), 'dedicated'),
|
||||
value_type = CASE
|
||||
WHEN value_type IN ('subscription', 'bmc_networks', 'delefiber', 'other') THEN value_type
|
||||
ELSE 'other'
|
||||
END,
|
||||
value_label = CASE
|
||||
WHEN value_type = 'other' AND COALESCE(NULLIF(TRIM(value_label), ''), '') = '' THEN 'Mangler klassifikation'
|
||||
WHEN value_type IN ('bmc_networks', 'delefiber', 'subscription') THEN NULL
|
||||
ELSE value_label
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE deleted_at IS NULL
|
||||
AND NOT (
|
||||
allocation_model = 'shared'
|
||||
AND value_type = 'bmc_networks'
|
||||
AND customer_id IN (
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND LOWER(name) = 'bmc networks'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_allocation_model
|
||||
ON internet_connections_connections(allocation_model);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_value_type
|
||||
ON internet_connections_connections(value_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_connections_subscription_id
|
||||
ON internet_connections_connections(subscription_id)
|
||||
WHERE subscription_id IS NOT NULL;
|
||||
@ -1,31 +0,0 @@
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ip_address,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ip_address
|
||||
ORDER BY
|
||||
CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END,
|
||||
id
|
||||
) AS row_no
|
||||
FROM internet_connections_ip_addresses
|
||||
),
|
||||
duplicates AS (
|
||||
SELECT id
|
||||
FROM ranked
|
||||
WHERE row_no > 1
|
||||
)
|
||||
UPDATE internet_connections_ip_addresses ipa
|
||||
SET deleted_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
comment = CONCAT(
|
||||
COALESCE(ipa.comment, ''),
|
||||
CASE WHEN COALESCE(ipa.comment, '') = '' THEN '' ELSE E'\n' END,
|
||||
'Automatisk deaktiveret som dublet-IP før unikregel.'
|
||||
)
|
||||
WHERE ipa.id IN (SELECT id FROM duplicates)
|
||||
AND ipa.deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_internet_connections_ip_addresses_ip_address_active
|
||||
ON internet_connections_ip_addresses (ip_address)
|
||||
WHERE deleted_at IS NULL;
|
||||
@ -1,53 +0,0 @@
|
||||
WITH bad_connections AS (
|
||||
SELECT id
|
||||
FROM internet_connections_connections
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider ILIKE 'GlobalConnect%'
|
||||
AND (address IS NULL OR BTRIM(address) = '')
|
||||
)
|
||||
UPDATE internet_connections_ip_addresses
|
||||
SET deleted_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
comment = CONCAT(
|
||||
COALESCE(comment, ''),
|
||||
CASE WHEN COALESCE(comment, '') = '' THEN '' ELSE E'\n' END,
|
||||
'Skjult sammen med fejlimporteret forbindelse uden adresse.'
|
||||
)
|
||||
WHERE deleted_at IS NULL
|
||||
AND range_id IN (
|
||||
SELECT id
|
||||
FROM internet_connections_ip_ranges
|
||||
WHERE connection_id IN (SELECT id FROM bad_connections)
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
WITH bad_connections AS (
|
||||
SELECT id
|
||||
FROM internet_connections_connections
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider ILIKE 'GlobalConnect%'
|
||||
AND (address IS NULL OR BTRIM(address) = '')
|
||||
)
|
||||
UPDATE internet_connections_ip_ranges
|
||||
SET deleted_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
description = CONCAT(
|
||||
COALESCE(description, ''),
|
||||
CASE WHEN COALESCE(description, '') = '' THEN '' ELSE E'\n' END,
|
||||
'Skjult sammen med fejlimporteret forbindelse uden adresse.'
|
||||
)
|
||||
WHERE deleted_at IS NULL
|
||||
AND connection_id IN (SELECT id FROM bad_connections);
|
||||
|
||||
UPDATE internet_connections_connections
|
||||
SET deleted_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
notes = CONCAT(
|
||||
COALESCE(notes, ''),
|
||||
CASE WHEN COALESCE(notes, '') = '' THEN '' ELSE E'\n' END,
|
||||
'Automatisk skjult fordi forbindelsen manglede adresse efter import.'
|
||||
),
|
||||
status = 'pending'
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider ILIKE 'GlobalConnect%'
|
||||
AND (address IS NULL OR BTRIM(address) = '');
|
||||
@ -1,27 +0,0 @@
|
||||
WITH normalized_extraction_addresses AS (
|
||||
SELECT
|
||||
regexp_replace(UPPER(COALESCE(provider_reference, circuit_id, '')), '[^A-Z0-9]', '', 'g') AS ref_norm,
|
||||
NULLIF(BTRIM(service_address), '') AS service_address
|
||||
FROM extraction_lines
|
||||
WHERE NULLIF(BTRIM(service_address), '') IS NOT NULL
|
||||
),
|
||||
unique_addresses AS (
|
||||
SELECT
|
||||
ref_norm,
|
||||
MIN(service_address) AS service_address
|
||||
FROM normalized_extraction_addresses
|
||||
GROUP BY ref_norm
|
||||
HAVING COUNT(DISTINCT service_address) = 1
|
||||
)
|
||||
UPDATE internet_connections_connections c
|
||||
SET address = u.service_address,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
notes = CONCAT(
|
||||
COALESCE(c.notes, ''),
|
||||
CASE WHEN COALESCE(c.notes, '') = '' THEN '' ELSE E'\n' END,
|
||||
'Adresse backfill fra extraction-data.'
|
||||
)
|
||||
FROM unique_addresses u
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND (c.address IS NULL OR BTRIM(c.address) = '')
|
||||
AND regexp_replace(UPPER(COALESCE(c.circuit_number, '')), '[^A-Z0-9]', '', 'g') = u.ref_norm;
|
||||
@ -1,159 +0,0 @@
|
||||
WITH seed_products AS (
|
||||
SELECT *
|
||||
FROM (
|
||||
VALUES
|
||||
(
|
||||
'BMCnet 100/100',
|
||||
'Delt BMC internetforbindelse 100/100 Mbit',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'internet_access',
|
||||
'connection_type', 'fiber',
|
||||
'speed_mbps', 100,
|
||||
'download_mbps', 100,
|
||||
'upload_mbps', 100
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'BMCnet 250/250',
|
||||
'Delt BMC internetforbindelse 250/250 Mbit',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'internet_access',
|
||||
'connection_type', 'fiber',
|
||||
'speed_mbps', 250,
|
||||
'download_mbps', 250,
|
||||
'upload_mbps', 250
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'BMCnet 500/500',
|
||||
'Delt BMC internetforbindelse 500/500 Mbit',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'internet_access',
|
||||
'connection_type', 'fiber',
|
||||
'speed_mbps', 500,
|
||||
'download_mbps', 500,
|
||||
'upload_mbps', 500
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'BMCnet 1000/1000',
|
||||
'Delt BMC internetforbindelse 1000/1000 Mbit',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'internet_access',
|
||||
'connection_type', 'fiber',
|
||||
'speed_mbps', 1000,
|
||||
'download_mbps', 1000,
|
||||
'upload_mbps', 1000
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'/30 IP',
|
||||
'Offentlig IPv4-allokering /30',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'ip_allocation',
|
||||
'ip_prefix_length', 30
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'/29 IP',
|
||||
'Offentlig IPv4-allokering /29',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'ip_allocation',
|
||||
'ip_prefix_length', 29
|
||||
)
|
||||
)
|
||||
),
|
||||
(
|
||||
'/28 IP',
|
||||
'Offentlig IPv4-allokering /28',
|
||||
'service',
|
||||
'monthly',
|
||||
0.00::DECIMAL(10,2),
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'ip_allocation',
|
||||
'ip_prefix_length', 28
|
||||
)
|
||||
)
|
||||
)
|
||||
) AS t(name, short_description, type, billing_period, sales_price, attributes_json)
|
||||
),
|
||||
updated AS (
|
||||
UPDATE products p
|
||||
SET short_description = s.short_description,
|
||||
type = COALESCE(NULLIF(p.type, ''), s.type),
|
||||
billing_period = COALESCE(NULLIF(p.billing_period, ''), s.billing_period),
|
||||
sales_price = COALESCE(p.sales_price, s.sales_price),
|
||||
status = 'active',
|
||||
billable = true,
|
||||
attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || s.attributes_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM seed_products s
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND LOWER(p.name) = LOWER(s.name)
|
||||
RETURNING p.id, p.name
|
||||
)
|
||||
INSERT INTO products (
|
||||
name,
|
||||
short_description,
|
||||
type,
|
||||
status,
|
||||
sales_price,
|
||||
vat_rate,
|
||||
billing_period,
|
||||
billable,
|
||||
attributes_json
|
||||
)
|
||||
SELECT
|
||||
s.name,
|
||||
s.short_description,
|
||||
s.type,
|
||||
'active',
|
||||
s.sales_price,
|
||||
25.00,
|
||||
s.billing_period,
|
||||
true,
|
||||
s.attributes_json
|
||||
FROM seed_products s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM products p
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND LOWER(p.name) = LOWER(s.name)
|
||||
);
|
||||
@ -1,26 +0,0 @@
|
||||
WITH bmc_owner AS (
|
||||
SELECT MIN(id) AS id
|
||||
FROM customers
|
||||
WHERE lower(name) = 'bmc networks'
|
||||
AND is_active = true
|
||||
),
|
||||
candidate_connections AS (
|
||||
SELECT DISTINCT ic.id
|
||||
FROM internet_connections_connections ic
|
||||
JOIN internet_connections_ip_ranges ir
|
||||
ON ir.connection_id = ic.id
|
||||
AND ir.deleted_at IS NULL
|
||||
WHERE ic.deleted_at IS NULL
|
||||
AND ic.parent_id IS NULL
|
||||
AND ic.subscription_id IS NULL
|
||||
AND ic.provider ILIKE 'GlobalConnect%'
|
||||
AND ir.customer_id IS NULL
|
||||
AND COALESCE(ic.address, '') <> ''
|
||||
)
|
||||
UPDATE internet_connections_connections ic
|
||||
SET customer_id = COALESCE((SELECT id FROM bmc_owner), ic.customer_id),
|
||||
allocation_model = 'shared',
|
||||
value_type = 'bmc_networks',
|
||||
value_label = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ic.id IN (SELECT id FROM candidate_connections);
|
||||
@ -1,35 +0,0 @@
|
||||
WITH mismatched AS (
|
||||
SELECT
|
||||
ir.id AS range_id,
|
||||
ir.service_address
|
||||
FROM internet_connections_ip_ranges ir
|
||||
JOIN internet_connections_connections current_conn
|
||||
ON current_conn.id = ir.connection_id
|
||||
WHERE ir.deleted_at IS NULL
|
||||
AND current_conn.deleted_at IS NULL
|
||||
AND current_conn.allocation_model <> 'shared'
|
||||
AND ir.service_address IS NOT NULL
|
||||
AND regexp_replace(upper(coalesce(ir.service_address, '')), '[^A-Z0-9]', '', 'g')
|
||||
<> regexp_replace(upper(coalesce(current_conn.address, '')), '[^A-Z0-9]', '', 'g')
|
||||
),
|
||||
candidate_matches AS (
|
||||
SELECT
|
||||
m.range_id,
|
||||
candidate.id AS target_connection_id,
|
||||
COUNT(*) OVER (PARTITION BY m.range_id) AS candidate_count
|
||||
FROM mismatched m
|
||||
JOIN internet_connections_connections candidate
|
||||
ON candidate.deleted_at IS NULL
|
||||
AND regexp_replace(upper(coalesce(candidate.address, '')), '[^A-Z0-9]', '', 'g')
|
||||
= regexp_replace(upper(coalesce(m.service_address, '')), '[^A-Z0-9]', '', 'g')
|
||||
),
|
||||
unique_targets AS (
|
||||
SELECT range_id, target_connection_id
|
||||
FROM candidate_matches
|
||||
WHERE candidate_count = 1
|
||||
)
|
||||
UPDATE internet_connections_ip_ranges ir
|
||||
SET connection_id = ut.target_connection_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM unique_targets ut
|
||||
WHERE ir.id = ut.range_id;
|
||||
@ -1,15 +0,0 @@
|
||||
WITH bmc_owner AS (
|
||||
SELECT MIN(id) AS id
|
||||
FROM customers
|
||||
WHERE lower(name) = 'bmc networks'
|
||||
AND is_active = true
|
||||
)
|
||||
UPDATE internet_connections_connections ic
|
||||
SET customer_id = COALESCE((SELECT id FROM bmc_owner), ic.customer_id),
|
||||
allocation_model = 'shared',
|
||||
value_type = 'bmc_networks',
|
||||
value_label = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ic.deleted_at IS NULL
|
||||
AND ic.parent_id IS NULL
|
||||
AND ic.value_type = 'bmc_networks';
|
||||
@ -1,8 +0,0 @@
|
||||
UPDATE internet_connections_ip_ranges
|
||||
SET service_address = 'Rydagervej 27, 2620 Albertslund',
|
||||
customer_id = NULL,
|
||||
monthly_cost = 64.00,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider_reference = 'NKA-020900'
|
||||
AND cidr = '87.116.1.200/29';
|
||||
@ -1,58 +0,0 @@
|
||||
WITH seed_product AS (
|
||||
SELECT
|
||||
'BMCnet Statisk WAN IP'::TEXT AS name,
|
||||
'Statisk offentlig WAN IPv4-adresse'::TEXT AS short_description,
|
||||
'service'::TEXT AS type,
|
||||
'monthly'::TEXT AS billing_period,
|
||||
0.00::DECIMAL(10,2) AS sales_price,
|
||||
jsonb_build_object(
|
||||
'network',
|
||||
jsonb_build_object(
|
||||
'kind', 'ip_allocation',
|
||||
'ip_prefix_length', 32
|
||||
)
|
||||
) AS attributes_json
|
||||
),
|
||||
updated AS (
|
||||
UPDATE products p
|
||||
SET short_description = s.short_description,
|
||||
type = COALESCE(NULLIF(p.type, ''), s.type),
|
||||
billing_period = COALESCE(NULLIF(p.billing_period, ''), s.billing_period),
|
||||
sales_price = COALESCE(p.sales_price, s.sales_price),
|
||||
status = 'active',
|
||||
billable = true,
|
||||
attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || s.attributes_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM seed_product s
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND LOWER(p.name) = LOWER(s.name)
|
||||
RETURNING p.id
|
||||
)
|
||||
INSERT INTO products (
|
||||
name,
|
||||
short_description,
|
||||
type,
|
||||
status,
|
||||
sales_price,
|
||||
vat_rate,
|
||||
billing_period,
|
||||
billable,
|
||||
attributes_json
|
||||
)
|
||||
SELECT
|
||||
s.name,
|
||||
s.short_description,
|
||||
s.type,
|
||||
'active',
|
||||
s.sales_price,
|
||||
25.00,
|
||||
s.billing_period,
|
||||
true,
|
||||
s.attributes_json
|
||||
FROM seed_product s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM products p
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND LOWER(p.name) = LOWER(s.name)
|
||||
);
|
||||
@ -1,68 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_customer_documents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
original_filename VARCHAR(500) NOT NULL,
|
||||
file_path VARCHAR(1000) NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type VARCHAR(120),
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
extracted_text TEXT,
|
||||
notes TEXT,
|
||||
uploaded_by INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_customer
|
||||
ON internet_connections_customer_documents(customer_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_connection
|
||||
ON internet_connections_customer_documents(connection_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_checksum
|
||||
ON internet_connections_customer_documents(checksum);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_created_at
|
||||
ON internet_connections_customer_documents(created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_internet_customer_documents_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_update_internet_customer_documents_updated_at
|
||||
ON internet_connections_customer_documents;
|
||||
|
||||
CREATE TRIGGER trigger_update_internet_customer_documents_updated_at
|
||||
BEFORE UPDATE ON internet_connections_customer_documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_internet_customer_documents_updated_at();
|
||||
|
||||
COMMENT ON TABLE internet_connections_customer_documents IS 'Kundeoplaeste tekstfiler til internetforbindelser og historisk research';
|
||||
COMMENT ON COLUMN internet_connections_customer_documents.extracted_text IS 'Udtrukket/forsynlig tekst som wizard v2 og AI kan soege i';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_connections_customer_document_segments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
document_id INTEGER NOT NULL REFERENCES internet_connections_customer_documents(id) ON DELETE CASCADE,
|
||||
block_index INTEGER NOT NULL,
|
||||
block_title VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
ip_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
cidr_blocks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
references_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
socket_numbers JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(document_id, block_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_customer_document_segments_document
|
||||
ON internet_connections_customer_document_segments(document_id);
|
||||
@ -1,17 +0,0 @@
|
||||
-- Repair migration: ensure invoice error finder import permission exists on older prod databases
|
||||
-- and is granted to the intended operator groups.
|
||||
|
||||
INSERT INTO permissions (code, description, category)
|
||||
VALUES (
|
||||
'invoice_error_finder.run_import',
|
||||
'Trigger invoice/error data imports',
|
||||
'invoice_error_finder'
|
||||
)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
JOIN permissions p ON p.code = 'invoice_error_finder.run_import'
|
||||
WHERE g.name IN ('Administrators', 'Managers')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -1,39 +0,0 @@
|
||||
-- Backfill invoice error finder permissions and group grants on environments
|
||||
-- where migration 1007 did not run completely.
|
||||
|
||||
INSERT INTO permissions (code, description, category) VALUES
|
||||
('invoice_error_finder.view', 'View invoice error finder dashboard and issues', 'invoice_error_finder'),
|
||||
('invoice_error_finder.run_import', 'Trigger invoice/error data imports', 'invoice_error_finder'),
|
||||
('invoice_error_finder.analyze', 'Run invoice error detection analysis', 'invoice_error_finder'),
|
||||
('invoice_error_finder.update_status', 'Update issue status and assignee', 'invoice_error_finder'),
|
||||
('invoice_error_finder.create_sag', 'Create/link sag from invoice error issue', 'invoice_error_finder'),
|
||||
('invoice_error_finder.create_ordre_draft', 'Create ordre draft from invoice error issue', 'invoice_error_finder'),
|
||||
('invoice_error_finder.ignore', 'Ignore invoice error issues', 'invoice_error_finder'),
|
||||
('invoice_error_finder.admin', 'Administer invoice error finder settings', 'invoice_error_finder')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
JOIN permissions p ON p.category = 'invoice_error_finder'
|
||||
WHERE g.name IN ('Administrators', 'Managers')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
JOIN permissions p ON p.code IN (
|
||||
'invoice_error_finder.view',
|
||||
'invoice_error_finder.update_status',
|
||||
'invoice_error_finder.create_sag',
|
||||
'invoice_error_finder.create_ordre_draft'
|
||||
)
|
||||
WHERE g.name = 'Technicians'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id
|
||||
FROM groups g
|
||||
JOIN permissions p ON p.code = 'invoice_error_finder.view'
|
||||
WHERE g.name = 'Viewers'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@ -1,165 +0,0 @@
|
||||
-- Backfill invoice error finder schema on environments where module migrations
|
||||
-- were not executed through the main production migration flow.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_import_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'partial', 'failed')),
|
||||
records_imported INTEGER NOT NULL DEFAULT 0,
|
||||
records_failed INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
triggered_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
is_scheduled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_import_runs_source
|
||||
ON invoice_error_finder_import_runs(source_type, started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE,
|
||||
source_invoice_number VARCHAR(80),
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'booked',
|
||||
customer_number INTEGER,
|
||||
customer_name VARCHAR(255),
|
||||
invoice_date DATE,
|
||||
due_date DATE,
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
net_amount NUMERIC(14,2),
|
||||
vat_amount NUMERIC(14,2),
|
||||
total_amount NUMERIC(14,2),
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_ief_economic_invoice_import UNIQUE (import_run_id, source_invoice_number, source_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_run
|
||||
ON invoice_error_finder_economic_invoices(import_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_customer
|
||||
ON invoice_error_finder_economic_invoices(customer_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_invoices_date
|
||||
ON invoice_error_finder_economic_invoices(invoice_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_economic_invoice_lines (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoice_error_finder_economic_invoices(id) ON DELETE CASCADE,
|
||||
line_number INTEGER,
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
description TEXT,
|
||||
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
line_net_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_percentage NUMERIC(5,2) DEFAULT 0,
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_invoice
|
||||
ON invoice_error_finder_economic_invoice_lines(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_economic_lines_product
|
||||
ON invoice_error_finder_economic_invoice_lines(product_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_simply_sales_orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_run_id INTEGER NOT NULL REFERENCES invoice_error_finder_import_runs(id) ON DELETE CASCADE,
|
||||
source_record_id VARCHAR(80) NOT NULL,
|
||||
salesorder_no VARCHAR(80),
|
||||
account_id VARCHAR(80),
|
||||
customer_name VARCHAR(255),
|
||||
customer_cvr VARCHAR(32),
|
||||
subject TEXT,
|
||||
status VARCHAR(50),
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
start_period DATE,
|
||||
end_period DATE,
|
||||
source_raw JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_ief_simply_order_import UNIQUE (source_record_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_run
|
||||
ON invoice_error_finder_simply_sales_orders(import_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_account
|
||||
ON invoice_error_finder_simply_sales_orders(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_simply_orders_status
|
||||
ON invoice_error_finder_simply_sales_orders(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_error_finder_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
issue_type VARCHAR(50) NOT NULL CHECK (issue_type IN (
|
||||
'missing_line',
|
||||
'open_order_not_invoiced',
|
||||
'quantity_drop',
|
||||
'price_change',
|
||||
'new_item_never_invoiced'
|
||||
)),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'open' CHECK (status IN (
|
||||
'open',
|
||||
'investigating',
|
||||
'approved_change',
|
||||
'error_found',
|
||||
'ready_to_invoice',
|
||||
'invoiced',
|
||||
'ignored',
|
||||
'resolved'
|
||||
)),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
customer_name VARCHAR(255),
|
||||
subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
|
||||
simply_order_id INTEGER REFERENCES invoice_error_finder_simply_sales_orders(id) ON DELETE SET NULL,
|
||||
simply_source_record_id VARCHAR(80),
|
||||
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||
product_number VARCHAR(100),
|
||||
product_name VARCHAR(500),
|
||||
reference_period_start DATE,
|
||||
reference_period_end DATE,
|
||||
expected_quantity NUMERIC(14,4),
|
||||
actual_quantity NUMERIC(14,4),
|
||||
expected_price NUMERIC(14,4),
|
||||
actual_price NUMERIC(14,4),
|
||||
last_invoice_number VARCHAR(80),
|
||||
last_invoice_date DATE,
|
||||
sales_order_number VARCHAR(80),
|
||||
amount_impact NUMERIC(14,2),
|
||||
currency VARCHAR(10) DEFAULT 'DKK',
|
||||
assigned_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
ignored_until DATE,
|
||||
resolved_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_type
|
||||
ON invoice_error_finder_issues(issue_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_status
|
||||
ON invoice_error_finder_issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_customer
|
||||
ON invoice_error_finder_issues(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_period
|
||||
ON invoice_error_finder_issues(reference_period_start, reference_period_end);
|
||||
CREATE INDEX IF NOT EXISTS idx_ief_issues_assigned
|
||||
ON invoice_error_finder_issues(assigned_user_id)
|
||||
WHERE assigned_user_id IS NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_ief_issues_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_ief_issues_updated_at ON invoice_error_finder_issues;
|
||||
CREATE TRIGGER trigger_ief_issues_updated_at
|
||||
BEFORE UPDATE ON invoice_error_finder_issues
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ief_issues_updated_at();
|
||||
@ -1,26 +0,0 @@
|
||||
-- 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'
|
||||
));
|
||||
@ -1,15 +0,0 @@
|
||||
-- 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');
|
||||
@ -1,38 +0,0 @@
|
||||
-- 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();
|
||||
@ -1,17 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,8 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,25 +0,0 @@
|
||||
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);
|
||||
@ -1,7 +0,0 @@
|
||||
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;
|
||||
@ -1,39 +0,0 @@
|
||||
-- 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);
|
||||
@ -1,9 +0,0 @@
|
||||
-- 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);
|
||||
@ -1,20 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,8 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,11 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
@ -1,40 +0,0 @@
|
||||
-- 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);
|
||||
@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reprocess stored supplier-invoice files through the app code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from app.billing.backend.supplier_invoices import reprocess_uploaded_file
|
||||
from app.core.database import init_db
|
||||
|
||||
|
||||
async def _run(file_ids: list[int]) -> list[dict]:
|
||||
results = []
|
||||
for file_id in file_ids:
|
||||
result = await reprocess_uploaded_file(file_id)
|
||||
results.append({"file_id": file_id, "result": result})
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("file_ids", nargs="+", type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
init_db()
|
||||
payload = asyncio.run(_run(args.file_ids))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user