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):
|
while i < len(lines_arr):
|
||||||
line = lines_arr[i].strip()
|
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
|
# Skip empty or header lines
|
||||||
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
|
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
|
||||||
i += 1
|
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"
|
# 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
|
# Whitespace-separated columns: position item_number description quantity unit_price total_price
|
||||||
# Most specific pattern - try first!
|
# 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:
|
if layout_match:
|
||||||
|
unit_price = _parse_invoice_number(layout_match.group(5))
|
||||||
|
line_total = _parse_invoice_number(layout_match.group(6))
|
||||||
items.append({
|
items.append({
|
||||||
'line_number': len(items) + 1,
|
'line_number': len(items) + 1,
|
||||||
'position': layout_match.group(1),
|
'position': layout_match.group(1),
|
||||||
'item_number': layout_match.group(2),
|
'item_number': layout_match.group(2),
|
||||||
|
'sku': layout_match.group(2),
|
||||||
'description': layout_match.group(3).strip(),
|
'description': layout_match.group(3).strip(),
|
||||||
'quantity': layout_match.group(4),
|
'quantity': _parse_invoice_number(layout_match.group(4)),
|
||||||
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
|
'unit_price': unit_price,
|
||||||
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
|
'line_total': line_total,
|
||||||
|
'total_price': line_total,
|
||||||
'raw_text': line
|
'raw_text': line
|
||||||
})
|
})
|
||||||
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
|
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
|
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 ==========
|
# ========== CRUD OPERATIONS ==========
|
||||||
|
|
||||||
@router.get("/supplier-invoices")
|
@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'),
|
"_validation_warning": llm_json_data.get('_validation_warning'),
|
||||||
"_vat_warning": llm_json_data.get('_vat_warning'),
|
"_vat_warning": llm_json_data.get('_vat_warning'),
|
||||||
"_validation_details": llm_json_data.get('_validation_details'),
|
"_validation_details": llm_json_data.get('_validation_details'),
|
||||||
|
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
|
||||||
}
|
}
|
||||||
elif extraction:
|
elif extraction:
|
||||||
# Fallback to extraction table columns if no LLM JSON
|
# 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))
|
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")
|
@router.post("/supplier-invoices/ai/analyze")
|
||||||
async def ai_analyze_invoice(request: Dict):
|
async def ai_analyze_invoice(request: Dict):
|
||||||
"""Brug AI til at analysere faktura og foreslå template felter"""
|
"""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
|
# Build enhanced PDF text with instruction
|
||||||
from app.core.config import settings
|
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.
|
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!
|
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_number": "5082481",
|
||||||
"invoice_date": "24/10-25",
|
"invoice_date": "24/10-25",
|
||||||
"total_amount": "1471.20",
|
"total_amount": "1471.20",
|
||||||
"cvr": "29522790",
|
"cvr": "26686091",
|
||||||
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
|
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
|
||||||
"lines_start": "Nr.VarenrTekst",
|
"lines_start": "Nr.VarenrTekst",
|
||||||
"lines_end": "Subtotal"
|
"lines_end": "Subtotal"
|
||||||
@ -3690,7 +3935,7 @@ VIGTIGT:
|
|||||||
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data
|
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data
|
||||||
|
|
||||||
PDF TEKST:
|
PDF TEKST:
|
||||||
{pdf_text[:2000]}
|
{excerpt}
|
||||||
|
|
||||||
RETURNER KUN JSON - intet andet!"""
|
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.
|
# Do not expose that dict as a successful analysis to the browser.
|
||||||
raise HTTPException(status_code=502, detail=str(result["error"]))
|
raise HTTPException(status_code=502, detail=str(result["error"]))
|
||||||
|
|
||||||
logger.info(f"✅ AI analyse gennemført: {result}")
|
vendor = None
|
||||||
return result
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -4916,6 +5166,7 @@ async def reprocess_uploaded_file(file_id: int):
|
|||||||
if template_id and confidence >= 0.5:
|
if template_id and confidence >= 0.5:
|
||||||
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
|
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
|
||||||
extracted_fields = template_service.extract_fields(text, template_id)
|
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)
|
# Check if this is an invoice2data template (ID -1)
|
||||||
is_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'),
|
extracted_fields.get('currency', 'DKK'),
|
||||||
1.0, # invoice2data always 100% confidence
|
1.0, # invoice2data always 100% confidence
|
||||||
json.dumps(extracted_fields), # llm_response_json
|
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
|
# 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}")
|
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)
|
confidence = llm_result.get('confidence', 0.75)
|
||||||
|
|
||||||
# Post-process: clear own CVR(s) if AI mistakenly returned them
|
# 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'),
|
llm_result.get('document_type', 'invoice'),
|
||||||
confidence,
|
confidence,
|
||||||
json.dumps(llm_result),
|
json.dumps(llm_result),
|
||||||
'extracted')
|
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert line items if extracted
|
# Insert line items if extracted
|
||||||
|
|||||||
@ -2916,7 +2916,9 @@ async function reviewExtractedData(fileId) {
|
|||||||
let aiData = null;
|
let aiData = null;
|
||||||
if (ext.llm_response_json) {
|
if (ext.llm_response_json) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to parse llm_response_json:', e);
|
console.error('Failed to parse llm_response_json:', e);
|
||||||
}
|
}
|
||||||
@ -2929,8 +2931,14 @@ async function reviewExtractedData(fileId) {
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Check for validation warnings
|
// Check for validation warnings
|
||||||
const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
|
const hybridValidation = aiData?._hybrid_validation || null;
|
||||||
const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
|
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
|
// Build modal content
|
||||||
let modalContent = `
|
let modalContent = `
|
||||||
@ -2946,14 +2954,15 @@ async function reviewExtractedData(fileId) {
|
|||||||
|
|
||||||
${hasValidationIssues ? `
|
${hasValidationIssues ? `
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
|
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6>
|
||||||
${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
|
${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>` : ''}
|
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : allValidationsPassed ? `
|
` : allValidationsPassed ? `
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
|
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6>
|
||||||
<p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
|
<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>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
@ -207,6 +207,8 @@
|
|||||||
3. Systemet laver automatisk patterns!
|
3. Systemet laver automatisk patterns!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="aiLineItemsResult" class="d-none mb-3"></div>
|
||||||
|
|
||||||
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn">
|
<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
|
<i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
|
||||||
</button>
|
</button>
|
||||||
@ -1466,6 +1468,7 @@ async function autoGenerateTemplate() {
|
|||||||
const linesEnd = result.lines_end?.pattern || result.lines_end;
|
const linesEnd = result.lines_end?.pattern || result.lines_end;
|
||||||
document.getElementById('linesEndPattern').value = linesEnd;
|
document.getElementById('linesEndPattern').value = linesEnd;
|
||||||
}
|
}
|
||||||
|
renderAiLineItems(result.line_items || [], result.line_count || 0);
|
||||||
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it
|
// 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!';
|
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() {
|
async function saveTemplate() {
|
||||||
const vendorId = document.getElementById('vendorSelect').value;
|
const vendorId = document.getElementById('vendorSelect').value;
|
||||||
const templateName = document.getElementById('templateName').value;
|
const templateName = document.getElementById('templateName').value;
|
||||||
|
|||||||
@ -94,17 +94,18 @@ def _rate_limit(user_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_customer_id() -> int:
|
def _resolve_customer_id() -> int:
|
||||||
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
# Resolve our own company by identity, never by an arbitrary/old numeric ID.
|
||||||
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
|
# Customer IDs differ between environments and ID 1 may belong to a supplier.
|
||||||
if configured_row:
|
|
||||||
return int(configured_row["id"])
|
|
||||||
|
|
||||||
named_row = execute_query_single(
|
named_row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT c.id
|
||||||
FROM customers
|
FROM customers c
|
||||||
WHERE LOWER(name) = LOWER(%s)
|
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
|
||||||
ORDER BY id ASC
|
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
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
("BMC Networks",),
|
("BMC Networks",),
|
||||||
@ -112,11 +113,23 @@ def _resolve_customer_id() -> int:
|
|||||||
if named_row:
|
if named_row:
|
||||||
return int(named_row["id"])
|
return int(named_row["id"])
|
||||||
|
|
||||||
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
|
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
||||||
if fallback:
|
configured_row = execute_query_single(
|
||||||
return int(fallback["id"])
|
"""
|
||||||
|
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)
|
@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:
|
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
|
||||||
raise HTTPException(status_code=400, detail="Screenshot too large")
|
raise HTTPException(status_code=400, detail="Screenshot too large")
|
||||||
|
|
||||||
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
|
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
|
||||||
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
|
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
|
# Attach logs as json file
|
||||||
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
|
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
|||||||
@ -672,10 +672,27 @@
|
|||||||
renderJobLines();
|
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 renderMapEditor = (line) => {
|
||||||
const lineId = Number(line.id);
|
const lineId = Number(line.id);
|
||||||
const selected = state.lineCustomerSelections[lineId];
|
const selected = state.lineCustomerSelections[lineId];
|
||||||
const results = state.lineCustomerSearchResults[lineId] || [];
|
|
||||||
const query = state.lineCustomerSearchQueries[lineId] || '';
|
const query = state.lineCustomerSearchQueries[lineId] || '';
|
||||||
|
|
||||||
return `
|
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="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
|
||||||
`}
|
`}
|
||||||
<div class="map-results mb-2">
|
<div class="map-results mb-2" data-company-results-line-id="${lineId}">
|
||||||
${results.length
|
${renderCustomerSearchResults(lineId)}
|
||||||
? 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>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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'}>
|
<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 renderProductMapEditor = (line) => {
|
||||||
const lineId = Number(line.id);
|
const lineId = Number(line.id);
|
||||||
const selected = state.lineProductSelections[lineId];
|
const selected = state.lineProductSelections[lineId];
|
||||||
const results = state.lineProductSearchResults[lineId] || [];
|
|
||||||
const query = state.lineProductSearchQueries[lineId] || '';
|
const query = state.lineProductSearchQueries[lineId] || '';
|
||||||
|
|
||||||
return `
|
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="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
|
||||||
`}
|
`}
|
||||||
<div class="map-results mb-2">
|
<div class="map-results mb-2" data-product-results-line-id="${lineId}">
|
||||||
${results.length
|
${renderProductSearchResults(lineId)}
|
||||||
? 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>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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'}>
|
<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;
|
state.lineCustomerSearchQueries[lineId] = query;
|
||||||
if ((query || '').trim().length < 2) {
|
if ((query || '').trim().length < 2) {
|
||||||
state.lineCustomerSearchResults[lineId] = [];
|
state.lineCustomerSearchResults[lineId] = [];
|
||||||
renderJobLines();
|
updateCustomerSearchResults(lineId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
|
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
if (state.lineCustomerSearchQueries[lineId] !== query) return;
|
||||||
state.lineCustomerSearchResults[lineId] = rows || [];
|
state.lineCustomerSearchResults[lineId] = rows || [];
|
||||||
renderJobLines();
|
updateCustomerSearchResults(lineId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCompanyMapping = async (lineId) => {
|
const saveCompanyMapping = async (lineId) => {
|
||||||
@ -873,13 +892,14 @@
|
|||||||
state.lineProductSearchQueries[lineId] = query;
|
state.lineProductSearchQueries[lineId] = query;
|
||||||
if ((query || '').trim().length < 2) {
|
if ((query || '').trim().length < 2) {
|
||||||
state.lineProductSearchResults[lineId] = [];
|
state.lineProductSearchResults[lineId] = [];
|
||||||
renderJobLines();
|
updateProductSearchResults(lineId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
|
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
if (state.lineProductSearchQueries[lineId] !== query) return;
|
||||||
state.lineProductSearchResults[lineId] = rows || [];
|
state.lineProductSearchResults[lineId] = rows || [];
|
||||||
renderJobLines();
|
updateProductSearchResults(lineId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProductMapping = async (lineId) => {
|
const saveProductMapping = async (lineId) => {
|
||||||
|
|||||||
@ -77,6 +77,79 @@
|
|||||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
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 {
|
.email-ai-decision {
|
||||||
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
|
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -1074,7 +1147,19 @@
|
|||||||
? 'Høj sikkerhed'
|
? 'Høj sikkerhed'
|
||||||
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt';
|
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt';
|
||||||
|
|
||||||
|
const email = state.selectedEmail || {};
|
||||||
|
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
|
||||||
|
|
||||||
const effects = [];
|
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) => {
|
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
|
||||||
if (row.effect) effects.push(row.effect);
|
if (row.effect) effects.push(row.effect);
|
||||||
});
|
});
|
||||||
@ -1084,42 +1169,36 @@
|
|||||||
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
|
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const uniqueEffects = [...new Set(effects)];
|
const uniqueEffects = [...new Set(effects)];
|
||||||
const hasActions = uniqueEffects.length > 0;
|
const hasActions = uniqueEffects.length > 0;
|
||||||
const automaticExecution = Boolean(preview.automatic_execution);
|
const automaticExecution = Boolean(preview.automatic_execution);
|
||||||
|
|
||||||
host.innerHTML = `
|
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="email-ai-type">${escapeHtml(typeLabel)}</div>
|
||||||
<div class="d-flex justify-content-between small mt-2 mb-1">
|
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
|
||||||
<span>${escapeHtml(confidenceLabel)}</span>
|
|
||||||
<strong>${confidencePercent}%</strong>
|
|
||||||
</div>
|
</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()}
|
${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 ? `
|
${hasActions ? `
|
||||||
<ol class="email-ai-actions mb-3">
|
<ol class="email-ai-actions mb-2">
|
||||||
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
||||||
</ol>
|
</ol>
|
||||||
${automaticExecution ? `
|
${automaticExecution ? `
|
||||||
<div class="alert alert-danger py-2 small mb-0">
|
<div class="alert alert-danger py-2 small mb-0">
|
||||||
<i class="bi bi-lightning-charge-fill me-1"></i>
|
<i class="bi bi-lightning-charge-fill me-1"></i>
|
||||||
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
|
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
|
||||||
</div>` : `
|
</div>` : ''}
|
||||||
<button id="v2ApproveActions" class="btn btn-success w-100">
|
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
|
||||||
<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>
|
||||||
`}
|
|
||||||
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
|
|
||||||
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
|
|
||||||
bindIdentityDecisionActions();
|
bindIdentityDecisionActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1129,7 +1208,12 @@
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
|
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
|
||||||
try {
|
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();
|
await executeWorkflowsCurrent();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (document.body.contains(button)) {
|
if (document.body.contains(button)) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
@ -1158,6 +1242,8 @@
|
|||||||
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
|
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
|
||||||
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
|
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
|
||||||
const emailMeta = preview.email || {};
|
const emailMeta = preview.email || {};
|
||||||
|
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
|
||||||
|
&& Boolean(state.selectedEmail?.supplier_id);
|
||||||
|
|
||||||
const systemHtml = system.map((row) => {
|
const systemHtml = system.map((row) => {
|
||||||
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
|
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
|
||||||
@ -1169,7 +1255,15 @@
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) => `
|
? matching.map((wf) => `
|
||||||
<div class="emails-v2-kv">
|
<div class="emails-v2-kv">
|
||||||
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
|
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
|
||||||
@ -1604,26 +1698,48 @@
|
|||||||
const hasCustomer = Boolean(email.customer_id);
|
const hasCustomer = Boolean(email.customer_id);
|
||||||
const hasSupplier = Boolean(email.supplier_id);
|
const hasSupplier = Boolean(email.supplier_id);
|
||||||
const hasIdentity = hasCustomer || hasSupplier;
|
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.className = 'emails-v2-quick-toolbar';
|
||||||
quickActions.innerHTML = email.linked_case_id ? `
|
quickActions.innerHTML = email.linked_case_id ? `
|
||||||
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
<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)}
|
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||||
</a>
|
</a>
|
||||||
` : `
|
` : `
|
||||||
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
|
<div class="emails-v2-flow-picker">
|
||||||
<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'}">
|
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
|
||||||
<i class="bi bi-person-workspace me-1"></i>Kundesag
|
<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>
|
||||||
<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'}">
|
<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'}">
|
||||||
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
${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>
|
||||||
<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'}">
|
<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'}">
|
||||||
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
|
${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>
|
||||||
<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'}">
|
<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'}">
|
||||||
<i class="bi bi-archive me-1"></i>Arkivér mail
|
${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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
mailBody.className = 'emails-v2-mail-body';
|
mailBody.className = 'emails-v2-mail-body';
|
||||||
@ -1661,8 +1777,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ? `
|
${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>
|
<h6>Allerede knyttet til sag</h6>
|
||||||
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
<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)}
|
<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
|
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
|
||||||
</button>
|
</button>
|
||||||
</div>` : `
|
</div>` : `
|
||||||
<div class="emails-v2-card emails-primary-action">
|
<details class="border rounded p-3 mb-3">
|
||||||
<h6>Opret sag fra denne email</h6>
|
<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">
|
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<select id="v2CaseType" class="form-select form-select-sm">
|
<select id="v2CaseType" class="form-select form-select-sm">
|
||||||
@ -1686,15 +1809,16 @@
|
|||||||
<i class="bi bi-plus-lg me-1"></i>Opret
|
<i class="bi bi-plus-lg me-1"></i>Opret
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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…">
|
<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 id="v2SagResults" class="emails-v2-sag-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="emails-v2-card">
|
<div class="border rounded p-3 mb-3">
|
||||||
<h6>Hurtige mailhandlinger</h6>
|
<h6>Hurtige mailhandlinger</h6>
|
||||||
<div class="emails-v2-actions mb-0">
|
<div class="emails-v2-actions mb-0">
|
||||||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
||||||
@ -1710,7 +1834,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
|
||||||
<div class="emails-v2-actions mt-3 mb-2">
|
<div class="emails-v2-actions mt-3 mb-2">
|
||||||
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
|
<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>
|
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
|
||||||
</details>
|
</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>
|
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
|
||||||
<h6 class="mt-3">Leverandør faktura</h6>
|
<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>
|
<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>
|
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="emails-v2-card">
|
<details class="border rounded p-3">
|
||||||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
|
<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({
|
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
|
||||||
email_id: email.id,
|
email_id: email.id,
|
||||||
@ -1755,6 +1879,8 @@
|
|||||||
linked_case_id: email.linked_case_id,
|
linked_case_id: email.linked_case_id,
|
||||||
}, null, 2))}</pre>
|
}, null, 2))}</pre>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
|
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
|
||||||
|
|||||||
@ -1270,6 +1270,26 @@ class QuickBmcnetCreatePayload(BaseModel):
|
|||||||
mark_gateway: bool = False
|
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):
|
class DelefiberProductPricePayload(BaseModel):
|
||||||
product_id: int
|
product_id: int
|
||||||
monthly_price: float
|
monthly_price: float
|
||||||
@ -3098,6 +3118,168 @@ async def remove_delefiber_product_price(connection_id: int, product_id: int):
|
|||||||
return {"deleted": True}
|
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")
|
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
|
||||||
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
|
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
|
||||||
head_row = execute_query_single(
|
head_row = execute_query_single(
|
||||||
|
|||||||
@ -137,6 +137,33 @@
|
|||||||
padding: 1rem;
|
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 {
|
.invoice-sync-status {
|
||||||
display:inline-flex;
|
display:inline-flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
@ -179,8 +206,8 @@
|
|||||||
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
|
<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
|
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
|
<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>Ny forbindelse
|
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -388,6 +415,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="quick-type-grid mb-3">
|
||||||
|
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
|
||||||
|
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
|
||||||
|
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
|
||||||
|
<div class="row g-3 d-none mt-1" id="quickSetupFields">
|
||||||
|
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
|
||||||
|
<div class="col-12" id="quickCustomerWrap">
|
||||||
|
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
|
||||||
|
<input type="hidden" id="quickCustomerId">
|
||||||
|
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
|
||||||
|
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
|
||||||
|
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
|
||||||
|
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
|
||||||
|
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
|
||||||
|
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
|
||||||
|
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
|
||||||
|
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
|
||||||
|
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
|
||||||
|
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
|
||||||
|
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
|
||||||
|
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
|
||||||
|
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
|
||||||
|
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
|
||||||
|
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
|
||||||
|
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
|
||||||
|
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
|
||||||
|
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -442,6 +515,11 @@
|
|||||||
let invoiceSyncItems = [];
|
let invoiceSyncItems = [];
|
||||||
let activeTab = 'all';
|
let activeTab = 'all';
|
||||||
let subscriptionOptions = [];
|
let subscriptionOptions = [];
|
||||||
|
let quickCustomerOptions = [];
|
||||||
|
let quickProductOptions = [];
|
||||||
|
let quickCustomerTimer = null;
|
||||||
|
let quickSetupType = null;
|
||||||
|
let quickSharedHeadOptions = [];
|
||||||
let ipNordicPreview = null;
|
let ipNordicPreview = null;
|
||||||
let allocationSuggestionsByConnection = new Map();
|
let allocationSuggestionsByConnection = new Map();
|
||||||
|
|
||||||
@ -844,6 +922,7 @@
|
|||||||
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
|
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
|
||||||
const uniqueSuggestion = allocation?.unique_suggestion;
|
const uniqueSuggestion = allocation?.unique_suggestion;
|
||||||
const suggestionCount = Number(allocation?.suggestions?.length || 0);
|
const suggestionCount = Number(allocation?.suggestions?.length || 0);
|
||||||
|
const isOwnedSharedFiber = Boolean(item.is_shared_head && (item.value_type === 'delefiber' || item.is_manual_shared));
|
||||||
return `
|
return `
|
||||||
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
|
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
|
||||||
<td>
|
<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>` : ''}
|
${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>
|
||||||
<td>
|
<td>
|
||||||
<div>${escapeHtml(item.customer_name || '-')}</div>
|
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
|
||||||
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
|
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `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>` : ''}
|
${!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>` : ''}
|
||||||
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
|
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
|
||||||
${item.sla_subscription_id
|
${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 ${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>`}
|
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
|
||||||
</td>
|
</td>
|
||||||
@ -1052,11 +1131,308 @@
|
|||||||
const vendors = response.ok ? await response.json() : [];
|
const vendors = response.ok ? await response.json() : [];
|
||||||
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
|
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
|
||||||
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
|
.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) {
|
} catch (error) {
|
||||||
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
|
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.addEventListener('DOMContentLoaded', async () => {
|
||||||
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') loadInternetPage();
|
if (event.key === 'Enter') loadInternetPage();
|
||||||
@ -1064,6 +1440,34 @@
|
|||||||
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
|
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
|
||||||
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
|
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();
|
toggleCreateValueFields();
|
||||||
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
|
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
|
||||||
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
||||||
|
|||||||
@ -843,11 +843,31 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
c.phone,
|
c.phone,
|
||||||
c.mobile,
|
c.mobile,
|
||||||
c.title,
|
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
|
FROM sag_kontakter sk
|
||||||
JOIN contacts c ON sk.contact_id = c.id
|
JOIN contacts c ON sk.contact_id = c.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT cu.name AS customer_name
|
SELECT cu.id AS customer_id, cu.name AS customer_name
|
||||||
FROM contact_companies cc
|
FROM contact_companies cc
|
||||||
JOIN customers cu ON cu.id = cc.customer_id
|
JOIN customers cu ON cu.id = cc.customer_id
|
||||||
WHERE cc.contact_id = c.id
|
WHERE cc.contact_id = c.id
|
||||||
@ -1204,11 +1224,25 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
|||||||
c.phone,
|
c.phone,
|
||||||
c.mobile,
|
c.mobile,
|
||||||
c.title,
|
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
|
FROM sag_kontakter sk
|
||||||
JOIN contacts c ON sk.contact_id = c.id
|
JOIN contacts c ON sk.contact_id = c.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT cu.name AS customer_name
|
SELECT cu.id AS customer_id, cu.name AS customer_name
|
||||||
FROM contact_companies cc
|
FROM contact_companies cc
|
||||||
JOIN customers cu ON cu.id = cc.customer_id
|
JOIN customers cu ON cu.id = cc.customer_id
|
||||||
WHERE cc.contact_id = c.id
|
WHERE cc.contact_id = c.id
|
||||||
|
|||||||
@ -2097,6 +2097,36 @@
|
|||||||
border-bottom: none;
|
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 {
|
.contact-row .contact-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@ -8034,8 +8064,9 @@
|
|||||||
<span class="text-end">Handlinger</span>
|
<span class="text-end">Handlinger</span>
|
||||||
</div>
|
</div>
|
||||||
{% for contact in contacts %}
|
{% 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
|
<div
|
||||||
class="contact-row"
|
class="contact-row{% if external_contact %} contact-external{% endif %}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick="showContactInfoModal(this)"
|
onclick="showContactInfoModal(this)"
|
||||||
@ -8049,9 +8080,17 @@
|
|||||||
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
||||||
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
|
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.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">
|
<div class="contact-actions">
|
||||||
{% if contact.mobile %}
|
{% if contact.mobile %}
|
||||||
<button
|
<button
|
||||||
@ -15166,6 +15205,31 @@
|
|||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
<input type="text" class="form-control" id="entitySearchInput" placeholder="Søg (min. 2 tegn)..." autocomplete="off">
|
<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>
|
||||||
<div class="text-center d-none" id="entitySearchSpinner">
|
<div class="text-center d-none" id="entitySearchSpinner">
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
@ -15777,6 +15841,8 @@
|
|||||||
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
|
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
|
||||||
document.getElementById('entitySearchInput').value = '';
|
document.getElementById('entitySearchInput').value = '';
|
||||||
document.getElementById('entitySearchResults').innerHTML = '';
|
document.getElementById('entitySearchResults').innerHTML = '';
|
||||||
|
document.getElementById('quickContactCreateBtn').classList.toggle('d-none', type !== 'contact');
|
||||||
|
toggleQuickContactCreate(false);
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
|
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
@ -15784,6 +15850,94 @@
|
|||||||
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
|
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) {
|
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
|
||||||
clearTimeout(searchDebounceIds);
|
clearTimeout(searchDebounceIds);
|
||||||
const query = e.target.value.trim();
|
const query = e.target.value.trim();
|
||||||
@ -15809,7 +15963,7 @@
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error('Search failed');
|
if (!res.ok) throw new Error('Search failed');
|
||||||
const results = await res.json();
|
const results = await res.json();
|
||||||
renderResults(results);
|
renderResults(results, query);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
|
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');
|
const container = document.getElementById('entitySearchResults');
|
||||||
if (results.length === 0) {
|
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>';
|
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
|
||||||
|
}
|
||||||
return;
|
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 => {
|
container.innerHTML = results.map(item => {
|
||||||
let title = '', subtitle = '', icon = '', id = item.id;
|
let title = '', subtitle = '', icon = '', id = item.id;
|
||||||
|
let extraClass = '';
|
||||||
|
let relationBadge = '';
|
||||||
|
|
||||||
if (currentSearchType === 'hardware') {
|
if (currentSearchType === 'hardware') {
|
||||||
title = `${item.brand} ${item.model}`;
|
title = `${item.brand} ${item.model}`;
|
||||||
@ -15839,8 +16012,14 @@
|
|||||||
icon = 'bi-geo-alt';
|
icon = 'bi-geo-alt';
|
||||||
} else if (currentSearchType === 'contact') {
|
} else if (currentSearchType === 'contact') {
|
||||||
title = `${item.first_name} ${item.last_name}`;
|
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';
|
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') {
|
} else if (currentSearchType === 'customer') {
|
||||||
title = item.name;
|
title = item.name;
|
||||||
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
|
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
|
||||||
@ -15848,10 +16027,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
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 class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold">${title}</div>
|
<div class="fw-bold">${title}${relationBadge}</div>
|
||||||
<small class="text-muted">${subtitle}</small>
|
<small class="text-muted">${subtitle}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -7,10 +7,10 @@ router = APIRouter()
|
|||||||
async def search_customers(q: str = Query(..., min_length=2)):
|
async def search_customers(q: str = Query(..., min_length=2)):
|
||||||
"""
|
"""
|
||||||
Autocomplete search for customers.
|
Autocomplete search for customers.
|
||||||
Returns list of {id, name, cvr_nummer, email}
|
Returns customer identity and registered address for address-aware workflows.
|
||||||
"""
|
"""
|
||||||
sql = """
|
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
|
FROM customers
|
||||||
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
|
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
|
||||||
AND deleted_at IS NULL
|
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
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS user_company,
|
) 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')
|
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
|
||||||
FROM contact_companies cc
|
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 CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
|
||||||
OR c.phone ILIKE %s
|
OR c.phone ILIKE %s
|
||||||
OR c.mobile 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
|
ORDER BY c.first_name ASC, c.last_name ASC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
"""
|
"""
|
||||||
term = f"%{q}%"
|
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
|
return results
|
||||||
|
|
||||||
@router.get("/search/hardware")
|
@router.get("/search/hardware")
|
||||||
|
|||||||
@ -977,20 +977,26 @@ class EmailService:
|
|||||||
return {"success": False, "reason": "Email has no message id"}
|
return {"success": False, "reason": "Email has no message id"}
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
params = {
|
|
||||||
"$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")),
|
|
||||||
"$select": "id,subject,hasAttachments",
|
|
||||||
"$top": 2,
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
async with ClientSession() as session:
|
async with ClientSession() as session:
|
||||||
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
|
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:
|
async with session.get(url, params=params, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
detail = await response.text()
|
detail = await response.text()
|
||||||
logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail)
|
logger.warning("⚠️ Could not resolve Graph email %s using %s: %s %s", email_id, candidate, response.status, detail)
|
||||||
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
|
continue
|
||||||
matches = (await response.json()).get("value", [])
|
matches = (await response.json()).get("value", [])
|
||||||
|
if matches:
|
||||||
|
break
|
||||||
if not matches:
|
if not matches:
|
||||||
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}
|
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}
|
||||||
|
|
||||||
|
|||||||
@ -1860,6 +1860,29 @@ class EmailWorkflowService:
|
|||||||
elif not isinstance(attachments, list):
|
elif not isinstance(attachments, list):
|
||||||
attachments = [attachments]
|
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:
|
if not attachments:
|
||||||
logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
|
logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
|
||||||
return {
|
return {
|
||||||
@ -1904,11 +1927,17 @@ class EmailWorkflowService:
|
|||||||
|
|
||||||
# Check if file already exists
|
# Check if file already exists
|
||||||
existing = execute_query(
|
existing = execute_query(
|
||||||
"SELECT file_id FROM incoming_files WHERE checksum = %s",
|
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
|
||||||
(checksum,))
|
(checksum,))
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
logger.info(f"⚠️ File already exists: {attachment['filename']}")
|
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
|
continue
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
# Create uploads directory if it doesn't exist
|
||||||
@ -1953,12 +1982,43 @@ class EmailWorkflowService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if uploaded_files:
|
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 {
|
return {
|
||||||
'action': 'extract_invoice_data',
|
'action': 'extract_invoice_data',
|
||||||
'success': True,
|
'success': True,
|
||||||
'files_uploaded': len(uploaded_files),
|
'files_uploaded': len(uploaded_files),
|
||||||
'file_ids': [f['file_id'] for f in 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:
|
else:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -2006,7 +2006,7 @@ if (bmcOriginalFetch) {
|
|||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.5"></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/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" %}
|
{% include "shared/frontend/internal_message.html" %}
|
||||||
<script src="/static/js/message-ui.js?v=1"></script>
|
<script src="/static/js/message-ui.js?v=1"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.71"></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">
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" id="bugCaptureDisplayMediaBtn">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="bugReportStatus" class="small text-muted mt-2"></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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
<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
|
## Aktive planer
|
||||||
|
|
||||||
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
|
- [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
|
## 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;
|
if (!el || !el.tagName) return false;
|
||||||
const tag = String(el.tagName).toUpperCase();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,7 +128,7 @@
|
|||||||
|
|
||||||
async function renderScreenshot(target, opts) {
|
async function renderScreenshot(target, opts) {
|
||||||
const canvas = await window.html2canvas(target, opts);
|
const canvas = await window.html2canvas(target, opts);
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/jpeg', 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureHtml2Canvas() {
|
async function ensureHtml2Canvas() {
|
||||||
@ -184,8 +184,21 @@
|
|||||||
throw new Error('Canvas context unavailable');
|
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);
|
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 {
|
} finally {
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
}
|
}
|
||||||
@ -193,92 +206,22 @@
|
|||||||
|
|
||||||
async function takeScreenshot() {
|
async function takeScreenshot() {
|
||||||
await ensureHtml2Canvas();
|
await ensureHtml2Canvas();
|
||||||
|
return await renderScreenshot(document.body || document.documentElement, {
|
||||||
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 = {
|
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: true,
|
allowTaint: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
imageTimeout: 7000,
|
imageTimeout: 2500,
|
||||||
ignoreElements: shouldIgnoreInScreenshot,
|
ignoreElements: shouldIgnoreInScreenshot,
|
||||||
removeContainer: true,
|
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,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
windowWidth: window.innerWidth,
|
windowWidth: window.innerWidth,
|
||||||
windowHeight: window.innerHeight,
|
windowHeight: window.innerHeight,
|
||||||
scrollX: window.scrollX,
|
scrollX: -window.scrollX,
|
||||||
scrollY: window.scrollY,
|
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) {
|
function setStatus(text, isError) {
|
||||||
@ -328,46 +271,48 @@
|
|||||||
|
|
||||||
async function captureScreenshotBeforeModal() {
|
async function captureScreenshotBeforeModal() {
|
||||||
try {
|
try {
|
||||||
screenshotDataUrl = await withTimeout(
|
pendingScreenshotPromise = pendingScreenshotPromise || withTimeout(
|
||||||
takeScreenshotViaDisplayMedia(),
|
takeScreenshot(), 7000, 'Automatisk screenshot tog for lang tid'
|
||||||
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'
|
|
||||||
);
|
);
|
||||||
screenshotDataUrl = await pendingScreenshotPromise;
|
screenshotDataUrl = await pendingScreenshotPromise;
|
||||||
return { ok: true, recovered: true };
|
return { ok: true };
|
||||||
} catch (secondError) {
|
} catch (error) {
|
||||||
console.warn('Bug report screenshot retry failed', secondError);
|
console.warn('Bug report screenshot failed', error);
|
||||||
screenshotDataUrl = null;
|
screenshotDataUrl = null;
|
||||||
return { ok: false, error: secondError || firstError };
|
return { ok: false, error };
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
pendingScreenshotPromise = null;
|
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) {
|
async function openBugReportModal(statusText, statusIsError) {
|
||||||
if (!bugModal) {
|
if (!bugModal) {
|
||||||
const modalEl = document.getElementById('bugReportModal');
|
const modalEl = document.getElementById('bugReportModal');
|
||||||
@ -379,7 +324,7 @@
|
|||||||
setStatus(
|
setStatus(
|
||||||
statusText || (screenshotDataUrl
|
statusText || (screenshotDataUrl
|
||||||
? 'Screenshot klar. Udfyld felterne og send.'
|
? '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)
|
Boolean(statusIsError)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -411,7 +356,7 @@
|
|||||||
const dataUrl = await takeScreenshotViaDisplayMedia();
|
const dataUrl = await takeScreenshotViaDisplayMedia();
|
||||||
screenshotDataUrl = dataUrl;
|
screenshotDataUrl = dataUrl;
|
||||||
setPreview(dataUrl);
|
setPreview(dataUrl);
|
||||||
setStatus('Screenshot taget via skærmdeling.');
|
setStatus('Nyt screenshot af siden er taget uden bugformularen.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Bug report display media capture failed', 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);
|
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
|
||||||
@ -419,7 +364,7 @@
|
|||||||
isCapturingDisplayMedia = false;
|
isCapturingDisplayMedia = false;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
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) {
|
if (!res || !res.ok) {
|
||||||
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
|
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.');
|
setStatus('Fejl rapporteret.');
|
||||||
@ -512,22 +457,13 @@
|
|||||||
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
|
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
|
||||||
const modalEl = document.getElementById('bugReportModal');
|
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) {
|
if (btn) {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const capture = await captureScreenshotBeforeModal();
|
startFastBugReport();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -567,20 +503,7 @@
|
|||||||
if (isTyping) return;
|
if (isTyping) return;
|
||||||
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
captureScreenshotBeforeModal().then((capture) => {
|
startFastBugReport();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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