diff --git a/MDfile/RELEASE_NOTES_v2.8.3.md b/MDfile/RELEASE_NOTES_v2.8.3.md
new file mode 100644
index 0000000..8c0759b
--- /dev/null
+++ b/MDfile/RELEASE_NOTES_v2.8.3.md
@@ -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.
diff --git a/VERSION b/VERSION
index 1817afe..9f8d8a9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.8.2
+2.8.3
diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py
index 31cc6df..ac72834 100644
--- a/app/billing/backend/supplier_invoices.py
+++ b/app/billing/backend/supplier_invoices.py
@@ -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 rå 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
diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html
index 36c1d37..d0715b5 100644
--- a/app/billing/frontend/supplier_invoices.html
+++ b/app/billing/frontend/supplier_invoices.html
@@ -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 ? `
-
Beløbs-validering
- ${aiData._validation_warning ? `
⚠️ ${aiData._validation_warning}
` : ''}
+
Kræver manuel kontrol
+ ${hybridWarnings.map(warning => `
⚠️ ${escapeHtml(warning)}
`).join('')}
+ ${!hybridWarnings.length && aiData._validation_warning ? `
⚠️ ${escapeHtml(aiData._validation_warning)}
` : ''}
${aiData._vat_warning ? `
⚠️ ${aiData._vat_warning}
` : ''}
` : allValidationsPassed ? `
-
Beløbs-validering
-
✅ Varelinjer summer korrekt til subtotal
✅ Moms beregning er korrekt (25%)
+
Automatisk kontrol bestået
+
${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}
` : ''}
diff --git a/app/billing/frontend/template_builder.html b/app/billing/frontend/template_builder.html
index 56869fe..7998ca6 100644
--- a/app/billing/frontend/template_builder.html
+++ b/app/billing/frontend/template_builder.html
@@ -206,6 +206,8 @@
2. Eller markér tekst manuelt og vælg felttype
3. Systemet laver automatisk patterns!
+
+