Compare commits

...

12 Commits

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

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

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

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

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

feat(migrations): introduce display order for physical panels

feat(migrations): link wall outlets to switch hardware

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

feat(migrations): create hardware network links table

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

feat(migrations): create UISP devices and link to hardware assets
2026-07-18 10:10:31 +02:00
Christian
d4d9ac22a5 feat: add locations outlet and cross-field management 2026-07-17 10:23:57 +02:00
Christian
0655b4c4f8 feat: Enhance opportunity listing and invoice error finder functionality
- Added customer_id and contact_id filters to the list_opportunities endpoint for improved querying.
- Implemented a redirect for opportunity detail pages to a new format.
- Refactored SubscriptionMatrixService to load invoices from a local snapshot instead of an external service, improving performance and reliability.
- Updated settings to include 'pipeline' as a case type and added a new section for managing ignored product texts in the invoice error finder.
- Introduced a new user_sag_create_preferences table to store per-user default case types for new cases.
- Enhanced frontend settings page with invoice error finder configuration options and improved handling of ignored product texts.
- Added migrations to support new features, including resolved status for invoice error finder issues and user-specific case type preferences.
2026-07-17 01:58:02 +02:00
Christian
db2e8c3157 chore(release): bump version to 2.3.30 2026-07-11 11:48:42 +02:00
Christian
b91f9fcc2d chore(release): bump version to 2.3.29 2026-07-11 11:45:24 +02:00
Christian
2b4dd6b010 chore(release): bump version to 2.3.28 2026-07-11 10:31:20 +02:00
Christian
09fef6ff8c chore(release): bump version to 2.3.27 2026-07-11 09:57:38 +02:00
Christian
48f7b80c49 chore(release): bump version to 2.3.26 2026-07-10 16:22:57 +02:00
Christian
ca23ac1f2b chore(release): bump version to 2.3.25 2026-07-10 08:10:31 +02:00
Christian
fd5d7dd7fb chore(release): bump version to 2.3.24 2026-07-10 07:08:58 +02:00
Christian
128a2b83d0 feat(invoice_error_finder): add invoice error finder module
- Detect missing invoice lines, open orders not invoiced, quantity drops, price changes
- Import e-conomic invoices and Simply CRM sales orders
- Dashboard and issues UI with sag/ordre-draft actions
- Scheduled daily sync job at 05:00
- Add invoice_error_finder permissions
2026-07-10 07:08:49 +02:00
Christian
3311b8e590 Add comprehensive tests for internet connections module, invoice parsing, and subscription provisioning
- Implement tests for the internet connections module, covering routes, IP range creation, and connection validation.
- Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices.
- Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations.
- Include validation checks for subtotal mismatches and ensure error handling for missing IP selections.
2026-07-09 23:44:30 +02:00
111 changed files with 22626 additions and 16949 deletions

View File

@ -1 +1 @@
2.3.23 2.3.30

View File

@ -217,6 +217,22 @@ class UserProfileUpdate(BaseModel):
phone: Optional[str] = None phone: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
anydesk_id: Optional[str] = None anydesk_id: Optional[str] = None
default_case_type: Optional[str] = None
def _allowed_case_types() -> list[str]:
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
try:
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
if rows:
import json
configured = json.loads(rows[0].get("value") or "[]")
values = [str(value).strip().lower() for value in configured if str(value).strip()]
if values:
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
except Exception:
logger.warning("Could not load configured case types for profile preference", exc_info=True)
return fallback
@router.get("/me/profile") @router.get("/me/profile")
@ -228,7 +244,18 @@ async def get_my_profile(current_user: dict = Depends(get_current_user)):
) )
if not rows: if not rows:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return dict(rows[0]) profile = dict(rows[0])
profile["default_case_type"] = "ticket"
try:
preference = execute_query(
"SELECT default_case_type FROM user_sag_create_preferences WHERE user_id = %s",
(current_user["id"],),
) or []
if preference:
profile["default_case_type"] = preference[0].get("default_case_type") or "ticket"
except Exception:
logger.warning("Sag create preferences table not available yet")
return profile
@router.patch("/me/profile") @router.patch("/me/profile")
@ -253,12 +280,29 @@ async def update_my_profile(
fields.append("anydesk_id = %s") fields.append("anydesk_id = %s")
values.append(payload.anydesk_id.strip() or None) values.append(payload.anydesk_id.strip() or None)
if not fields: if payload.default_case_type is not None:
default_case_type = payload.default_case_type.strip().lower() or "ticket"
if default_case_type not in _allowed_case_types():
raise HTTPException(status_code=400, detail="Ukendt sagstype")
try:
execute_query(
"""
INSERT INTO user_sag_create_preferences (user_id, default_case_type, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (user_id) DO UPDATE
SET default_case_type = EXCLUDED.default_case_type, updated_at = NOW()
""",
(current_user["id"], default_case_type),
)
except Exception as exc:
raise HTTPException(status_code=409, detail="Profilindstillingen er ikke klar. Kør migration 214 først.") from exc
if not fields and payload.default_case_type is None:
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")
if fields:
fields.append("updated_at = NOW()") fields.append("updated_at = NOW()")
values.append(current_user["id"]) values.append(current_user["id"])
execute_query( execute_query(
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s", f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
tuple(values) tuple(values)

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,51 @@
.status-processing { background-color: #6c757d; color: #fff; } .status-processing { background-color: #6c757d; color: #fff; }
.status-failed { background-color: var(--danger); color: #fff; } .status-failed { background-color: var(--danger); color: #fff; }
.status-completed { background-color: var(--success); 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> </style>
{% endblock %} {% endblock %}
@ -2036,6 +2081,114 @@ function getFileStatusBadge(status) {
return badges[status] || `<span class="badge bg-secondary">${status}</span>`; 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 // NEW: Batch analyze all files
async function batchAnalyzeAllFiles() { 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.')) { 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.')) {
@ -2757,6 +2910,7 @@ async function reviewExtractedData(fileId) {
const ext = data.extraction; const ext = data.extraction;
const lines = data.extraction_lines || []; const lines = data.extraction_lines || [];
const syncPreview = data.internet_sync_preview || null;
// Parse JSON if llm_response_json exists // Parse JSON if llm_response_json exists
let aiData = null; let aiData = null;
@ -2840,6 +2994,8 @@ 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> <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>
` : '<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>'} ` : '<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; document.getElementById('reviewModalContent').innerHTML = modalContent;
@ -3188,7 +3344,11 @@ async function createInvoiceFromExtraction() {
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}`); 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}`);
// Close modal and refresh // Close modal and refresh
const modalInstance = bootstrap.Modal.getInstance(modal); const modalInstance = bootstrap.Modal.getInstance(modal);

View File

@ -28,8 +28,15 @@ class ContactCreate(BaseModel):
last_name: str = "" last_name: str = ""
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
mobile: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
department: Optional[str] = None
company_id: Optional[int] = 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): class ContactUpdate(BaseModel):
@ -220,18 +227,33 @@ async def create_contact(contact: ContactCreate):
pass pass
insert_query = """ insert_query = """
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active) INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
VALUES (%s, %s, %s, %s, %s, true) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""" """
contact_id = execute_insert( contact_id = execute_insert(
insert_query, insert_query,
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title) (
contact.first_name,
contact.last_name,
contact.email,
contact.phone,
contact.mobile,
contact.title,
contact.department,
contact.is_active,
)
) )
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 # Link to company if provided
if contact.company_id: for idx, company_id in enumerate(company_ids):
try: try:
link_query = """ link_query = """
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
@ -240,9 +262,25 @@ async def create_contact(contact: ContactCreate):
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
RETURNING id RETURNING id
""" """
execute_insert(link_query, (contact_id, contact.company_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),
)
except Exception as e: except Exception as e:
logger.error(f"Failed to link new contact {contact_id} to company {contact.company_id}: {e}") logger.error(f"Failed to link new contact {contact_id} to company {company_id}: {e}")
# Don't fail the whole request, just log it # Don't fail the whole request, just log it
return await get_contact(contact_id) return await get_contact(contact_id)

View File

@ -1383,7 +1383,7 @@ async function loadContactOpportunities() {
<td>${escapeHtml(stage)}</td> <td>${escapeHtml(stage)}</td>
<td>${probability}</td> <td>${probability}</td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/opportunities/${o.id}"> <a class="btn btn-sm btn-outline-primary" href="/sag/${o.id}/v3">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
</td> </td>

View File

@ -817,6 +817,8 @@ let lastLoadedQueryKey = '';
let availableCompanies = []; let availableCompanies = [];
let selectedCompanyIds = new Set(); let selectedCompanyIds = new Set();
let currentContactsData = []; let currentContactsData = [];
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
let currentSort = { let currentSort = {
key: 'name', key: 'name',
direction: 'asc' direction: 'asc'
@ -830,12 +832,26 @@ let visibleColumns = {
// Load contacts on page load // Load contacts on page load
document.addEventListener('DOMContentLoaded', () => { 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(); loadTablePreferences();
applyColumnVisibility(); applyColumnVisibility();
updateSortIndicators(); updateSortIndicators();
loadContacts(); loadContacts();
loadCompaniesForSelect(); loadCompaniesForSelect();
if (shouldOpenCreateModal) {
setTimeout(() => {
showCreateContactModal();
}, 0);
}
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn'); const clearBtn = document.getElementById('searchClearBtn');
@ -915,7 +931,7 @@ function setFilter(filter) {
loadContacts(); loadContacts();
} }
async function loadContacts() { async function loadContacts(force = false) {
const tbody = document.getElementById('contactsTableBody'); 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>'; tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
@ -942,7 +958,7 @@ async function loadContacts() {
} }
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`; const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
if (queryKey === lastLoadedQueryKey) { if (!force && queryKey === lastLoadedQueryKey) {
return; return;
} }
lastLoadedQueryKey = queryKey; lastLoadedQueryKey = queryKey;
@ -1339,6 +1355,9 @@ async function loadCompaniesForSelect() {
availableCompanies = Array.isArray(data.customers) availableCompanies = Array.isArray(data.customers)
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() })) ? 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 || ''); renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
renderSelectedCompanies(); renderSelectedCompanies();
} catch (error) { } catch (error) {
@ -1411,6 +1430,9 @@ function showCreateContactModal() {
document.getElementById('createContactForm').reset(); document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true; document.getElementById('isActiveInput').checked = true;
selectedCompanyIds = new Set(); selectedCompanyIds = new Set();
if (pendingCreateModalCustomerId) {
selectedCompanyIds.add(pendingCreateModalCustomerId);
}
const companySearchInput = document.getElementById('companySearchInput'); const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) { if (companySearchInput) {
companySearchInput.value = ''; companySearchInput.value = '';
@ -1470,8 +1492,33 @@ async function createContact() {
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
modal.hide(); 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 // Reload contact list
await loadContacts(); 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;
}
// Show success message // Show success message
alert('Kontakt oprettet succesfuldt!'); alert('Kontakt oprettet succesfuldt!');

View File

@ -1220,6 +1220,145 @@ async def get_customer_contacts(customer_id: int):
return rows or [] return rows or []
@router.get("/customers/{customer_id}/economic-invoices")
async def get_customer_economic_invoices(customer_id: int, limit: int = Query(default=100, ge=1, le=500)):
"""Get imported e-conomic invoices for a customer from invoice_error_finder staging data."""
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
economic_customer_number = customer.get("economic_customer_number")
if not economic_customer_number:
return {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"economic_customer_number": None,
"items": [],
}
rows = execute_query(
"""
WITH ranked_invoices AS (
SELECT
inv.id,
inv.source_invoice_number,
inv.invoice_date,
inv.due_date,
inv.total_amount,
inv.net_amount,
inv.vat_amount,
inv.currency,
inv.source_type,
COALESCE(inv.source_raw::jsonb -> 'notes' ->> 'heading', '') AS heading,
NULLIF(
CONCAT_WS(
E'\n',
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine1', ''),
NULLIF(inv.source_raw::jsonb -> 'notes' ->> 'textLine2', '')
),
''
) AS note_text,
CASE inv.source_type
WHEN 'paid' THEN 1
WHEN 'booked' THEN 2
WHEN 'unpaid' THEN 3
WHEN 'draft' THEN 4
ELSE 9
END AS source_rank
FROM invoice_error_finder_economic_invoices inv
WHERE inv.customer_number = %s
),
selected_invoices AS (
SELECT DISTINCT ON (source_invoice_number)
id,
source_invoice_number,
invoice_date,
due_date,
total_amount,
net_amount,
vat_amount,
currency,
source_type,
heading,
note_text
FROM ranked_invoices
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
)
SELECT
si.id AS invoice_id,
si.source_invoice_number,
si.invoice_date,
si.due_date,
si.total_amount,
si.net_amount,
si.vat_amount,
si.currency,
si.source_type,
si.heading,
si.note_text,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount
FROM selected_invoices si
LEFT JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = si.id
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
LIMIT %s
""",
(economic_customer_number, limit * 25),
) or []
invoices: List[Dict[str, Any]] = []
invoices_by_id: Dict[int, Dict[str, Any]] = {}
for row in rows:
invoice_id = row.get("invoice_id")
if invoice_id is None:
continue
if invoice_id not in invoices_by_id:
payload = {
"invoice_id": invoice_id,
"invoice_number": row.get("source_invoice_number"),
"invoice_date": row["invoice_date"].isoformat() if row.get("invoice_date") else None,
"due_date": row["due_date"].isoformat() if row.get("due_date") else None,
"total_amount": float(row.get("total_amount") or 0),
"net_amount": float(row.get("net_amount") or 0),
"vat_amount": float(row.get("vat_amount") or 0),
"currency": row.get("currency") or "DKK",
"source_type": row.get("source_type"),
"heading": row.get("heading") or None,
"note_text": row.get("note_text") or None,
"lines": [],
}
invoices_by_id[invoice_id] = payload
invoices.append(payload)
if row.get("line_number") is not None:
invoices_by_id[invoice_id]["lines"].append(
{
"line_number": int(row.get("line_number") or 0),
"product_number": row.get("product_number"),
"product_name": row.get("product_name"),
"description": row.get("description"),
"quantity": float(row.get("quantity") or 0),
"unit_price": float(row.get("unit_price") or 0),
"line_net_amount": float(row.get("line_net_amount") or 0),
}
)
return {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"economic_customer_number": economic_customer_number,
"items": invoices[:limit],
}
@router.get("/customers/{customer_id}/kontakt") @router.get("/customers/{customer_id}/kontakt")
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)): async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
"""Get unified contact communication history (calls + SMS) for all company contacts.""" """Get unified contact communication history (calls + SMS) for all company contacts."""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
"""
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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Internet connections module package."""

View File

@ -0,0 +1,143 @@
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

View File

@ -0,0 +1,33 @@
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"},
)

View File

@ -0,0 +1,11 @@
{
"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

View File

@ -0,0 +1,633 @@
{% 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 %}

View File

@ -0,0 +1,619 @@
{% 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 %}

View File

@ -0,0 +1 @@
"""Invoice Error Finder module."""

View File

@ -0,0 +1 @@
"""Invoice Error Finder backend."""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
"""Invoice Error Finder frontend."""

View File

@ -0,0 +1,67 @@
"""
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(),
},
)

View File

@ -0,0 +1,180 @@
-- 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();

View File

@ -0,0 +1,17 @@
{
"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
}
}
}

View File

@ -0,0 +1 @@
"""Invoice Error Finder services."""

View File

@ -0,0 +1,954 @@
"""
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

View File

@ -0,0 +1,290 @@
"""
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]

View File

@ -0,0 +1,249 @@
"""
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]

View File

@ -0,0 +1,256 @@
{% 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 %}

View File

@ -0,0 +1,567 @@
{% 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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 %}

View File

@ -40,7 +40,9 @@ from app.modules.locations.models.schemas import (
Service, ServiceCreate, ServiceUpdate, Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate, Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats, BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse LocationWizardCreateRequest, LocationWizardCreateResponse,
WallOutlet, WallOutletCreate, WallOutletUpdate, CrossField, CrossFieldCreate, CrossFieldUpdate,
CrossFieldPortLabelsUpdate
) )
router = APIRouter() router = APIRouter()
@ -164,15 +166,21 @@ async def create_location(request: Request):
logger.warning("⚠️ Invalid location payload") logger.warning("⚠️ Invalid location payload")
raise HTTPException(status_code=422, detail=e.errors()) raise HTTPException(status_code=422, detail=e.errors())
# Check for duplicate name # Names only need to be unique within the same customer and hierarchy.
check_query = "SELECT id FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (data.name,)) SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (data.name, data.parent_location_id, data.customer_id))
if existing: if existing:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name: {data.name}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location with name '{data.name}' already exists" detail=f"Location with name '{data.name}' already exists under the same customer/location"
) )
if data.customer_id is not None: if data.customer_id is not None:
@ -201,9 +209,9 @@ async def create_location(request: Request):
INSERT INTO locations_locations ( INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city, name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude, address_postal_code, address_country, latitude, longitude,
phone, email, notes, is_active, created_at, updated_at phone, email, notes, is_active, has_cross_field, created_at, updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING * RETURNING *
""" """
@ -221,7 +229,8 @@ async def create_location(request: Request):
data.phone, data.phone,
data.email, data.email,
data.notes, data.notes,
data.is_active data.is_active,
data.has_cross_field if data.location_type == 'rum' else False
) )
result = execute_query(insert_query, params) result = execute_query(insert_query, params)
@ -426,32 +435,39 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
def _normalize_name(value: str) -> str: def _normalize_name(value: str) -> str:
return (value or "").strip().lower() return (value or "").strip().lower()
def _name_exists(value: str) -> bool: def _name_exists(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> bool:
normalized = _normalize_name(value) normalized = _normalize_name(value)
if normalized in reserved_names: scope_key = (normalized, parent_location_id, customer_id)
if scope_key in reserved_names:
return True return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL" check_query = """
existing = execute_query(check_query, (value,)) SELECT 1 FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND deleted_at IS NULL
"""
existing = execute_query(check_query, (value, parent_location_id, customer_id))
return bool(existing) return bool(existing)
def _reserve_name(value: str) -> None: def _reserve_name(value: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> None:
normalized = _normalize_name(value) normalized = _normalize_name(value)
if normalized: if normalized:
reserved_names.add(normalized) reserved_names.add((normalized, parent_location_id, customer_id))
def _resolve_unique_name(base_name: str) -> str: def _resolve_unique_name(base_name: str, parent_location_id: Optional[int], customer_id: Optional[int]) -> str:
if not auto_suffix: if not auto_suffix:
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
base_name = base_name.strip() base_name = base_name.strip()
if not _name_exists(base_name): if not _name_exists(base_name, parent_location_id, customer_id):
_reserve_name(base_name) _reserve_name(base_name, parent_location_id, customer_id)
return base_name return base_name
suffix = 2 suffix = 2
while True: while True:
candidate = f"{base_name} ({suffix})" candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate): if not _name_exists(candidate, parent_location_id, customer_id):
_reserve_name(candidate) _reserve_name(candidate, parent_location_id, customer_id)
return candidate return candidate
suffix += 1 suffix += 1
@ -492,7 +508,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
raise HTTPException(status_code=500, detail="Failed to create location") raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0]) return Location(**result[0])
resolved_root_name = _resolve_unique_name(root.name) resolved_root_name = _resolve_unique_name(root.name, root.parent_location_id, root.customer_id)
root_location = insert_location_record( root_location = insert_location_record(
name=resolved_root_name, name=resolved_root_name,
location_type=root.location_type, location_type=root.location_type,
@ -522,7 +538,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
room_ids: List[int] = [] room_ids: List[int] = []
for floor in data.floors: for floor in data.floors:
resolved_floor_name = _resolve_unique_name(floor.name) resolved_floor_name = _resolve_unique_name(floor.name, root_location.id, root.customer_id)
floor_location = insert_location_record( floor_location = insert_location_record(
name=resolved_floor_name, name=resolved_floor_name,
location_type=floor.location_type, location_type=floor.location_type,
@ -548,7 +564,7 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
)) ))
for room in floor.rooms: for room in floor.rooms:
resolved_room_name = _resolve_unique_name(room.name) resolved_room_name = _resolve_unique_name(room.name, floor_location.id, root.customer_id)
room_location = insert_location_record( room_location = insert_location_record(
name=resolved_room_name, name=resolved_room_name,
location_type=room.location_type, location_type=room.location_type,
@ -594,6 +610,317 @@ async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
# 3. GET /api/v1/locations/{id} - Get single location with all relationships # 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================ # ============================================================================
_OUTLET_LOCATION_TYPES = ('bygning', 'etage', 'rum', 'customer_site')
def _cross_field_port_insert_sql() -> str:
"""Generate physical labels in a stable order, e.g. 1A, 1B, 2A, 2B."""
return '''
INSERT INTO locations_cross_field_ports (cross_field_id, port_number, port_order)
SELECT %s,
CASE WHEN %s = 'paired'
THEN (%s + ((port_no + 1) / 2) - 1)::TEXT || CASE WHEN port_no %% 2 = 1 THEN 'A' ELSE 'B' END
ELSE (%s + port_no - 1)::TEXT
END,
port_no
FROM generate_series(%s, %s) AS port_no
'''
def _validate_cross_field_layout(port_count: int, label_format: str) -> None:
if label_format == 'paired' and port_count % 2:
raise HTTPException(status_code=400, detail='Parrede A/B-porte kræver et lige antal porte')
@router.get('/locations/cross-fields', response_model=List[CrossField])
async def list_cross_fields(location_id: Optional[int] = Query(None, ge=1)):
where = 'WHERE cf.deleted_at IS NULL AND cf.is_active = TRUE'
params: tuple = ()
if location_id:
where += ' AND cf.location_id = %s'
params = (location_id,)
fields = execute_query(f'''SELECT cf.* FROM locations_cross_fields cf {where} ORDER BY cf.display_order, cf.id''', params) or []
for field in fields:
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or []
return [CrossField(**field) for field in fields]
@router.get('/locations/cross-field-ports')
async def list_cross_field_ports():
return execute_query('''
SELECT p.id, p.port_number, cf.name AS cross_field_name,
l.name AS location_name, l.id AS location_id
FROM locations_cross_field_ports p
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
JOIN locations_locations l ON l.id = cf.location_id
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
WHERE p.is_active = TRUE AND cf.is_active = TRUE AND cf.deleted_at IS NULL AND o.id IS NULL
ORDER BY l.name, cf.name, p.port_order
''') or []
@router.post('/locations/cross-fields', response_model=CrossField, status_code=201)
async def create_cross_field(data: CrossFieldCreate):
location = execute_query('''SELECT id, location_type, has_cross_field FROM locations_locations WHERE id = %s AND deleted_at IS NULL''', (data.location_id,)) or []
if not location:
raise HTTPException(status_code=404, detail='Lokationen blev ikke fundet')
if location[0]['location_type'] != 'rum' or not location[0].get('has_cross_field'):
raise HTTPException(status_code=400, detail='Krydsfelt kan kun oprettes på et rum, der er markeret med krydsfelt')
_validate_cross_field_layout(data.port_count, data.port_label_format)
try:
created = execute_query('''INSERT INTO locations_cross_fields (location_id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes)
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, (SELECT COALESCE(MAX(display_order), 0) + 1 FROM locations_cross_fields WHERE location_id = %s)), %s)
RETURNING *''', (data.location_id, data.name.strip(), data.port_count, data.port_label_format, data.start_port_number, data.panel_row_size, data.display_order, data.location_id, data.notes)) or []
if not created:
raise HTTPException(status_code=500, detail='Krydsfelt kunne ikke oprettes')
field = created[0]
execute_query(_cross_field_port_insert_sql(), (field['id'], data.port_label_format, data.start_port_number, data.start_port_number, 1, data.port_count))
except HTTPException:
raise
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (field['id'],)) or []
return CrossField(**field)
@router.patch('/locations/cross-fields/{cross_field_id}', response_model=CrossField)
async def update_cross_field(cross_field_id: int, data: CrossFieldUpdate):
field_rows = execute_query('''SELECT * FROM locations_cross_fields WHERE id = %s AND deleted_at IS NULL''', (cross_field_id,)) or []
if not field_rows:
raise HTTPException(status_code=404, detail='Krydsfeltet blev ikke fundet')
field = field_rows[0]
changes = data.model_dump(exclude_unset=True)
requested_ports = changes.pop('port_count', None)
if requested_ports is not None and requested_ports < field['port_count']:
raise HTTPException(status_code=400, detail='Antal porte kan kun øges for et eksisterende krydsfelt')
if changes:
assignments = ', '.join(f'{column} = %s' for column in changes)
try:
updated = execute_query(f'''UPDATE locations_cross_fields SET {assignments}, updated_at = NOW() WHERE id = %s RETURNING *''', tuple(changes.values()) + (cross_field_id,)) or []
field = updated[0]
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Et krydsfelt med dette navn findes allerede i rummet') from exc
raise
if requested_ports and requested_ports > field['port_count']:
start_number = field.get('start_port_number', 1)
execute_query(_cross_field_port_insert_sql(), (cross_field_id, field.get('port_label_format', 'numeric'), start_number, start_number, field['port_count'] + 1, requested_ports))
field = (execute_query('''UPDATE locations_cross_fields SET port_count = %s, updated_at = NOW() WHERE id = %s RETURNING *''', (requested_ports, cross_field_id)) or [])[0]
field['ports'] = execute_query('''SELECT id, port_number, port_order, is_active FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''', (cross_field_id,)) or []
return CrossField(**field)
@router.patch('/locations/cross-fields/{cross_field_id}/port-labels')
async def update_cross_field_port_labels(cross_field_id: int, data: CrossFieldPortLabelsUpdate):
"""Rename physical port labels without changing the linked outlet/port identity."""
existing = execute_query(
'''SELECT id FROM locations_cross_field_ports WHERE cross_field_id = %s ORDER BY port_order''',
(cross_field_id,),
) or []
if not existing:
raise HTTPException(status_code=404, detail='Krydsfeltet eller dets porte blev ikke fundet')
submitted = {item.id: item.port_number.strip() for item in data.ports}
existing_ids = {row['id'] for row in existing}
if set(submitted) != existing_ids:
raise HTTPException(status_code=400, detail='Alle porte skal have en mærkning')
if any(not label for label in submitted.values()):
raise HTTPException(status_code=400, detail='Portmærkning må ikke være tom')
labels_lower = [label.casefold() for label in submitted.values()]
if len(labels_lower) != len(set(labels_lower)):
raise HTTPException(status_code=400, detail='Hver portmærkning skal være unik i panelet')
# Use temporary labels first, so labels can safely be swapped (e.g. 1A ↔ 1B).
placeholders = ', '.join(['%s'] * len(existing_ids))
execute_query(
f'''UPDATE locations_cross_field_ports SET port_number = '__tmp__' || id::TEXT
WHERE cross_field_id = %s AND id IN ({placeholders})''',
(cross_field_id, *existing_ids),
fetch=False,
)
values_sql = ', '.join(['(%s::INTEGER, %s::VARCHAR)'] * len(submitted))
params = []
for port_id, label in submitted.items():
params.extend((port_id, label))
updated = execute_query(
f'''UPDATE locations_cross_field_ports AS p
SET port_number = incoming.port_number
FROM (VALUES {values_sql}) AS incoming(id, port_number)
WHERE p.id = incoming.id AND p.cross_field_id = %s
RETURNING p.id, p.port_number, p.port_order''',
tuple(params) + (cross_field_id,),
) or []
return {'updated': len(updated), 'ports': updated}
def _outlet_location(location_id: int) -> dict:
rows = execute_query(
"SELECT id, name, location_type FROM locations_locations WHERE id = %s AND deleted_at IS NULL",
(location_id,),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
location = dict(rows[0])
if location.get('location_type') not in _OUTLET_LOCATION_TYPES:
raise HTTPException(status_code=400, detail="Vægstik kan kun oprettes på kundesite, bygning, etage eller rum")
return location
_OUTLET_SELECT = """
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name,
outlet_customer.name AS outlet_customer_name,
COALESCE(path.hierarchy_path, l.name) AS hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN customers outlet_customer ON outlet_customer.id = o.customer_id
LEFT JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE id = l.id
UNION ALL
SELECT parent.id, parent.name, parent.parent_location_id,
parent.name || ' > ' || ancestors.hierarchy_path
FROM locations_locations parent
JOIN ancestors ON ancestors.parent_location_id = parent.id
)
SELECT hierarchy_path FROM ancestors WHERE parent_location_id IS NULL LIMIT 1
) path ON TRUE
"""
@router.get('/locations/outlets', response_model=List[WallOutlet])
async def list_wall_outlets(
q: Optional[str] = Query(None), location_id: Optional[int] = Query(None, ge=1),
status: Optional[str] = Query(None), include_inactive: bool = Query(False),
):
where = ['o.deleted_at IS NULL']
params: List[Any] = []
if not include_inactive:
where.append('o.is_active = TRUE')
if location_id:
where.append('o.location_id = %s')
params.append(location_id)
if status:
where.append('o.status = %s')
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f'%{q.strip()}%'] * 7)
rows = execute_query(_OUTLET_SELECT + ' WHERE ' + ' AND '.join(where) + ' ORDER BY hierarchy_path, o.outlet_number', tuple(params)) or []
return [WallOutlet(**row) for row in rows]
def _replace_switch_port_if_confirmed(
*, switch_hardware_id: Optional[int], switch_name: Optional[str], switch_port: Optional[str],
exclude_outlet_id: Optional[int], confirmed: bool,
) -> None:
"""Guard the one-to-one physical switch-port assignment."""
if not switch_port or not (switch_hardware_id or switch_name):
return
where = ['deleted_at IS NULL', 'is_active = TRUE', 'switch_port = %s']
params: List[Any] = [switch_port]
if switch_hardware_id:
where.append('switch_hardware_id = %s')
params.append(switch_hardware_id)
else:
where.append('LOWER(COALESCE(switch_name, \'\')) = LOWER(%s)')
params.append(switch_name)
if exclude_outlet_id:
where.append('id <> %s')
params.append(exclude_outlet_id)
conflicts = execute_query(
f'''SELECT id, outlet_number FROM locations_wall_outlets WHERE {' AND '.join(where)}''',
tuple(params),
) or []
if not conflicts:
return
if not confirmed:
raise HTTPException(
status_code=409,
detail=f"Switch-porten bruges allerede af vægstik {conflicts[0]['outlet_number']}. Bekræft overskrivning for at flytte forbindelsen.",
)
conflict_ids = tuple(row['id'] for row in conflicts)
placeholders = ', '.join(['%s'] * len(conflict_ids))
execute_query(
f'''UPDATE locations_wall_outlets
SET switch_hardware_id = NULL, switch_name = NULL, switch_port = NULL, updated_at = NOW()
WHERE id IN ({placeholders})''',
conflict_ids,
fetch=False,
)
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id)
_replace_switch_port_if_confirmed(
switch_hardware_id=data.switch_hardware_id,
switch_name=data.switch_name,
switch_port=data.switch_port,
exclude_outlet_id=None,
confirmed=data.replace_existing_switch_port,
)
try:
rows = execute_query(
"""INSERT INTO locations_wall_outlets
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active),
) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
outlet_id = rows[0]['id']
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.patch('/locations/outlets/{outlet_id}', response_model=WallOutlet)
async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
changes = data.model_dump(exclude_unset=True)
replace_existing_switch_port = changes.pop('replace_existing_switch_port', False)
if not changes:
raise HTTPException(status_code=400, detail='Ingen ændringer sendt')
if 'outlet_number' in changes:
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
current = execute_query(
'''SELECT switch_hardware_id, switch_name, switch_port
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
(outlet_id,),
) or []
if not current:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
_replace_switch_port_if_confirmed(
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_name=changes.get('switch_name', current[0].get('switch_name')),
switch_port=changes.get('switch_port', current[0].get('switch_port')),
exclude_outlet_id=outlet_id,
confirmed=replace_existing_switch_port,
)
fields = ', '.join(f'{field} = %s' for field in changes)
try:
rows = execute_query(f'UPDATE locations_wall_outlets SET {fields} WHERE id = %s AND deleted_at IS NULL RETURNING id', tuple(changes.values()) + (outlet_id,)) or []
except Exception as exc:
if 'unique' in str(exc).lower():
raise HTTPException(status_code=400, detail='Stiknummer findes allerede på denne lokation') from exc
raise
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
result = execute_query(_OUTLET_SELECT + ' WHERE o.id = %s', (outlet_id,)) or []
return WallOutlet(**result[0])
@router.delete('/locations/outlets/{outlet_id}')
async def delete_wall_outlet(outlet_id: int):
rows = execute_query('UPDATE locations_wall_outlets SET deleted_at = NOW(), is_active = FALSE WHERE id = %s AND deleted_at IS NULL RETURNING id', (outlet_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
return {'status': 'deleted', 'id': outlet_id}
@router.get("/locations/{id}", response_model=LocationDetail) @router.get("/locations/{id}", response_model=LocationDetail)
async def get_location(id: int): async def get_location(id: int):
""" """
@ -645,6 +972,12 @@ async def get_location(id: int):
capacity_result = execute_query(capacity_query, (id,)) capacity_result = execute_query(capacity_query, (id,))
capacity = [dict(row) for row in capacity_result] if capacity_result else [] capacity = [dict(row) for row in capacity_result] if capacity_result else []
outlet_result = execute_query(
_OUTLET_SELECT + " WHERE o.location_id = %s AND o.deleted_at IS NULL ORDER BY o.outlet_number",
(id,),
)
wall_outlets = [dict(row) for row in (outlet_result or [])]
# Build hierarchy breadcrumb (ancestors from root to parent) # Build hierarchy breadcrumb (ancestors from root to parent)
hierarchy_query = """ hierarchy_query = """
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
@ -693,7 +1026,8 @@ async def get_location(id: int):
contacts=contacts, contacts=contacts,
hours=hours, hours=hours,
services=services, services=services,
capacity=capacity capacity=capacity,
wall_outlets=wall_outlets,
) )
logger.info(f"📍 Location retrieved: {location.name} (ID: {id})") logger.info(f"📍 Location retrieved: {location.name} (ID: {id})")
@ -740,15 +1074,26 @@ async def update_location(id: int, data: LocationUpdate):
old_location = Location(**existing[0]) old_location = Location(**existing[0])
# Check for duplicate name if name is being updated # Check the resulting name/customer/parent scope, including when only
if data.name is not None and data.name != old_location.name: # customer or parent is changed.
dup_query = "SELECT id FROM locations_locations WHERE name = %s AND id != %s AND deleted_at IS NULL" if data.name is not None or data.parent_location_id is not None or data.customer_id is not None:
dup_check = execute_query(dup_query, (data.name, id)) candidate_name = data.name if data.name is not None else old_location.name
candidate_parent_id = data.parent_location_id if data.parent_location_id is not None else old_location.parent_location_id
candidate_customer_id = data.customer_id if data.customer_id is not None else old_location.customer_id
dup_query = """
SELECT id FROM locations_locations
WHERE lower(name) = lower(%s)
AND parent_location_id IS NOT DISTINCT FROM %s
AND customer_id IS NOT DISTINCT FROM %s
AND id != %s
AND deleted_at IS NULL
"""
dup_check = execute_query(dup_query, (candidate_name, candidate_parent_id, candidate_customer_id, id))
if dup_check: if dup_check:
logger.warning(f"⚠️ Duplicate location name: {data.name}") logger.warning(f"⚠️ Duplicate location name in scope: {candidate_name}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location with name '{data.name}' already exists" detail=f"Location with name '{candidate_name}' already exists under the same customer/location"
) )
# Build UPDATE query with only provided fields # Build UPDATE query with only provided fields
@ -770,7 +1115,8 @@ async def update_location(id: int, data: LocationUpdate):
'phone': 'phone', 'phone': 'phone',
'email': 'email', 'email': 'email',
'notes': 'notes', 'notes': 'notes',
'is_active': 'is_active' 'is_active': 'is_active',
'has_cross_field': 'has_cross_field'
} }
update_data = {} update_data = {}
@ -792,6 +1138,30 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400, status_code=400,
detail="parent_location_id does not exist" detail="parent_location_id does not exist"
) )
descendant_check = execute_query(
"""
WITH RECURSIVE descendants AS (
SELECT id
FROM locations_locations
WHERE parent_location_id = %s AND deleted_at IS NULL
UNION ALL
SELECT l.id
FROM locations_locations l
JOIN descendants d ON l.parent_location_id = d.id
WHERE l.deleted_at IS NULL
)
SELECT id FROM descendants WHERE id = %s LIMIT 1
""",
(id, value),
)
if descendant_check:
logger.warning("⚠️ parent_location_id cannot reference a descendant")
raise HTTPException(
status_code=400,
detail="parent_location_id cannot reference a descendant"
)
if key == 'customer_id': if key == 'customer_id':
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL" customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
customer = execute_query(customer_query, (value,)) customer = execute_query(customer_query, (value,))
@ -809,6 +1179,10 @@ async def update_location(id: int, data: LocationUpdate):
status_code=400, status_code=400,
detail=f"location_type must be one of: {', '.join(allowed_types)}" detail=f"location_type must be one of: {', '.join(allowed_types)}"
) )
if key == 'has_cross_field' and value:
resulting_type = data.location_type or old_location.location_type
if resulting_type != 'rum':
raise HTTPException(status_code=400, detail="Kun rum kan markeres som indeholdende et krydsfelt")
update_parts.append(f"{db_column} = %s") update_parts.append(f"{db_column} = %s")
params.append(value) params.append(value)
update_data[key] = value update_data[key] = value

View File

@ -21,6 +21,7 @@ from fastapi import APIRouter, Query, HTTPException, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from jinja2 import Environment, FileSystemLoader, TemplateNotFound from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from pathlib import Path as PathlibPath from pathlib import Path as PathlibPath
import json
import logging import logging
from typing import Optional from typing import Optional
from app.core.database import execute_query, execute_update from app.core.database import execute_query, execute_update
@ -57,6 +58,100 @@ LOCATION_TYPES = [
{"value": "vehicle", "label": "Køretøj"}, {"value": "vehicle", "label": "Køretøj"},
] ]
LOCATION_TYPE_LABELS = {
"kompleks": "Kompleks",
"bygning": "Bygning",
"etage": "Etage",
"customer_site": "Kundesite",
"rum": "Rum",
"kantine": "Kantine",
"moedelokale": "Mødelokale",
"vehicle": "Køretøj",
}
def get_location_type_label(location_type: Optional[str]) -> str:
return LOCATION_TYPE_LABELS.get(location_type or "", location_type or "Ukendt")
def get_parent_location_choices(exclude_id: Optional[int] = None) -> list[dict]:
exclude_ids = []
if exclude_id is not None:
exclude_tree = execute_query(
"""
WITH RECURSIVE descendants AS (
SELECT id
FROM locations_locations
WHERE id = %s
UNION ALL
SELECT l.id
FROM locations_locations l
JOIN descendants d ON l.parent_location_id = d.id
WHERE l.deleted_at IS NULL
)
SELECT id FROM descendants
""",
(exclude_id,),
)
exclude_ids = [row["id"] for row in (exclude_tree or []) if row.get("id") is not None]
parent_locations = execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT
id,
name,
location_type,
parent_location_id,
customer_id,
is_active,
name::text AS hierarchy_path,
0 AS depth
FROM locations_locations
WHERE deleted_at IS NULL AND parent_location_id IS NULL
UNION ALL
SELECT
l.id,
l.name,
l.location_type,
l.parent_location_id,
l.customer_id,
l.is_active,
(lt.hierarchy_path || ' > ' || l.name)::text AS hierarchy_path,
lt.depth + 1 AS depth
FROM locations_locations l
JOIN location_tree lt ON l.parent_location_id = lt.id
WHERE l.deleted_at IS NULL
)
SELECT
id,
name,
location_type,
parent_location_id,
customer_id,
is_active,
hierarchy_path,
depth
FROM location_tree
WHERE is_active = true
ORDER BY hierarchy_path
LIMIT 2000
"""
)
choices = []
for row in parent_locations or []:
if row.get("id") in exclude_ids:
continue
row["type_label"] = get_location_type_label(row.get("location_type"))
row["display_name"] = f"{row.get('hierarchy_path')} ({row['type_label']})"
choices.append(row)
return choices
def render_template(template_name: str, **context) -> str: def render_template(template_name: str, **context) -> str:
""" """
@ -167,7 +262,7 @@ def list_locations_view(
""" """
query_params.extend([limit, skip]) query_params.extend([limit, skip])
locations = execute_query(query, tuple(query_params)) locations = execute_query(query, tuple(query_params)) or []
def build_tree(items: list) -> list: def build_tree(items: list) -> list:
nodes = {} nodes = {}
@ -247,7 +342,10 @@ def list_locations_view(
# ============================================================================ # ============================================================================
@router.get("/app/locations/create", response_class=HTMLResponse) @router.get("/app/locations/create", response_class=HTMLResponse)
def create_location_view(): def create_location_view(
parent_location_id: Optional[int] = Query(None, gt=0),
customer_id: Optional[int] = Query(None, gt=0),
):
""" """
Render the location creation form. Render the location creation form.
@ -268,14 +366,11 @@ def create_location_view():
try: try:
logger.info("🆕 Rendering create location form") logger.info("🆕 Rendering create location form")
# Query parent locations parent_locations = get_parent_location_choices() or []
parent_locations = execute_query(""" selected_parent = next((row for row in parent_locations if row.get("id") == parent_location_id), None)
SELECT id, name, location_type
FROM locations_locations if selected_parent and customer_id is None and selected_parent.get("customer_id") is not None:
WHERE deleted_at IS NULL AND is_active = true customer_id = selected_parent.get("customer_id")
ORDER BY name
LIMIT 1000
""")
# Query customers # Query customers
customers = execute_query(""" customers = execute_query("""
@ -295,7 +390,10 @@ def create_location_view():
cancel_url="/app/locations", cancel_url="/app/locations",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, parent_locations=parent_locations,
customers=customers, customers=customers or [],
selected_parent_id=parent_location_id,
selected_customer_id=customer_id,
selected_parent=selected_parent,
location=None, # No location data for create form location=None, # No location data for create form
) )
@ -321,13 +419,7 @@ def location_wizard_view():
try: try:
logger.info("🧭 Rendering location wizard") logger.info("🧭 Rendering location wizard")
parent_locations = execute_query(""" parent_locations = get_parent_location_choices()
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
customers = execute_query(""" customers = execute_query("""
SELECT id, name, email, phone SELECT id, name, email, phone
@ -356,7 +448,47 @@ def location_wizard_view():
# ============================================================================ # ============================================================================
# 3. GET /app/locations/{id} - Detail view (HTML) # 3. GET /app/locations/outlets - Wall outlet overview
# ============================================================================
@router.get("/app/locations/outlets", response_class=HTMLResponse)
def wall_outlets_view(q: Optional[str] = Query(None), status: Optional[str] = Query(None)):
try:
where = ["o.deleted_at IS NULL", "o.is_active = TRUE"]
params = []
if status:
where.append("o.status = %s")
params.append(status)
if q:
where.append("(o.outlet_number ILIKE %s OR o.category ILIKE %s OR o.patch_panel ILIKE %s OR o.patch_port ILIKE %s OR o.switch_name ILIKE %s OR o.switch_port ILIKE %s OR l.name ILIKE %s)")
params.extend([f"%{q.strip()}%"] * 7)
outlets = execute_query(f"""
WITH RECURSIVE tree AS (
SELECT id, name, parent_location_id, name::text AS hierarchy_path
FROM locations_locations WHERE parent_location_id IS NULL AND deleted_at IS NULL
UNION ALL
SELECT l.id, l.name, l.parent_location_id, tree.hierarchy_path || ' > ' || l.name
FROM locations_locations l JOIN tree ON l.parent_location_id = tree.id
WHERE l.deleted_at IS NULL
)
SELECT o.*, l.name AS location_name, l.location_type, c.name AS customer_name, tree.hierarchy_path
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
LEFT JOIN customers c ON c.id = l.customer_id
LEFT JOIN tree ON tree.id = l.id
WHERE {' AND '.join(where)}
ORDER BY tree.hierarchy_path, o.outlet_number
""", tuple(params)) or []
return HTMLResponse(render_template(
"modules/locations/templates/outlets.html", outlets=outlets, query=q or '', selected_status=status or ''
))
except Exception as exc:
logger.error("Error rendering wall outlets overview: %s", exc)
raise HTTPException(status_code=500, detail="Kunne ikke vise vægstik")
# ============================================================================
# 4. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================ # ============================================================================
@router.get("/app/locations/{id}", response_class=HTMLResponse) @router.get("/app/locations/{id}", response_class=HTMLResponse)
@ -465,14 +597,96 @@ def detail_location_view(id: int = Path(..., gt=0)):
hardware = execute_query( hardware = execute_query(
""" """
SELECT id, asset_type, brand, model, serial_number, status SELECT id, asset_type, brand, model, serial_number, status, hardware_specs, location_display_order
FROM hardware_assets FROM hardware_assets
WHERE current_location_id = %s AND deleted_at IS NULL WHERE current_location_id = %s AND deleted_at IS NULL
ORDER BY brand ASC, model ASC, serial_number ASC ORDER BY location_display_order NULLS LAST, brand ASC, model ASC, serial_number ASC
""", """,
(id,) (id,)
) )
wall_outlets = execute_query(
"""
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active
FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL
ORDER BY outlet_number
""",
(id,),
)
# Render the same physical port map directly under each switch on the location page.
hardware_link_map = {}
hardware_ids = [hw['id'] for hw in (hardware or [])]
if hardware_ids:
hardware_link_rows = execute_query(
"""SELECT l.source_hardware_id, l.source_port, l.target_hardware_id, l.target_port,
target.brand AS target_brand, target.model AS target_model, target.serial_number AS target_serial,
source.brand AS source_brand, source.model AS source_model, source.serial_number AS source_serial
FROM hardware_network_links l
JOIN hardware_assets target ON target.id = l.target_hardware_id
JOIN hardware_assets source ON source.id = l.source_hardware_id
WHERE (l.source_hardware_id = ANY(%s) OR l.target_hardware_id = ANY(%s)) AND l.deleted_at IS NULL
ORDER BY l.id""",
(hardware_ids, hardware_ids),
) or []
for row in hardware_link_rows:
if row.get('source_port'):
hardware_link_map[(row['source_hardware_id'], str(row['source_port']))] = row
if row.get('target_port'):
reverse_row = dict(row)
reverse_row.update({
'target_hardware_id': row.get('source_hardware_id'),
'target_brand': row.get('source_brand'),
'target_model': row.get('source_model'),
'target_serial': row.get('source_serial'),
'target_port': row.get('source_port'),
})
hardware_link_map[(row['target_hardware_id'], str(row['target_port']))] = reverse_row
for hw in hardware or []:
hw['switch_ports'] = []
if str(hw.get('asset_type') or '').lower() != 'netværk':
continue
specs = hw.get('hardware_specs') or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
port_count = int((specs or {}).get('port_count') or 0)
linked = {
str(outlet.get('switch_port')): outlet
for outlet in (wall_outlets or [])
if outlet.get('switch_hardware_id') == hw.get('id') and outlet.get('switch_port')
}
hw['switch_ports'] = [
{
'port_number': str(port),
'outlet': linked.get(str(port)),
'hardware_link': hardware_link_map.get((hw['id'], str(port))),
}
for port in range(1, port_count + 1)
]
cross_fields = execute_query(
"""SELECT id, name, port_count, port_label_format, start_port_number, panel_row_size, display_order, notes, is_active
FROM locations_cross_fields
WHERE location_id = %s AND deleted_at IS NULL AND is_active = TRUE
ORDER BY display_order, id""",
(id,),
)
for cross_field in cross_fields or []:
cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.port_order, p.is_active,
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status,
l.name AS outlet_location_name
FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
LEFT JOIN locations_locations l ON l.id = o.location_id
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
(cross_field["id"],),
) or []
audit_log = execute_query( audit_log = execute_query(
""" """
SELECT id, location_id, event_type, user_id, changes, created_at SELECT id, location_id, event_type, user_id, changes, created_at
@ -490,6 +704,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["services"] = services or [] location["services"] = services or []
location["capacity"] = capacity or [] location["capacity"] = capacity or []
location["hardware"] = hardware or [] location["hardware"] = hardware or []
location["wall_outlets"] = wall_outlets or []
location["cross_fields"] = cross_fields or []
location["audit_log"] = audit_log or [] location["audit_log"] = audit_log or []
# Query customers # Query customers
@ -505,6 +721,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts") # contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours") # hours = call_api("GET", f"/api/v1/locations/{id}/hours")
customers = customers or []
# Render template with context # Render template with context
html = render_template( html = render_template(
"modules/locations/templates/detail.html", "modules/locations/templates/detail.html",
@ -555,14 +773,11 @@ def edit_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result location = location[0] # Get first result
# Query parent locations (exclude self) parent_locations = get_parent_location_choices(exclude_id=id) or []
parent_locations = execute_query(""" selected_parent = next(
SELECT id, name, location_type (row for row in parent_locations if row.get("id") == location.get("parent_location_id")),
FROM locations_locations None,
WHERE is_active = true AND id != %s )
ORDER BY name
LIMIT 1000
""", (id,))
# Query customers # Query customers
customers = execute_query(""" customers = execute_query("""
@ -584,7 +799,8 @@ def edit_location_view(id: int = Path(..., gt=0)):
cancel_url=f"/app/locations/{id}", cancel_url=f"/app/locations/{id}",
location_types=LOCATION_TYPES, location_types=LOCATION_TYPES,
parent_locations=parent_locations, parent_locations=parent_locations,
customers=customers, customers=customers or [],
selected_parent=selected_parent,
http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field http_method="PATCH", # Pass actual HTTP method for form to use via JavaScript/hidden field
) )
@ -625,6 +841,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
latitude = %s, latitude = %s,
longitude = %s, longitude = %s,
notes = %s, notes = %s,
has_cross_field = %s,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", ( """, (
@ -642,6 +859,7 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
float(form.get("latitude")) if form.get("latitude") else None, float(form.get("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None, float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"), form.get("notes"),
form.get("has_cross_field") == "on" and form.get("location_type") == "rum",
id id
)) ))

View File

@ -17,7 +17,7 @@ from decimal import Decimal
class LocationBase(BaseModel): class LocationBase(BaseModel):
"""Shared fields for location models""" """Shared fields for location models"""
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)") name: str = Field(..., min_length=1, max_length=255, description="Location name (unique within its customer and hierarchy)")
location_type: str = Field( location_type: str = Field(
..., ...,
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
@ -40,6 +40,7 @@ class LocationBase(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = Field(True, description="Whether location is active") is_active: bool = Field(True, description="Whether location is active")
has_cross_field: bool = Field(False, description="Whether this room contains a network cross-connect field")
@field_validator('location_type') @field_validator('location_type')
@classmethod @classmethod
@ -75,6 +76,7 @@ class LocationUpdate(BaseModel):
email: Optional[str] = None email: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
has_cross_field: Optional[bool] = None
@field_validator('location_type') @field_validator('location_type')
@classmethod @classmethod
@ -101,6 +103,126 @@ class Location(LocationBase):
from_attributes = True from_attributes = True
# ============================================================================
# NETWORK WALL OUTLET MODELS
# ============================================================================
OUTLET_STATUSES = {'available', 'active', 'reserved', 'faulty', 'unknown'}
class WallOutletBase(BaseModel):
location_id: int = Field(..., ge=1)
outlet_number: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, ge=1)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: str = Field('unknown')
notes: Optional[str] = None
is_active: bool = True
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutletCreate(WallOutletBase):
replace_existing_switch_port: bool = False
class WallOutletUpdate(BaseModel):
outlet_number: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, ge=1)
category: Optional[str] = Field(None, max_length=50)
patch_panel: Optional[str] = Field(None, max_length=255)
patch_port: Optional[str] = Field(None, max_length=100)
cross_field_port_id: Optional[int] = Field(None, ge=1)
switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100)
status: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
replace_existing_switch_port: bool = False
@field_validator('status')
@classmethod
def validate_outlet_status(cls, value):
if value is not None and value not in OUTLET_STATUSES:
raise ValueError(f'status must be one of {sorted(OUTLET_STATUSES)}')
return value
class WallOutlet(WallOutletBase):
id: int
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
location_name: Optional[str] = None
location_type: Optional[str] = None
customer_name: Optional[str] = None
outlet_customer_name: Optional[str] = None
hierarchy_path: Optional[str] = None
class CrossFieldCreate(BaseModel):
location_id: int = Field(..., ge=1)
name: str = Field(..., min_length=1, max_length=100)
port_count: int = Field(..., ge=1, le=999)
port_label_format: str = Field(default='numeric', pattern='^(numeric|paired)$')
start_port_number: int = Field(default=1, ge=1, le=9999)
panel_row_size: int = Field(default=24, ge=1, le=48)
display_order: Optional[int] = Field(default=None, ge=1, le=9999)
notes: Optional[str] = None
class CrossFieldUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
port_count: Optional[int] = Field(None, ge=1, le=999)
start_port_number: Optional[int] = Field(None, ge=1, le=9999)
panel_row_size: Optional[int] = Field(None, ge=1, le=48)
display_order: Optional[int] = Field(None, ge=1, le=9999)
notes: Optional[str] = None
class CrossFieldPort(BaseModel):
id: int
port_number: str
port_order: int
is_active: bool
class CrossFieldPortLabelUpdate(BaseModel):
id: int = Field(..., ge=1)
port_number: str = Field(..., min_length=1, max_length=20)
class CrossFieldPortLabelsUpdate(BaseModel):
ports: List[CrossFieldPortLabelUpdate] = Field(..., min_length=1, max_length=999)
class CrossField(BaseModel):
id: int
location_id: int
name: str
port_count: int
port_label_format: str = 'numeric'
start_port_number: int = 1
display_order: int = 1
panel_row_size: int = 24
notes: Optional[str] = None
is_active: bool
created_at: datetime
ports: List[CrossFieldPort] = []
# ============================================================================ # ============================================================================
# 2. CONTACT MODELS # 2. CONTACT MODELS
# ============================================================================ # ============================================================================
@ -360,6 +482,7 @@ class LocationDetail(Location):
hours: List[OperatingHours] = Field(default_factory=list) hours: List[OperatingHours] = Field(default_factory=list)
services: List[Service] = Field(default_factory=list) services: List[Service] = Field(default_factory=list)
capacity: List[Capacity] = Field(default_factory=list) capacity: List[Capacity] = Field(default_factory=list)
wall_outlets: List[WallOutlet] = Field(default_factory=list)
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):

View File

@ -2,6 +2,40 @@
{% block title %}Opret lokation - BMC Hub{% endblock %} {% block title %}Opret lokation - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.relation-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 1rem;
}
.relation-summary {
border: 1px dashed rgba(15, 76, 117, 0.22);
border-radius: 0.85rem;
background: rgba(15, 76, 117, 0.05);
padding: 0.85rem 1rem;
}
.relation-summary.empty {
background: #f8fafc;
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}
.relation-path {
font-weight: 600;
color: #1f3b53;
}
.relation-meta {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@ -57,19 +91,52 @@
</select> </select>
</div> </div>
<div class="relation-card mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
<div class="text-muted small">Vælg hurtigt, hvor lokationen skal ligge, og søg i hele træet.</div>
</div>
{% if selected_parent %}
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye me-2"></i>Åbn valgt parent
</a>
{% endif %}
</div>
<div class="mb-3">
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, bygningsdel eller sti...">
</div>
<div class="mb-3"> <div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id"> <select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option> <option value="">Ingen (øverste niveau)</option>
{% if parent_locations %} {% if parent_locations %}
{% for parent in parent_locations %} {% for parent in parent_locations %}
<option value="{{ parent.id }}"> <option
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %} value="{{ parent.id }}"
data-path="{{ parent.hierarchy_path }}"
data-type="{{ parent.type_label }}"
data-customer-id="{{ parent.customer_id | default('') }}"
{% if selected_parent_id == parent.id %}selected{% endif %}>
{{ parent.display_name }}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div> <div class="form-text">Bruges til hierarki, fx Kompleks → Bygning → Etage → Rum.</div>
</div>
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
{% if selected_parent %}
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
<div class="relation-meta">{{ selected_parent.type_label }}</div>
{% else %}
<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>
{% endif %}
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -78,11 +145,11 @@
<option value="">Ingen</option> <option value="">Ingen</option>
{% if customers %} {% if customers %}
{% for customer in customers %} {% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option> <option value="{{ customer.id }}" {% if selected_customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
<div class="form-text">Valgfri kan knyttes til alle typer.</div> <div class="form-text">Hvis du vælger en parent med kunde, kan den forudfyldes automatisk.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -91,6 +158,14 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crossFieldOption" hidden>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field">
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
<div class="form-text">Krydsfeltet er udstyr i rummet, ikke en separat lokationstype.</div>
</div>
</div>
</fieldset> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -185,12 +260,79 @@ document.addEventListener('DOMContentLoaded', function() {
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const notesField = document.getElementById('notes'); const notesField = document.getElementById('notes');
const charCount = document.getElementById('charCount'); const charCount = document.getElementById('charCount');
const parentLocationSelect = document.getElementById('parentLocation');
const parentLocationSearch = document.getElementById('parentLocationSearch');
const customerSelect = document.getElementById('customerId');
const parentSummary = document.getElementById('parentSummary');
// Character counter for notes // Character counter for notes
notesField.addEventListener('input', function() { notesField.addEventListener('input', function() {
charCount.textContent = this.value.length; charCount.textContent = this.value.length;
}); });
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function updateParentSummary() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const path = selectedOption?.dataset?.path || '';
const type = selectedOption?.dataset?.type || '';
if (!selectedOption || !selectedOption.value) {
parentSummary.classList.add('empty');
parentSummary.innerHTML = '<div class="small text-muted">Lokationen oprettes i topniveau, indtil du vælger en overordnet lokation.</div>';
return;
}
parentSummary.classList.remove('empty');
parentSummary.innerHTML = `
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">${escapeHtml(path)}</div>
<div class="relation-meta">${escapeHtml(type)}</div>
`;
}
function filterParentLocations() {
const query = (parentLocationSearch.value || '').trim().toLowerCase();
Array.from(parentLocationSelect.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
option.hidden = query ? !haystack.includes(query) : false;
});
}
if (parentLocationSearch) {
parentLocationSearch.addEventListener('input', filterParentLocations);
}
if (parentLocationSelect) {
parentLocationSelect.addEventListener('change', function() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const parentCustomerId = selectedOption?.dataset?.customerId;
if ((!customerSelect.value || customerSelect.dataset.autofilled === 'true') && parentCustomerId) {
customerSelect.value = parentCustomerId;
customerSelect.dataset.autofilled = 'true';
}
updateParentSummary();
});
updateParentSummary();
}
if (customerSelect) {
customerSelect.addEventListener('change', function() {
customerSelect.dataset.autofilled = 'false';
});
}
// Form submission // Form submission
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@ -202,7 +344,10 @@ document.addEventListener('DOMContentLoaded', function() {
const data = { const data = {
name: formData.get('name'), name: formData.get('name'),
location_type: formData.get('location_type'), location_type: formData.get('location_type'),
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), address_postal_code: formData.get('address_postal_code'),
@ -239,6 +384,17 @@ document.addEventListener('DOMContentLoaded', function() {
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation'; submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
} }
}); });
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,18 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
.patch-port .patch-port-outlet { font-size:.58rem; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:0 .15rem; }
@media (max-width: 1100px) { .patch-panel-grid { grid-template-columns: repeat(12, minmax(38px, 1fr)); } }
@media (max-width: 700px) { .patch-panel-grid { grid-template-columns: repeat(6, minmax(38px, 1fr)); } }
.locations-detail-page { .locations-detail-page {
--loc-accent: var(--accent, #0f4c75); --loc-accent: var(--accent, #0f4c75);
} }
@ -247,6 +259,9 @@
<span class="case-type-chip" style="--tcolor: {{ type_color }};"> <span class="case-type-chip" style="--tcolor: {{ type_color }};">
{{ type_label }} {{ type_label }}
</span> </span>
{% if location.has_cross_field %}
<span class="case-type-chip" style="--tcolor: #6f42c1;"><i class="bi bi-diagram-3 me-1"></i>Krydsfelt</span>
{% endif %}
{% if location.is_active %} {% if location.is_active %}
<span class="case-status-chip open"> <span class="case-status-chip open">
<span class="case-status-dot"></span>Aktiv <span class="case-status-dot"></span>Aktiv
@ -328,6 +343,13 @@
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span> <span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button> </button>
</li> </li>
{% if location.location_type == 'rum' and location.has_cross_field %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="crossFieldTab" data-bs-toggle="tab" data-bs-target="#crossFieldContent" type="button" role="tab" aria-controls="crossFieldContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Krydsfelt
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false"> <button class="nav-link" id="relationsTab" data-bs-toggle="tab" data-bs-target="#relationsContent" type="button" role="tab" aria-controls="relationsContent" aria-selected="false">
<i class="bi bi-diagram-3 me-2"></i>Relationer <i class="bi bi-diagram-3 me-2"></i>Relationer
@ -715,9 +737,15 @@
</div> </div>
<div class="card border-0"> <div class="card border-0">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h5 class="card-title mb-0">Tilføj underlokation</h5> <h5 class="card-title mb-0">Tilføj underlokation</h5>
<a href="/app/locations/create?parent_location_id={{ location.id }}{% if location.customer_id %}&customer_id={{ location.customer_id }}{% endif %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-box-arrow-up-right me-1"></i>Fuld formular
</a>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-3">Den hurtige formular opretter direkte under <strong>{{ location.name }}</strong>. Brug fuld formular, hvis du også vil sætte adresse, noter eller GPS med det samme.</p>
<form action="/api/v1/locations" method="post" class="row g-2"> <form action="/api/v1/locations" method="post" class="row g-2">
<input type="hidden" name="parent_location_id" value="{{ location.id }}"> <input type="hidden" name="parent_location_id" value="{{ location.id }}">
<input type="hidden" name="redirect_to" value="/app/locations/{id}"> <input type="hidden" name="redirect_to" value="/app/locations/{id}">
@ -747,7 +775,7 @@
<option value="">Ingen</option> <option value="">Ingen</option>
{% if customers %} {% if customers %}
{% for customer in customers %} {% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option> <option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
@ -763,6 +791,32 @@
</div> </div>
</div> </div>
<div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2">
<div>
<h5 class="card-title mb-0">Vægstik</h5>
<div class="small text-muted">Netværksstik registreret direkte på denne lokation.</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/app/locations/outlets"><i class="bi bi-list-ul me-1"></i>Oversigt</a>
{% if location.location_type in ['customer_site', 'bygning', 'etage', 'rum'] %}
<button type="button" class="btn btn-primary btn-sm" id="addOutletBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj stik</button>
{% endif %}
</div>
</div>
<div class="card-body">
{% if location.location_type not in ['customer_site', 'bygning', 'etage', 'rum'] %}
<span class="text-muted">Vægstik kan oprettes på kundesites, bygninger, etager og rum.</span>
{% elif location.wall_outlets %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %}
</tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
</div>
</div>
<div class="card border-0 mt-4"> <div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5> <h5 class="card-title mb-0">Hierarki (træ)</h5>
@ -803,6 +857,23 @@
</div> </div>
</div> </div>
{% if location.location_type == 'rum' and location.has_cross_field %}
<div class="tab-pane fade" id="crossFieldContent" role="tabpanel" aria-labelledby="crossFieldTab">
<div class="card border-0">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
<div><h5 class="card-title mb-0">Krydsfelt</h5><div class="small text-muted">Patchfelter og porte i dette rum.</div></div>
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
</div>
<div class="card-body">
{% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tab 7: Hardware --> <!-- Tab 7: Hardware -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab"> <div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0"> <div class="card border-0">
@ -813,12 +884,14 @@
{% if location.hardware %} {% if location.hardware %}
<div class="list-group"> <div class="list-group">
{% for hw in location.hardware %} {% for hw in location.hardware %}
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item">
<div> <div class="d-flex justify-content-between align-items-center gap-3">
<div class="fw-600">{{ hw.brand }} {{ hw.model }}</div> <div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div> <div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div>
</div> </div>
<span class="badge bg-secondary">{{ hw.status }}</span> {% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}" title="{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Lilla porte er forbundet med hardware; grønne porte går til et vægstik.</div></details>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -976,6 +1049,50 @@
</div> </div>
</div> </div>
<!-- Wall outlet modal -->
<div class="modal fade" id="crossFieldModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><form class="modal-content" id="crossFieldForm"><div class="modal-header"><h5 class="modal-title" id="crossFieldModalTitle">Tilføj krydsfelt</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="mb-3"><label class="form-label">Navn</label><input class="form-control" id="crossFieldName" required maxlength="100" placeholder="Fx XF-1 eller Patchpanel A"></div>
<input type="hidden" id="crossFieldId">
<div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="crossFieldPortCount" min="1" max="999" required placeholder="Fx 48"></div>
<div class="mb-3"><label class="form-label">Portmærkning</label><select class="form-select" id="crossFieldPortLabelFormat"><option value="numeric">1, 2, 3 …</option><option value="paired">1A, 1B, 2A, 2B …</option></select><div class="form-text">A/B-par kræver et lige antal porte, fx 48 porte = 1A24B.</div></div>
<div class="mb-3"><label class="form-label">Startnummer</label><input class="form-control" type="number" id="crossFieldStartPortNumber" min="1" max="9999" value="1"><div class="form-text">Næste panel kan fx starte ved 25, så det bliver 25A, 25B …</div></div>
<div class="mb-3"><label class="form-label">Porte pr. række</label><input class="form-control" type="number" id="crossFieldPanelRowSize" min="1" max="48" value="24"><div class="form-text">Sæt fx 24 for samme brede panelopstilling som på billedet.</div></div>
<div class="mb-3"><label class="form-label">Visningsrækkefølge</label><input class="form-control" type="number" id="crossFieldDisplayOrder" min="1" max="9999" value="1"><div class="form-text">Laveste nummer vises øverst. Ændr fx et panel til 1 og et andet til 2.</div></div>
<div><label class="form-label">Note</label><textarea class="form-control" id="crossFieldNotes"></textarea></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Opret porte</button></div></form></div>
</div>
<div class="modal fade" id="crossFieldPortLabelsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="crossFieldPortLabelsForm">
<div class="modal-header"><h5 class="modal-title">Rediger portnumre: <span id="crossFieldPortLabelsName"></span></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><p class="small text-muted">Ændr de fysiske mærkninger frit, fx <code>1A</code>, <code>Kontor-12</code> eller <code>Rack2-07</code>. Forbindelser bevares på den samme port.</p><div id="crossFieldPortLabelsList" class="row g-2"></div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary">Gem portnumre</button></div>
</form></div>
</div>
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div>
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
</div></div>
</div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -1005,6 +1122,8 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal')); const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | tojson }};
const existingContactSearchInput = document.getElementById('existingContactSearch'); const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults'); const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId'); const existingContactIdInput = document.getElementById('existingContactId');
@ -1292,6 +1411,270 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
}); });
const crossFieldButton = document.getElementById('addCrossFieldBtn');
const crossFieldModalElement = document.getElementById('crossFieldModal');
const crossFieldModal = crossFieldModalElement ? new bootstrap.Modal(crossFieldModalElement) : null;
if (crossFieldButton) crossFieldButton.addEventListener('click', () => { document.getElementById('crossFieldId').value = ''; document.getElementById('crossFieldForm').reset(); document.getElementById('crossFieldPortLabelFormat').disabled = false; document.getElementById('crossFieldStartPortNumber').disabled = false; document.getElementById('crossFieldStartPortNumber').value = 1; document.getElementById('crossFieldPanelRowSize').value = 24; document.getElementById('crossFieldDisplayOrder').value = 1; document.getElementById('crossFieldModalTitle').textContent = 'Tilføj krydsfelt'; crossFieldModal.show(); });
document.querySelectorAll('.edit-cross-field-btn').forEach(button => button.addEventListener('click', () => { document.getElementById('crossFieldId').value = button.dataset.id; document.getElementById('crossFieldName').value = button.dataset.name; document.getElementById('crossFieldPortCount').value = button.dataset.portCount; document.getElementById('crossFieldPortLabelFormat').value = button.dataset.labelFormat || 'numeric'; document.getElementById('crossFieldStartPortNumber').value = button.dataset.startNumber || 1; document.getElementById('crossFieldPanelRowSize').value = button.dataset.rowSize || 24; document.getElementById('crossFieldDisplayOrder').value = button.dataset.displayOrder || 1; document.getElementById('crossFieldPortLabelFormat').disabled = true; document.getElementById('crossFieldStartPortNumber').disabled = true; document.getElementById('crossFieldNotes').value = button.dataset.notes; document.getElementById('crossFieldModalTitle').textContent = 'Rediger krydsfelt'; crossFieldModal.show(); }));
document.getElementById('crossFieldForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const crossFieldId = document.getElementById('crossFieldId').value;
const payload = {location_id: locationId, name: document.getElementById('crossFieldName').value, port_count: Number(document.getElementById('crossFieldPortCount').value), panel_row_size: Number(document.getElementById('crossFieldPanelRowSize').value), display_order: Number(document.getElementById('crossFieldDisplayOrder').value), notes: document.getElementById('crossFieldNotes').value || null};
if (!crossFieldId) { payload.port_label_format = document.getElementById('crossFieldPortLabelFormat').value; payload.start_port_number = Number(document.getElementById('crossFieldStartPortNumber').value); }
const response = await fetch(crossFieldId ? `/api/v1/locations/cross-fields/${crossFieldId}` : '/api/v1/locations/cross-fields', {method: crossFieldId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Krydsfeltet kunne ikke oprettes'); }
});
const crossFieldPortLabelsModalElement = document.getElementById('crossFieldPortLabelsModal');
const crossFieldPortLabelsModal = crossFieldPortLabelsModalElement ? new bootstrap.Modal(crossFieldPortLabelsModalElement) : null;
let activeCrossFieldPortLabelsId = null;
document.querySelectorAll('.edit-port-labels-btn').forEach(button => button.addEventListener('click', async () => {
try {
const response = await fetch(`/api/v1/locations/cross-fields?location_id=${locationId}`);
const fields = await response.json();
const field = Array.isArray(fields) ? fields.find(item => Number(item.id) === Number(button.dataset.id)) : null;
if (!response.ok || !field) throw new Error('Krydsfeltet kunne ikke indlæses');
activeCrossFieldPortLabelsId = field.id;
document.getElementById('crossFieldPortLabelsName').textContent = field.name;
const list = document.getElementById('crossFieldPortLabelsList');
list.innerHTML = '';
field.ports.forEach(port => {
const wrapper = document.createElement('div');
wrapper.className = 'col-md-4';
const label = document.createElement('label');
label.className = 'form-label small mb-1';
label.textContent = `Port ${port.port_number}`;
const input = document.createElement('input');
input.className = 'form-control form-control-sm cross-field-port-label-input';
input.maxLength = 20;
input.required = true;
input.value = port.port_number;
input.dataset.portId = port.id;
wrapper.append(label, input);
list.appendChild(wrapper);
});
crossFieldPortLabelsModal.show();
} catch (error) {
alert(error.message || 'Kunne ikke indlæse portnumre');
}
}));
document.getElementById('crossFieldPortLabelsForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!activeCrossFieldPortLabelsId) return;
const ports = Array.from(document.querySelectorAll('.cross-field-port-label-input')).map(input => ({id: Number(input.dataset.portId), port_number: input.value.trim()}));
const response = await fetch(`/api/v1/locations/cross-fields/${activeCrossFieldPortLabelsId}/port-labels`, {method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ports})});
if (response.ok) location.reload(); else { const error = await response.json(); alert(error.detail || 'Portnumrene kunne ikke gemmes'); }
});
const crossFieldHardwareModal = new bootstrap.Modal(document.getElementById('crossFieldHardwareModal'));
document.getElementById('addCrossFieldHardwareBtn')?.addEventListener('click', () => crossFieldHardwareModal.show());
document.getElementById('crossFieldHardwareForm')?.addEventListener('submit', async (event) => { event.preventDefault(); const portCount = document.getElementById('switchPortCount').value; const response = await fetch('/api/v1/hardware', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({asset_type:'netværk', brand:outletValue('switchBrand'), model:outletValue('switchModel'), serial_number:outletValue('switchSerial'), current_location_id:locationId, status:'active', hardware_specs: portCount ? {port_count:Number(portCount)} : null})}); if (response.ok) location.reload(); else alert('Switchen kunne ikke oprettes'); });
const outletModalElement = document.getElementById('outletModal');
const outletModal = outletModalElement ? new bootstrap.Modal(outletModalElement) : null;
const outletForm = document.getElementById('outletForm');
const outletValue = (id) => document.getElementById(id).value.trim() || null;
function switchDisplayName(hardware) {
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
}
function switchPortCount(hardware) {
let specs = hardware.hardware_specs || {};
if (typeof specs === 'string') {
try { specs = JSON.parse(specs); } catch (_) { specs = {}; }
}
const count = Number(specs?.port_count || specs?.ports || 0);
return Number.isInteger(count) && count > 0 ? count : 0;
}
function selectedSwitchHardwareId() {
const selectedName = document.getElementById('outletSwitch').value;
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
return match ? Number(match.id) : null;
}
function normalizeSwitchName(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
function switchPortConflict(switchHardware, switchName, portNumber, currentOutletId = null) {
if (!portNumber) return null;
return (locationWallOutlets || []).find(outlet => {
if (currentOutletId && Number(outlet.id) === Number(currentOutletId)) return false;
if (String(outlet.switch_port || '') !== String(portNumber)) return false;
if (switchHardware?.id && outlet.switch_hardware_id) return Number(outlet.switch_hardware_id) === Number(switchHardware.id);
return normalizeSwitchName(outlet.switch_name) === normalizeSwitchName(switchName);
}) || null;
}
function loadSwitchChoices(selectedName = '', selectedPort = '', currentOutletId = null) {
const switchInput = document.getElementById('outletSwitch');
const switchOptions = document.getElementById('outletSwitchOptions');
const portOptions = document.getElementById('outletSwitchPort');
const portHelp = document.getElementById('outletSwitchPortHelp');
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
switchOptions.innerHTML = '';
switches.forEach(item => {
const option = document.createElement('option');
option.value = switchDisplayName(item);
option.label = switchPortCount(item) ? `${switchPortCount(item)} porte` : 'Antal porte ikke angivet';
switchOptions.appendChild(option);
});
switchInput.value = selectedName || '';
const selectedSwitch = switches.find(item => switchDisplayName(item) === switchInput.value);
const count = selectedSwitch ? switchPortCount(selectedSwitch) : 0;
portOptions.innerHTML = '<option value="">Vælg port</option>';
if (count) {
for (let number = 1; number <= count; number += 1) {
const option = document.createElement('option');
option.value = String(number);
const conflict = switchPortConflict(selectedSwitch, switchInput.value, number, currentOutletId);
option.textContent = conflict
? `Port ${number} — OPTAGET: ${conflict.outlet_number} (${conflict.status || 'ukendt'})`
: `Port ${number} — ledig`;
portOptions.appendChild(option);
}
portHelp.textContent = `${count} porte på den valgte switch.`;
} else if (switchInput.value) {
portHelp.textContent = 'Ingen portliste på switchen — du kan skrive porten manuelt.';
} else {
portHelp.textContent = switches.length ? 'Vælg en switch for at se dens porte.' : 'Ingen registrerede switches på denne lokation endnu.';
}
document.getElementById('outletSwitchPort').value = selectedPort || '';
}
document.getElementById('outletSwitch')?.addEventListener('input', () => {
loadSwitchChoices(document.getElementById('outletSwitch').value, '', document.getElementById('outletId').value || null);
});
async function loadCrossFieldPorts() {
const select = document.getElementById('outletCrossFieldPort');
const ports = await fetch('/api/v1/locations/cross-field-ports').then(r => r.ok ? r.json() : []);
select.innerHTML = '<option value="">Vælg senere / ingen kobling</option>' + ports.map(port => `<option value="${port.id}">${port.location_name} · ${port.cross_field_name} · port ${port.port_number}</option>`).join('');
}
async function loadOutletLocations() {
const select = document.getElementById('outletLocationId');
const locations = await fetch('/api/v1/locations?limit=100').then(r => r.ok ? r.json() : []);
const allowed = locations.filter(location => ['customer_site', 'bygning', 'etage', 'rum'].includes(location.location_type));
select.innerHTML = allowed.map(location => `<option value="${location.id}">${location.name}</option>`).join('');
select.value = String(locationId);
}
async function loadOutletCustomers(selectedCustomerId = null) {
const select = document.getElementById('outletCustomerId');
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []);
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(customers || []).forEach(customer => {
const option = document.createElement('option');
option.value = String(customer.id);
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
option.selected = String(customer.id) === String(selectedCustomerId || '');
select.appendChild(option);
});
}
async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || '';
document.getElementById('outletPatchPanel').value = outlet?.panel || '';
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletNotes').value = outlet?.notes || '';
if (selectedPort) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(selectedPort.id);
if (portSelect.value !== String(selectedPort.id)) {
portSelect.insertAdjacentHTML('beforeend', `<option value="${selectedPort.id}" selected>${selectedPort.fieldName} · port ${selectedPort.portNumber}</option>`);
}
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
}
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
outletModal.show();
}
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber})));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort,
status: btn.dataset.status, notes: btn.dataset.notes
})));
outletForm?.addEventListener('submit', async (event) => {
event.preventDefault();
const outletId = document.getElementById('outletId').value;
const payload = {
location_id: Number(document.getElementById('outletLocationId').value), outlet_number: outletValue('outletNumber'), customer_id: document.getElementById('outletCustomerId').value ? Number(document.getElementById('outletCustomerId').value) : null, category: outletValue('outletCategory'),
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
};
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
const conflict = switchPortConflict(selectedSwitch, payload.switch_name, payload.switch_port, outletId || null);
if (conflict) {
const message = `Switch-port ${payload.switch_port} er allerede registreret på vægstik ${conflict.outlet_number}. Vil du flytte forbindelsen til dette vægstik?`;
if (!confirm(message)) return;
payload.replace_existing_switch_port = true;
}
const response = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (response.status === 409) {
const error = await response.json().catch(() => ({}));
if (confirm(`${error.detail || 'Porten er allerede i brug.'}\n\nVil du overskrive forbindelsen?`)) {
payload.replace_existing_switch_port = true;
const retry = await fetch(outletId ? `/api/v1/locations/outlets/${outletId}` : '/api/v1/locations/outlets', {
method: outletId ? 'PATCH' : 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (retry.ok) { location.reload(); return; }
}
return;
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme vægstik');
return;
}
location.reload();
});
document.getElementById('deleteOutletBtn')?.addEventListener('click', async () => {
const outletId = document.getElementById('outletId').value;
if (!outletId || !confirm('Slet dette vægstik?')) return;
const response = await fetch(`/api/v1/locations/outlets/${outletId}`, {method: 'DELETE'});
if (response.ok) location.reload();
else alert('Kunne ikke slette vægstik');
});
document.querySelectorAll('.save-hardware-order-btn').forEach(button => button.addEventListener('click', async () => {
const hardwareId = button.dataset.hardwareId;
const input = document.querySelector(`.hardware-display-order[data-hardware-id="${hardwareId}"]`);
const order = Number(input?.value);
if (!Number.isInteger(order) || order < 1) {
alert('Angiv et positivt heltal for rækkefølgen.');
return;
}
button.disabled = true;
const response = await fetch(`/api/v1/hardware/${hardwareId}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({location_display_order: order})
});
if (response.ok) location.reload();
else {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Kunne ikke gemme rækkefølgen');
button.disabled = false;
}
}));
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -2,6 +2,40 @@
{% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %} {% block title %}Rediger {{ location.name }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.relation-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 1rem;
}
.relation-summary {
border: 1px dashed rgba(15, 76, 117, 0.22);
border-radius: 0.85rem;
background: rgba(15, 76, 117, 0.05);
padding: 0.85rem 1rem;
}
.relation-summary.empty {
background: #f8fafc;
border-style: solid;
border-color: rgba(0, 0, 0, 0.08);
}
.relation-path {
font-weight: 600;
color: #1f3b53;
}
.relation-meta {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-4 py-4"> <div class="container-fluid px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@ -58,19 +92,52 @@
</select> </select>
</div> </div>
<div class="relation-card mb-3">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<label for="parentLocation" class="form-label mb-1">Placering i hierarki</label>
<div class="text-muted small">Flyt lokationen ved at vælge en ny overordnet lokation.</div>
</div>
{% if selected_parent %}
<a href="/app/locations/{{ selected_parent.id }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye me-2"></i>Åbn valgt parent
</a>
{% endif %}
</div>
<div class="mb-3">
<label for="parentLocationSearch" class="form-label small text-muted">Søg overordnet lokation</label>
<input type="text" class="form-control" id="parentLocationSearch" placeholder="Søg efter navn, sti eller type...">
</div>
<div class="mb-3"> <div class="mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id"> <select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option> <option value="">Ingen (øverste niveau)</option>
{% if parent_locations %} {% if parent_locations %}
{% for parent in parent_locations %} {% for parent in parent_locations %}
<option value="{{ parent.id }}" {% if location.parent_location_id == parent.id %}selected{% endif %}> <option
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %} value="{{ parent.id }}"
data-path="{{ parent.hierarchy_path }}"
data-type="{{ parent.type_label }}"
data-customer-id="{{ parent.customer_id | default('') }}"
{% if location.parent_location_id == parent.id %}selected{% endif %}>
{{ parent.display_name }}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</select> </select>
<div class="form-text">Bruges til hierarki (fx Bygning → Etage → Rum).</div> <div class="form-text">Listen viser hele stien, så du ikke skal gætte, hvor lokationen lander.</div>
</div>
<div id="parentSummary" class="relation-summary{% if not selected_parent %} empty{% endif %}">
{% if selected_parent %}
<div class="small text-muted mb-1">Nuværende overordnet lokation</div>
<div class="relation-path">{{ selected_parent.hierarchy_path }}</div>
<div class="relation-meta">{{ selected_parent.type_label }}</div>
{% else %}
<div class="small text-muted">Lokationen ligger i topniveau.</div>
{% endif %}
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -92,6 +159,13 @@
<label class="form-check-label" for="isActive">Lokation er aktiv</label> <label class="form-check-label" for="isActive">Lokation er aktiv</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crossFieldOption" {% if location.location_type != 'rum' %}hidden{% endif %}>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hasCrossField" name="has_cross_field" {% if location.has_cross_field %}checked{% endif %}>
<label class="form-check-label" for="hasCrossField">Rummet indeholder krydsfelt</label>
</div>
</div>
</fieldset> </fieldset>
<!-- Section 2: Address --> <!-- Section 2: Address -->
@ -130,7 +204,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ location.email | default('') }}" placeholder="f.eks. kontakt@lokation.dk"> <input type="email" class="form-control" id="email" name="email" value="{{ location.email or '' }}" placeholder="f.eks. kontakt@lokation.dk">
</div> </div>
</fieldset> </fieldset>
@ -160,7 +234,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="notes" class="form-label">Noter og kommentarer</label> <label for="notes" class="form-label">Noter og kommentarer</label>
<textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea> <textarea class="form-control" id="notes" name="notes" rows="4" maxlength="500" placeholder="Eventuelle noter eller særlige oplysninger om lokationen">{{ location.notes | default('') }}</textarea>
<small class="form-text text-muted"><span id="charCount">{{ (location.notes | default('')) | length }}</span> / 500 tegn</small> <small class="form-text text-muted"><span id="charCount">{{ (location.notes or '') | length }}</span> / 500 tegn</small>
</div> </div>
</fieldset> </fieldset>
@ -216,12 +290,64 @@ document.addEventListener('DOMContentLoaded', function() {
const deleteModalElement = document.getElementById('deleteModal'); const deleteModalElement = document.getElementById('deleteModal');
const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null; const deleteModal = (window.bootstrap && deleteModalElement) ? new bootstrap.Modal(deleteModalElement) : null;
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const parentLocationSelect = document.getElementById('parentLocation');
const parentLocationSearch = document.getElementById('parentLocationSearch');
const parentSummary = document.getElementById('parentSummary');
// Character counter for notes // Character counter for notes
notesField.addEventListener('input', function() { notesField.addEventListener('input', function() {
charCount.textContent = this.value.length; charCount.textContent = this.value.length;
}); });
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function updateParentSummary() {
const selectedOption = parentLocationSelect.options[parentLocationSelect.selectedIndex];
const path = selectedOption?.dataset?.path || '';
const type = selectedOption?.dataset?.type || '';
if (!selectedOption || !selectedOption.value) {
parentSummary.classList.add('empty');
parentSummary.innerHTML = '<div class="small text-muted">Lokationen ligger i topniveau.</div>';
return;
}
parentSummary.classList.remove('empty');
parentSummary.innerHTML = `
<div class="small text-muted mb-1">Valgt overordnet lokation</div>
<div class="relation-path">${escapeHtml(path)}</div>
<div class="relation-meta">${escapeHtml(type)}</div>
`;
}
function filterParentLocations() {
const query = (parentLocationSearch.value || '').trim().toLowerCase();
Array.from(parentLocationSelect.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const haystack = `${option.text} ${option.dataset.path || ''} ${option.dataset.type || ''}`.toLowerCase();
option.hidden = query ? !haystack.includes(query) : false;
});
}
if (parentLocationSearch) {
parentLocationSearch.addEventListener('input', filterParentLocations);
}
if (parentLocationSelect) {
parentLocationSelect.addEventListener('change', updateParentSummary);
updateParentSummary();
}
// Form submission // Form submission
form.addEventListener('submit', async function(e) { form.addEventListener('submit', async function(e) {
if (form.dataset.noIntercept === 'true') { if (form.dataset.noIntercept === 'true') {
@ -239,6 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null, parent_location_id: formData.get('parent_location_id') ? parseInt(formData.get('parent_location_id')) : null,
customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null, customer_id: formData.get('customer_id') ? parseInt(formData.get('customer_id')) : null,
is_active: formData.get('is_active') === 'on', is_active: formData.get('is_active') === 'on',
has_cross_field: formData.get('has_cross_field') === 'on',
address_street: formData.get('address_street'), address_street: formData.get('address_street'),
address_city: formData.get('address_city'), address_city: formData.get('address_city'),
address_postal_code: formData.get('address_postal_code'), address_postal_code: formData.get('address_postal_code'),
@ -275,6 +402,17 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
const locationType = document.getElementById('locationType');
const crossFieldOption = document.getElementById('crossFieldOption');
const hasCrossField = document.getElementById('hasCrossField');
const updateCrossFieldOption = () => {
const isRoom = locationType.value === 'rum';
crossFieldOption.hidden = !isRoom;
if (!isRoom) hasCrossField.checked = false;
};
locationType.addEventListener('change', updateCrossFieldOption);
updateCrossFieldOption();
// Delete location // Delete location
document.getElementById('confirmDeleteBtn').addEventListener('click', function() { document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
fetch(`/api/v1/locations/${locationId}`, { fetch(`/api/v1/locations/${locationId}`, {

View File

@ -6,81 +6,271 @@
<style> <style>
.locations-list-page { .locations-list-page {
--loc-accent: #0f4c75; --loc-accent: #0f4c75;
--loc-accent-soft: rgba(15, 76, 117, 0.08); --loc-accent-soft: rgba(15, 76, 117, 0.06);
--loc-border: rgba(15, 76, 117, 0.16); --loc-border: rgba(15, 76, 117, 0.12);
--loc-surface: #f7f9fc;
} }
.locations-list-page .locations-hero { .locations-list-page .locations-hero {
border: 1px solid var(--loc-border); border: 1px solid rgba(0, 0, 0, 0.08);
background: background: var(--bg-card);
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.18), transparent 45%), border-radius: 0.9rem;
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.16), transparent 42%), box-shadow: 0 10px 28px rgba(15, 76, 117, 0.05);
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.9));
border-radius: 1rem;
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
}
[data-theme="dark"] .locations-list-page .locations-hero {
background:
radial-gradient(circle at 12% 22%, rgba(52, 152, 219, 0.2), transparent 45%),
radial-gradient(circle at 88% 12%, rgba(26, 188, 156, 0.18), transparent 42%),
linear-gradient(145deg, rgba(17, 34, 51, 0.9), rgba(11, 25, 38, 0.92));
} }
.locations-list-page .stat-tile { .locations-list-page .stat-tile {
background: var(--loc-accent-soft); background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
border: 1px solid var(--loc-border); border: 1px solid rgba(15, 76, 117, 0.08);
border-radius: 0.9rem; border-radius: 0.8rem;
padding: 0.8rem 0.95rem; padding: 0.75rem 0.9rem;
}
.locations-list-page .section-label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--loc-accent);
} }
.locations-list-page .stat-tile .stat-value { .locations-list-page .stat-tile .stat-value {
font-size: 1.15rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--loc-accent); color: var(--loc-accent);
line-height: 1.1; line-height: 1.1;
} }
[data-theme="dark"] .locations-list-page .stat-tile .stat-value { .locations-list-page .stat-tile .stat-note {
color: #8fd0ff; font-size: 0.76rem;
color: var(--text-secondary);
} }
.locations-list-page .table thead th { .locations-list-page .table thead th {
font-size: 0.8rem; font-size: 0.76rem;
letter-spacing: 0.02em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
border-bottom: 1px solid rgba(15, 76, 117, 0.1);
background: #f8fafc;
} }
.locations-list-page .location-row { .locations-list-page .location-row {
transition: background-color 0.18s ease, transform 0.16s ease; transition: background-color 0.16s ease;
} }
.locations-list-page .location-row:hover { .locations-list-page .location-row:hover {
background-color: rgba(52, 152, 219, 0.08); background-color: rgba(15, 76, 117, 0.03);
} }
.locations-list-page .toggle-row { .locations-list-page .table > :not(caption) > * > * {
padding-top: 0.95rem;
padding-bottom: 0.95rem;
vertical-align: middle;
}
.locations-list-page .table tbody tr:last-child td {
border-bottom: 0;
}
.locations-list-page .toggle-row,
.locations-list-page .location-tree-spacer {
color: var(--loc-accent);
width: 1.2rem;
display: inline-flex;
justify-content: center;
flex: 0 0 1.2rem;
}
.locations-list-page .filter-shell,
.locations-list-page .content-shell {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.9rem;
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.04);
}
.locations-list-page .filter-shell {
padding: 1.05rem 1.1rem;
}
.locations-list-page .content-shell-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.locations-list-page .content-shell-toolbar,
.locations-list-page .filter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.locations-list-page .content-shell-title {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 700;
color: var(--text-secondary);
}
.locations-list-page .content-shell-subtitle {
font-size: 0.92rem;
color: var(--text-primary);
font-weight: 600;
}
.locations-list-page .content-shell-subtitle span {
color: var(--text-secondary);
font-weight: 500;
}
.locations-list-page .location-name-link {
color: var(--text-primary);
font-weight: 600;
font-size: 0.98rem;
}
.locations-list-page .location-name-link:hover {
color: var(--loc-accent); color: var(--loc-accent);
} }
.locations-list-page .location-primary {
min-width: 0;
}
.locations-list-page .location-summary {
display: flex;
align-items: flex-start;
gap: 0.7rem;
min-width: 0;
}
.locations-list-page .location-indent {
display: inline-flex;
align-items: stretch;
flex: 0 0 auto;
}
.locations-list-page .location-name-block {
min-width: 0;
}
.locations-list-page .location-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.25rem;
}
.locations-list-page .location-meta-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
color: #2d5670;
font-size: 0.73rem;
font-weight: 600;
}
.locations-list-page .type-pill {
display: inline-flex;
align-items: center;
padding: 0.32rem 0.62rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
line-height: 1;
color: #fff;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.18);
}
.locations-list-page .status-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.locations-list-page .status-pill.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.locations-list-page .status-pill.inactive {
background: rgba(108, 117, 125, 0.12);
color: #495057;
}
.locations-list-page .shortcut-hint { .locations-list-page .shortcut-hint {
border: 1px dashed var(--loc-border); border: 1px dashed rgba(0, 0, 0, 0.12);
border-radius: 0.55rem; border-radius: 0.55rem;
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.locations-list-page .hero-actions {
align-items: center;
}
.locations-list-page .list-status-note {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.4rem 0.65rem;
border-radius: 999px;
background: var(--loc-surface);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
}
.locations-list-page .actions-inline {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: nowrap;
}
.locations-list-page .actions-inline .btn {
border-radius: 0.7rem;
}
.locations-list-page .empty-state-soft {
max-width: 420px;
margin: 0 auto;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.locations-list-page { .locations-list-page {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.locations-list-page .stat-tile { .locations-list-page .stat-tile { padding: 0.65rem 0.75rem; }
padding: 0.65rem 0.75rem; .locations-list-page .content-shell-header { align-items: flex-start; flex-direction: column; }
} .locations-list-page .content-shell-toolbar,
.locations-list-page .filter-toolbar { align-items: flex-start; flex-direction: column; }
.locations-list-page .hero-actions { width: 100%; }
.locations-list-page .hero-actions .btn { flex: 1 1 auto; }
.locations-list-page .location-summary { gap: 0.55rem; }
.locations-list-page .location-name-link { font-size: 0.94rem; }
} }
</style> </style>
{% endblock %} {% endblock %}
@ -96,59 +286,83 @@
</nav> </nav>
<!-- Header Section --> <!-- Header Section -->
<div class="row mb-4"> <div class="locations-hero p-3 p-lg-4 mb-4">
<div class="col-12">
<div class="locations-hero p-3 p-lg-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap"> <div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div> <div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1> <div class="section-label mb-2">
<p class="text-muted small mb-0">Oversigt over alle lokationer og faciliteter</p> <i class="bi bi-diagram-3"></i>
Lokationsstruktur
</div>
<h1 class="h2 fw-700 mb-1">Lokaliteter</h1>
<p class="text-muted small mb-0">Få overblik over steder, underlokationer og status uden at miste hierarkiet.</p>
</div>
<div class="d-flex gap-2 flex-wrap hero-actions">
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard
</a>
<a href="/app/locations/outlets" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-ethernet me-2"></i>Vægstik
</a>
<span class="shortcut-hint">Tip: Tryk / for søgning</span>
</div> </div>
<span class="shortcut-hint">Tip: Tryk / for at fokusere søgning</span>
</div> </div>
<div class="row g-2 mt-2"> <div class="row g-2 mt-2">
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Total</div> <div class="small text-muted">Total</div>
<div class="stat-value" id="statTotal">{{ total or 0 }}</div> <div class="stat-value" id="statTotal">{{ total or 0 }}</div>
<div class="stat-note">Alle registrerede lokationer</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Aktive</div> <div class="small text-muted">Aktive</div>
<div class="stat-value" id="statActive">0</div> <div class="stat-value" id="statActive">0</div>
<div class="stat-note">Klar til daglig brug</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Inaktive</div> <div class="small text-muted">Inaktive</div>
<div class="stat-value" id="statInactive">0</div> <div class="stat-value" id="statInactive">0</div>
<div class="stat-note">Skjulte eller lukkede</div>
</div> </div>
</div> </div>
<div class="col-6 col-lg-3"> <div class="col-6 col-lg-3">
<div class="stat-tile"> <div class="stat-tile">
<div class="small text-muted">Synlige nu</div> <div class="small text-muted">Synlige nu</div>
<div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div> <div class="stat-value" id="statVisible">{{ locations|length if locations else 0 }}</div>
</div> <div class="stat-note">Efter søgning og foldning</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Filter Card --> <!-- Filter Card -->
<div class="card mb-4 border-0"> <div class="filter-shell mb-4">
<div class="card-body"> <div class="filter-toolbar mb-3">
<div>
<div class="content-shell-title">Filtre</div>
<div class="text-muted small">Søg i navn og by, eller afgræns efter type og status.</div>
</div>
<div class="list-status-note">
<i class="bi bi-lightning-charge"></i>
Hierarkiet kan foldes direkte i listen
</div>
</div>
<form id="filterForm" method="get" class="row g-3 align-items-end"> <form id="filterForm" method="get" class="row g-3 align-items-end">
<div class="col-md-4"> <div class="col-lg-5">
<label for="locationSearch" class="form-label small text-muted">Søg</label> <label for="locationSearch" class="form-label small text-muted">Søg</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span> <span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter navn, hierarki eller by..."> <input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by...">
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4 col-lg-3">
<label for="locationTypeFilter" class="form-label small text-muted">Type</label> <label for="locationTypeFilter" class="form-label small text-muted">Type</label>
<select class="form-select" id="locationTypeFilter" name="location_type"> <select class="form-select" id="locationTypeFilter" name="location_type">
<option value="">Alle typer</option> <option value="">Alle typer</option>
@ -164,7 +378,7 @@
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-3 col-lg-2">
<label for="statusFilter" class="form-label small text-muted">Status</label> <label for="statusFilter" class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" name="is_active"> <select class="form-select" id="statusFilter" name="is_active">
<option value="">Alle</option> <option value="">Alle</option>
@ -173,44 +387,43 @@
</select> </select>
</div> </div>
<div class="col-md-2 d-flex gap-2"> <div class="col-md-5 col-lg-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100"> <button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel me-2"></i>Anvend filtre <i class="bi bi-funnel me-2"></i>Filtrer
</button> </button>
<a href="/app/locations" class="btn btn-outline-secondary btn-sm"> <a href="/app/locations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-lg"></i> <i class="bi bi-arrow-counterclockwise"></i>
</a> </a>
</div> </div>
</form> </form>
</div> </div>
</div>
<!-- Toolbar Section --> <!-- Main Content Section -->
<div class="row mb-3"> <div class="content-shell">
<div class="col-12 d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="content-shell-header">
<div class="d-flex gap-2"> <div class="content-shell-toolbar w-100">
<a href="/app/locations/create" class="btn btn-primary btn-sm"> <div>
<i class="bi bi-plus-lg me-2"></i>Opret lokation <div class="content-shell-title">Lokationsliste</div>
</a> <div class="content-shell-subtitle">
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm"> {% if total %}
<i class="bi bi-diagram-3 me-2"></i>Wizard Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
</a> <span>med tydeligt hierarki og status</span>
{% else %}
Ingen lokationer endnu
{% endif %}
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<span class="list-status-note">
<i class="bi bi-check2-circle"></i>
Klik kun på navn eller handlinger for at åbne
</span>
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled> <button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte <i class="bi bi-trash me-2"></i>Slet valgte
</button> </button>
</div> </div>
<div class="text-muted small">
{% if total %}
Viser <strong id="visibleCount">{{ locations|length }}</strong> af <strong>{{ total }}</strong> lokationer
{% else %}
Ingen lokationer
{% endif %}
</div> </div>
</div> </div>
</div>
<!-- Main Content Section -->
<div class="card border-0">
<div class="table-responsive"> <div class="table-responsive">
{% if location_tree %} {% if location_tree %}
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@ -250,26 +463,44 @@
'vehicle': '#8e44ad' 'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %} }.get(node.location_type, '#6c757d') %}
{% set child_count = node.children|length if node.children else 0 %}
<tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}"> <tr class="location-row{% if node.children %} has-children{% endif %}" data-location-id="{{ node.id }}" data-parent-id="{{ parent_id if parent_id else '' }}" data-depth="{{ depth }}" data-has-children="{{ 'true' if node.children else 'false' }}">
<td> <td>
<input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}"> <input type="checkbox" class="form-check-input location-checkbox" value="{{ node.id }}">
</td> </td>
<td> <td>
<div class="d-flex align-items-center" style="padding-left: {{ depth * 18 }}px;"> <div class="location-summary">
<div class="location-indent" style="padding-left: {{ depth * 18 }}px;">
{% if node.children %} {% if node.children %}
<button type="button" class="btn btn-link btn-sm p-0 me-2 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind"> <button type="button" class="btn btn-link btn-sm p-0 toggle-row" data-target-id="{{ node.id }}" aria-expanded="false" title="Fold ud/ind">
<i class="bi bi-caret-right-fill"></i> <i class="bi bi-caret-right-fill"></i>
</button> </button>
{% else %} {% else %}
<span class="text-muted me-2"></span> <span class="location-tree-spacer text-muted"></span>
{% endif %} {% endif %}
<a href="/app/locations/{{ node.id }}" class="text-decoration-none fw-500"> </div>
<div class="location-name-block">
<a href="/app/locations/{{ node.id }}" class="text-decoration-none location-name-link">
{{ node.name }} {{ node.name }}
</a> </a>
<div class="location-meta">
{% if depth > 0 %}
<span class="location-meta-chip"><i class="bi bi-diagram-3"></i>Niveau {{ depth + 1 }}</span>
{% endif %}
{% if child_count %}
<span class="location-meta-chip"><i class="bi bi-collection"></i>{{ child_count }} underlokationer</span>
{% endif %}
{% if node.address_city %}
<span class="location-meta-chip"><i class="bi bi-geo-alt"></i>{{ node.address_city }}</span>
{% endif %}
<span class="location-meta-chip"><i class="bi bi-hash"></i>ID {{ node.id }}</span>
</div>
</div>
</div> </div>
</td> </td>
<td> <td>
<span class="badge" style="background-color: {{ type_color }}; color: white;"> <span class="type-pill" style="background-color: {{ type_color }};">
{{ type_label }} {{ type_label }}
</span> </span>
</td> </td>
@ -278,20 +509,20 @@
</td> </td>
<td> <td>
{% if node.is_active %} {% if node.is_active %}
<span class="badge bg-success">Aktiv</span> <span class="status-pill active"><span></span>Aktiv</span>
{% else %} {% else %}
<span class="badge bg-secondary">Inaktiv</span> <span class="status-pill inactive"><span></span>Inaktiv</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
<div class="btn-group btn-group-sm" role="group"> <div class="actions-inline" role="group" aria-label="Handlinger">
<a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary" title="Vis"> <a href="/app/locations/{{ node.id }}" class="btn btn-outline-secondary btn-sm" title="Vis">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
<a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary" title="Rediger"> <a href="/app/locations/{{ node.id }}/edit" class="btn btn-outline-secondary btn-sm" title="Rediger">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
<button type="button" class="btn btn-outline-danger delete-location-btn" <button type="button" class="btn btn-outline-danger btn-sm delete-location-btn"
data-location-id="{{ node.id }}" data-location-id="{{ node.id }}"
data-location-name="{{ node.name }}" data-location-name="{{ node.name }}"
title="Slet"> title="Slet">
@ -315,6 +546,7 @@
{% else %} {% else %}
<!-- Empty State --> <!-- Empty State -->
<div class="text-center py-5"> <div class="text-center py-5">
<div class="empty-state-soft">
<div class="mb-3"> <div class="mb-3">
<i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i> <i class="bi bi-pin-map" style="font-size: 3rem; color: var(--text-secondary);"></i>
</div> </div>
@ -324,6 +556,7 @@
<i class="bi bi-plus-lg me-2"></i>Opret lokation <i class="bi bi-plus-lg me-2"></i>Opret lokation
</a> </a>
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -608,12 +841,14 @@ document.addEventListener('DOMContentLoaded', function() {
let currentDeleteId = null; let currentDeleteId = null;
// Select all functionality // Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() { selectAllCheckbox.addEventListener('change', function() {
locationCheckboxes.forEach(checkbox => { locationCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked; checkbox.checked = this.checked;
}); });
updateBulkDeleteButton(); updateBulkDeleteButton();
}); });
}
// Individual checkbox functionality // Individual checkbox functionality
locationCheckboxes.forEach(checkbox => { locationCheckboxes.forEach(checkbox => {
@ -632,6 +867,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
function updateSelectAllCheckbox() { function updateSelectAllCheckbox() {
if (!selectAllCheckbox) return;
const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked); const allChecked = Array.from(locationCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked); const someChecked = Array.from(locationCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked; selectAllCheckbox.checked = allChecked;
@ -678,6 +914,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// Bulk delete // Bulk delete
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener('click', function() { bulkDeleteBtn.addEventListener('click', function() {
const selectedIds = Array.from(locationCheckboxes) const selectedIds = Array.from(locationCheckboxes)
.filter(cb => cb.checked) .filter(cb => cb.checked)
@ -696,18 +933,8 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
}); });
// Clickable rows
document.querySelectorAll('.location-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking checkbox or action buttons
if (e.target.tagName === 'INPUT' || e.target.closest('.btn-group')) {
return;
} }
const link = this.querySelector('a');
if (link) link.click();
});
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vægstik - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div><div class="small text-uppercase text-muted fw-semibold mb-1">Lokaliteter</div><h1 class="h3 mb-1">Vægstik</h1><p class="text-muted mb-0">Søg og find netværksstik på tværs af bygninger, etager og rum.</p></div>
<a href="/app/locations" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Lokaliteter</a>
</div>
<div class="card border-0 shadow-sm mb-4"><div class="card-body">
<form class="row g-3 align-items-end" method="get">
<div class="col-md-7"><label class="form-label">Søg</label><input class="form-control" name="q" value="{{ query }}" placeholder="Stiknummer, lokation, patchpanel eller switch-port"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="">Alle</option>{% for value, label in [('available', 'Ledig'), ('active', 'Aktiv'), ('reserved', 'Reserveret'), ('faulty', 'Defekt'), ('unknown', 'Ukendt')] %}<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-2 d-grid"><button class="btn btn-primary"><i class="bi bi-search me-1"></i>Søg</button></div>
</form>
</div></div>
<div class="card border-0 shadow-sm"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Stik</th><th>Lokation</th><th>Patchpanel</th><th>Switch</th><th>Status</th></tr></thead><tbody>
{% for outlet in outlets %}<tr><td><strong>{{ outlet.outlet_number }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><a href="/app/locations/{{ outlet.location_id }}" class="text-decoration-none">{{ outlet.hierarchy_path or outlet.location_name }}</a>{% if outlet.customer_name %}<div class="small text-muted">{{ outlet.customer_name }}</div>{% endif %}</td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td></tr>{% else %}<tr><td colspan="5" class="text-center text-muted py-5">Ingen vægstik matcher søgningen.</td></tr>{% endfor %}
</tbody></table></div></div>
</div>
{% endblock %}

View File

@ -14,7 +14,8 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings from app.core.config import settings
from app.services.email_service import EmailService from app.services.email_service import EmailService
@ -310,6 +311,17 @@ class RewriteTextResponse(BaseModel):
context: Optional[str] = None context: Optional[str] = None
class CaseCreateRewriteRequest(BaseModel):
title: str = Field(default="", max_length=500)
description: str = Field(..., min_length=1, max_length=10000)
class CaseCreateRewriteResponse(BaseModel):
title: str
description: str
model: Optional[str] = None
class SagSendEmailRequest(BaseModel): class SagSendEmailRequest(BaseModel):
to: List[str] to: List[str]
subject: str = Field(..., min_length=1, max_length=998) subject: str = Field(..., min_length=1, max_length=998)
@ -730,6 +742,19 @@ async def analyze_quick_create(request: QuickCreateRequest):
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
@router.post("/sag/rewrite-case-create", response_model=CaseCreateRewriteResponse)
async def rewrite_case_create(request: CaseCreateRewriteRequest):
"""Return a fact-preserving title and description suggestion for a new case."""
result = await ollama_service.rewrite_case_creation(request.title, request.description)
if not result or result.get("error"):
raise HTTPException(status_code=502, detail=(result or {}).get("error") or "Could not rewrite case")
return CaseCreateRewriteResponse(
title=result.get("title", ""),
description=result.get("description", ""),
model=result.get("model"),
)
@router.post("/sag/rewrite-text", response_model=RewriteTextResponse) @router.post("/sag/rewrite-text", response_model=RewriteTextResponse)
async def rewrite_sag_text(request: RewriteTextRequest): async def rewrite_sag_text(request: RewriteTextRequest):
"""Rewrite case/email text using Ollama with configurable prompt.""" """Rewrite case/email text using Ollama with configurable prompt."""
@ -903,7 +928,7 @@ async def list_all_sale_items(
@router.post("/sag") @router.post("/sag")
async def create_sag(data: dict): async def create_sag(data: dict):
"""Create a new case.""" """Create a case and its optional pipeline/order data atomically."""
try: try:
if not data.get("titel"): if not data.get("titel"):
raise HTTPException(status_code=400, detail="titel is required") raise HTTPException(status_code=400, detail="titel is required")
@ -919,32 +944,118 @@ async def create_sag(data: dict):
_validate_user_id(ansvarlig_bruger_id) _validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id) _validate_group_id(assigned_group_id)
query = """ case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
INSERT INTO sag_sager pipeline = data.get("pipeline") if case_type == "pipeline" else None
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status) order_items = data.get("order_items") if case_type == "ordre" else []
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) if pipeline is not None and not isinstance(pipeline, dict):
RETURNING * raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
""" if not isinstance(order_items, list):
params = ( raise HTTPException(status_code=400, detail="order_items skal være en liste")
data.get("titel"),
data.get("beskrivelse", ""),
data.get("template_key") or data.get("type", "ticket"),
status,
data.get("customer_id"),
ansvarlig_bruger_id,
assigned_group_id,
data.get("created_by_user_id", 1),
deadline,
deferred_until,
data.get("deferred_until_case_id"),
data.get("deferred_until_status"),
)
result = execute_query(query, params) conn = get_db_connection()
if result: try:
logger.info("✅ Case created: %s", result[0]["id"]) with conn.cursor(cursor_factory=RealDictCursor) as cursor:
return result[0] pipeline_values = {"amount": None, "probability": None, "stage_id": None, "description": None}
if pipeline:
pipeline_values.update({key: pipeline.get(key) for key in pipeline_values})
if pipeline_values["amount"] not in (None, ""):
try:
pipeline_values["amount"] = float(pipeline_values["amount"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else:
pipeline_values["amount"] = None
if pipeline_values["probability"] not in (None, ""):
try:
pipeline_values["probability"] = int(pipeline_values["probability"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være et helt tal") from exc
if not 0 <= pipeline_values["probability"] <= 100:
raise HTTPException(status_code=400, detail="Pipeline-sandsynlighed skal være mellem 0 og 100")
else:
pipeline_values["probability"] = None
if pipeline_values["stage_id"] not in (None, ""):
try:
pipeline_values["stage_id"] = int(pipeline_values["stage_id"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage") from exc
cursor.execute("SELECT id FROM pipeline_stages WHERE id = %s", (pipeline_values["stage_id"],))
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Ugyldig pipeline-stage")
else:
pipeline_values["stage_id"] = None
normalized_items = []
for item in order_items:
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinje")
description = str(item.get("description") or "").strip()
item_type = str(item.get("type") or "sale").lower()
if not description:
raise HTTPException(status_code=400, detail="Ordrelinjens beskrivelse er påkrævet")
if item_type not in ("sale", "purchase"):
raise HTTPException(status_code=400, detail="Ordrelinjetype skal være køb eller salg")
if item.get("amount") in (None, ""):
raise HTTPException(status_code=400, detail="Ordrelinjens beløb er påkrævet")
try:
normalized_items.append({
"type": item_type,
"description": description,
"quantity": float(item["quantity"]) if item.get("quantity") not in (None, "") else None,
"unit": item.get("unit") or None,
"unit_price": float(item["unit_price"]) if item.get("unit_price") not in (None, "") else None,
"amount": float(item["amount"]),
"currency": str(item.get("currency") or "DKK").upper(),
"status": str(item.get("status") or "draft").lower(),
"line_date": item.get("line_date") or None,
"external_ref": item.get("external_ref") or None,
"purchase_purpose": _normalize_purchase_purpose(item.get("purchase_purpose"), item_type),
})
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
cursor.execute(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
)
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items:
if has_purchase_columns:
cursor.execute(
"""INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, purchase_purpose)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"], item["purchase_purpose"]),
)
else:
cursor.execute(
"""INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
)
conn.commit()
logger.info("✅ Case created: %s", result["id"])
return dict(result)
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -162,6 +162,12 @@
</div> </div>
<form id="createForm" novalidate> <form id="createForm" novalidate>
<div class="mb-4 p-3 rounded-3 border bg-light">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
</div>
<!-- Section: Relations --> <!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
@ -206,15 +212,24 @@
</div> </div>
<div class="col-md-12"> <div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label> <div class="d-flex justify-content-between align-items-center mb-2">
<label for="beskrivelse" class="form-label mb-0">Beskrivelse</label>
<button type="button" id="caseCreateRewriteBtn" class="btn btn-sm btn-outline-primary" title="Renskriv kun det, du allerede har skrevet">
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea> <textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div> <div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
</div>
</div> </div>
</div> </div>
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk --> <!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-12"> <div class="col-12">
@ -246,23 +261,38 @@
</div> </div>
</div> </div>
</div> </div>
</section>
<section id="pipelineSection" class="d-none">
<hr class="my-4 opacity-25">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
<div class="row g-4 mb-4">
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
</div>
</section>
<section id="orderSection" class="d-none">
<hr class="my-4 opacity-25">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
</div>
</div>
<div id="orderLines" class="vstack gap-3 mb-4"></div>
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
</section>
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Metadata --> <!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-3"> <div class="col-md-4">
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="type" required>
<option value="ticket" selected>🎫 Ticket</option>
<option value="opgave">🧩 Opgave</option>
<option value="ordre">🧾 Ordre</option>
<option value="projekt">📁 Projekt</option>
<option value="service">🛠️ Service</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label> <label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required> <select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option> <option value="åben" selected>🟢 Åben</option>
@ -270,7 +300,7 @@
<option value="lukket">🔴 Lukket</option> <option value="lukket">🔴 Lukket</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label> <label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id"> <select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option> <option value="">Ingen</option>
@ -280,7 +310,7 @@
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label> <label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id"> <select class="form-select" id="assigned_group_id">
<option value="">Ingen</option> <option value="">Ingen</option>
@ -317,6 +347,34 @@
</div> </div>
</div> </div>
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="caseCreateRewriteModalLabel"><i class="bi bi-magic me-2"></i>AI-forslag til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">
Gennemgå forslaget før du bruger det. AI må kun have rettet formulering og foreslået en titel ud fra din egen tekst.
</div>
<div class="mb-3">
<label for="caseCreateSuggestedTitle" class="form-label">Foreslået titel</label>
<input id="caseCreateSuggestedTitle" class="form-control" type="text">
</div>
<div>
<label for="caseCreateSuggestedDescription" class="form-label">Renskrevet beskrivelse</label>
<textarea id="caseCreateSuggestedDescription" class="form-control" rows="10"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="caseCreateApplyRewriteBtn" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Brug forslag</button>
</div>
</div>
</div>
</div>
<script> <script>
let selectedCustomer = null; let selectedCustomer = null;
let selectedContacts = {}; let selectedContacts = {};
@ -324,6 +382,7 @@
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
let successAlertTimeout; let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0; let topAlertLoadToken = 0;
@ -421,6 +480,76 @@
}); });
} }
// --- AI renskrivning ved oprettelse ---
// Forslaget bliver altid vist først; formularens titel og beskrivelse ændres
// først når brugeren aktivt vælger "Brug forslag".
async function requestCaseCreateRewrite() {
const descriptionInput = document.getElementById('beskrivelse');
const titleInput = document.getElementById('titel');
const button = document.getElementById('caseCreateRewriteBtn');
const source = (descriptionInput?.value || '').trim();
if (!source) {
descriptionInput?.focus();
alert('Skriv en beskrivelse først.');
return;
}
const originalButton = button?.innerHTML || '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const response = await fetch('/api/v1/sag/rewrite-case-create', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleInput?.value || '', description: source })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.detail || `HTTP ${response.status}`);
}
const suggestion = {
title: String(payload?.title || '').trim(),
description: String(payload?.description || '').trim()
};
if (!suggestion.description) {
throw new Error('AI returnerede ikke en beskrivelse');
}
document.getElementById('caseCreateSuggestedTitle').value = suggestion.title || titleInput?.value || '';
document.getElementById('caseCreateSuggestedDescription').value = suggestion.description;
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalButton;
}
}
}
document.getElementById('caseCreateRewriteBtn')?.addEventListener('click', requestCaseCreateRewrite);
document.getElementById('caseCreateApplyRewriteBtn')?.addEventListener('click', () => {
const suggestedTitle = document.getElementById('caseCreateSuggestedTitle').value.trim();
const suggestedDescription = document.getElementById('caseCreateSuggestedDescription').value.trim();
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input'));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
// --- Search Logic --- // --- Search Logic ---
function initializeSearch() { function initializeSearch() {
// Customer Search // Customer Search
@ -889,28 +1018,113 @@
} }
} }
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service'
};
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Denne sagstype bruger kun de fælles sagsfelter.';
}
function renderOrderLinesEmptyState() {
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
}
function addOrderLine(type = 'sale') {
const id = ++orderLineCounter;
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
const purpose = type === 'purchase' ? `
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
const container = document.getElementById('orderLines');
container.insertAdjacentHTML('beforeend', `
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
<input type="hidden" class="order-type" value="${type}">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
${purpose}
</div>
</div>`);
renderOrderLinesEmptyState();
}
function removeOrderLine(id) {
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
renderOrderLinesEmptyState();
}
function collectOrderItems() {
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
type: line.querySelector('.order-type').value,
description: line.querySelector('.order-description').value.trim(),
quantity: line.querySelector('.order-quantity').value || null,
unit: line.querySelector('.order-unit').value.trim() || null,
unit_price: line.querySelector('.order-unit-price').value || null,
amount: line.querySelector('.order-amount').value || null,
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
status: line.querySelector('.order-status').value,
external_ref: line.querySelector('.order-reference').value.trim() || null,
purchase_purpose: line.querySelector('.order-purpose')?.value || null
}));
}
async function loadPipelineStages() {
const select = document.getElementById('pipeline_stage_id');
if (!select) return;
try {
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
if (!response.ok) return;
const stages = await response.json();
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
} catch (err) { console.error('Failed to load pipeline stages', err); }
}
async function loadCaseTypesSelect() { async function loadCaseTypesSelect() {
const select = document.getElementById('type'); const select = document.getElementById('type');
if (!select) return; if (!select) return;
try { try {
const res = await fetch('/api/v1/settings/case_types'); const [typesRes, profileRes] = await Promise.all([
if (!res.ok) return; fetch('/api/v1/settings/case_types', { credentials: 'include' }),
const setting = await res.json(); fetch('/api/v1/auth/me/profile', { credentials: 'include' })
const types = JSON.parse(setting.value || '[]'); ]);
if (!Array.isArray(types) || types.length === 0) return; const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
select.innerHTML = types const configured = JSON.parse(setting.value || '[]');
.map((type) => `<option value="${type}">${type}</option>`) const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
.join(''); if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (err) { } catch (err) {
console.error('Failed to load case types', err); console.error('Failed to load case types', err);
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
} }
updateCaseTypeSections();
} }
// --- Initialization --- // --- Initialization ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeSearch(); initializeSearch();
loadCaseTypesSelect(); loadCaseTypesSelect();
loadPipelineStages();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill(); applyTelefoniPrefill();
}); });
@ -993,6 +1207,18 @@
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };
if (data.type === 'pipeline') {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
probability: document.getElementById('pipeline_probability').value || null,
description: document.getElementById('pipeline_description').value || null
};
}
if (data.type === 'ordre') {
data.order_items = collectOrderItems();
}
try { try {
const response = await fetch('/api/v1/sag', { const response = await fetch('/api/v1/sag', {
method: 'POST', method: 'POST',

File diff suppressed because it is too large Load Diff

View File

@ -8451,6 +8451,40 @@
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div> <div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
</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 class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
</div> </div>
@ -8520,9 +8554,15 @@
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea> <textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
</div> </div>
<div class="col-12"> <div class="col-12">
<button type="button" class="btn btn-primary" onclick="createSubscription()"> <div id="subscriptionCreateHint" class="alert alert-info d-none mb-3"></div>
<i class="bi bi-plus-circle me-1"></i>Opret abonnement <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> </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> </div>
</form> </form>
</div> </div>
@ -8577,6 +8617,26 @@
<label class="form-label">Kort beskrivelse</label> <label class="form-label">Kort beskrivelse</label>
<input type="text" class="form-control" id="subscriptionProductDescription"> <input type="text" class="form-control" id="subscriptionProductDescription">
</div> </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> </div>
</form> </form>
</div> </div>
@ -16361,6 +16421,8 @@
let currentSubscription = null; let currentSubscription = null;
let subscriptionProducts = []; let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null; let lastCreatedSubscriptionProductId = null;
let subscriptionProvisioningData = null;
let subscriptionEditMode = false;
function formatSubscriptionInterval(interval) { function formatSubscriptionInterval(interval) {
const map = { const map = {
@ -16442,6 +16504,81 @@
} }
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); 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, '&quot;')}"></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() { function populateSubscriptionProductSelects() {
@ -16455,6 +16592,7 @@
option.textContent = product.name; option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? ''; option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? ''; option.dataset.description = product.short_description ?? '';
option.dataset.attributes = JSON.stringify(product.attributes_json ?? {});
select.appendChild(option); select.appendChild(option);
}); });
if (currentValue) { if (currentValue) {
@ -16466,6 +16604,51 @@
lastCreatedSubscriptionProductId = null; 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) { function applySubscriptionProduct(select) {
const row = select.closest('tr'); const row = select.closest('tr');
if (!row) return; if (!row) return;
@ -16484,6 +16667,7 @@
unitPriceInput.value = salesPrice; unitPriceInput.value = salesPrice;
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function addSubscriptionLine() { function addSubscriptionLine() {
@ -16507,6 +16691,7 @@
body.appendChild(row); body.appendChild(row);
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function removeSubscriptionLine(button) { function removeSubscriptionLine(button) {
@ -16521,6 +16706,7 @@
row.remove(); row.remove();
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function updateSubscriptionLineTotals() { function updateSubscriptionLineTotals() {
@ -16594,13 +16780,28 @@
} }
async function createSubscriptionProduct() { 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 = { const payload = {
name: document.getElementById('subscriptionProductName').value.trim(), name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null, type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value, status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null, sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null, billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null short_description: document.getElementById('subscriptionProductDescription').value.trim() || null,
attributes_json: networkKind ? attributes : null
}; };
if (!payload.name) { if (!payload.name) {
@ -16627,8 +16828,398 @@
updateSubscriptionLineTotals(); 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) { function renderSubscription(subscription) {
currentSubscription = subscription; currentSubscription = subscription;
subscriptionEditMode = false;
const empty = document.getElementById('subscriptionEmpty'); const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm'); const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails'); const details = document.getElementById('subscriptionDetails');
@ -16685,10 +17276,21 @@
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0); 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'); const actions = document.getElementById('subscriptionActions');
if (!actions) return; if (!actions) return;
const buttons = []; 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') { 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>`); buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
} }
@ -16748,17 +17350,26 @@
} }
try { try {
const res = await fetch('/api/v1/sag-subscriptions', { const isEditing = subscriptionEditMode && currentSubscription?.id;
method: 'POST', const url = isEditing
headers: { 'Content-Type': 'application/json' }, ? `/api/v1/sag-subscriptions/${currentSubscription.id}`
body: JSON.stringify({ : '/api/v1/sag-subscriptions';
sag_id: subscriptionCaseId, const method = isEditing ? 'PATCH' : 'POST';
const payload = {
billing_interval: billingInterval, billing_interval: billingInterval,
billing_day: billingDay, billing_day: billingDay,
start_date: startDate, start_date: startDate,
notes: notes || null, notes: notes || null,
line_items: lineItems 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) { if (!res.ok) {
@ -16768,6 +17379,9 @@
const subscription = await res.json(); const subscription = await res.json();
renderSubscription(subscription); renderSubscription(subscription);
if (subscription?.requires_network_provisioning) {
await loadSubscriptionProvisioningInline();
}
} catch (e) { } catch (e) {
alert(e.message || e); alert(e.message || e);
} }

View File

@ -34,6 +34,50 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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") @router.post("/sms/send")
async def send_sms(payload: SmsSendRequest, request: Request): async def send_sms(payload: SmsSendRequest, request: Request):
user_id = getattr(request.state, "user_id", None) user_id = getattr(request.state, "user_id", None)
@ -48,7 +92,8 @@ async def send_sms(payload: SmsSendRequest, request: Request):
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt") raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
try: try:
result = SmsService.send_sms(payload.to, payload.message, payload.sender) recipient_number = _resolve_sms_recipient_from_contact(payload.to, contact_id)
result = SmsService.send_sms(recipient_number, payload.message, payload.sender)
execute_query( execute_query(
""" """
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response) INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
@ -57,7 +102,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
( (
contact_id, contact_id,
user_id, user_id,
result.get("recipient") or payload.to, result.get("recipient") or recipient_number or payload.to,
payload.sender or settings.SMS_SENDER, payload.sender or settings.SMS_SENDER,
payload.message, payload.message,
"sent", "sent",
@ -700,9 +745,7 @@ async def list_calls(
t.bruger_id, t.bruger_id,
t.direction, t.direction,
t.ekstern_nummer, t.ekstern_nummer,
COALESCE( NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.intern_extension, t.intern_extension,
t.kontakt_id, t.kontakt_id,
t.sag_id, t.sag_id,
@ -744,13 +787,13 @@ async def list_calls(
repaired = normalize_external_number(display_raw) repaired = normalize_external_number(display_raw)
if repaired: if repaired:
row["display_number"] = repaired row["display_number"] = repaired
if rows:
return rows
# Fallback: legacy mission call history (read-only rows) for environments # Legacy mission call history (read-only rows) for environments where
# where historical calls were stored before telefoni_opkald was populated. # 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 user_id is not None:
return [] return rows
legacy_where = [] legacy_where = []
legacy_params = [] legacy_params = []
@ -803,9 +846,34 @@ async def list_calls(
except Exception: except Exception:
legacy_rows = [] 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: if without_case:
return [r for r in legacy_rows if not r.get("sag_id")] return [r for r in paged_rows if not r.get("sag_id")]
return legacy_rows return paged_rows
@router.patch("/telefoni/calls/{call_id}") @router.patch("/telefoni/calls/{call_id}")

View File

@ -20,9 +20,7 @@ async def telefoni_log_page(request: Request):
SELECT SELECT
t.id, t.id,
t.direction, t.direction,
COALESCE( NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.started_at, t.started_at,
t.duration_sec, t.duration_sec,
t.ended_at, t.ended_at,

View File

@ -271,14 +271,11 @@ function normalizeDisplayNumber(value) {
if (digits.length >= 10 && digits.slice(0, 2) === '45') { if (digits.length >= 10 && digits.slice(0, 2) === '45') {
return `+${digits.slice(0, 10)}`; 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')) { if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
return `+${digits.slice(-10)}`; return `+${digits.slice(-10)}`;
} }
if (digits.length >= 8) { if (digits.length >= 9) {
return `+45${digits.slice(-8)}`; return `+${digits.slice(-15)}`;
} }
} }
@ -286,7 +283,7 @@ function normalizeDisplayNumber(value) {
return digits.length >= 9 ? `+${digits}` : raw; return digits.length >= 9 ? `+${digits}` : raw;
} }
if (digits.length === 8) return `+45${digits}`; if (digits.length === 8) return raw;
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`; if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
if (digits.length >= 9) return `+${digits}`; if (digits.length >= 9) return `+${digits}`;
return raw; return raw;
@ -1215,13 +1212,6 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('filterTo').addEventListener('change', loadCalls); document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').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 }); await loadCalls({ preserveOnEmpty: true, skipLoadingState: true });
}); });
</script> </script>

View File

@ -16,7 +16,9 @@ router = APIRouter()
async def list_opportunities( async def list_opportunities(
q: Optional[str] = None, q: Optional[str] = None,
stage: Optional[str] = None, stage: Optional[str] = None,
status: Optional[str] = None status: Optional[str] = None,
customer_id: Optional[int] = Query(default=None),
contact_id: Optional[int] = Query(default=None),
): ):
""" """
List all 'pipeline' cases. List all 'pipeline' cases.
@ -71,6 +73,33 @@ async def list_opportunities(
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)" query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
params.extend([f"%{q}%", f"%{q}%"]) params.extend([f"%{q}%", f"%{q}%"])
if customer_id is not None:
query += """
AND (
s.customer_id = %s
OR EXISTS (
SELECT 1
FROM sag_kunder sk
WHERE sk.sag_id = s.id
AND sk.customer_id = %s
AND sk.deleted_at IS NULL
)
)
"""
params.extend([customer_id, customer_id])
if contact_id is not None:
query += """
AND EXISTS (
SELECT 1
FROM sag_kontakter sk
WHERE sk.sag_id = s.id
AND sk.contact_id = %s
AND sk.deleted_at IS NULL
)
"""
params.append(contact_id)
if status and status != 'all': if status and status != 'all':
if status == 'open': if status == 'open':
query += " AND s.status = 'åben'" query += " AND s.status = 'åben'"

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -9,3 +10,8 @@ templates = Jinja2Templates(directory="app")
@router.get("/opportunities", response_class=HTMLResponse) @router.get("/opportunities", response_class=HTMLResponse)
async def opportunities_page(request: Request): async def opportunities_page(request: Request):
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request}) return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
@router.get("/opportunities/{opportunity_id}", include_in_schema=False)
async def opportunity_detail_redirect(opportunity_id: int):
return RedirectResponse(url=f"/sag/{opportunity_id}/v3", status_code=307)

View File

@ -668,6 +668,7 @@ async def list_products(
serial_number_required, serial_number_required,
asset_required, asset_required,
rental_asset_enabled, rental_asset_enabled,
attributes_json,
image_url image_url
FROM products FROM products
{where_clause} {where_clause}

View File

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

View File

@ -60,6 +60,299 @@ class Invoice2DataService:
logger.warning("⚠️ No template matched") logger.warning("⚠️ No template matched")
return None 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]: def extract_with_template(self, text: str, template_name: str) -> Dict[str, Any]:
""" """
Extract invoice data using specific template Extract invoice data using specific template
@ -68,6 +361,9 @@ class Invoice2DataService:
raise ValueError(f"Template not found: {template_name}") raise ValueError(f"Template not found: {template_name}")
template = self.templates[template_name] template = self.templates[template_name]
if template_name == "dk.globalconnect":
return self._extract_globalconnect(text, template_name, template)
fields = template.get('fields', {}) fields = template.get('fields', {})
options = template.get('options', {}) options = template.get('options', {})
@ -113,49 +409,14 @@ class Invoice2DataService:
# Convert type # Convert type
if field_type == 'float': 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', ',') decimal_sep = options.get('decimal_separator', ',')
thousands_sep = options.get('thousands_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': elif field_type == 'int':
value = int(value) value = int(value)
elif field_type == 'date': elif field_type == 'date':
# Try to parse Danish dates
date_formats = options.get('date_formats', ['%B %d, %Y', '%d-%m-%Y']) 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 extracted[field_name] = value
logger.debug(f"{field_name}: {value}") logger.debug(f"{field_name}: {value}")
@ -396,6 +657,17 @@ class Invoice2DataService:
if vat_amount is not None: if vat_amount is not None:
subtotal = total_amount - vat_amount 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) # Check if line sum matches subtotal (allow 1 DKK difference for rounding)
if abs(line_sum - subtotal) > 1.0: 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})") logger.warning(f"⚠️ Amount validation: Line sum {line_sum:.2f} != subtotal {subtotal:.2f} (diff: {abs(line_sum - subtotal):.2f})")
@ -406,12 +678,17 @@ class Invoice2DataService:
# Check VAT calculation (25%) # Check VAT calculation (25%)
if vat_amount is not None: if vat_amount is not None:
expected_vat = subtotal * 0.25 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: if abs(vat_amount - expected_vat) > 1.0:
logger.warning(f"⚠️ VAT validation: VAT {vat_amount:.2f} != 25% of {subtotal:.2f} ({expected_vat:.2f})") 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})" extracted['_vat_warning'] = f"Moms ({vat_amount:.2f}) passer ikke med 25% af subtotal ({expected_vat:.2f})"
else: else:
logger.info(f"✅ VAT validation: 25% VAT calculation correct ({vat_amount:.2f})") logger.info(f"✅ VAT validation: 25% VAT calculation correct ({vat_amount:.2f})")
extracted['_validation_details'] = validation_details
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Amount validation failed: {e}") logger.warning(f"⚠️ Amount validation failed: {e}")

View File

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

View File

@ -14,7 +14,6 @@ import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from collections import defaultdict from collections import defaultdict
from app.services.economic_service import get_economic_service
from app.core.database import execute_query from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,9 +22,6 @@ logger = logging.getLogger(__name__)
class SubscriptionMatrixService: class SubscriptionMatrixService:
"""Generate billing matrix for customer subscriptions""" """Generate billing matrix for customer subscriptions"""
def __init__(self):
self.economic_service = get_economic_service()
async def generate_billing_matrix( async def generate_billing_matrix(
self, self,
customer_id: int, customer_id: int,
@ -87,13 +83,9 @@ class SubscriptionMatrixService:
economic_customer_number = customer[0]['economic_customer_number'] economic_customer_number = customer[0]['economic_customer_number']
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}") logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
# Fetch invoices from e-conomic # Fetch imported invoice snapshot from local invoice_error_finder tables
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}") invoices = self._load_imported_invoices(str(economic_customer_number))
invoices = await self.economic_service.get_customer_invoices( logger.info(f"🔍 [MATRIX] Loaded %s imported invoices from local snapshot", len(invoices))
economic_customer_number,
include_lines=True
)
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
if not invoices: if not invoices:
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}") logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
@ -131,6 +123,125 @@ class SubscriptionMatrixService:
"products": [] "products": []
} }
def _load_imported_invoices(self, economic_customer_number: str) -> List[Dict]:
rows = execute_query(
"""
WITH ranked_invoices AS (
SELECT
inv.id,
inv.source_invoice_number,
inv.source_type,
inv.invoice_date,
inv.due_date,
inv.net_amount,
inv.vat_amount,
inv.total_amount,
inv.currency,
inv.source_raw,
CASE inv.source_type
WHEN 'paid' THEN 1
WHEN 'booked' THEN 2
WHEN 'unpaid' THEN 3
WHEN 'draft' THEN 4
ELSE 9
END AS source_rank
FROM invoice_error_finder_economic_invoices inv
WHERE inv.customer_number = %s
),
selected_invoices AS (
SELECT DISTINCT ON (source_invoice_number)
id,
source_invoice_number,
source_type,
invoice_date,
due_date,
net_amount,
vat_amount,
total_amount,
currency,
source_raw
FROM ranked_invoices
ORDER BY source_invoice_number, source_rank, invoice_date DESC, id DESC
)
SELECT
si.id AS invoice_id,
si.source_invoice_number,
si.source_type,
si.invoice_date,
si.due_date,
si.net_amount,
si.vat_amount,
si.total_amount,
si.currency,
si.source_raw AS invoice_source_raw,
line.line_number,
line.product_number,
line.product_name,
line.description,
line.quantity,
line.unit_price,
line.line_net_amount,
line.source_raw AS line_source_raw
FROM selected_invoices si
LEFT JOIN invoice_error_finder_economic_invoice_lines line
ON line.invoice_id = si.id
ORDER BY si.invoice_date DESC NULLS LAST, si.source_invoice_number DESC, line.line_number ASC
""",
(economic_customer_number,),
) or []
invoices: List[Dict] = []
invoices_by_id: Dict[int, Dict] = {}
for row in rows:
invoice_id = row.get("invoice_id")
if invoice_id is None:
continue
invoice_source_raw = self._ensure_dict(row.get("invoice_source_raw"))
line_source_raw = self._ensure_dict(row.get("line_source_raw"))
if invoice_id not in invoices_by_id:
invoice_payload = {
"id": invoice_id,
"status": row.get("source_type"),
"bookedInvoiceNumber": row.get("source_invoice_number"),
"date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
"dueDate": row.get("due_date").isoformat() if row.get("due_date") else None,
"netAmount": float(row.get("net_amount") or 0),
"vatAmount": float(row.get("vat_amount") or 0),
"grossAmount": float(row.get("total_amount") or 0),
"currency": row.get("currency") or "DKK",
"notes": invoice_source_raw.get("notes"),
"heading": invoice_source_raw.get("heading"),
"description": invoice_source_raw.get("description"),
"text": invoice_source_raw.get("text"),
"subject": invoice_source_raw.get("subject"),
"otherReference": invoice_source_raw.get("otherReference"),
"orderNumberDb": invoice_source_raw.get("orderNumberDb"),
"lines": [],
}
invoices_by_id[invoice_id] = invoice_payload
invoices.append(invoice_payload)
if row.get("line_number") is None:
continue
invoices_by_id[invoice_id]["lines"].append({
"lineNumber": row.get("line_number"),
"description": row.get("description"),
"quantity": float(row.get("quantity") or 0),
"unitNetPrice": float(row.get("unit_price") or 0),
"totalNetAmount": float(row.get("line_net_amount") or 0),
"period": (line_source_raw.get("period") if isinstance(line_source_raw, dict) else None) or {},
"product": {
"productNumber": row.get("product_number"),
"name": row.get("product_name"),
},
})
return invoices
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]: def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
""" """
Group invoice lines by product number and aggregate by month Group invoice lines by product number and aggregate by month
@ -378,6 +489,18 @@ class SubscriptionMatrixService:
return products return products
@staticmethod
def _ensure_dict(value) -> Dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
@staticmethod @staticmethod
def _generate_month_range(num_months: int) -> List[str]: def _generate_month_range(num_months: int) -> List[str]:
""" """

View File

@ -168,7 +168,7 @@ async def get_setting(key: str):
seed_query, seed_query,
( (
"case_types", "case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]', '["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]',
"system", "system",
"Sags-typer", "Sags-typer",
"json", "json",
@ -416,6 +416,14 @@ async def execute_migration_api(payload: dict):
return execute_migration(model) return execute_migration(model)
@router.post("/settings/migrations/execute-missing", tags=["Settings"])
async def execute_missing_migrations_api():
"""Run schema-detected missing migrations via the API namespace."""
from app.settings.backend.views import execute_missing_migrations
return execute_missing_migrations()
@router.post("/settings/sync-from-env", tags=["Settings"]) @router.post("/settings/sync-from-env", tags=["Settings"])
async def sync_settings_from_env(): async def sync_settings_from_env():
"""Sync settings from .env file into database (only updates empty values)""" """Sync settings from .env file into database (only updates empty values)"""
@ -992,4 +1000,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}") logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__ err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")

View File

@ -3,6 +3,7 @@ Settings Frontend Views
""" """
from datetime import datetime from datetime import datetime
import time
from pathlib import Path from pathlib import Path
import re import re
from fastapi import APIRouter, Request, HTTPException, Depends from fastapi import APIRouter, Request, HTTPException, Depends
@ -414,3 +415,55 @@ def execute_migration(payload: MigrationExecution):
release_db_connection(conn) release_db_connection(conn)
return {"message": "Migration executed successfully"} return {"message": "Migration executed successfully"}
@router.post("/settings/migrations/execute-missing", tags=["Frontend"])
def execute_missing_migrations():
"""Run schema-detected missing migrations in numeric order and return a per-file log."""
migrations_dir = Path(__file__).resolve().parents[3] / "migrations"
files = sorted(migrations_dir.glob("*.sql"), key=_migration_sort_key) if migrations_dir.exists() else []
conn = get_db_connection()
logs = []
try:
actual_tables, actual_columns, actual_indexes = _get_actual_schema_snapshot(conn)
candidates = []
for migration_file in files:
sql = migration_file.read_text(encoding="utf-8")
status = _status_for_migration_file(sql, actual_tables, actual_columns, actual_indexes)
if status["status"] == "red":
candidates.append((migration_file, sql, status))
for migration_file, sql, status in candidates:
started = time.monotonic()
try:
with conn.cursor() as cursor:
cursor.execute(sql)
conn.commit()
logs.append({
"file_name": migration_file.name,
"status": "success",
"summary": status["summary"],
"duration_ms": round((time.monotonic() - started) * 1000),
})
except Exception as exc:
conn.rollback()
logs.append({
"file_name": migration_file.name,
"status": "failed",
"summary": str(exc).splitlines()[0],
"duration_ms": round((time.monotonic() - started) * 1000),
})
return {
"message": "Manglende migrationer behandlet",
"checked": len(files),
"candidates": len(candidates),
"executed": sum(item["status"] == "success" for item in logs),
"failed": sum(item["status"] == "failed" for item in logs),
"logs": logs,
"detection_note": "Kun røde migrationer med manglende schema-elementer køres automatisk. Grå migrationer kræver manuel vurdering.",
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Kørsel af manglende migrationer fejlede: {exc}")
finally:
release_db_connection(conn)

View File

@ -69,6 +69,9 @@
<button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()"> <button id="checkMigrationStatusBtn" class="btn btn-sm btn-outline-success" onclick="checkMigrationStatuses()">
<i class="bi bi-check2-circle me-1"></i>Tjek status <i class="bi bi-check2-circle me-1"></i>Tjek status
</button> </button>
<button id="runMissingMigrationsBtn" class="btn btn-sm btn-success" onclick="runMissingMigrations()">
<i class="bi bi-play-fill me-1"></i>Kør manglende
</button>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@ -356,5 +359,35 @@
button.disabled = false; button.disabled = false;
} }
} }
async function runMissingMigrations() {
const button = document.getElementById('runMissingMigrationsBtn');
const feedback = document.getElementById('migrationFeedback');
button.disabled = true;
feedback.className = 'alert alert-info mt-3';
feedback.textContent = 'Tjekker og kører manglende migrationer...';
feedback.classList.remove('d-none');
try {
const urls = buildMigrationActionUrls('execute-missing');
let data = null;
let lastError = null;
for (const url of urls) {
const response = await fetch(url, {method: 'POST', credentials: 'include'});
const payload = await response.json().catch(() => ({}));
if (response.ok) { data = payload; break; }
if (response.status !== 404 && response.status !== 405) throw new Error(payload.detail || `HTTP ${response.status}`);
lastError = payload.detail || `HTTP ${response.status}`;
}
if (!data) throw new Error(lastError || 'Endpointet blev ikke fundet');
const logs = (data.logs || []).map(item => `${item.status === 'success' ? '✓' : '✗'} ${item.file_name}: ${item.summary} (${item.duration_ms} ms)`).join('\n');
feedback.className = data.failed ? 'alert alert-warning mt-3' : 'alert alert-success mt-3';
feedback.innerHTML = `<strong>${data.executed} kørt, ${data.failed} fejlet, ${data.candidates} fundet.</strong><pre class="mb-0 mt-2">${logs || 'Ingen manglende migrationer fundet.'}</pre><div class="small mt-2">${data.detection_note}</div>`;
} catch (error) {
feedback.className = 'alert alert-danger mt-3';
feedback.textContent = `Fejl: ${error.message}`;
} finally {
button.disabled = false;
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -184,6 +184,16 @@
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
<div>
<h5 class="mb-1 fw-bold">Faktura-fejl-finder</h5>
<p class="text-muted mb-0">Varetekster som skal ignoreres i analysen, fx gebyrer, porto og fragt.</p>
</div>
</div>
<div id="invoiceErrorFinderSettingsCard"></div>
</div>
<div class="card p-4 mt-4"> <div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
@ -2374,7 +2384,8 @@ async function testTelefoniCall() {
function renderDriftConnectors() { function renderDriftConnectors() {
const container = document.getElementById('driftConnectorCards'); const container = document.getElementById('driftConnectorCards');
if (!container) return; const invoiceErrorFinderContainer = document.getElementById('invoiceErrorFinderSettingsCard');
if (!container && !invoiceErrorFinderContainer) return;
const connectors = [ const connectors = [
{ {
@ -2440,10 +2451,35 @@ function renderDriftConnectors() {
<span id="uispSaveStatus" class="small text-muted"></span> <span id="uispSaveStatus" class="small text-muted"></span>
</div> </div>
` `
},
{
key: 'invoice-error-finder',
title: 'Faktura-fejl-finder',
description: 'Styr hvilke varetekster der skal ignoreres, sa gebyrer og fragt ikke opretter falske fejl.',
badge: 'Okonomi',
body: `
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Ignorer varetekster</label>
<div class="input-group">
<input type="text" class="form-control" id="invoiceErrorFinderIgnoreInput" placeholder="fx Faktureringsgebyr, Porto eller Fragt" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="addInvoiceErrorFinderIgnoreItem()">Tilfoej</button>
</div>
<div class="form-text">Matcher paa varetekst og beskrivelse i e-conomic samt varenavn i Simply-ordrer.</div>
<div id="invoiceErrorFinderIgnoreList" class="d-flex flex-wrap gap-2 mt-2"></div>
</div>
</div>
<div class="d-flex align-items-center gap-3 mt-4">
<button class="btn btn-primary" onclick="saveInvoiceErrorFinderSettings()">
<i class="bi bi-save me-2"></i>Gem faktura-fejl-finder
</button>
<span id="invoiceErrorFinderSaveStatus" class="small text-muted"></span>
</div>
`
} }
]; ];
container.innerHTML = connectors.map(connector => ` const renderConnectorCard = (connector) => `
<div class="card border-0 bg-light p-4"> <div class="card border-0 bg-light p-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-3"> <div class="d-flex align-items-center justify-content-between gap-2 mb-3">
<div> <div>
@ -2454,7 +2490,21 @@ function renderDriftConnectors() {
</div> </div>
${connector.body} ${connector.body}
</div> </div>
`).join(''); `;
if (container) {
container.innerHTML = connectors
.filter(connector => connector.key !== 'invoice-error-finder')
.map(renderConnectorCard)
.join('');
}
if (invoiceErrorFinderContainer) {
const invoiceErrorFinderConnector = connectors.find(connector => connector.key === 'invoice-error-finder');
invoiceErrorFinderContainer.innerHTML = invoiceErrorFinderConnector
? renderConnectorCard(invoiceErrorFinderConnector)
: '';
}
} }
async function loadSettings() { async function loadSettings() {
@ -2477,6 +2527,7 @@ async function loadSettings() {
renderDriftConnectors(); renderDriftConnectors();
await loadUptimeKumaSettings(); await loadUptimeKumaSettings();
await loadUISPSettings(); await loadUISPSettings();
await loadInvoiceErrorFinderSettings();
await loadLabelPrinterSettings(); await loadLabelPrinterSettings();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@ -2805,6 +2856,30 @@ async function loadUISPSettings() {
} }
let driftBlacklistItems = []; let driftBlacklistItems = [];
const DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS = [
'faktureringsgebyr',
'gebyr',
'porto',
'fragt',
'fragtomkostning',
'forsendelse',
'shipping',
'levering',
'engangsydelse',
'engangsarbejde',
'oprettelse',
'opstartsgebyr',
'installation',
'installationsgebyr',
'timeforbrug',
'arbejdstid',
'konsulenttimer',
'supporttid',
'teknikertid',
'montørtimer',
'projektarbejde'
];
let invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
function parseDriftBlacklistValue(rawValue) { function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim(); const raw = String(rawValue || '').trim();
@ -2862,6 +2937,131 @@ function removeDriftBlacklistItem(item) {
renderDriftBlacklistList(); renderDriftBlacklistList();
} }
function parseInvoiceErrorFinderIgnoreValue(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
let values = [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
values = parsed;
} else if (typeof parsed === 'string') {
values = [parsed];
}
} catch (e) {
values = raw.replaceAll(';', '\n').replaceAll(',', '\n').split('\n');
}
const seen = new Set();
const cleaned = [];
[...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS, ...values].forEach(item => {
const normalized = String(item || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
cleaned.push(normalized);
});
return cleaned;
}
function renderInvoiceErrorFinderIgnoreList() {
const list = document.getElementById('invoiceErrorFinderIgnoreList');
if (!list) return;
if (!invoiceErrorFinderIgnoreItems.length) {
list.innerHTML = '<span class="text-muted small">Ingen varetekster ignoreres endnu.</span>';
return;
}
list.innerHTML = invoiceErrorFinderIgnoreItems.map(item => {
const safe = String(item).replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<span class="badge text-bg-dark">${safe} <button type="button" class="btn btn-sm btn-link text-white p-0 ms-1" onclick="removeInvoiceErrorFinderIgnoreItem(${JSON.stringify(item)})" title="Fjern">&times;</button></span>`;
}).join('');
}
function addInvoiceErrorFinderIgnoreItem() {
const input = document.getElementById('invoiceErrorFinderIgnoreInput');
if (!input) return;
const value = String(input.value || '').trim().toLowerCase();
if (!value) return;
if (!invoiceErrorFinderIgnoreItems.includes(value)) {
invoiceErrorFinderIgnoreItems.push(value);
}
input.value = '';
renderInvoiceErrorFinderIgnoreList();
}
function removeInvoiceErrorFinderIgnoreItem(item) {
invoiceErrorFinderIgnoreItems = invoiceErrorFinderIgnoreItems.filter(v => v !== item);
renderInvoiceErrorFinderIgnoreList();
}
async function loadInvoiceErrorFinderSettings() {
try {
const response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', { credentials: 'include' });
if (!response.ok) {
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
renderInvoiceErrorFinderIgnoreList();
return;
}
const setting = await response.json();
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(setting?.value || '[]');
renderInvoiceErrorFinderIgnoreList();
} catch (e) {
console.warn('Invoice Error Finder settings load failed:', e);
invoiceErrorFinderIgnoreItems = [...DEFAULT_INVOICE_ERROR_FINDER_IGNORE_TEXTS];
renderInvoiceErrorFinderIgnoreList();
}
}
async function saveInvoiceErrorFinderSettings() {
const statusEl = document.getElementById('invoiceErrorFinderSaveStatus');
statusEl.textContent = 'Gemmer...';
statusEl.className = 'small text-muted';
const value = JSON.stringify(parseInvoiceErrorFinderIgnoreValue(invoiceErrorFinderIgnoreItems));
const payload = {
key: 'invoice_error_finder_ignored_product_texts',
value,
category: 'finance',
description: 'JSON array of invoice product texts/descriptions ignored by Invoice Error Finder',
value_type: 'string',
is_public: false
};
try {
let response = await fetch('/api/v1/settings/invoice_error_finder_ignored_product_texts', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
});
if (response.status === 404 || response.status === 405) {
response = await fetch('/api/v1/settings', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme ignore-listen'));
}
invoiceErrorFinderIgnoreItems = parseInvoiceErrorFinderIgnoreValue(value);
renderInvoiceErrorFinderIgnoreList();
statusEl.textContent = '✅ Gemt';
statusEl.className = 'small text-success';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
showNotification('Ignore-liste gemt', 'success');
} catch (error) {
statusEl.textContent = '❌ Kunne ikke gemme';
statusEl.className = 'small text-danger';
showNotification(error.message || 'Kunne ikke gemme ignore-listen', 'error');
}
}
async function saveUISPSettings() { async function saveUISPSettings() {
const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim(); const baseUrl = (document.getElementById('uispBaseUrl').value || '').trim();
const apiToken = (document.getElementById('uispApiToken').value || '').trim(); const apiToken = (document.getElementById('uispApiToken').value || '').trim();

View File

@ -1000,6 +1000,10 @@
<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-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-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-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> </ul>
</li> </li>
</ul> </ul>
@ -1015,6 +1019,7 @@
<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-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-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-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-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> <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> </ul>
@ -1427,7 +1432,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/task-template-selector.js?v=1.1"></script> <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/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.4"></script> <script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.0"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.43"></script> <script src="/static/js/bottom-bar.js?v=2.43"></script>
<script> <script>
@ -1699,13 +1704,13 @@ if (bmcOriginalFetch) {
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
} }
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate // Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
} }
// ESC to close // ESC to close
@ -1714,22 +1719,14 @@ if (bmcOriginalFetch) {
} }
}); });
// QuickCreate modal opener function function openNewCasePage() {
function openQuickCreateModal() { window.location.href = '/sag/new';
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
quickCreateModal.show();
setTimeout(() => {
const textInput = document.getElementById('quickCreateText');
if (textInput) {
textInput.focus();
}
}, 300);
} }
// QuickCreate button click handler // QuickCreate button click handler
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => { document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
openQuickCreateModal(); openNewCasePage();
}); });
// Reset search when modal is closed // Reset search when modal is closed
@ -2252,9 +2249,6 @@ if (bmcOriginalFetch) {
}); });
</script> </script>
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Manual Help Modal --> <!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %} {% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
@ -2300,6 +2294,11 @@ if (bmcOriginalFetch) {
<label class="form-label fw-semibold">Mobilnummer</label> <label class="form-label fw-semibold">Mobilnummer</label>
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78"> <input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div> </div>
<div class="col-md-6">
<label class="form-label fw-semibold">Standard sagstype</label>
<select class="form-select" id="prof_default_case_type"></select>
<div class="form-text">Bruges som udgangspunkt på “Ny sag”.</div>
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold"> <label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs <i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
@ -2439,6 +2438,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' }, { 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-wizard', label: 'Data migration: Servicekontrakt Migration' },
{ key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' }, { 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-orders', label: 'Data migration: Ordrer' },
{ key: 'menu-datamigration-customers', label: 'Data migration: Kunder' }, { key: 'menu-datamigration-customers', label: 'Data migration: Kunder' },
]; ];
@ -2667,10 +2667,27 @@ if (bmcOriginalFetch) {
document.getElementById('prof_full_name').value = p.full_name || ''; document.getElementById('prof_full_name').value = p.full_name || '';
document.getElementById('prof_title').value = p.title || ''; document.getElementById('prof_title').value = p.title || '';
document.getElementById('prof_phone').value = p.phone || ''; document.getElementById('prof_phone').value = p.phone || '';
await loadProfileCaseTypes(p.default_case_type || 'ticket');
} catch (e) { console.error('Failed to load profile', e); } } catch (e) { console.error('Failed to load profile', e); }
loadAnyDeskChips(); loadAnyDeskChips();
} }
async function loadProfileCaseTypes(selectedType = 'ticket') {
const select = document.getElementById('prof_default_case_type');
if (!select) return;
const labels = { ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service' };
try {
const res = await fetch('/api/v1/settings/case_types', { credentials: 'include' });
const setting = res.ok ? await res.json() : { value: '[]' };
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(value => String(value).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(labels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${labels[type] || type}</option>`).join('');
select.value = finalTypes.includes(selectedType) ? selectedType : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (e) { console.error('Failed to load profile case types', e); }
}
function buildInitials(name) { function buildInitials(name) {
const clean = String(name || '').trim(); const clean = String(name || '').trim();
if (!clean) return 'BR'; if (!clean) return 'BR';
@ -2757,6 +2774,7 @@ if (bmcOriginalFetch) {
full_name: document.getElementById('prof_full_name').value || null, full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null, title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null, phone: document.getElementById('prof_phone').value || null,
default_case_type: document.getElementById('prof_default_case_type').value || 'ticket',
}; };
try { try {
const res = await fetch('/api/v1/auth/me/profile', { const res = await fetch('/api/v1/auth/me/profile', {

View File

@ -14,6 +14,7 @@ from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from fastapi import Request from fastapi import Request
from app.services.simplycrm_service import SimplyCRMService from app.services.simplycrm_service import SimplyCRMService
from app.modules.internet_connections.backend.provisioning_utils import summarize_subscription_network_requirements
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -26,6 +27,108 @@ ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"} 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: def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
if status == "approved": if status == "approved":
return "approved" return "approved"
@ -239,26 +342,8 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
if allow_missing: if allow_missing:
return {"subscription": None, "line_items": []} return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found") raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query( subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
""" return _attach_network_provisioning(subscription)
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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -579,8 +664,8 @@ async def create_subscription(payload: Dict[str, Any]):
conn.commit() conn.commit()
subscription["line_items"] = cleaned_items subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
return subscription return _attach_network_provisioning(dict(subscription))
finally: finally:
release_db_connection(conn) release_db_connection(conn)
except HTTPException: except HTTPException:
@ -594,78 +679,7 @@ async def create_subscription(payload: Dict[str, Any]):
async def get_subscription(subscription_id: int): async def get_subscription(subscription_id: int):
"""Get a single subscription by ID with all details.""" """Get a single subscription by ID with all details."""
try: try:
query = """ return _load_subscription_with_context(subscription_id)
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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -686,6 +700,45 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
# Extract line_items before processing other fields # Extract line_items before processing other fields
line_items = payload.pop("line_items", None) 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 # Build dynamic update query
allowed_fields = { allowed_fields = {
@ -729,7 +782,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
result = cursor.fetchone() result = cursor.fetchone()
# Update line items if provided # Update line items if provided
if line_items is not None: if normalized_line_items is not None:
# Delete existing line items # Delete existing line items
cursor.execute( cursor.execute(
"DELETE FROM sag_subscription_items WHERE subscription_id = %s", "DELETE FROM sag_subscription_items WHERE subscription_id = %s",
@ -737,16 +790,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
) )
# Insert new line items # Insert new line items
for idx, item in enumerate(line_items, start=1): for idx, item in enumerate(normalized_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( cursor.execute(
""" """
INSERT INTO sag_subscription_items ( INSERT INTO sag_subscription_items (
@ -759,8 +803,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) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
subscription_id, idx, description, subscription_id, idx, item["description"],
quantity, unit_price, line_total, item["quantity"], item["unit_price"], item["line_total"],
item.get("product_id"), item.get("product_id"),
item.get("asset_id"), item.get("asset_id"),
item.get("period_from"), item.get("period_from"),
@ -775,7 +819,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
) )
conn.commit() conn.commit()
return result return _load_subscription_with_context(subscription_id)
finally: finally:
release_db_connection(conn) release_db_connection(conn)
except HTTPException: except HTTPException:
@ -802,7 +846,7 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
result = execute_query(query, (status, subscription_id)) result = execute_query(query, (status, subscription_id))
if not result: if not result:
raise HTTPException(status_code=404, detail="Subscription not found") raise HTTPException(status_code=404, detail="Subscription not found")
return result[0] return _load_subscription_with_context(subscription_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -7,9 +7,9 @@ SYNC ARCHITECTURE - Field Ownership:
E-CONOMIC owns and syncs: E-CONOMIC owns and syncs:
- economic_customer_number (primary key from e-conomic) - economic_customer_number (primary key from e-conomic)
- address, city, postal_code, country (physical address) - name, phone, address, city, postal_code, country (company and physical address)
- email_domain, website (contact information) - email_domain, website (contact information)
- cvr_number (used for matching only, not overwritten if already set) - cvr_number (company metadata; may be shared by several customers)
vTIGER owns and syncs: vTIGER owns and syncs:
- vtiger_id (primary key from vTiger) - vtiger_id (primary key from vTiger)
@ -18,7 +18,7 @@ vTIGER owns and syncs:
HUB owns (manual or first-sync only): HUB owns (manual or first-sync only):
- name (can be synced initially but not overwritten) - name (can be synced initially but not overwritten)
- cvr_number (used for matching, set once) - cvr_number (informational; can be refreshed from e-conomic)
- Tags, notes, custom fields - Tags, notes, custom fields
SYNC RULES: SYNC RULES:
@ -163,6 +163,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
country = eco_customer.get('country', 'DK') country = eco_customer.get('country', 'DK')
email = eco_customer.get('email', '') email = eco_customer.get('email', '')
website = eco_customer.get('website', '') website = eco_customer.get('website', '')
phone = eco_customer.get('phone') or eco_customer.get('telephone') or ''
if not customer_number or not name: if not customer_number or not name:
skipped_count += 1 skipped_count += 1
@ -192,7 +193,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
# Strict matching: ONLY match by economic_customer_number # Strict matching: ONLY match by economic_customer_number
existing = execute_query( existing = execute_query(
""" """
SELECT id, name, email_domain, address, city, postal_code, country, website SELECT id, name, phone, cvr_number, email_domain, address, city, postal_code, country, website
FROM customers FROM customers
WHERE economic_customer_number = %s WHERE economic_customer_number = %s
ORDER BY id ORDER BY id
@ -221,6 +222,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if existing: if existing:
target_customer_id = existing[0]['id'] target_customer_id = existing[0]['id']
current_values = { current_values = {
"name": existing[0].get("name"),
"phone": existing[0].get("phone"),
"cvr_number": existing[0].get("cvr_number"),
"email_domain": existing[0].get("email_domain"), "email_domain": existing[0].get("email_domain"),
"address": existing[0].get("address"), "address": existing[0].get("address"),
"city": existing[0].get("city"), "city": existing[0].get("city"),
@ -229,6 +233,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
"website": existing[0].get("website"), "website": existing[0].get("website"),
} }
proposed_values = { proposed_values = {
"name": name,
"phone": phone,
"cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
"address": address, "address": address,
"city": city, "city": city,
@ -252,6 +259,9 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
update_query = """ update_query = """
UPDATE customers SET UPDATE customers SET
economic_customer_number = %s, economic_customer_number = %s,
name = %s,
phone = %s,
cvr_number = %s,
email_domain = %s, email_domain = %s,
address = %s, address = %s,
city = %s, city = %s,
@ -262,7 +272,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
WHERE id = %s WHERE id = %s
""" """
execute_query(update_query, ( execute_query(update_query, (
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id customer_number, name, phone, cvr, email_domain, address, city, zip_code, country, website, target_customer_id
)) ))
logger.info( logger.info(
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", "✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
@ -276,6 +286,7 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
else: else:
would_create.append({ would_create.append({
"name": name, "name": name,
"phone": phone,
"economic_customer_number": customer_number, "economic_customer_number": customer_number,
"cvr_number": cvr, "cvr_number": cvr,
"email_domain": email_domain, "email_domain": email_domain,
@ -289,13 +300,13 @@ async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]
if apply_changes: if apply_changes:
insert_query = """ insert_query = """
INSERT INTO customers INSERT INTO customers
(name, economic_customer_number, cvr_number, email_domain, (name, phone, economic_customer_number, cvr_number, email_domain,
address, city, postal_code, country, website, last_synced_at) address, city, postal_code, country, website, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id RETURNING id
""" """
result = execute_query(insert_query, ( result = execute_query(insert_query, (
name, customer_number, cvr, email_domain, address, city, zip_code, country, website name, phone, customer_number, cvr, email_domain, address, city, zip_code, country, website
)) ))
if result: if result:
logger.info( logger.info(

23
main.py
View File

@ -145,6 +145,10 @@ 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.backend import router as drift_api
from app.modules.drift.frontend import views as drift_views from app.modules.drift.frontend import views as drift_views
from app.modules.drift.backend.router import run_uptime_kuma_sync 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 from app.bug_reports.backend import router as bug_reports_api
# Configure logging # Configure logging
@ -281,6 +285,19 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ Drift Uptime Kuma sync job scheduled (every 120 seconds)") 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") logger.info("✅ System initialized successfully")
yield yield
# Shutdown # Shutdown
@ -367,6 +384,8 @@ async def auth_middleware(request: Request, call_next):
or any(path.startswith(prefix) for prefix in public_prefixes) or any(path.startswith(prefix) for prefix in public_prefixes)
or path.startswith("/static") or path.startswith("/static")
or path.startswith("/docs") or path.startswith("/docs")
or path.startswith("/api/v1/internet-connections")
or path.startswith("/economy/internet-connections")
): ):
return await call_next(request) return await call_next(request)
@ -479,6 +498,8 @@ 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(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(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) 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: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api from app.modules.links.backend import router as links_api
@ -516,6 +537,8 @@ app.include_router(fedex_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"]) app.include_router(anydesk_views.router, tags=["Frontend"])
app.include_router(manual_views.router, tags=["Frontend"]) app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(drift_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: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,13 @@
-- CVR is company metadata, not an external customer identity. Multiple
-- e-conomic customer records may legitimately share the same CVR number.
ALTER TABLE customers
DROP CONSTRAINT IF EXISTS customers_cvr_number_key;
DROP INDEX IF EXISTS customers_cvr_number_unique_idx;
CREATE INDEX IF NOT EXISTS idx_customers_cvr
ON customers(cvr_number)
WHERE cvr_number IS NOT NULL AND cvr_number <> '';
COMMENT ON COLUMN customers.cvr_number IS
'Danish CVR number. Informational/searchable; duplicates are allowed.';

View File

@ -0,0 +1,58 @@
-- 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;

View File

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

View File

@ -0,0 +1,75 @@
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);

View File

@ -0,0 +1,43 @@
-- 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);

View File

@ -0,0 +1,52 @@
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;

View File

@ -0,0 +1,31 @@
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;

View File

@ -0,0 +1,53 @@
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) = '');

View File

@ -0,0 +1,27 @@
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;

View File

@ -0,0 +1,159 @@
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)
);

View File

@ -0,0 +1,26 @@
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);

View File

@ -0,0 +1,35 @@
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;

View File

@ -0,0 +1,15 @@
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';

View File

@ -0,0 +1,8 @@
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';

View File

@ -0,0 +1,58 @@
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)
);

View File

@ -0,0 +1,68 @@
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);

View File

@ -0,0 +1,17 @@
-- 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;

View File

@ -0,0 +1,39 @@
-- 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;

View File

@ -0,0 +1,165 @@
-- 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();

View File

@ -0,0 +1,26 @@
-- Migration 213: allow smart-sync to mark invoice error finder issues as resolved
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'invoice_error_finder_issues_status_check'
) THEN
ALTER TABLE invoice_error_finder_issues
DROP CONSTRAINT invoice_error_finder_issues_status_check;
END IF;
END $$;
ALTER TABLE invoice_error_finder_issues
ADD CONSTRAINT invoice_error_finder_issues_status_check
CHECK (status IN (
'open',
'investigating',
'approved_change',
'error_found',
'ready_to_invoice',
'invoiced',
'ignored',
'resolved'
));

View File

@ -0,0 +1,15 @@
-- Per-user default for the type selected on the new-case page.
CREATE TABLE IF NOT EXISTS user_sag_create_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
default_case_type VARCHAR(50) NOT NULL DEFAULT 'ticket',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_sag_create_preferences_updated_at
ON user_sag_create_preferences(updated_at DESC);
-- Make pipeline available in installations that already have the configured list.
UPDATE settings
SET value = ((value::jsonb || '["pipeline"]'::jsonb)::text)
WHERE key = 'case_types'
AND NOT (value::jsonb ? 'pipeline');

View File

@ -0,0 +1,38 @@
-- Network wall outlets attached to buildings, floors, or rooms.
CREATE TABLE IF NOT EXISTS locations_wall_outlets (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
outlet_number VARCHAR(100) NOT NULL,
category VARCHAR(50),
patch_panel VARCHAR(255),
patch_port VARCHAR(100),
switch_name VARCHAR(255),
switch_port VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'unknown'
CHECK (status IN ('available', 'active', 'reserved', 'faulty', 'unknown')),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_wall_outlets_unique_location_number
ON locations_wall_outlets(location_id, lower(outlet_number))
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_location ON locations_wall_outlets(location_id);
CREATE INDEX IF NOT EXISTS idx_locations_wall_outlets_status ON locations_wall_outlets(status) WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_locations_wall_outlets_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_locations_wall_outlets_updated_at ON locations_wall_outlets;
CREATE TRIGGER trg_locations_wall_outlets_updated_at
BEFORE UPDATE ON locations_wall_outlets
FOR EACH ROW EXECUTE FUNCTION update_locations_wall_outlets_updated_at();

View File

@ -0,0 +1,17 @@
-- Location names are meaningful within a customer and hierarchy, not globally.
-- Example: every building may have an "1 Sal".
BEGIN;
ALTER TABLE locations_locations
DROP CONSTRAINT IF EXISTS locations_locations_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_name_scope_unique
ON locations_locations (
COALESCE(parent_location_id, 0),
COALESCE(customer_id, 0),
lower(name)
)
WHERE deleted_at IS NULL;
COMMIT;

View File

@ -0,0 +1,8 @@
-- A technical room can contain a network cross-connect / patch field without
-- becoming a separate location type.
ALTER TABLE locations_locations
ADD COLUMN IF NOT EXISTS has_cross_field BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_locations_has_cross_field
ON locations_locations(has_cross_field)
WHERE has_cross_field = TRUE AND deleted_at IS NULL;

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS locations_cross_fields (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
port_count INTEGER NOT NULL CHECK (port_count BETWEEN 1 AND 999),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cross_fields_location_name_active
ON locations_cross_fields(location_id, lower(name)) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS locations_cross_field_ports (
id SERIAL PRIMARY KEY,
cross_field_id INTEGER NOT NULL REFERENCES locations_cross_fields(id) ON DELETE CASCADE,
port_number INTEGER NOT NULL CHECK (port_number > 0),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(cross_field_id, port_number)
);
CREATE INDEX IF NOT EXISTS idx_cross_field_ports_field ON locations_cross_field_ports(cross_field_id);

View File

@ -0,0 +1,7 @@
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS cross_field_port_id INTEGER
REFERENCES locations_cross_field_ports(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wall_outlet_cross_field_port_active
ON locations_wall_outlets(cross_field_port_id)
WHERE cross_field_port_id IS NOT NULL AND deleted_at IS NULL;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
#!/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