release: v2.8.3 invoice and internet workflows
This commit is contained in:
parent
056ce7b871
commit
41a0785de2
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal file
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal 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.
|
||||
@ -2244,6 +2244,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):
|
||||
i += 1
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
` : ''}
|
||||
|
||||
|
||||
@ -207,6 +207,8 @@
|
||||
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
|
||||
</button>
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,7 +1147,19 @@
|
||||
? '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 = [];
|
||||
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);
|
||||
});
|
||||
@ -1084,42 +1169,36 @@
|
||||
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="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>
|
||||
<div class="d-flex justify-content-between small mt-2 mb-1">
|
||||
<span>${escapeHtml(confidenceLabel)}</span>
|
||||
<strong>${confidencePercent}%</strong>
|
||||
<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>
|
||||
<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:' : 'Når du godkender, gør systemet dette:'}</div>
|
||||
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
|
||||
${hasActions ? `
|
||||
<ol class="email-ai-actions mb-3">
|
||||
<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 {
|
||||
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,26 +1698,48 @@
|
||||
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>
|
||||
`;
|
||||
|
||||
mailBody.className = 'emails-v2-mail-body';
|
||||
@ -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)));
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
@ -388,6 +415,52 @@
|
||||
</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">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
@ -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()]);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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('"', '"') }}"
|
||||
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) {
|
||||
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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
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: %s %s", email_id, response.status, detail)
|
||||
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
|
||||
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"}
|
||||
|
||||
|
||||
@ -1860,6 +1860,29 @@ class EmailWorkflowService:
|
||||
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}")
|
||||
return {
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
173
fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md
Normal file
173
fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md
Normal 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.
|
||||
@ -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');
|
||||
}
|
||||
|
||||
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/png');
|
||||
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,
|
||||
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');
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
@ -328,46 +271,48 @@
|
||||
|
||||
async function captureScreenshotBeforeModal() {
|
||||
try {
|
||||
screenshotDataUrl = await withTimeout(
|
||||
takeScreenshotViaDisplayMedia(),
|
||||
12000,
|
||||
'Skærmvalg timed out'
|
||||
);
|
||||
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();
|
||||
return { ok: true };
|
||||
} catch (firstError) {
|
||||
console.warn('Bug report screenshot first attempt failed', firstError);
|
||||
|
||||
try {
|
||||
pendingScreenshotPromise = withTimeout(
|
||||
takeScreenshot(),
|
||||
8000,
|
||||
'Automatic screenshot timed out'
|
||||
pendingScreenshotPromise = pendingScreenshotPromise || withTimeout(
|
||||
takeScreenshot(), 7000, 'Automatisk screenshot tog for lang tid'
|
||||
);
|
||||
screenshotDataUrl = await pendingScreenshotPromise;
|
||||
return { ok: true, recovered: true };
|
||||
} catch (secondError) {
|
||||
console.warn('Bug report screenshot retry failed', secondError);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.warn('Bug report screenshot failed', error);
|
||||
screenshotDataUrl = null;
|
||||
return { ok: false, error: secondError || firstError };
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
106
tests/test_internet_quick_setup.py
Normal file
106
tests/test_internet_quick_setup.py
Normal 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
|
||||
104
tests/test_invoice_hybrid_extraction.py
Normal file
104
tests/test_invoice_hybrid_extraction.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user