release: v2.8.3 invoice and internet workflows

This commit is contained in:
Christian 2026-09-11 13:51:10 +02:00
parent 056ce7b871
commit 41a0785de2
22 changed files with 2006 additions and 301 deletions

View File

@ -0,0 +1,38 @@
# BMC Hub v2.8.3
## Internetforbindelser og abonnementer
- Ny hurtig oprettelse med særskilte flows for fysisk forbindelse, delt hovedfiber og BMCnet.
- Delt hovedfiber oprettes med BMC Networks som ejer og uden produkt, abonnement eller abonnementssag.
- BMCnet opretter og forbinder kunde, internetprodukt, abonnement og delt hovedforbindelse i ét flow.
- Kundens adresse bruges til tolerant forslag af delt hovedforbindelse, inklusive mindre staveforskelle.
- Forenklet BMCnet-formular, hvor tekniske værdier arves og kun et valgfrit notefelt vises.
- Forbedret overblik over delte hovedforbindelser, BMCnet-kunder, kapacitet og økonomi.
## Leverandørfakturaer
- Hybrid fakturaudtræk kombinerer tekst, layout og AI med kontrolleret fallback.
- Forbedret udtræk af danske beløb, fakturafelter og varelinjer fra forskellige leverandørformater.
- Template Builder viser fundne data tydeligere og håndterer AI-fejl mere robust.
- Internetfakturaer kan behandles og følges i internetforbindelsesmodulet.
## E-mail og arbejdsgange
- Forenklet valg af mailtype og arbejdsflow med tydeligere handlinger.
- Leverandørfaktura-flow kan registrere internetfakturaer til efterfølgende behandling.
- Forbedret leverandør- og kundematch samt mere kompakt visning af avancerede oplysninger.
## Sager, kontakter og brugerflade
- Kontakter fra andre virksomheder markeres tydeligt på en sag.
- Kontakter kan oprettes direkte fra kontaktsøgningen, også når søgningen ikke giver resultat.
- Bugrapporter bruger en hurtigere og mere stabil lokal indsamling af relevant sideinformation.
- Rettelser til ALSO Cloud-søgning, fælles navigation og øvrige betjeningsdetaljer.
- Plan for AI-baseret kundestemning og sikker fakturabehandling er gemt under `fremtidige planer`.
## Verifikation
- 279 tests bestået og 1 sprunget over.
- Fire kendte, ikke-release-relaterede tests fejler fortsat: tre forældede invoice-error mocks og én databaseafhængig deaktiveringstest.
- Nye regressionstests for hybrid fakturaudtræk og hurtig oprettelse af internetforbindelser består.
- Python- og JavaScript-syntaks samt diff-kontrol bestået.

View File

@ -1 +1 @@
2.8.2
2.8.3

View File

@ -2243,6 +2243,29 @@ def _smart_extract_lines(text: str) -> List[Dict]:
while i < len(lines_arr):
line = lines_arr[i].strip()
# Attach common continuation metadata to the preceding product row.
if items:
ean_match = re.match(r'^EAN\s*:\s*(\d{8,14})\s*$', line, re.IGNORECASE)
kn8_match = re.match(r'^KN8\s*:\s*(\d{6,10})\s*$', line, re.IGNORECASE)
serial_match = re.match(r'^S/N\s*:\s*(\S+)\s*$', line, re.IGNORECASE)
if ean_match:
items[-1]['ean'] = ean_match.group(1)
i += 1
continue
if kn8_match:
items[-1]['kn8'] = kn8_match.group(1)
i += 1
continue
if serial_match:
items[-1].setdefault('serial_numbers', []).append(serial_match.group(1))
i += 1
continue
if re.match(r'^Omvendt betalingspligt', line, re.IGNORECASE):
items[-1]['reverse_charge'] = True
items[-1]['vat_rate'] = 0.0
i += 1
continue
# Skip empty or header lines
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
@ -2252,16 +2275,25 @@ def _smart_extract_lines(text: str) -> List[Dict]:
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
# Whitespace-separated columns: position item_number description quantity unit_price total_price
# Most specific pattern - try first!
layout_match = re.match(r'^\s*(\d{1,2})\s+(\d{4,10})\s+(.+?)\s(\d{1,2})\s+([\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$', line)
amount_pattern = r'-?(?:\d{1,3}(?:[. ]\d{3})+|\d+),\d{2}|-?(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}'
layout_match = re.match(
rf'^\s*(\d{{1,3}})\s+([A-Z0-9][A-Z0-9._/-]{{2,29}})\s+(.+?)\s+(\d{{1,3}}(?:[.,]\d+)?)\s+({amount_pattern})\s+({amount_pattern})\s*$',
line,
re.IGNORECASE,
)
if layout_match:
unit_price = _parse_invoice_number(layout_match.group(5))
line_total = _parse_invoice_number(layout_match.group(6))
items.append({
'line_number': len(items) + 1,
'position': layout_match.group(1),
'item_number': layout_match.group(2),
'sku': layout_match.group(2),
'description': layout_match.group(3).strip(),
'quantity': layout_match.group(4),
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
'quantity': _parse_invoice_number(layout_match.group(4)),
'unit_price': unit_price,
'line_total': line_total,
'total_price': line_total,
'raw_text': line
})
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
@ -2366,6 +2398,130 @@ def _smart_extract_lines(text: str) -> List[Dict]:
return items
def _parse_invoice_number(value):
"""Parse common Danish and English invoice number formats safely."""
if value is None or value == '':
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float, Decimal)):
return float(value)
cleaned = re.sub(r'[^0-9,.-]', '', str(value)).strip('.,')
if not cleaned:
return None
if ',' in cleaned and '.' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.') if cleaned.rfind(',') > cleaned.rfind('.') else cleaned.replace(',', '')
elif ',' in cleaned:
cleaned = cleaned.replace(',', '.')
elif cleaned.count('.') > 1 or ('.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3):
cleaned = cleaned.replace('.', '')
try:
return float(cleaned)
except ValueError:
return None
def _extract_invoice_totals(text: str) -> Dict:
"""Find explicitly labelled totals without guessing from unrelated amounts."""
labels = {
'subtotal': r'(?:Varebeløb|Subtotal|Netto(?:beløb)?)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_basis': r'(?:Momsgrundlag|VAT basis)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_amount': r'(?:Moms(?:beløb)?|VAT amount)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'total_amount': r'(?:Totalbeløb(?:\s+DKK)?|I alt(?:\s+DKK)?|Amount due)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
}
result = {}
compact = re.sub(r'[ \t]+', ' ', text or '')
summary_row = re.search(
r'Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*'
r'(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+'
r'[\d.,]+%\s+(-?[\d.\s]+,\d{2})',
compact,
re.IGNORECASE,
)
if summary_row:
result.update({
'subtotal': _parse_invoice_number(summary_row.group(1)),
'vat_basis': _parse_invoice_number(summary_row.group(2)),
'vat_amount': _parse_invoice_number(summary_row.group(3)),
'total_amount': _parse_invoice_number(summary_row.group(4)),
})
for key, pattern in labels.items():
if key in result:
continue
matches = list(re.finditer(pattern, compact, re.IGNORECASE))
if matches:
result[key] = _parse_invoice_number(matches[-1].group(1))
return result
def _hybrid_validate_invoice(extracted: Dict, text: str) -> Dict:
"""Cross-check parser and AI output and attach actionable review metadata."""
result = dict(extracted or {})
ai_lines = result.get('lines') if isinstance(result.get('lines'), list) else []
parsed_lines = _smart_extract_lines(text or '')
if len(parsed_lines) > len(ai_lines):
result['lines'] = parsed_lines
selected_lines = parsed_lines
line_source = 'layout_text'
else:
selected_lines = ai_lines
line_source = 'ai_or_template'
totals = _extract_invoice_totals(text or '')
for key in ('total_amount', 'vat_amount'):
current = _parse_invoice_number(result.get(key))
if current is None and totals.get(key) is not None:
result[key] = totals[key]
elif current is not None:
result[key] = current
warnings = []
invalid_rows = []
line_sum = 0.0
for index, line in enumerate(selected_lines, start=1):
if not isinstance(line, dict):
invalid_rows.append(index)
continue
quantity = _parse_invoice_number(line.get('quantity'))
unit_price = _parse_invoice_number(line.get('unit_price'))
line_total = _parse_invoice_number(line.get('line_total', line.get('total_price')))
if line_total is not None:
line_sum += line_total
if quantity is not None and unit_price is not None and line_total is not None:
if abs((quantity * unit_price) - line_total) > 0.05:
invalid_rows.append(index)
subtotal = totals.get('subtotal')
vat_basis = totals.get('vat_basis')
vat_amount = totals.get('vat_amount', _parse_invoice_number(result.get('vat_amount')))
total_amount = totals.get('total_amount', _parse_invoice_number(result.get('total_amount')))
if invalid_rows:
warnings.append(f"Kontrollér beregningen på varelinje(r): {', '.join(map(str, invalid_rows))}")
if selected_lines and subtotal is not None and abs(line_sum - subtotal) > 0.05:
warnings.append(f"Varelinjer summerer til {line_sum:.2f}, men varebeløbet er {subtotal:.2f}")
if subtotal is not None and vat_amount is not None and total_amount is not None and abs((subtotal + vat_amount) - total_amount) > 0.05:
warnings.append(f"Varebeløb + moms stemmer ikke med totalen ({subtotal:.2f} + {vat_amount:.2f} != {total_amount:.2f})")
if vat_basis is not None and vat_amount is not None and abs((vat_basis * 0.25) - vat_amount) > 0.05:
warnings.append(f"Moms {vat_amount:.2f} stemmer ikke med 25% af momsgrundlaget {vat_basis:.2f}")
if not selected_lines:
warnings.append("Ingen sikre varelinjer fundet")
result['_hybrid_validation'] = {
'status': 'needs_review' if warnings else 'validated',
'requires_review': bool(warnings),
'line_source': line_source,
'line_count': len(selected_lines),
'line_sum': round(line_sum, 2),
'document_totals': totals,
'warnings': warnings,
'methods': ['layout_text', 'ai'] if ai_lines else ['layout_text', 'template'],
}
if warnings:
result['_validation_warning'] = ' · '.join(warnings)
result['_validation_details'] = result['_hybrid_validation']
return result
# ========== CRUD OPERATIONS ==========
@router.get("/supplier-invoices")
@ -2838,6 +2994,7 @@ async def get_file_extracted_data(file_id: int):
"_validation_warning": llm_json_data.get('_validation_warning'),
"_vat_warning": llm_json_data.get('_vat_warning'),
"_validation_details": llm_json_data.get('_validation_details'),
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
}
elif extraction:
# Fallback to extraction table columns if no LLM JSON
@ -3637,6 +3794,93 @@ async def search_vendor_by_info(request: Dict):
raise HTTPException(status_code=500, detail=str(e))
_OWN_COMPANY_CVRS = {"29522790", "14416285"}
_OWN_COMPANY_MARKERS = ("bmc denmark", "lejrvej 39", "3500 værløse")
def _pattern_matches_value(pattern: str, text: str, expected) -> bool:
try:
match = re.search(pattern, text or "", re.IGNORECASE | re.MULTILINE)
if not match or not match.groups():
return False
actual = str(match.group(1)).strip()
if isinstance(expected, (int, float, Decimal)):
parsed = _parse_invoice_number(actual)
return parsed is not None and abs(parsed - float(expected)) < 0.01
return re.sub(r"\D", "", actual) == re.sub(r"\D", "", str(expected)) if re.search(r"\d", str(expected)) else actual == str(expected)
except re.error:
return False
def _build_template_builder_result(pdf_text: str, ai_result: Optional[Dict] = None, vendor: Optional[Dict] = None) -> Dict:
"""Build safe template suggestions; AI may suggest, but text evidence wins."""
text = pdf_text or ""
result = dict(ai_result or {})
invoice_pattern = r"(?:Faktura(?:nr\.?|nummer)?|Nummer)\s*[:#]?\s*(\d{4,})"
invoice_match = re.search(invoice_pattern, text, re.IGNORECASE | re.MULTILINE)
if invoice_match:
result["invoice_number"] = {"value": invoice_match.group(1), "pattern": invoice_pattern}
date_pattern = r"(?:Fakturadato|Dato)\s*[:#]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})"
date_match = re.search(date_pattern, text, re.IGNORECASE | re.MULTILINE)
if date_match:
result["invoice_date"] = {"value": date_match.group(1), "pattern": date_pattern}
totals = _extract_invoice_totals(text)
total = totals.get("total_amount")
total_patterns = [
(r"Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*"
r"-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+[\d.,]+%\s+(-?[\d.\s]+,\d{2})"),
r"(?:Totalbeløb|I alt|Amount due)\s*(?:DKK)?\s*:?\s*(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})",
]
if total is not None:
for pattern in total_patterns:
if _pattern_matches_value(pattern, text, total):
result["total_amount"] = {"value": f"{total:.2f}", "pattern": pattern}
break
own_cvrs = set(_OWN_COMPANY_CVRS)
configured_own_cvr = re.sub(r"\D", "", str(getattr(settings, "OWN_CVR", "")))
if configured_own_cvr:
own_cvrs.add(configured_own_cvr)
vendor_cvr = re.sub(r"\D", "", str((vendor or {}).get("cvr_number") or ""))
cvr_candidates = re.findall(r"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*(\d{8})", text, re.IGNORECASE)
safe_cvrs = [cvr for cvr in cvr_candidates if cvr not in own_cvrs]
selected_cvr = vendor_cvr if vendor_cvr and vendor_cvr in safe_cvrs else (safe_cvrs[0] if safe_cvrs else "")
if selected_cvr:
cvr_pattern = rf"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*({re.escape(selected_cvr)})"
if _pattern_matches_value(cvr_pattern, text, selected_cvr):
result["cvr"] = {"value": selected_cvr, "pattern": cvr_pattern}
else:
result.pop("cvr", None)
result.pop("vendor_cvr", None)
candidates = []
vendor_name = str((vendor or {}).get("name") or "").strip()
if vendor_name and vendor_name.lower() in text.lower():
candidates.append(vendor_name)
if selected_cvr:
candidates.append(selected_cvr)
candidates.extend(re.findall(r"\b[\w.+-]+@([\w.-]+\.[A-Za-z]{2,})\b", text))
candidates.extend(re.findall(r"\b(?:www\.)?([A-Za-z0-9-]+\.(?:dk|com|eu|net))\b", text, re.IGNORECASE))
safe_patterns = []
for candidate in candidates:
candidate = str(candidate).strip()
lowered = candidate.lower()
if not candidate or any(marker in lowered for marker in _OWN_COMPANY_MARKERS) or re.sub(r"\D", "", candidate) in own_cvrs:
continue
if candidate.lower() not in {item.lower() for item in safe_patterns}:
safe_patterns.append(candidate)
result["detection_patterns"] = safe_patterns[:5]
result["lines_start"] = {"pattern": r"Nr\.?\s+Varenr\.?\s+Tekst"}
result["lines_end"] = {"pattern": r"(?:Varebeløb|Subtotal|Totalbeløb)"}
line_items = _smart_extract_lines(text)
result["line_count"] = len(line_items)
result["line_items"] = line_items
return result
@router.post("/supplier-invoices/ai/analyze")
async def ai_analyze_invoice(request: Dict):
"""Brug AI til at analysere faktura og foreslå template felter"""
@ -3650,6 +3894,7 @@ async def ai_analyze_invoice(request: Dict):
# Build enhanced PDF text with instruction
from app.core.config import settings
excerpt = pdf_text if len(pdf_text) <= 14000 else f"{pdf_text[:7000]}\n[...midt udeladt...]\n{pdf_text[-7000:]}"
enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
@ -3659,7 +3904,7 @@ REQUIRED STRUKTUR (alle felter skal med):
"invoice_number": "5082481",
"invoice_date": "24/10-25",
"total_amount": "1471.20",
"cvr": "29522790",
"cvr": "26686091",
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
"lines_start": "Nr.VarenrTekst",
"lines_end": "Subtotal"
@ -3690,7 +3935,7 @@ VIGTIGT:
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk data
PDF TEKST:
{pdf_text[:2000]}
{excerpt}
RETURNER KUN JSON - intet andet!"""
@ -3705,8 +3950,13 @@ RETURNER KUN JSON - intet andet!"""
# Do not expose that dict as a successful analysis to the browser.
raise HTTPException(status_code=502, detail=str(result["error"]))
logger.info(f"✅ AI analyse gennemført: {result}")
return result
vendor = None
if vendor_id:
rows = execute_query("SELECT id, name, cvr_number FROM vendors WHERE id = %s LIMIT 1", (vendor_id,))
vendor = rows[0] if rows else None
safe_result = _build_template_builder_result(pdf_text, result, vendor)
logger.info("✅ AI analyse gennemført og valideret mod fakturateksten")
return safe_result
except HTTPException:
raise
@ -4916,6 +5166,7 @@ async def reprocess_uploaded_file(file_id: int):
if template_id and confidence >= 0.5:
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
extracted_fields = template_service.extract_fields(text, template_id)
extracted_fields = _hybrid_validate_invoice(extracted_fields, text)
# Check if this is an invoice2data template (ID -1)
is_invoice2data = (template_id == -1)
@ -4992,7 +5243,7 @@ async def reprocess_uploaded_file(file_id: int):
extracted_fields.get('currency', 'DKK'),
1.0, # invoice2data always 100% confidence
json.dumps(extracted_fields), # llm_response_json
'extracted') # status
'needs_review' if extracted_fields.get('_hybrid_validation', {}).get('requires_review') else 'validated')
)
# Insert line items if extracted
@ -5067,7 +5318,8 @@ async def reprocess_uploaded_file(file_id: int):
raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
extracted_fields = llm_result
extracted_fields = _hybrid_validate_invoice(llm_result, text)
llm_result = extracted_fields
confidence = llm_result.get('confidence', 0.75)
# Post-process: clear own CVR(s) if AI mistakenly returned them
@ -5128,7 +5380,7 @@ async def reprocess_uploaded_file(file_id: int):
llm_result.get('document_type', 'invoice'),
confidence,
json.dumps(llm_result),
'extracted')
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated')
)
# Insert line items if extracted

View File

@ -2916,7 +2916,9 @@ async function reviewExtractedData(fileId) {
let aiData = null;
if (ext.llm_response_json) {
try {
aiData = JSON.parse(ext.llm_response_json);
aiData = typeof ext.llm_response_json === 'string'
? JSON.parse(ext.llm_response_json)
: ext.llm_response_json;
} catch (e) {
console.error('Failed to parse llm_response_json:', e);
}
@ -2929,8 +2931,14 @@ async function reviewExtractedData(fileId) {
.trim();
// Check for validation warnings
const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
const hybridValidation = aiData?._hybrid_validation || null;
const hybridWarnings = hybridValidation?.warnings || [];
const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0);
const sourceLabels = {
layout_text: 'PDF-layout',
ai_or_template: 'AI/template'
};
// Build modal content
let modalContent = `
@ -2946,14 +2954,15 @@ async function reviewExtractedData(fileId) {
${hasValidationIssues ? `
<div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6>
${hybridWarnings.map(warning => `<p class="mb-1">⚠️ ${escapeHtml(warning)}</p>`).join('')}
${!hybridWarnings.length && aiData._validation_warning ? `<p class="mb-1">⚠️ ${escapeHtml(aiData._validation_warning)}</p>` : ''}
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
</div>
` : allValidationsPassed ? `
<div class="alert alert-success">
<h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
<p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6>
<p class="mb-0">${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}</p>
</div>
` : ''}

View File

@ -206,6 +206,8 @@
2. Eller markér tekst manuelt og vælg felttype<br>
3. Systemet laver automatisk patterns!
</div>
<div id="aiLineItemsResult" class="d-none mb-3"></div>
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn">
<i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
@ -1466,6 +1468,7 @@ async function autoGenerateTemplate() {
const linesEnd = result.lines_end?.pattern || result.lines_end;
document.getElementById('linesEndPattern').value = linesEnd;
}
renderAiLineItems(result.line_items || [], result.line_count || 0);
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it
btn.innerHTML = '<i class="bi bi-check-circle me-2"></i>✅ AI analyse færdig!';
@ -1482,6 +1485,57 @@ async function autoGenerateTemplate() {
}
}
function templateEscapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderAiLineItems(lines, reportedCount) {
const container = document.getElementById('aiLineItemsResult');
if (!container) return;
const count = Array.isArray(lines) ? lines.length : Number(reportedCount || 0);
if (!count) {
container.className = 'alert alert-warning mb-3';
container.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i><strong>Ingen varelinjer fundet.</strong> Kontrollér teksten eller angiv et linjemønster manuelt.';
return;
}
const labels = {
line_number: 'Linje', position: 'Position', item_number: 'Varenummer', sku: 'SKU',
description: 'Beskrivelse / model', quantity: 'Antal', unit_price: 'Stykpris',
line_total: 'Linjebeløb', total_price: 'Samlet beløb', ean: 'EAN', kn8: 'KN8',
serial_numbers: 'Serienummer', reverse_charge: 'Omvendt betalingspligt',
vat_rate: 'Momssats', raw_text: 'Rå tekst'
};
const itemCards = (lines || []).map((line, index) => {
const fields = Object.entries(line)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => {
let displayValue = Array.isArray(value) ? value.join(', ') : value;
if (typeof value === 'boolean') displayValue = value ? 'Ja' : 'Nej';
return `<div class="col-md-6 col-xl-4 mb-2">
<div class="small text-muted">${templateEscapeHtml(labels[key] || key)}</div>
<div class="text-break">${templateEscapeHtml(displayValue)}</div>
</div>`;
}).join('');
const heading = line.item_number || line.sku || `Linje ${index + 1}`;
return `<div class="card mb-2">
<div class="card-header py-2 fw-semibold">Vare ${index + 1}: ${templateEscapeHtml(heading)}</div>
<div class="card-body py-2"><div class="row">${fields}</div></div>
</div>`;
}).join('');
container.className = 'alert alert-success mb-3 p-3';
container.innerHTML = `
<div class="fw-bold mb-2"><i class="bi bi-check-circle me-2"></i>AI fandt ${count} varelinjer</div>
<div>${itemCards}</div>`;
}
async function saveTemplate() {
const vendorId = document.getElementById('vendorSelect').value;
const templateName = document.getElementById('templateName').value;

View File

@ -94,17 +94,18 @@ def _rate_limit(user_id: int) -> None:
def _resolve_customer_id() -> int:
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
if configured_row:
return int(configured_row["id"])
# Resolve our own company by identity, never by an arbitrary/old numeric ID.
# Customer IDs differ between environments and ID 1 may belong to a supplier.
named_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE LOWER(name) = LOWER(%s)
ORDER BY id ASC
SELECT c.id
FROM customers c
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
WHERE LOWER(TRIM(c.name)) = LOWER(%s)
AND c.deleted_at IS NULL
AND COALESCE(c.is_active, TRUE) = TRUE
GROUP BY c.id
ORDER BY COUNT(s.id) DESC, c.id ASC
LIMIT 1
""",
("BMC Networks",),
@ -112,11 +113,23 @@ def _resolve_customer_id() -> int:
if named_row:
return int(named_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
if fallback:
return int(fallback["id"])
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE id = %s
AND (
LOWER(name) LIKE '%%bmc networks%%'
OR REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ('29522790', '14416285')
)
""",
(configured_id,),
)
if configured_row:
return int(configured_row["id"])
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
raise HTTPException(status_code=500, detail="BMC Networks-kunden blev ikke fundet; bugrapporten blev ikke oprettet")
@router.post("/bug-reports", response_model=BugReportResult)
@ -176,8 +189,10 @@ async def create_bug_report(payload: BugReportPayload, request: Request):
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large")
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
filename = f"screenshot.{extension}"
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.{extension}")
_create_sag_file_record(sag_id, filename, content_type, size, stored_name)
# Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")

View File

@ -672,10 +672,27 @@
renderJobLines();
};
const renderCustomerSearchResults = (lineId) => {
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateCustomerSearchResults = (lineId) => {
const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderCustomerSearchResults(lineId);
};
const renderMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return `
@ -699,16 +716,8 @@
` : `
<div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
`}
<div class="map-results mb-2">
${results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
<div class="map-results mb-2" data-company-results-line-id="${lineId}">
${renderCustomerSearchResults(lineId)}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -720,10 +729,27 @@
`;
};
const renderProductSearchResults = (lineId) => {
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateProductSearchResults = (lineId) => {
const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderProductSearchResults(lineId);
};
const renderProductMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return `
@ -747,16 +773,8 @@
` : `
<div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
`}
<div class="map-results mb-2">
${results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
<div class="map-results mb-2" data-product-results-line-id="${lineId}">
${renderProductSearchResults(lineId)}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -824,13 +842,14 @@
state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineCustomerSearchResults[lineId] = [];
renderJobLines();
updateCustomerSearchResults(lineId);
return;
}
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
if (state.lineCustomerSearchQueries[lineId] !== query) return;
state.lineCustomerSearchResults[lineId] = rows || [];
renderJobLines();
updateCustomerSearchResults(lineId);
};
const saveCompanyMapping = async (lineId) => {
@ -873,13 +892,14 @@
state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineProductSearchResults[lineId] = [];
renderJobLines();
updateProductSearchResults(lineId);
return;
}
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
if (state.lineProductSearchQueries[lineId] !== query) return;
state.lineProductSearchResults[lineId] = rows || [];
renderJobLines();
updateProductSearchResults(lineId);
};
const saveProductMapping = async (lineId) => {

View File

@ -77,6 +77,79 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 74px;
padding: 0.72rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #52606d;
color: #fff;
text-align: left;
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
}
.emails-v2-flow-choice:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.emails-v2-flow-choice.recommended {
border: 3px solid #fff;
outline: 3px solid var(--accent);
}
.emails-v2-flow-choice.flow-support { background: #1769aa; }
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
.emails-v2-flow-choice.flow-archive { background: #59636e; }
.emails-v2-flow-choice:disabled {
opacity: 0.42;
box-shadow: none;
}
.emails-v2-flow-choice .flow-title {
display: block;
font-weight: 700;
}
.emails-v2-flow-choice .flow-description {
display: block;
margin-top: 0.22rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.76rem;
line-height: 1.25;
}
.emails-v2-flow-choice .flow-badge {
position: absolute;
top: -0.55rem;
right: 0.45rem;
padding: 0.12rem 0.42rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
}
@media (max-width: 1500px) {
.emails-v2-flow-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
@ -1074,52 +1147,58 @@
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = [];
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
if (isApprovedSupplierInvoice) {
effects.push(
'Beholder tilknytningen til den valgte leverandør',
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
'Aflæser fakturanummer, beløb og varelinjer',
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
'Markerer emailen som behandlet'
);
} else {
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
}
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<div class="d-flex justify-content-between small mt-2 mb-1">
<span>${escapeHtml(confidenceLabel)}</span>
<strong>${confidencePercent}%</strong>
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
</div>
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-3">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div>
<div class="mt-3">
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
<div class="mt-3">
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-2">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : `
<button id="v2ApproveActions" class="btn btn-success w-100">
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
</button>`}
` : `
<div class="alert alert-secondary py-2 small mb-0">
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
</div>` : ''}
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
</div>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div>
`;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions();
}
@ -1129,7 +1208,12 @@
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
await executeWorkflowsCurrent();
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
} finally {
if (document.body.contains(button)) {
button.disabled = false;
@ -1158,6 +1242,8 @@
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
@ -1169,7 +1255,15 @@
`;
}).join('');
const matchingHtml = matching.length
const matchingHtml = usesSupplierApprovalFlow
? `<div class="emails-v2-kv">
<div class="k">Godkendelsesflow</div>
<div class="v">
<span class="badge bg-success">Leverandørfaktura</span>
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
</div>
</div>`
: matching.length
? matching.map((wf) => `
<div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
@ -1604,25 +1698,47 @@
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase();
const isGlobalConnect = supplierIdentity.includes('globalconnect');
const classification = String(email.classification || '').toLowerCase();
const recommendedFlow = classification === 'invoice'
? 'create_supplier_invoice'
: (classification === 'customer_email' ? 'create_customer_case' : '');
const recommendationBadge = (flow) => recommendedFlow === flow
? '<span class="flow-badge">ANBEFALET</span>'
: '';
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
<i class="bi bi-person-workspace me-1"></i>Kundesag
<div class="emails-v2-flow-picker">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
${recommendationBadge('create_customer_case')}
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
<span class="flow-description">Opret en almindelig kundesag</span>
</button>
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
${recommendationBadge('create_supplier_invoice')}
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
<span class="flow-description">${isGlobalConnect ? 'Internetfaktura genkendt · opdaterer Internetforbindelser' : 'Aflæs PDF og kør leverandørflowet'}</span>
</button>
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
${recommendationBadge('create_accounting_case')}
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
</button>
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
<i class="bi bi-archive me-1"></i>Arkivér mail
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
${recommendationBadge('archive_entity_mail')}
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
<span class="flow-description">Ingen sag eller anden behandling</span>
</button>
</div>
</div>
`;
@ -1661,8 +1777,14 @@
</div>
</div>
<details class="emails-v2-card">
<summary class="fw-semibold" style="cursor:pointer;">
<i class="bi bi-three-dots me-1"></i>Flere handlinger
</summary>
<div class="mt-3">
${email.linked_case_id ? `
<div class="emails-v2-card emails-primary-action">
<div class="emails-primary-action rounded p-3 mb-3">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
@ -1671,8 +1793,9 @@
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<div class="emails-v2-card emails-primary-action">
<h6>Opret sag fra denne email</h6>
<details class="border rounded p-3 mb-3">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
<div class="mt-3">
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
@ -1686,15 +1809,16 @@
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>`}
</div>
</details>`}
<div class="emails-v2-card">
<div class="border rounded p-3 mb-3">
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
<div class="emails-v2-card">
<div class="border rounded p-3 mb-3">
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
@ -1710,7 +1834,7 @@
</div>
</div>
<details class="emails-v2-card">
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
@ -1721,7 +1845,7 @@
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="emails-v2-card">
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
@ -1743,7 +1867,7 @@
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details>
<details class="emails-v2-card">
<details class="border rounded p-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id,
@ -1755,6 +1879,8 @@
linked_case_id: email.linked_case_id,
}, null, 2))}</pre>
</details>
</div>
</details>
`;
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));

View File

@ -1270,6 +1270,26 @@ class QuickBmcnetCreatePayload(BaseModel):
mark_gateway: bool = False
class QuickInternetSetupPayload(BaseModel):
customer_id: Optional[int] = None
product_id: Optional[int] = None
start_date: Optional[date] = None
shared_fiber: bool = False
connection_id: Optional[int] = None
connection_name: Optional[str] = None
vendor_id: Optional[int] = None
circuit_number: Optional[str] = None
address: Optional[str] = None
technology: Optional[str] = None
download_mbps: Optional[int] = None
upload_mbps: Optional[int] = None
monthly_cost: float = 0
unit_price: Optional[float] = None
billing_interval: str = "monthly"
billing_day: int = 1
notes: Optional[str] = None
class DelefiberProductPricePayload(BaseModel):
product_id: int
monthly_price: float
@ -3098,6 +3118,168 @@ async def remove_delefiber_product_price(connection_id: int, product_id: int):
return {"deleted": True}
@router.post("/internet-connections/quick-setup")
async def create_quick_internet_setup(payload: QuickInternetSetupPayload):
"""Create a connection, and for customer connections also create a draft subscription."""
customer_id = payload.customer_id
if payload.shared_fiber:
own_customer = execute_query_single(
"""
SELECT c.id, c.name
FROM customers c
WHERE c.deleted_at IS NULL AND c.is_active = true
AND LOWER(TRIM(c.name)) IN ('bmc networks', 'bmc networks aps')
ORDER BY
(SELECT COUNT(*) FROM sag_sager s WHERE s.customer_id = c.id) DESC,
(SELECT COUNT(*) FROM internet_connections_connections ic WHERE ic.customer_id = c.id AND ic.deleted_at IS NULL) DESC,
c.id ASC
LIMIT 1
"""
)
if not own_customer:
raise HTTPException(status_code=409, detail="BMC Networks-kunden blev ikke fundet")
customer_id = int(own_customer["id"])
if not customer_id:
raise HTTPException(status_code=400, detail="Vælg en kunde")
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
if payload.shared_fiber:
name = str(payload.connection_name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Skriv et navn til hovedforbindelsen")
connection = await create_connection(ConnectionCreatePayload(
name=name,
vendor_id=payload.vendor_id,
customer_id=customer_id,
address=str(payload.address or "").strip() or None,
status="planned",
monthly_cost=float(payload.monthly_cost or 0),
sales_price=0,
technology=str(payload.technology or "").strip() or None,
circuit_number=str(payload.circuit_number or "").strip() or None,
download_mbps=payload.download_mbps,
upload_mbps=payload.upload_mbps,
contract_start=payload.start_date,
notes=str(payload.notes or "").strip() or None,
allocation_model="shared",
value_type="delefiber",
value_label="Delefiber",
is_manual_shared=True,
))
return {"success": True, "sag": None, "subscription": None, "connection": connection}
if not payload.product_id or not payload.start_date:
raise HTTPException(status_code=400, detail="Vælg internetprodukt og startdato")
product = execute_query_single(
"""
SELECT id, name, short_description, sales_price, attributes_json, type
FROM products
WHERE id = %s AND deleted_at IS NULL
""",
(payload.product_id,),
)
if not product:
raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet")
profile = build_network_product_profile(product, fallback_text=product.get("short_description"))
if profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Det valgte produkt er ikke markeret som internetprodukt")
existing_connection = None
if payload.connection_id:
existing_connection = execute_query_single(
"""
SELECT id, name, customer_id, subscription_id
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(payload.connection_id,),
)
if not existing_connection:
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
if existing_connection.get("subscription_id"):
raise HTTPException(status_code=409, detail="Forbindelsen har allerede et abonnement")
if existing_connection.get("customer_id") not in (None, customer_id):
raise HTTPException(status_code=409, detail="Forbindelsen er tilknyttet en anden kunde")
elif not str(payload.connection_name or "").strip():
raise HTTPException(status_code=400, detail="Skriv et navn til den nye forbindelse")
customer_name = str(customer.get("name") or f"Kunde #{payload.customer_id}")
product_name = str(product.get("name") or "Internet")
case_title = str(payload.connection_name or "").strip() or f"Internet - {customer_name}"
description = "\n".join(filter(None, [
f"Internetabonnement: {product_name}",
f"Forbindelse: {(existing_connection or {}).get('name') or case_title}",
f"Adresse: {str(payload.address or '').strip()}" if payload.address else None,
str(payload.notes or "").strip() or None,
]))
created_case = execute_query_single(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s, %s, 'abonnement', 'åben', %s, %s, 1)
RETURNING *
""",
(case_title, description, customer_id, _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])),
)
if not created_case:
raise HTTPException(status_code=500, detail="Abonnementssagen kunne ikke oprettes")
resolved_price = float(payload.unit_price) if payload.unit_price is not None else float(product.get("sales_price") or 0)
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
subscription = await create_sag_subscription({
"sag_id": int(created_case["id"]),
"billing_interval": payload.billing_interval,
"billing_day": int(payload.billing_day),
"start_date": payload.start_date.isoformat(),
"period_start": payload.start_date.isoformat(),
"notice_period_days": 30,
"line_items": [{
"product_id": int(product["id"]),
"description": str(product.get("short_description") or product_name),
"quantity": 1,
"unit_price": resolved_price,
}],
"notes": str(payload.notes or "").strip() or None,
})
connection_update = ConnectionUpdatePayload(
customer_id=customer_id,
subscription_id=int(subscription["id"]),
value_type="subscription",
value_label=None,
)
if existing_connection:
connection = await update_connection(int(existing_connection["id"]), connection_update)
else:
connection = await create_connection(ConnectionCreatePayload(
name=case_title,
vendor_id=payload.vendor_id,
customer_id=customer_id,
address=str(payload.address or "").strip() or None,
status="planned",
monthly_cost=float(payload.monthly_cost or 0),
sales_price=resolved_price,
technology=str(payload.technology or "").strip() or None,
circuit_number=str(payload.circuit_number or "").strip() or None,
download_mbps=payload.download_mbps,
upload_mbps=payload.upload_mbps,
contract_start=payload.start_date,
notes=str(payload.notes or "").strip() or None,
allocation_model="shared" if payload.shared_fiber else "dedicated",
value_type="delefiber" if payload.shared_fiber else "subscription",
subscription_id=int(subscription["id"]),
is_manual_shared=payload.shared_fiber,
))
return {"success": True, "sag": dict(created_case), "subscription": subscription, "connection": connection}
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single(

View File

@ -137,6 +137,33 @@
padding: 1rem;
}
.quick-customer-results {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
background: var(--bg-card);
}
.quick-customer-result {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
text-align: left;
}
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
.invoice-sync-status {
display:inline-flex;
align-items:center;
@ -179,8 +206,8 @@
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="modal" data-bs-target="#quickSetupModal">
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
</button>
</div>
</div>
@ -386,6 +413,52 @@
</table>
</div>
</div>
</div>
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="quick-type-grid mb-3">
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
</div>
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
<div class="row g-3 d-none mt-1" id="quickSetupFields">
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
<div class="col-12" id="quickCustomerWrap">
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
<input type="hidden" id="quickCustomerId">
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
</div>
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
</div>
</div>
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
</div>
</div>
</div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
@ -442,6 +515,11 @@
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
let quickCustomerOptions = [];
let quickProductOptions = [];
let quickCustomerTimer = null;
let quickSetupType = null;
let quickSharedHeadOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
@ -844,6 +922,7 @@
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
const isOwnedSharedFiber = Boolean(item.is_shared_head && (item.value_type === 'delefiber' || item.is_manual_shared));
return `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
@ -854,11 +933,11 @@
${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>${escapeHtml(item.customer_name || '-')}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
${uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${item.sla_subscription_id
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div>
${!isOwnedSharedFiber && uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${isOwnedSharedFiber ? '' : item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
</td>
@ -1052,11 +1131,308 @@
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
const quickVendor = document.getElementById('quickVendor');
if (quickVendor) quickVendor.innerHTML = '<option value="">Vælg leverandør…</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
function quickCustomerLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function resolveQuickCustomerId() {
const raw = document.getElementById('quickCustomerLookup').value.trim();
const normalized = raw.toLowerCase();
const match = quickCustomerOptions.find((item) =>
quickCustomerLabel(item).toLowerCase() === normalized
|| String(item.name || '').trim().toLowerCase() === normalized
|| String(item.id) === raw
);
const id = match ? Number(match.id) : null;
document.getElementById('quickCustomerId').value = id || '';
if (match && !document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
}
if (match) {
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
}
updateQuickSetupSummary();
return id;
}
async function searchQuickCustomers(query) {
const q = String(query || '').trim();
const results = document.getElementById('quickCustomerResults');
if (q.length < 2) {
quickCustomerOptions = [];
results.innerHTML = '';
results.classList.add('d-none');
return;
}
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
if (response.status === 401) {
quickCustomerOptions = [];
results.innerHTML = '<div class="p-3 text-danger"><strong>Din session er udløbet.</strong><br><a href="/login">Log ind igen</a> og åbn derefter guiden på ny.</div>';
results.classList.remove('d-none');
return;
}
if (!response.ok) {
quickCustomerOptions = [];
results.innerHTML = `<div class="p-3 text-danger">Kunderne kunne ikke hentes (fejl ${response.status}).</div>`;
results.classList.remove('d-none');
return;
}
quickCustomerOptions = response.ok ? await response.json() : [];
results.innerHTML = quickCustomerOptions.length
? quickCustomerOptions.map((item) => `
<button class="quick-customer-result" type="button" data-quick-customer-id="${item.id}">
<strong>${escapeHtml(item.name || '-')}</strong>
<span class="small text-muted ms-2">${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}</span>
${item.address ? `<div class="small text-muted">${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}</div>` : ''}
</button>`).join('')
: '<div class="small text-muted p-3">Ingen kunder fundet</div>';
results.classList.remove('d-none');
}
function selectQuickCustomer(customerId) {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
if (!customer) return;
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
document.getElementById('quickCustomerId').value = String(customer.id);
document.getElementById('quickCustomerResults').classList.add('d-none');
if (!document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
}
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
updateQuickSetupSummary();
}
async function loadQuickProducts() {
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
const products = response.ok ? await response.json() : [];
quickProductOptions = products.filter((item) => {
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
const network = item.attributes_json?.network || {};
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
});
document.getElementById('quickProduct').innerHTML = '<option value="">Vælg internetprodukt…</option>'
+ quickProductOptions.map((item) => `<option value="${item.id}" data-price="${Number(item.sales_price || 0)}">${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}</option>`).join('');
}
function selectQuickSetupType(type) {
quickSetupType = type;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
document.getElementById('quickTypeHint').classList.add('d-none');
document.getElementById('quickSetupFields').classList.remove('d-none');
document.getElementById('quickSetupSubmit').classList.remove('d-none');
const isBmcnet = type === 'bmcnet';
const isShared = type === 'shared';
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
? '<i class="bi bi-check2-circle me-1"></i>Opret BMCnet og abonnement'
: isShared
? '<i class="bi bi-check2-circle me-1"></i>Opret delt hovedfiber'
: '<i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement';
toggleQuickConnectionMode();
}
function toggleQuickConnectionMode() {
const existing = document.getElementById('quickConnectionMode').value === 'existing';
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
updateQuickSetupSummary();
}
async function populateQuickConnections() {
let connections = [];
try {
const response = await fetch('/api/v1/internet-connections');
connections = response.ok ? await response.json() : [];
} catch (error) {
connections = allConnections;
}
document.getElementById('quickExistingConnection').innerHTML = '<option value="">Vælg forbindelse…</option>'
+ connections.filter((item) => !item.subscription_id).map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}</option>`).join('');
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
document.getElementById('quickSharedHead').innerHTML = '<option value="">Vælg hovedforbindelse…</option>'
+ quickSharedHeadOptions.map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}</option>`).join('');
}
function normalizeQuickAddress(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function quickEditDistance(left, right) {
const previous = Array.from({length: right.length + 1}, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
);
diagonal = above;
}
}
return previous[right.length];
}
function quickTokenSimilarity(left, right) {
if (left === right) return 1;
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
}
function quickAddressMatchScore(input, candidate) {
const wanted = normalizeQuickAddress(input);
const existing = normalizeQuickAddress(candidate);
if (!wanted || !existing) return 0;
if (wanted === existing) return 100;
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const existingParts = existing.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const tokenScore = wantedParts.length
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
: 0;
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
const samePostal = Boolean(wantedPostal && existingNumbers.has(wantedPostal));
const sameHouseNumber = Boolean(wantedHouse && existingNumbers.has(wantedHouse));
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
}
function suggestQuickSharedHead() {
if (quickSetupType !== 'bmcnet') return;
const address = document.getElementById('quickAddress').value.trim();
const select = document.getElementById('quickSharedHead');
const hint = document.getElementById('quickSharedHeadSuggestion');
if (address.length < 4) {
hint.className = 'small text-muted mt-1';
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
return;
}
const matches = quickSharedHeadOptions
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
.filter((match) => match.score >= 55)
.sort((a, b) => b.score - a.score);
if (!matches.length) {
hint.className = 'small text-warning mt-1';
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
return;
}
const best = matches[0];
select.value = String(best.item.id);
hint.className = 'small text-success fw-semibold mt-1';
hint.innerHTML = `<i class="bi bi-check-circle me-1"></i>Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
updateQuickSetupSummary();
}
function updateQuickSetupSummary() {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const connectionText = existing
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
? `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
: `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
}
async function submitQuickSetup() {
const feedback = document.getElementById('quickSetupFeedback');
const button = document.getElementById('quickSetupSubmit');
const customerId = resolveQuickCustomerId();
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const payload = {
customer_id: customerId,
product_id: productId,
start_date: document.getElementById('quickStartDate').value,
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
address: document.getElementById('quickAddress').value.trim() || null,
technology: document.getElementById('quickTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('quickCost').value || 0),
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
billing_interval: document.getElementById('quickInterval').value,
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
shared_fiber: quickSetupType === 'shared',
};
if (!quickSetupType) {
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
return;
}
const missingSubscription = quickSetupType !== 'shared' && (!payload.customer_id || !payload.product_id || !payload.start_date);
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
feedback.textContent = quickSetupType === 'shared'
? 'Skriv et navn til hovedforbindelsen.'
: 'Vælg kunde, produkt, startdato og forbindelse.';
return;
}
button.disabled = true;
feedback.textContent = 'Opretter og forbinder…';
try {
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
if (quickSetupType === 'bmcnet' && !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
const endpoint = quickSetupType === 'bmcnet'
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
: '/api/v1/internet-connections/quick-setup';
const requestPayload = quickSetupType === 'bmcnet' ? {
customer_id: payload.customer_id,
case_title: payload.connection_name,
service_label: payload.connection_name,
address: payload.address,
billing_interval: payload.billing_interval,
billing_day: payload.billing_day,
start_date: payload.start_date,
internet_product_id: payload.product_id,
internet_unit_price: payload.unit_price,
notes: payload.notes,
} : payload;
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
const connectionLink = `<a href="/economy/internet-connections/${result.connection.id}">Åbn forbindelsen</a>`;
const subscriptionLink = result.sag?.id ? ` · <a href="/sag/${result.sag.id}/v3?tab=subscription">Åbn abonnementet</a>` : '';
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
await loadInternetPage();
} catch (error) {
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
@ -1064,6 +1440,34 @@
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
document.getElementById('quickCustomerId').value = '';
clearTimeout(quickCustomerTimer);
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
});
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
const button = event.target.closest('[data-quick-customer-id]');
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
});
document.getElementById('quickProduct').addEventListener('change', (event) => {
const option = event.target.selectedOptions[0];
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
updateQuickSetupSummary();
});
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
quickSetupType = null;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
document.getElementById('quickTypeHint').classList.remove('d-none');
document.getElementById('quickSetupFields').classList.add('d-none');
document.getElementById('quickSetupSubmit').classList.add('d-none');
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
await loadQuickProducts();
await populateQuickConnections();
toggleQuickConnectionMode();
});
toggleCreateValueFields();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);

View File

@ -843,11 +843,31 @@ async def sag_detaljer(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
WHERE cc_match.contact_id = c.id
AND cc_match.customer_id IN (
SELECT s_match.customer_id
FROM sag_sager s_match
WHERE s_match.id = sk.sag_id AND s_match.customer_id IS NOT NULL
UNION
SELECT sk_customer.customer_id
FROM sag_kunder sk_customer
WHERE sk_customer.sag_id = sk.sag_id AND sk_customer.deleted_at IS NULL
)
) AS is_external
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
@ -1204,11 +1224,25 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
JOIN sag_sager s_match ON s_match.id = sk.sag_id
WHERE cc_match.contact_id = c.id
AND s_match.customer_id IS NOT NULL
AND cc_match.customer_id = s_match.customer_id
) AS is_external
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id

View File

@ -2097,6 +2097,36 @@
border-bottom: none;
}
.contact-row.contact-external {
background: #fce7f3;
border: 2px solid #be185d;
border-left: 8px solid #be185d;
border-radius: 0.45rem;
margin: 0.3rem 0;
padding-left: 0.5rem;
}
.external-contact-badge {
color: #fff;
background: #be185d;
border: 1px solid #831843;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.contact-search-external {
background: #fce7f3;
border: 2px solid #be185d !important;
border-left: 8px solid #be185d !important;
margin-bottom: 0.35rem;
}
[data-bs-theme="dark"] .contact-row.contact-external,
[data-bs-theme="dark"] .contact-search-external {
background: rgba(190, 24, 93, 0.3);
}
.contact-row .contact-name {
font-weight: 600;
}
@ -8034,8 +8064,9 @@
<span class="text-end">Handlinger</span>
</div>
{% for contact in contacts %}
{% set external_contact = contact.is_external or (contact.customer_name and customer and contact.customer_name|lower != customer.name|lower) or (contact.customer_name and not customer) %}
<div
class="contact-row"
class="contact-row{% if external_contact %} contact-external{% endif %}"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
@ -8049,9 +8080,17 @@
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
<div class="contact-name">{{ contact.contact_name }}</div>
<div class="contact-name">
{{ contact.contact_name }}
{% if external_contact %}
<span class="badge external-contact-badge ms-1">Ekstern kontakt</span>
{% endif %}
</div>
<small>{{ contact.title or '-' }}</small>
<small>{{ contact.customer_name or '-' }}</small>
<small>
{{ contact.customer_name or 'Firma ikke registreret' }}
{% if external_contact %}<br><strong style="color:#9d174d;">Andet firma end sagens kunde</strong>{% endif %}
</small>
<div class="contact-actions">
{% if contact.mobile %}
<button
@ -15166,6 +15205,31 @@
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="entitySearchInput" placeholder="Søg (min. 2 tegn)..." autocomplete="off">
<button type="button" class="btn btn-primary d-none" id="quickContactCreateBtn" onclick="toggleQuickContactCreate(true)">
<i class="bi bi-person-plus me-1"></i>Opret kontakt
</button>
</div>
<div id="quickContactCreate" class="d-none border rounded p-3 mb-3 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<strong><i class="bi bi-person-plus me-1"></i>Ny kontakt</strong>
<button type="button" class="btn-close" onclick="toggleQuickContactCreate(false)"></button>
</div>
<div class="row g-2">
<div class="col-sm-6"><input id="quickContactFirstName" class="form-control" placeholder="Fornavn *"></div>
<div class="col-sm-6"><input id="quickContactLastName" class="form-control" placeholder="Efternavn"></div>
<div class="col-sm-6"><input id="quickContactEmail" type="email" class="form-control" placeholder="E-mail"></div>
<div class="col-sm-6"><input id="quickContactMobile" class="form-control" placeholder="Mobil/telefon"></div>
<div class="col-12 position-relative">
<input id="quickContactCompanySearch" class="form-control" placeholder="Søg firma (valgfrit)" autocomplete="off">
<input id="quickContactCompanyId" type="hidden">
<div id="quickContactCompanyResults" class="list-group position-absolute w-100 shadow d-none" style="z-index:1080;max-height:180px;overflow:auto;"></div>
</div>
<div class="col-12"><div id="quickContactStatus" class="small"></div></div>
<div class="col-12 d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="toggleQuickContactCreate(false)">Annuller</button>
<button type="button" class="btn btn-success" id="quickContactSaveBtn" onclick="createAndAttachQuickContact()">Opret og tilknyt</button>
</div>
</div>
</div>
<div class="text-center d-none" id="entitySearchSpinner">
<div class="spinner-border text-primary" role="status"></div>
@ -15777,6 +15841,8 @@
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
document.getElementById('entitySearchInput').value = '';
document.getElementById('entitySearchResults').innerHTML = '';
document.getElementById('quickContactCreateBtn').classList.toggle('d-none', type !== 'contact');
toggleQuickContactCreate(false);
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
modal.show();
@ -15784,6 +15850,94 @@
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
}
function toggleQuickContactCreate(show) {
const panel = document.getElementById('quickContactCreate');
if (!panel) return;
panel.classList.toggle('d-none', !show);
if (show) {
document.getElementById('quickContactCompanyId').value = {{ case.customer_id or "''" }};
document.getElementById('quickContactCompanySearch').value = {{ (customer.name if customer else '')|tojson }};
setTimeout(() => document.getElementById('quickContactFirstName').focus(), 50);
}
}
function quickCreateFromSearch(encodedQuery) {
const query = decodeURIComponent(encodedQuery || '').trim();
toggleQuickContactCreate(true);
document.getElementById('quickContactFirstName').value = '';
document.getElementById('quickContactLastName').value = '';
document.getElementById('quickContactEmail').value = '';
document.getElementById('quickContactMobile').value = '';
if (query.includes('@')) {
document.getElementById('quickContactEmail').value = query;
const localName = query.split('@')[0].replace(/[._-]+/g, ' ').trim();
document.getElementById('quickContactFirstName').value = localName;
} else {
const nameParts = query.split(/\s+/).filter(Boolean);
document.getElementById('quickContactFirstName').value = nameParts.shift() || '';
document.getElementById('quickContactLastName').value = nameParts.join(' ');
}
document.getElementById('quickContactStatus').textContent = '';
document.getElementById('quickContactFirstName').focus();
}
let quickCompanyTimer;
document.getElementById('quickContactCompanySearch').addEventListener('input', function(event) {
document.getElementById('quickContactCompanyId').value = '';
clearTimeout(quickCompanyTimer);
const query = event.target.value.trim();
const results = document.getElementById('quickContactCompanyResults');
if (query.length < 2) { results.classList.add('d-none'); return; }
quickCompanyTimer = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
const companies = response.ok ? await response.json() : [];
results.innerHTML = companies.map(company => `
<button type="button" class="list-group-item list-group-item-action" onclick="selectQuickContactCompany(${company.id}, decodeURIComponent('${encodeURIComponent(company.name)}'))">
<strong>${escapeHtml(company.name)}</strong>
</button>`).join('') || '<div class="list-group-item text-muted">Ingen firmaer fundet</div>';
results.classList.remove('d-none');
} catch (_) { results.classList.add('d-none'); }
}, 250);
});
function selectQuickContactCompany(id, name) {
document.getElementById('quickContactCompanyId').value = id;
document.getElementById('quickContactCompanySearch').value = name;
document.getElementById('quickContactCompanyResults').classList.add('d-none');
}
async function createAndAttachQuickContact() {
const firstName = document.getElementById('quickContactFirstName').value.trim();
const status = document.getElementById('quickContactStatus');
const button = document.getElementById('quickContactSaveBtn');
if (!firstName) { status.className = 'small text-danger'; status.textContent = 'Fornavn er påkrævet.'; return; }
const companyId = Number(document.getElementById('quickContactCompanyId').value || 0);
const payload = {
first_name: firstName,
last_name: document.getElementById('quickContactLastName').value.trim(),
email: document.getElementById('quickContactEmail').value.trim() || null,
mobile: document.getElementById('quickContactMobile').value.trim() || null,
company_id: companyId || null,
is_primary: Boolean(companyId)
};
button.disabled = true;
status.className = 'small text-muted'; status.textContent = 'Opretter kontakt…';
try {
const createResponse = await fetch('/api/v1/contacts', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
const contact = await createResponse.json().catch(() => ({}));
if (!createResponse.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
const attachResponse = await fetch(`/api/v1/sag/${caseIds}/contacts`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({contact_id:contact.id, role:'Kontakt'})});
const attachResult = await attachResponse.json().catch(() => ({}));
if (!attachResponse.ok) throw new Error(attachResult.detail || 'Kontakten blev oprettet, men kunne ikke tilknyttes');
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
reloadCasePreservingContext();
} catch (error) {
status.className = 'small text-danger'; status.textContent = error.message;
button.disabled = false;
}
}
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
clearTimeout(searchDebounceIds);
const query = e.target.value.trim();
@ -15809,7 +15963,7 @@
const res = await fetch(url);
if (!res.ok) throw new Error('Search failed');
const results = await res.json();
renderResults(results);
renderResults(results, query);
} catch (e) {
console.error(e);
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
@ -15819,15 +15973,34 @@
}
}
function renderResults(results) {
function renderResults(results, query = '') {
const container = document.getElementById('entitySearchResults');
if (results.length === 0) {
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
if (currentSearchType === 'contact') {
const safeQuery = escapeHtml(query);
const encodedQuery = encodeURIComponent(query).replace(/'/g, '%27');
container.innerHTML = `
<div class="text-center border rounded p-4 bg-light">
<i class="bi bi-person-x fs-3 text-muted d-block mb-2"></i>
<div class="text-muted mb-3">Ingen kontakt fundet for <strong>${safeQuery}</strong></div>
<button type="button" class="btn btn-success" onclick="quickCreateFromSearch('${encodedQuery}')">
<i class="bi bi-person-plus me-1"></i>Opret ${safeQuery} som ny kontakt
</button>
</div>`;
} else {
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
}
return;
}
const caseCustomerIds = [
{% if case.customer_id %}{{ case.customer_id }},{% endif %}
{% for linked_customer in customers %}{{ linked_customer.customer_id }},{% endfor %}
].map(Number);
container.innerHTML = results.map(item => {
let title = '', subtitle = '', icon = '', id = item.id;
let extraClass = '';
let relationBadge = '';
if (currentSearchType === 'hardware') {
title = `${item.brand} ${item.model}`;
@ -15839,8 +16012,14 @@
icon = 'bi-geo-alt';
} else if (currentSearchType === 'contact') {
title = `${item.first_name} ${item.last_name}`;
subtitle = item.email;
const companyIds = Array.isArray(item.customer_ids) ? item.customer_ids.map(Number) : [];
const isExternal = !companyIds.some((id) => caseCustomerIds.includes(id));
subtitle = [item.email, item.user_company].filter(Boolean).join(' • ');
icon = 'bi-person';
if (isExternal) {
extraClass = 'contact-search-external';
relationBadge = '<span class="badge external-contact-badge ms-2">Ekstern kontakt</span>';
}
} else if (currentSearchType === 'customer') {
title = item.name;
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
@ -15848,10 +16027,10 @@
}
return `
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" onclick="addEntity(${id})">
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center ${extraClass}" onclick="addEntity(${id})">
<div class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
<div>
<div class="fw-bold">${title}</div>
<div class="fw-bold">${title}${relationBadge}</div>
<small class="text-muted">${subtitle}</small>
</div>
</button>

View File

@ -7,10 +7,10 @@ router = APIRouter()
async def search_customers(q: str = Query(..., min_length=2)):
"""
Autocomplete search for customers.
Returns list of {id, name, cvr_nummer, email}
Returns customer identity and registered address for address-aware workflows.
"""
sql = """
SELECT id, name, cvr_number as cvr_nummer, email
SELECT id, name, cvr_number as cvr_nummer, email, address, city, postal_code
FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL
@ -45,6 +45,19 @@ async def search_contacts(q: str = Query(..., min_length=2)):
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company,
(
SELECT cu.id
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company_id,
ARRAY(
SELECT cc.customer_id
FROM contact_companies cc
WHERE cc.contact_id = c.id
) AS customer_ids,
(
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
FROM contact_companies cc
@ -61,11 +74,17 @@ async def search_contacts(q: str = Query(..., min_length=2)):
OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc_search
JOIN customers cu_search ON cu_search.id = cc_search.customer_id
WHERE cc_search.contact_id = c.id AND cu_search.name ILIKE %s
)
ORDER BY c.first_name ASC, c.last_name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term, term, term, term))
results = execute_query(sql, (term, term, term, term, term, term, term))
return results
@router.get("/search/hardware")

View File

@ -977,20 +977,26 @@ class EmailService:
return {"success": False, "reason": "Email has no message id"}
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")),
"$select": "id,subject,hasAttachments",
"$top": 2,
}
try:
async with ClientSession() as session:
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail)
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
matches = (await response.json()).get("value", [])
clean_message_id = message_id.strip().strip('<>')
message_id_candidates = list(dict.fromkeys((message_id.strip(), f"<{clean_message_id}>")))
matches = []
for candidate in message_id_candidates:
params = {
"$filter": "internetMessageId eq '{}'".format(candidate.replace("'", "''")),
"$select": "id,subject,hasAttachments,internetMessageId",
"$top": 2,
}
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s using %s: %s %s", email_id, candidate, response.status, detail)
continue
matches = (await response.json()).get("value", [])
if matches:
break
if not matches:
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}

View File

@ -1859,6 +1859,29 @@ class EmailWorkflowService:
attachments = []
elif not isinstance(attachments, list):
attachments = [attachments]
# Older/manual email imports may declare attachments without having saved
# their bytes. Recover the attachment before declaring the workflow failed.
if not attachments:
email_state = execute_query(
"SELECT has_attachments FROM email_messages WHERE id = %s",
(email_id,),
)
if email_state and email_state[0].get("has_attachments"):
try:
from app.services.email_service import EmailService
recovery = await EmailService().recover_graph_attachments(int(email_id))
if recovery.get("success"):
attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,),
) or []
except Exception as recovery_error:
logger.warning("Could not recover attachments for email %s: %s", email_id, recovery_error)
if not attachments:
logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
@ -1904,11 +1927,17 @@ class EmailWorkflowService:
# Check if file already exists
existing = execute_query(
"SELECT file_id FROM incoming_files WHERE checksum = %s",
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
(checksum,))
if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}")
uploaded_files.append({
'file_id': existing[0]['file_id'],
'filename': attachment['filename'],
'existing': True,
'status': existing[0].get('status'),
})
continue
# Create uploads directory if it doesn't exist
@ -1953,12 +1982,43 @@ class EmailWorkflowService:
continue
if uploaded_files:
processing_results = []
for uploaded in uploaded_files:
if uploaded.get('existing') and uploaded.get('status') in {'ai_extracted', 'processed'}:
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'already_processed',
})
continue
try:
# Lazy import avoids coupling router import order to email startup.
from app.billing.backend.supplier_invoices import reprocess_uploaded_file
processing_results.append(await reprocess_uploaded_file(int(uploaded['file_id'])))
except Exception as processing_error:
logger.exception("Immediate invoice processing failed for file %s", uploaded['file_id'])
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'failed',
'error': str(getattr(processing_error, 'detail', processing_error)),
})
failed_processing = [item for item in processing_results if item.get('status') == 'failed']
if failed_processing:
return {
'action': 'extract_invoice_data',
'success': False,
'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files],
'processing_results': processing_results,
'note': failed_processing[0].get('error') or 'Fakturaudtrækning fejlede',
}
return {
'action': 'extract_invoice_data',
'success': True,
'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files],
'note': f"{len(uploaded_files)} PDF(er) gemt i 'Mangler Behandling'"
'processing_results': processing_results,
'note': f"{len(uploaded_files)} PDF(er) gemt og behandlet"
}
else:
return {

View File

@ -2006,7 +2006,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.5"></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.6"></script>
{% include "shared/frontend/internal_message.html" %}
<script src="/static/js/message-ui.js?v=1"></script>
<script src="/static/js/bottom-bar.js?v=2.71"></script>

View File

@ -35,12 +35,12 @@
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="bugCaptureDisplayMediaBtn">
<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling
<i class="bi bi-display me-1"></i>Tag nyt screenshot af siden
</button>
</div>
<div id="bugReportStatus" class="small text-muted mt-2"></div>
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Hvis det fejler, brug skærmdeling-knappen eller indsæt med Cmd+V.</div>
<div class="small text-muted mt-2">Ved et nyt screenshot skjules denne formular kortvarigt, så fejlen på siden kommer med. Du kan også indsætte et billede med Cmd+V.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>

View File

@ -5,6 +5,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
## Aktive planer
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
## Arbejdsgang

View File

@ -0,0 +1,173 @@
# Sikkert fakturaudtræk med layoutanalyse og AI
## Status
Under udvikling. Første hybridlag med universel linjeudtrækning, AI-sammenligning,
matematisk validering og review-status er implementeret lokalt. Koordinatbaseret
PDF-evidens og visuel markering i originaldokumentet mangler fortsat.
## Formål
Erstat den nuværende tekst- og regex-tunge fakturabehandling med en revisionssikker hybridpipeline. Kritiske værdier må ikke accepteres alene på baggrund af et AI-svar.
## Integration i eksisterende flow
Den eksisterende behandling bevares som orkestrering, men ændres fra en fallback-kæde til flere uafhængige analyser:
```text
PDF
├─ original og checksum
├─ layoutudtræk med ordkoordinater
├─ deterministisk felt- og tabelparser
├─ kendt leverandørprofil/template
└─ AI-strukturering
sammenligning
matematisk validering
høj sikkerhed / manuel kontrol
```
## Nye komponenter
### `invoice_document_service`
- Åbner PDF'en én gang.
- Afgør om den har et brugbart tekstlag.
- Udtrækker ord, koordinater, sider og tabelkandidater.
- Renderer og OCR-behandler kun sider uden brugbart tekstlag.
- Gemmer et fælles dokumentformat til de øvrige analysatorer.
### `invoice_layout_parser`
- Finder labels og værdier ud fra position, ikke kun læserækkefølge.
- Genopbygger varetabeller ud fra kolonneoverskrifter og x-positioner.
- Samler fortsættelseslinjer under korrekt varelinje.
- Knytter model, EAN, KN8 og serienumre til den relevante position.
- Returnerer kildehenvisning og bounding box for hvert felt.
### `invoice_ai_analyzer`
- Modtager renset dokumenttekst og layoutparserens kandidater.
- Returnerer et fast JSON-schema.
- Bruges til semantisk strukturering og løsning af tvetydigheder.
- Må ikke overskrive sikre deterministiske værdier uden at skabe en konflikt.
### `invoice_validator`
- Sammenligner resultater fra layout, template og AI.
- Kontrollerer antal gange pris mod linjetotal.
- Kontrollerer subtotal, momsgrundlag, moms og total.
- Understøtter omvendt betalingspligt pr. varelinje.
- Kontrollerer sidetal, positionshuller, dubletter, valuta og fortegn.
- Afviser BMC's egne CVR-numre som leverandør-CVR.
### `invoice_review_service`
- Beregner samlet sikkerhed ud fra dokumenteret enighed og validering.
- Opretter konkrete review-punkter ved uenighed.
- Tillader manuel rettelse uden at slette det oprindelige resultat.
## Fælles resultatformat
Hvert kritisk felt skal indeholde:
- Normaliseret værdi
- Oprindelig tekst
- Side og bounding box
- Udtræksmetode
- AI-enighed
- Sikkerhed
- Eventuelle konflikter
Eksempel:
```json
{
"value": 17512.25,
"source_text": "Totalbeløb DKK 17.512,25",
"page": 2,
"bounding_box": [470, 315, 560, 332],
"methods": ["layout", "ai"],
"confidence": 0.99,
"validated": true
}
```
## Database
Bevar eksisterende faktura- og extraction-tabeller. Tilføj særskilt revisionsdata til:
- Dokumentudtræk og parser-version
- Felt-evidens
- Valideringsresultater
- Konflikter og advarsler
- Manuel korrektion
- Model- og prompt-version
Gem ikke store OCR-/layoutobjekter direkte i hovedtabellen, hvis de kan placeres i en separat analyse-/evidenstabel.
## Eksisterende templates
- Kendte invoice2data- og database-templates beholdes.
- De bliver en af flere kilder, ikke automatisk facit.
- Leverandørprofiler kan gemme tabelkolonner, labels, momsregler og kendte sideelementer.
- En profil aktiveres kun ved sikker identifikation af leverandør og dokumenttype.
## Brugerflade
Review-visningen skal vise PDF og udtrukne data side om side.
- Klik på et felt fremhæver kilden i PDF'en.
- Grøn betyder dokumenteret og valideret.
- Gul betyder lav sikkerhed eller mindre afvigelse.
- Rød betyder konflikt eller matematisk fejl.
- Brugeren ser den konkrete årsag til manuel kontrol.
- Manuelle rettelser gemmes med bruger og tidspunkt.
## Godkendelsesregler
Automatisk behandling må først ske, når alle følgende krav er opfyldt:
- Kendt og valideret leverandør
- Leverandør-CVR er ikke BMC's eget CVR
- Fakturanummer, dato, valuta og total har dokumenteret kilde
- Kritiske felter er bekræftet af mindst to metoder eller én metode plus matematisk bevis
- Alle relevante summer stemmer inden for afrundingstolerance
- Ingen manglende sider eller varepositioner
- Ingen ændrede betalingsoplysninger
- Ingen uløste konflikter
- Samlet sikkerhed over den fastsatte grænse
Indtil løsningen er evalueret på et repræsentativt datasæt, er manuel godkendelse standard.
## Fejlhåndtering
- AI-nedbrud må ikke stoppe deterministisk udtræk.
- OCR-fejl skal føre til manuel kontrol, ikke tomme værdier accepteret som succes.
- Ugyldig AI-JSON må aldrig gemmes som godkendt resultat.
- Parserfejl logges med dokument-, side-, parser- og modelversion.
- Genbehandling må ikke overskrive tidligere revisionsdata.
## Leveringsfaser
1. Fælles dokumentmodel med tekst, ordkoordinater og sider.
2. Koordinatbaseret parser for DCS-fakturaer og regressionstest med faktura 5509938.
3. Felt-evidens og matematisk validator.
4. AI-analyse med låst JSON-schema og konfliktsammenligning.
5. Review-UI med fremhævelse i PDF'en.
6. Pilot på historiske fakturaer uden automatisk bogføring.
7. Måling af præcision pr. leverandør og felttype.
8. Kontrolleret aktivering af automatisk behandling for godkendte leverandørprofiler.
## Acceptkriterier for DCS 5509938
- Alle otte varepositioner findes.
- Fakturanummer `5509938` og dato `2026-09-08` findes.
- DCS ApS og CVR `26686091` identificeres som leverandør.
- BMC CVR `29522790` identificeres som køber og ignoreres som leverandør.
- Varebeløb `17379.00`, momsgrundlag `533.00`, moms `133.25` og total `17512.25` findes.
- Omvendt betalingspligt knyttes til de relevante linjer.
- Kontrollen dokumenterer `17379.00 + 133.25 = 17512.25`.
- Hvert kritisk felt kan spores til side og placering i originaldokumentet.

View File

@ -114,7 +114,7 @@
if (!el || !el.tagName) return false;
const tag = String(el.tagName).toUpperCase();
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
if (el.id === 'bugReportModal' || tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
return true;
}
@ -128,7 +128,7 @@
async function renderScreenshot(target, opts) {
const canvas = await window.html2canvas(target, opts);
return canvas.toDataURL('image/png');
return canvas.toDataURL('image/jpeg', 0.82);
}
async function ensureHtml2Canvas() {
@ -184,8 +184,21 @@
throw new Error('Canvas context unavailable');
}
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/png');
const modalEl = document.getElementById('bugReportModal');
const backdrops = Array.from(document.querySelectorAll('.modal-backdrop'));
const modalVisibility = modalEl?.style.visibility || '';
const backdropVisibility = backdrops.map((item) => item.style.visibility || '');
try {
// Keep layout and entered form data intact, but expose the page underneath.
if (modalEl) modalEl.style.visibility = 'hidden';
backdrops.forEach((item) => { item.style.visibility = 'hidden'; });
await new Promise((resolve) => setTimeout(resolve, 300));
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/jpeg', 0.82);
} finally {
if (modalEl) modalEl.style.visibility = modalVisibility;
backdrops.forEach((item, index) => { item.style.visibility = backdropVisibility[index]; });
}
} finally {
stream.getTracks().forEach((t) => t.stop());
}
@ -193,92 +206,22 @@
async function takeScreenshot() {
await ensureHtml2Canvas();
const doc = document.documentElement;
const body = document.body;
const fullWidth = Math.max(
doc ? doc.scrollWidth : 0,
doc ? doc.offsetWidth : 0,
doc ? doc.clientWidth : 0,
body ? body.scrollWidth : 0,
body ? body.offsetWidth : 0,
window.innerWidth || 0
);
const fullHeight = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
doc ? doc.clientHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
window.innerHeight || 0
);
const common = {
return await renderScreenshot(document.body || document.documentElement, {
useCORS: true,
allowTaint: true,
allowTaint: false,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 7000,
imageTimeout: 2500,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 0: Viewport capture via foreignObject (works on many CSS-heavy pages)
try {
return await renderScreenshot(document.body || document.documentElement, {
...common,
foreignObjectRendering: true,
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY,
});
} catch (e0) {
console.warn('Bug report screenshot strategy 0 failed', e0);
}
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
...common,
width: fullWidth,
height: fullHeight,
windowWidth: fullWidth,
windowHeight: fullHeight,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
const contentRoot =
document.querySelector('.container-fluid.px-4.py-4') ||
document.querySelector('[data-bugreport-root]') ||
document.querySelector('#main-content') ||
document.querySelector('#content') ||
document.querySelector('main') ||
document.querySelector('.content-wrapper') ||
document.documentElement;
try {
return await renderScreenshot(contentRoot, {
...common,
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
scrollX: 0,
scrollY: 0,
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
}
throw new Error('Automatic screenshot failed');
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scrollX: -window.scrollX,
scrollY: -window.scrollY,
});
}
function setStatus(text, isError) {
@ -328,46 +271,48 @@
async function captureScreenshotBeforeModal() {
try {
screenshotDataUrl = await withTimeout(
takeScreenshotViaDisplayMedia(),
12000,
'Skærmvalg timed out'
pendingScreenshotPromise = pendingScreenshotPromise || withTimeout(
takeScreenshot(), 7000, 'Automatisk screenshot tog for lang tid'
);
return { ok: true, viaDisplayMedia: true };
} catch (displayErr) {
console.warn('Bug report display-media first attempt failed', displayErr);
}
const attemptAutoCapture = async () => {
const promise = pendingScreenshotPromise || takeScreenshot();
pendingScreenshotPromise = promise;
return await promise;
};
try {
screenshotDataUrl = await attemptAutoCapture();
screenshotDataUrl = await pendingScreenshotPromise;
return { ok: true };
} catch (firstError) {
console.warn('Bug report screenshot first attempt failed', firstError);
try {
pendingScreenshotPromise = withTimeout(
takeScreenshot(),
8000,
'Automatic screenshot timed out'
);
screenshotDataUrl = await pendingScreenshotPromise;
return { ok: true, recovered: true };
} catch (secondError) {
console.warn('Bug report screenshot retry failed', secondError);
screenshotDataUrl = null;
return { ok: false, error: secondError || firstError };
}
} catch (error) {
console.warn('Bug report screenshot failed', error);
screenshotDataUrl = null;
return { ok: false, error };
} finally {
pendingScreenshotPromise = null;
}
}
function formatError(value, fallback) {
if (!value) return fallback;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => formatError(item, '')).filter(Boolean).join(' · ') || fallback;
}
if (typeof value === 'object') {
if (value.msg) return String(value.msg);
if (value.message) return String(value.message);
if (value.detail) return formatError(value.detail, fallback);
try { return JSON.stringify(value); } catch (_) { return fallback; }
}
return String(value);
}
function startFastBugReport() {
openBugReportModal('Formularen er klar. Screenshot tages i baggrunden…', false);
captureScreenshotBeforeModal().then((capture) => {
setPreview(screenshotDataUrl);
if (capture.ok) {
setStatus('Screenshot klar. Udfyld felterne og send.');
} else {
const reason = formatError(capture.error, 'ukendt fejl');
setStatus(`Screenshot kunne ikke tages (${reason}). Prøv "Tag nyt screenshot af siden" eller Cmd+V.`, true);
}
});
}
async function openBugReportModal(statusText, statusIsError) {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
@ -379,7 +324,7 @@
setStatus(
statusText || (screenshotDataUrl
? 'Screenshot klar. Udfyld felterne og send.'
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.'),
: 'Kunne ikke tage screenshot automatisk. Klik "Tag nyt screenshot af siden" eller indsæt med Cmd+V.'),
Boolean(statusIsError)
);
@ -411,7 +356,7 @@
const dataUrl = await takeScreenshotViaDisplayMedia();
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot taget via skærmdeling.');
setStatus('Nyt screenshot af siden er taget uden bugformularen.');
} catch (e) {
console.warn('Bug report display media capture failed', e);
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
@ -419,7 +364,7 @@
isCapturingDisplayMedia = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling';
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag nyt screenshot af siden';
}
}
}
@ -488,7 +433,7 @@
if (!res || !res.ok) {
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
throw new Error(detail);
throw new Error(formatError(detail, 'Kunne ikke sende fejlrapport'));
}
setStatus('Fejl rapporteret.');
@ -512,22 +457,13 @@
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
const modalEl = document.getElementById('bugReportModal');
// Load the renderer before it is needed so opening a report does not wait on the CDN.
ensureHtml2Canvas().catch((error) => console.warn('Screenshot library preload failed', error));
if (btn) {
btn.addEventListener('click', async (e) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const capture = await captureScreenshotBeforeModal();
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
startFastBugReport();
});
}
@ -567,20 +503,7 @@
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
captureScreenshotBeforeModal().then((capture) => {
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
});
startFastBugReport();
}
});
});

View File

@ -0,0 +1,106 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_quick_setup_creates_draft_subscription_and_links_existing_connection(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
from app.subscriptions.backend import router as subscription_router
def fake_query_single(sql, params=None):
if "FROM customers" in sql:
return {"id": 7, "name": "Testkunde"}
if "FROM products" in sql:
return {
"id": 11,
"name": "Internet 1000/1000",
"short_description": "Fiber internet",
"sales_price": 799,
"attributes_json": {"network": {"kind": "internet_access"}},
"type": "service",
}
if "FROM internet_connections_connections" in sql:
return {"id": 23, "name": "GC kredsløb", "customer_id": None, "subscription_id": None}
if "INSERT INTO sag_sager" in sql:
return {"id": 31, "titel": "Internet - Testkunde", "customer_id": 7}
return None
async def fake_create_subscription(payload, current_user=None):
assert payload["sag_id"] == 31
assert payload["line_items"][0]["product_id"] == 11
return {"id": 41, "status": "draft"}
async def fake_update_connection(connection_id, payload):
assert connection_id == 23
assert payload.subscription_id == 41
assert payload.customer_id == 7
return {"id": 23, "subscription_id": 41, "customer_id": 7}
monkeypatch.setattr(internet_router, "execute_query_single", fake_query_single)
monkeypatch.setattr(
internet_router,
"build_network_product_profile",
lambda product, fallback_text=None: {"kind": "internet_access"},
)
monkeypatch.setattr(internet_router, "_resolve_group_id_by_name_tokens", lambda tokens: 3)
monkeypatch.setattr(internet_router, "update_connection", fake_update_connection)
monkeypatch.setattr(subscription_router, "create_subscription", fake_create_subscription)
app = FastAPI()
app.include_router(internet_router.router, prefix="/api/v1")
response = TestClient(app).post(
"/api/v1/internet-connections/quick-setup",
json={
"customer_id": 7,
"product_id": 11,
"start_date": "2026-10-01",
"connection_id": 23,
"billing_interval": "monthly",
"billing_day": 1,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["subscription"]["status"] == "draft"
assert body["connection"]["subscription_id"] == 41
def test_quick_setup_shared_fiber_does_not_require_or_create_subscription(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_query_single(sql, params=None):
if "LOWER(TRIM(c.name))" in sql:
return {"id": 1662, "name": "BMC Networks"}
if "SELECT id, name FROM customers" in sql:
return {"id": 1662, "name": "BMC Networks"}
return None
async def fake_create_connection(payload):
assert payload.customer_id == 1662
assert payload.allocation_model == "shared"
assert payload.value_type == "delefiber"
assert payload.is_manual_shared is True
assert payload.subscription_id is None
return {"id": 88, "name": payload.name, "customer_id": payload.customer_id}
monkeypatch.setattr(internet_router, "execute_query_single", fake_query_single)
monkeypatch.setattr(internet_router, "create_connection", fake_create_connection)
app = FastAPI()
app.include_router(internet_router.router, prefix="/api/v1")
response = TestClient(app).post(
"/api/v1/internet-connections/quick-setup",
json={
"shared_fiber": True,
"connection_name": "Delt fiber - Testvej 1",
"vendor_id": 2,
"address": "Testvej 1",
"monthly_cost": 1500,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["sag"] is None
assert body["subscription"] is None
assert body["connection"]["id"] == 88

View File

@ -0,0 +1,104 @@
from app.billing.backend.supplier_invoices import (
_build_template_builder_result,
_hybrid_validate_invoice,
_smart_extract_lines,
)
DCS_TEXT = """
Nr. Varenr Tekst Model Antal Pris Beløb-
1 1003550858 Apple iPhone 15 6,1 128GB - Blå - Grade A NP/IPH/15/128/BLUE/A 1 3.149,00 3.149,00
Omvendt betalingspligt*
KN8: 84713000
EAN: 5711802732536
S/N: MQJYVVF7TJ
2 1001562408 PanzerGlass SAFE. by TPU Case Transparent SAFE95538 1 49,00 49,00
3 1002365988 SAFE. by PanzerGlass Apple iPhone 16, 15 | Ul SAFE95875 4 49,00 196,00
4 1002484222 Apple iPhone 16 6,1 128GB - Sort MYE73QN/A 1 5.099,00 5.099,00
Omvendt betalingspligt*
5 1002365899 PanzerGlass CARE by Feature Case Transparent 1325 1 59,00 59,00
6 1000775307 Huawei E3372-325 Trådløs mobilmodem Trådløs 51071UXG 1 189,00 189,00
7 1003143468 Apple 11-inch iPad Air M3 Wi-Fi 11 128GB 8GB MC9W4KN/A 2 4.299,00 8.598,00
Omvendt betalingspligt*
8 68678 PostNord - Erhverv - Fast fragt 40 - DK - 1-5 1 40,00 40,00
Varebeløb Momsgrundlag Moms Momssats Totalbeløb DKK
17.379,00 533,00 133,25 25,00% 17.512,25
"""
def test_dcs_layout_extracts_all_lines_and_metadata():
lines = _smart_extract_lines(DCS_TEXT)
assert len(lines) == 8
assert lines[0]["unit_price"] == 3149.0
assert lines[0]["ean"] == "5711802732536"
assert lines[0]["serial_numbers"] == ["MQJYVVF7TJ"]
assert lines[0]["reverse_charge"] is True
assert lines[6]["line_total"] == 8598.0
def test_hybrid_validation_understands_reverse_charge_summary():
result = _hybrid_validate_invoice({"lines": [], "total_amount": "17.512,25"}, DCS_TEXT)
validation = result["_hybrid_validation"]
assert validation["status"] == "validated"
assert validation["line_count"] == 8
assert validation["line_sum"] == 17379.0
assert validation["document_totals"]["vat_basis"] == 533.0
assert result["total_amount"] == 17512.25
def test_generic_layout_accepts_alphanumeric_supplier_sku_and_english_amounts():
text = "1 ABC-123 Managed router service 2 1,250.00 2,500.00"
lines = _smart_extract_lines(text)
assert len(lines) == 1
assert lines[0]["sku"] == "ABC-123"
assert lines[0]["quantity"] == 2.0
assert lines[0]["unit_price"] == 1250.0
assert lines[0]["line_total"] == 2500.0
def test_template_builder_overrides_wrong_ai_values_and_excludes_buyer_identity():
text = """
Faktura
Nummer 5055616
Dato 2/10-25
Momsnr. DK29522790
BMC Denmark ApS
Lejrvej 39
Nr. Varenr Tekst Model Antal Pris Beløb-
1 1000822111 Apple TV 4K MN893MP/A 1 1.099,00 1.099,00
2 1000809070 Xiaomi Air Purifier BHR5860EU 2 499,00 998,00
3 1003114298 Lenovo ThinkPad L-T14G2-SCA-B102 1 1.999,00 1.999,00
4 1003283728 HPE SmartMemory RFB-815101-B21 2 569,00 1.138,00
5 68678 PostNord Erhverv Fast fragt 1 40,00 40,00
Varebeløb Momsgrundlag Moms Momssats Totalbeløb DKK
5.274,00 3.275,00 818,75 25,00% 6.092,75
DCS ApS CVR: DK26686091 www.dcs.dk
"""
bad_ai_result = {
"total_amount": "2097",
"cvr": "29522790",
"detection_patterns": ["BMC Denmark ApS", "Lejrvej 39", "3500 Værløse"],
}
result = _build_template_builder_result(
text,
bad_ai_result,
{"name": "DCS ApS", "cvr_number": "26686091"},
)
assert result["invoice_number"]["value"] == "5055616"
assert result["invoice_date"]["value"] == "2/10-25"
assert result["total_amount"]["value"] == "6092.75"
assert result["cvr"]["value"] == "26686091"
assert "BMC Denmark ApS" not in result["detection_patterns"]
assert "Lejrvej 39" not in result["detection_patterns"]
assert "DCS ApS" in result["detection_patterns"]
assert result["line_count"] == 5
for field in ("invoice_number", "invoice_date", "total_amount", "cvr"):
match = __import__("re").search(result[field]["pattern"], text, __import__("re").IGNORECASE | __import__("re").MULTILINE)
assert match, field