From f251bfebc4b4c27d0a3a3ff4254825bbb3154d99 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 11 Sep 2026 13:51:10 +0200 Subject: [PATCH] release: v2.8.3 invoice and internet workflows --- MDfile/RELEASE_NOTES_v2.8.3.md | 38 ++ VERSION | 2 +- app/billing/backend/supplier_invoices.py | 274 +++++++++++- app/billing/frontend/supplier_invoices.html | 23 +- app/billing/frontend/template_builder.html | 54 +++ app/bug_reports/backend/router.py | 45 +- app/economy/frontend/also_cloud.html | 72 +-- app/emails/frontend/emails_v2.html | 232 +++++++--- .../internet_connections/backend/router.py | 182 ++++++++ .../internet_connections/templates/index.html | 418 +++++++++++++++++- app/modules/sag/frontend/views.py | 42 +- app/modules/sag/templates/detail_v3.html | 197 ++++++++- app/modules/search/backend/router.py | 25 +- app/services/email_service.py | 28 +- app/services/email_workflow_service.py | 64 ++- app/shared/frontend/base.html | 2 +- app/shared/frontend/bug_report_modal.html | 4 +- fremtidige planer/README.md | 1 + .../sikker-fakturaudtraek-hybrid-ai.md | 173 ++++++++ static/js/bug-report.js | 221 +++------ tests/test_internet_quick_setup.py | 106 +++++ tests/test_invoice_hybrid_extraction.py | 104 +++++ 22 files changed, 2006 insertions(+), 301 deletions(-) create mode 100644 MDfile/RELEASE_NOTES_v2.8.3.md create mode 100644 fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md create mode 100644 tests/test_internet_quick_setup.py create mode 100644 tests/test_invoice_hybrid_extraction.py diff --git a/MDfile/RELEASE_NOTES_v2.8.3.md b/MDfile/RELEASE_NOTES_v2.8.3.md new file mode 100644 index 0000000..8c0759b --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.3.md @@ -0,0 +1,38 @@ +# BMC Hub v2.8.3 + +## Internetforbindelser og abonnementer + +- Ny hurtig oprettelse med særskilte flows for fysisk forbindelse, delt hovedfiber og BMCnet. +- Delt hovedfiber oprettes med BMC Networks som ejer og uden produkt, abonnement eller abonnementssag. +- BMCnet opretter og forbinder kunde, internetprodukt, abonnement og delt hovedforbindelse i ét flow. +- Kundens adresse bruges til tolerant forslag af delt hovedforbindelse, inklusive mindre staveforskelle. +- Forenklet BMCnet-formular, hvor tekniske værdier arves og kun et valgfrit notefelt vises. +- Forbedret overblik over delte hovedforbindelser, BMCnet-kunder, kapacitet og økonomi. + +## Leverandørfakturaer + +- Hybrid fakturaudtræk kombinerer tekst, layout og AI med kontrolleret fallback. +- Forbedret udtræk af danske beløb, fakturafelter og varelinjer fra forskellige leverandørformater. +- Template Builder viser fundne data tydeligere og håndterer AI-fejl mere robust. +- Internetfakturaer kan behandles og følges i internetforbindelsesmodulet. + +## E-mail og arbejdsgange + +- Forenklet valg af mailtype og arbejdsflow med tydeligere handlinger. +- Leverandørfaktura-flow kan registrere internetfakturaer til efterfølgende behandling. +- Forbedret leverandør- og kundematch samt mere kompakt visning af avancerede oplysninger. + +## Sager, kontakter og brugerflade + +- Kontakter fra andre virksomheder markeres tydeligt på en sag. +- Kontakter kan oprettes direkte fra kontaktsøgningen, også når søgningen ikke giver resultat. +- Bugrapporter bruger en hurtigere og mere stabil lokal indsamling af relevant sideinformation. +- Rettelser til ALSO Cloud-søgning, fælles navigation og øvrige betjeningsdetaljer. +- Plan for AI-baseret kundestemning og sikker fakturabehandling er gemt under `fremtidige planer`. + +## Verifikation + +- 279 tests bestået og 1 sprunget over. +- Fire kendte, ikke-release-relaterede tests fejler fortsat: tre forældede invoice-error mocks og én databaseafhængig deaktiveringstest. +- Nye regressionstests for hybrid fakturaudtræk og hurtig oprettelse af internetforbindelser består. +- Python- og JavaScript-syntaks samt diff-kontrol bestået. diff --git a/VERSION b/VERSION index 1817afe..9f8d8a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.2 +2.8.3 diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index 31cc6df..ac72834 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -2243,6 +2243,29 @@ def _smart_extract_lines(text: str) -> List[Dict]: while i < len(lines_arr): line = lines_arr[i].strip() + + # Attach common continuation metadata to the preceding product row. + if items: + ean_match = re.match(r'^EAN\s*:\s*(\d{8,14})\s*$', line, re.IGNORECASE) + kn8_match = re.match(r'^KN8\s*:\s*(\d{6,10})\s*$', line, re.IGNORECASE) + serial_match = re.match(r'^S/N\s*:\s*(\S+)\s*$', line, re.IGNORECASE) + if ean_match: + items[-1]['ean'] = ean_match.group(1) + i += 1 + continue + if kn8_match: + items[-1]['kn8'] = kn8_match.group(1) + i += 1 + continue + if serial_match: + items[-1].setdefault('serial_numbers', []).append(serial_match.group(1)) + i += 1 + continue + if re.match(r'^Omvendt betalingspligt', line, re.IGNORECASE): + items[-1]['reverse_charge'] = True + items[-1]['vat_rate'] = 0.0 + i += 1 + continue # Skip empty or header lines if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE): @@ -2252,16 +2275,25 @@ def _smart_extract_lines(text: str) -> List[Dict]: # Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20" # Whitespace-separated columns: position item_number description quantity unit_price total_price # Most specific pattern - try first! - layout_match = re.match(r'^\s*(\d{1,2})\s+(\d{4,10})\s+(.+?)\s(\d{1,2})\s+([\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$', line) + amount_pattern = r'-?(?:\d{1,3}(?:[. ]\d{3})+|\d+),\d{2}|-?(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}' + layout_match = re.match( + rf'^\s*(\d{{1,3}})\s+([A-Z0-9][A-Z0-9._/-]{{2,29}})\s+(.+?)\s+(\d{{1,3}}(?:[.,]\d+)?)\s+({amount_pattern})\s+({amount_pattern})\s*$', + line, + re.IGNORECASE, + ) if layout_match: + unit_price = _parse_invoice_number(layout_match.group(5)) + line_total = _parse_invoice_number(layout_match.group(6)) items.append({ 'line_number': len(items) + 1, 'position': layout_match.group(1), 'item_number': layout_match.group(2), + 'sku': layout_match.group(2), 'description': layout_match.group(3).strip(), - 'quantity': layout_match.group(4), - 'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'), - 'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'), + 'quantity': _parse_invoice_number(layout_match.group(4)), + 'unit_price': unit_price, + 'line_total': line_total, + 'total_price': line_total, 'raw_text': line }) logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...") @@ -2366,6 +2398,130 @@ def _smart_extract_lines(text: str) -> List[Dict]: return items +def _parse_invoice_number(value): + """Parse common Danish and English invoice number formats safely.""" + if value is None or value == '': + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float, Decimal)): + return float(value) + cleaned = re.sub(r'[^0-9,.-]', '', str(value)).strip('.,') + if not cleaned: + return None + if ',' in cleaned and '.' in cleaned: + cleaned = cleaned.replace('.', '').replace(',', '.') if cleaned.rfind(',') > cleaned.rfind('.') else cleaned.replace(',', '') + elif ',' in cleaned: + cleaned = cleaned.replace(',', '.') + elif cleaned.count('.') > 1 or ('.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3): + cleaned = cleaned.replace('.', '') + try: + return float(cleaned) + except ValueError: + return None + + +def _extract_invoice_totals(text: str) -> Dict: + """Find explicitly labelled totals without guessing from unrelated amounts.""" + labels = { + 'subtotal': r'(?:Varebeløb|Subtotal|Netto(?:beløb)?)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})', + 'vat_basis': r'(?:Momsgrundlag|VAT basis)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})', + 'vat_amount': r'(?:Moms(?:beløb)?|VAT amount)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})', + 'total_amount': r'(?:Totalbeløb(?:\s+DKK)?|I alt(?:\s+DKK)?|Amount due)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})', + } + result = {} + compact = re.sub(r'[ \t]+', ' ', text or '') + summary_row = re.search( + r'Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*' + r'(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+' + r'[\d.,]+%\s+(-?[\d.\s]+,\d{2})', + compact, + re.IGNORECASE, + ) + if summary_row: + result.update({ + 'subtotal': _parse_invoice_number(summary_row.group(1)), + 'vat_basis': _parse_invoice_number(summary_row.group(2)), + 'vat_amount': _parse_invoice_number(summary_row.group(3)), + 'total_amount': _parse_invoice_number(summary_row.group(4)), + }) + for key, pattern in labels.items(): + if key in result: + continue + matches = list(re.finditer(pattern, compact, re.IGNORECASE)) + if matches: + result[key] = _parse_invoice_number(matches[-1].group(1)) + return result + + +def _hybrid_validate_invoice(extracted: Dict, text: str) -> Dict: + """Cross-check parser and AI output and attach actionable review metadata.""" + result = dict(extracted or {}) + ai_lines = result.get('lines') if isinstance(result.get('lines'), list) else [] + parsed_lines = _smart_extract_lines(text or '') + if len(parsed_lines) > len(ai_lines): + result['lines'] = parsed_lines + selected_lines = parsed_lines + line_source = 'layout_text' + else: + selected_lines = ai_lines + line_source = 'ai_or_template' + + totals = _extract_invoice_totals(text or '') + for key in ('total_amount', 'vat_amount'): + current = _parse_invoice_number(result.get(key)) + if current is None and totals.get(key) is not None: + result[key] = totals[key] + elif current is not None: + result[key] = current + + warnings = [] + invalid_rows = [] + line_sum = 0.0 + for index, line in enumerate(selected_lines, start=1): + if not isinstance(line, dict): + invalid_rows.append(index) + continue + quantity = _parse_invoice_number(line.get('quantity')) + unit_price = _parse_invoice_number(line.get('unit_price')) + line_total = _parse_invoice_number(line.get('line_total', line.get('total_price'))) + if line_total is not None: + line_sum += line_total + if quantity is not None and unit_price is not None and line_total is not None: + if abs((quantity * unit_price) - line_total) > 0.05: + invalid_rows.append(index) + + subtotal = totals.get('subtotal') + vat_basis = totals.get('vat_basis') + vat_amount = totals.get('vat_amount', _parse_invoice_number(result.get('vat_amount'))) + total_amount = totals.get('total_amount', _parse_invoice_number(result.get('total_amount'))) + if invalid_rows: + warnings.append(f"Kontrollér beregningen på varelinje(r): {', '.join(map(str, invalid_rows))}") + if selected_lines and subtotal is not None and abs(line_sum - subtotal) > 0.05: + warnings.append(f"Varelinjer summerer til {line_sum:.2f}, men varebeløbet er {subtotal:.2f}") + if subtotal is not None and vat_amount is not None and total_amount is not None and abs((subtotal + vat_amount) - total_amount) > 0.05: + warnings.append(f"Varebeløb + moms stemmer ikke med totalen ({subtotal:.2f} + {vat_amount:.2f} != {total_amount:.2f})") + if vat_basis is not None and vat_amount is not None and abs((vat_basis * 0.25) - vat_amount) > 0.05: + warnings.append(f"Moms {vat_amount:.2f} stemmer ikke med 25% af momsgrundlaget {vat_basis:.2f}") + if not selected_lines: + warnings.append("Ingen sikre varelinjer fundet") + + result['_hybrid_validation'] = { + 'status': 'needs_review' if warnings else 'validated', + 'requires_review': bool(warnings), + 'line_source': line_source, + 'line_count': len(selected_lines), + 'line_sum': round(line_sum, 2), + 'document_totals': totals, + 'warnings': warnings, + 'methods': ['layout_text', 'ai'] if ai_lines else ['layout_text', 'template'], + } + if warnings: + result['_validation_warning'] = ' · '.join(warnings) + result['_validation_details'] = result['_hybrid_validation'] + return result + + # ========== CRUD OPERATIONS ========== @router.get("/supplier-invoices") @@ -2838,6 +2994,7 @@ async def get_file_extracted_data(file_id: int): "_validation_warning": llm_json_data.get('_validation_warning'), "_vat_warning": llm_json_data.get('_vat_warning'), "_validation_details": llm_json_data.get('_validation_details'), + "_hybrid_validation": llm_json_data.get('_hybrid_validation'), } elif extraction: # Fallback to extraction table columns if no LLM JSON @@ -3637,6 +3794,93 @@ async def search_vendor_by_info(request: Dict): raise HTTPException(status_code=500, detail=str(e)) +_OWN_COMPANY_CVRS = {"29522790", "14416285"} +_OWN_COMPANY_MARKERS = ("bmc denmark", "lejrvej 39", "3500 værløse") + + +def _pattern_matches_value(pattern: str, text: str, expected) -> bool: + try: + match = re.search(pattern, text or "", re.IGNORECASE | re.MULTILINE) + if not match or not match.groups(): + return False + actual = str(match.group(1)).strip() + if isinstance(expected, (int, float, Decimal)): + parsed = _parse_invoice_number(actual) + return parsed is not None and abs(parsed - float(expected)) < 0.01 + return re.sub(r"\D", "", actual) == re.sub(r"\D", "", str(expected)) if re.search(r"\d", str(expected)) else actual == str(expected) + except re.error: + return False + + +def _build_template_builder_result(pdf_text: str, ai_result: Optional[Dict] = None, vendor: Optional[Dict] = None) -> Dict: + """Build safe template suggestions; AI may suggest, but text evidence wins.""" + text = pdf_text or "" + result = dict(ai_result or {}) + + invoice_pattern = r"(?:Faktura(?:nr\.?|nummer)?|Nummer)\s*[:#]?\s*(\d{4,})" + invoice_match = re.search(invoice_pattern, text, re.IGNORECASE | re.MULTILINE) + if invoice_match: + result["invoice_number"] = {"value": invoice_match.group(1), "pattern": invoice_pattern} + + date_pattern = r"(?:Fakturadato|Dato)\s*[:#]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})" + date_match = re.search(date_pattern, text, re.IGNORECASE | re.MULTILINE) + if date_match: + result["invoice_date"] = {"value": date_match.group(1), "pattern": date_pattern} + + totals = _extract_invoice_totals(text) + total = totals.get("total_amount") + total_patterns = [ + (r"Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*" + r"-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+[\d.,]+%\s+(-?[\d.\s]+,\d{2})"), + r"(?:Totalbeløb|I alt|Amount due)\s*(?:DKK)?\s*:?\s*(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})", + ] + if total is not None: + for pattern in total_patterns: + if _pattern_matches_value(pattern, text, total): + result["total_amount"] = {"value": f"{total:.2f}", "pattern": pattern} + break + + own_cvrs = set(_OWN_COMPANY_CVRS) + configured_own_cvr = re.sub(r"\D", "", str(getattr(settings, "OWN_CVR", ""))) + if configured_own_cvr: + own_cvrs.add(configured_own_cvr) + vendor_cvr = re.sub(r"\D", "", str((vendor or {}).get("cvr_number") or "")) + cvr_candidates = re.findall(r"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*(\d{8})", text, re.IGNORECASE) + safe_cvrs = [cvr for cvr in cvr_candidates if cvr not in own_cvrs] + selected_cvr = vendor_cvr if vendor_cvr and vendor_cvr in safe_cvrs else (safe_cvrs[0] if safe_cvrs else "") + if selected_cvr: + cvr_pattern = rf"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*({re.escape(selected_cvr)})" + if _pattern_matches_value(cvr_pattern, text, selected_cvr): + result["cvr"] = {"value": selected_cvr, "pattern": cvr_pattern} + else: + result.pop("cvr", None) + result.pop("vendor_cvr", None) + + candidates = [] + vendor_name = str((vendor or {}).get("name") or "").strip() + if vendor_name and vendor_name.lower() in text.lower(): + candidates.append(vendor_name) + if selected_cvr: + candidates.append(selected_cvr) + candidates.extend(re.findall(r"\b[\w.+-]+@([\w.-]+\.[A-Za-z]{2,})\b", text)) + candidates.extend(re.findall(r"\b(?:www\.)?([A-Za-z0-9-]+\.(?:dk|com|eu|net))\b", text, re.IGNORECASE)) + safe_patterns = [] + for candidate in candidates: + candidate = str(candidate).strip() + lowered = candidate.lower() + if not candidate or any(marker in lowered for marker in _OWN_COMPANY_MARKERS) or re.sub(r"\D", "", candidate) in own_cvrs: + continue + if candidate.lower() not in {item.lower() for item in safe_patterns}: + safe_patterns.append(candidate) + result["detection_patterns"] = safe_patterns[:5] + result["lines_start"] = {"pattern": r"Nr\.?\s+Varenr\.?\s+Tekst"} + result["lines_end"] = {"pattern": r"(?:Varebeløb|Subtotal|Totalbeløb)"} + line_items = _smart_extract_lines(text) + result["line_count"] = len(line_items) + result["line_items"] = line_items + return result + + @router.post("/supplier-invoices/ai/analyze") async def ai_analyze_invoice(request: Dict): """Brug AI til at analysere faktura og foreslå template felter""" @@ -3650,6 +3894,7 @@ async def ai_analyze_invoice(request: Dict): # Build enhanced PDF text with instruction from app.core.config import settings + excerpt = pdf_text if len(pdf_text) <= 14000 else f"{pdf_text[:7000]}\n[...midt udeladt...]\n{pdf_text[-7000:]}" enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering. RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON! @@ -3659,7 +3904,7 @@ REQUIRED STRUKTUR (alle felter skal med): "invoice_number": "5082481", "invoice_date": "24/10-25", "total_amount": "1471.20", - "cvr": "29522790", + "cvr": "26686091", "detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"], "lines_start": "Nr.VarenrTekst", "lines_end": "Subtotal" @@ -3690,7 +3935,7 @@ VIGTIGT: - LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data PDF TEKST: -{pdf_text[:2000]} +{excerpt} RETURNER KUN JSON - intet andet!""" @@ -3705,8 +3950,13 @@ RETURNER KUN JSON - intet andet!""" # Do not expose that dict as a successful analysis to the browser. raise HTTPException(status_code=502, detail=str(result["error"])) - logger.info(f"✅ AI analyse gennemført: {result}") - return result + vendor = None + if vendor_id: + rows = execute_query("SELECT id, name, cvr_number FROM vendors WHERE id = %s LIMIT 1", (vendor_id,)) + vendor = rows[0] if rows else None + safe_result = _build_template_builder_result(pdf_text, result, vendor) + logger.info("✅ AI analyse gennemført og valideret mod fakturateksten") + return safe_result except HTTPException: raise @@ -4916,6 +5166,7 @@ async def reprocess_uploaded_file(file_id: int): if template_id and confidence >= 0.5: logger.info(f"✅ Matched template {template_id} ({confidence:.0%})") extracted_fields = template_service.extract_fields(text, template_id) + extracted_fields = _hybrid_validate_invoice(extracted_fields, text) # Check if this is an invoice2data template (ID -1) is_invoice2data = (template_id == -1) @@ -4992,7 +5243,7 @@ async def reprocess_uploaded_file(file_id: int): extracted_fields.get('currency', 'DKK'), 1.0, # invoice2data always 100% confidence json.dumps(extracted_fields), # llm_response_json - 'extracted') # status + 'needs_review' if extracted_fields.get('_hybrid_validation', {}).get('requires_review') else 'validated') ) # Insert line items if extracted @@ -5067,7 +5318,8 @@ async def reprocess_uploaded_file(file_id: int): raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}") - extracted_fields = llm_result + extracted_fields = _hybrid_validate_invoice(llm_result, text) + llm_result = extracted_fields confidence = llm_result.get('confidence', 0.75) # Post-process: clear own CVR(s) if AI mistakenly returned them @@ -5128,7 +5380,7 @@ async def reprocess_uploaded_file(file_id: int): llm_result.get('document_type', 'invoice'), confidence, json.dumps(llm_result), - 'extracted') + 'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated') ) # Insert line items if extracted diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index 36c1d37..d0715b5 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -2916,7 +2916,9 @@ async function reviewExtractedData(fileId) { let aiData = null; if (ext.llm_response_json) { try { - aiData = JSON.parse(ext.llm_response_json); + aiData = typeof ext.llm_response_json === 'string' + ? JSON.parse(ext.llm_response_json) + : ext.llm_response_json; } catch (e) { console.error('Failed to parse llm_response_json:', e); } @@ -2929,8 +2931,14 @@ async function reviewExtractedData(fileId) { .trim(); // Check for validation warnings - const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning; - const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0; + const hybridValidation = aiData?._hybrid_validation || null; + const hybridWarnings = hybridValidation?.warnings || []; + const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning; + const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0); + const sourceLabels = { + layout_text: 'PDF-layout', + ai_or_template: 'AI/template' + }; // Build modal content let modalContent = ` @@ -2946,14 +2954,15 @@ async function reviewExtractedData(fileId) { ${hasValidationIssues ? `
-
Beløbs-validering
- ${aiData._validation_warning ? `

⚠️ ${aiData._validation_warning}

` : ''} +
Kræver manuel kontrol
+ ${hybridWarnings.map(warning => `

⚠️ ${escapeHtml(warning)}

`).join('')} + ${!hybridWarnings.length && aiData._validation_warning ? `

⚠️ ${escapeHtml(aiData._validation_warning)}

` : ''} ${aiData._vat_warning ? `

⚠️ ${aiData._vat_warning}

` : ''}
` : allValidationsPassed ? `
-
Beløbs-validering
-

✅ Varelinjer summer korrekt til subtotal
✅ Moms beregning er korrekt (25%)

+
Automatisk kontrol bestået
+

${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}

` : ''} diff --git a/app/billing/frontend/template_builder.html b/app/billing/frontend/template_builder.html index 56869fe..7998ca6 100644 --- a/app/billing/frontend/template_builder.html +++ b/app/billing/frontend/template_builder.html @@ -206,6 +206,8 @@ 2. Eller markér tekst manuelt og vælg felttype
3. Systemet laver automatisk patterns! + +
+ `).join('') + : `
${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}
`; + }; + + const updateCustomerSearchResults = (lineId) => { + const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`); + if (host) host.innerHTML = renderCustomerSearchResults(lineId); + }; + const renderMapEditor = (line) => { const lineId = Number(line.id); const selected = state.lineCustomerSelections[lineId]; - const results = state.lineCustomerSearchResults[lineId] || []; const query = state.lineCustomerSearchQueries[lineId] || ''; return ` @@ -699,16 +716,8 @@ ` : `
Vælg kunde og gem mappingen for denne ALSO-company.
`} -
- ${results.length - ? results.map(customer => ` - - `).join('') - : `
${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}
` - } +
+ ${renderCustomerSearchResults(lineId)}
+ `).join('') + : `
${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}
`; + }; + + const updateProductSearchResults = (lineId) => { + const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`); + if (host) host.innerHTML = renderProductSearchResults(lineId); + }; + const renderProductMapEditor = (line) => { const lineId = Number(line.id); const selected = state.lineProductSelections[lineId]; - const results = state.lineProductSearchResults[lineId] || []; const query = state.lineProductSearchQueries[lineId] || ''; return ` @@ -747,16 +773,8 @@ ` : `
Vælg vare og gem mappingen for dette ALSO-produkt.
`} -
- ${results.length - ? results.map(product => ` - - `).join('') - : `
${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}
` - } +
+ ${renderProductSearchResults(lineId)}
`} - ` : ` -
- Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor. +
` : ''} + ` : '
Ingen automatiske handlinger foreslås.
'}
- `} -
- Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
`; - document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions); bindIdentityDecisionActions(); } @@ -1129,7 +1208,12 @@ button.disabled = true; button.innerHTML = 'Udfører…'; try { - await executeWorkflowsCurrent(); + const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase(); + if (classification === 'invoice' && state.selectedEmail?.supplier_id) { + await runQuickAction('create_supplier_invoice', button); + } else { + await executeWorkflowsCurrent(); + } } finally { if (document.body.contains(button)) { button.disabled = false; @@ -1158,6 +1242,8 @@ const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : []; const system = Array.isArray(preview.system_matches) ? preview.system_matches : []; const emailMeta = preview.email || {}; + const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice' + && Boolean(state.selectedEmail?.supplier_id); const systemHtml = system.map((row) => { const badge = row.matches ? 'Matcher' : 'Springes over'; @@ -1169,7 +1255,15 @@ `; }).join(''); - const matchingHtml = matching.length + const matchingHtml = usesSupplierApprovalFlow + ? `
+
Godkendelsesflow
+
+ Leverandørfaktura + PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet +
+
` + : matching.length ? matching.map((wf) => `
#${Number(wf.id)} ${escapeHtml(wf.name || '')}
@@ -1604,25 +1698,47 @@ const hasCustomer = Boolean(email.customer_id); const hasSupplier = Boolean(email.supplier_id); const hasIdentity = hasCustomer || hasSupplier; + const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase(); + const isGlobalConnect = supplierIdentity.includes('globalconnect'); + const classification = String(email.classification || '').toLowerCase(); + const recommendedFlow = classification === 'invoice' + ? 'create_supplier_invoice' + : (classification === 'customer_email' ? 'create_customer_case' : ''); + const recommendationBadge = (flow) => recommendedFlow === flow + ? 'ANBEFALET' + : ''; + const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : ''; + quickActions.className = 'emails-v2-quick-toolbar'; quickActions.innerHTML = email.linked_case_id ? ` Åbn SAG #${Number(email.linked_case_id)} ` : ` -
- - - - +
`; @@ -1661,8 +1777,14 @@
+
+ + Flere handlinger + +
+ ${email.linked_case_id ? ` -
+ ` : ` -
-
Opret sag fra denne email
+
+ Opret en anden type sag +
-
+
Hurtige mailhandlinger
-
+
Avanceret behandling
@@ -1721,7 +1845,7 @@
Henter workflow-preview…
-
+
Leverandør og kundematch
Leverandør faktura
Leverandør
${escapeHtml(email.extracted_vendor_name || '-')}
@@ -1743,7 +1867,7 @@
-
+
Avanceret metadata
${escapeHtml(JSON.stringify({
                     email_id: email.id,
@@ -1755,6 +1879,8 @@
                     linked_case_id: email.linked_case_id,
                 }, null, 2))}
+
+
`; document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read))); diff --git a/app/modules/internet_connections/backend/router.py b/app/modules/internet_connections/backend/router.py index 32e7960..7bcd802 100644 --- a/app/modules/internet_connections/backend/router.py +++ b/app/modules/internet_connections/backend/router.py @@ -1270,6 +1270,26 @@ class QuickBmcnetCreatePayload(BaseModel): mark_gateway: bool = False +class QuickInternetSetupPayload(BaseModel): + customer_id: Optional[int] = None + product_id: Optional[int] = None + start_date: Optional[date] = None + shared_fiber: bool = False + connection_id: Optional[int] = None + connection_name: Optional[str] = None + vendor_id: Optional[int] = None + circuit_number: Optional[str] = None + address: Optional[str] = None + technology: Optional[str] = None + download_mbps: Optional[int] = None + upload_mbps: Optional[int] = None + monthly_cost: float = 0 + unit_price: Optional[float] = None + billing_interval: str = "monthly" + billing_day: int = 1 + notes: Optional[str] = None + + class DelefiberProductPricePayload(BaseModel): product_id: int monthly_price: float @@ -3098,6 +3118,168 @@ async def remove_delefiber_product_price(connection_id: int, product_id: int): return {"deleted": True} +@router.post("/internet-connections/quick-setup") +async def create_quick_internet_setup(payload: QuickInternetSetupPayload): + """Create a connection, and for customer connections also create a draft subscription.""" + customer_id = payload.customer_id + if payload.shared_fiber: + own_customer = execute_query_single( + """ + SELECT c.id, c.name + FROM customers c + WHERE c.deleted_at IS NULL AND c.is_active = true + AND LOWER(TRIM(c.name)) IN ('bmc networks', 'bmc networks aps') + ORDER BY + (SELECT COUNT(*) FROM sag_sager s WHERE s.customer_id = c.id) DESC, + (SELECT COUNT(*) FROM internet_connections_connections ic WHERE ic.customer_id = c.id AND ic.deleted_at IS NULL) DESC, + c.id ASC + LIMIT 1 + """ + ) + if not own_customer: + raise HTTPException(status_code=409, detail="BMC Networks-kunden blev ikke fundet") + customer_id = int(own_customer["id"]) + if not customer_id: + raise HTTPException(status_code=400, detail="Vælg en kunde") + customer = execute_query_single( + "SELECT id, name FROM customers WHERE id = %s AND is_active = true", + (customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Kunden blev ikke fundet") + + if payload.shared_fiber: + name = str(payload.connection_name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Skriv et navn til hovedforbindelsen") + connection = await create_connection(ConnectionCreatePayload( + name=name, + vendor_id=payload.vendor_id, + customer_id=customer_id, + address=str(payload.address or "").strip() or None, + status="planned", + monthly_cost=float(payload.monthly_cost or 0), + sales_price=0, + technology=str(payload.technology or "").strip() or None, + circuit_number=str(payload.circuit_number or "").strip() or None, + download_mbps=payload.download_mbps, + upload_mbps=payload.upload_mbps, + contract_start=payload.start_date, + notes=str(payload.notes or "").strip() or None, + allocation_model="shared", + value_type="delefiber", + value_label="Delefiber", + is_manual_shared=True, + )) + return {"success": True, "sag": None, "subscription": None, "connection": connection} + + if not payload.product_id or not payload.start_date: + raise HTTPException(status_code=400, detail="Vælg internetprodukt og startdato") + + product = execute_query_single( + """ + SELECT id, name, short_description, sales_price, attributes_json, type + FROM products + WHERE id = %s AND deleted_at IS NULL + """, + (payload.product_id,), + ) + if not product: + raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet") + profile = build_network_product_profile(product, fallback_text=product.get("short_description")) + if profile.get("kind") != "internet_access": + raise HTTPException(status_code=400, detail="Det valgte produkt er ikke markeret som internetprodukt") + + existing_connection = None + if payload.connection_id: + existing_connection = execute_query_single( + """ + SELECT id, name, customer_id, subscription_id + FROM internet_connections_connections + WHERE id = %s AND deleted_at IS NULL + """, + (payload.connection_id,), + ) + if not existing_connection: + raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet") + if existing_connection.get("subscription_id"): + raise HTTPException(status_code=409, detail="Forbindelsen har allerede et abonnement") + if existing_connection.get("customer_id") not in (None, customer_id): + raise HTTPException(status_code=409, detail="Forbindelsen er tilknyttet en anden kunde") + elif not str(payload.connection_name or "").strip(): + raise HTTPException(status_code=400, detail="Skriv et navn til den nye forbindelse") + + customer_name = str(customer.get("name") or f"Kunde #{payload.customer_id}") + product_name = str(product.get("name") or "Internet") + case_title = str(payload.connection_name or "").strip() or f"Internet - {customer_name}" + description = "\n".join(filter(None, [ + f"Internetabonnement: {product_name}", + f"Forbindelse: {(existing_connection or {}).get('name') or case_title}", + f"Adresse: {str(payload.address or '').strip()}" if payload.address else None, + str(payload.notes or "").strip() or None, + ])) + created_case = execute_query_single( + """ + INSERT INTO sag_sager + (titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id) + VALUES (%s, %s, 'abonnement', 'åben', %s, %s, 1) + RETURNING * + """, + (case_title, description, customer_id, _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])), + ) + if not created_case: + raise HTTPException(status_code=500, detail="Abonnementssagen kunne ikke oprettes") + + resolved_price = float(payload.unit_price) if payload.unit_price is not None else float(product.get("sales_price") or 0) + from app.subscriptions.backend.router import create_subscription as create_sag_subscription + subscription = await create_sag_subscription({ + "sag_id": int(created_case["id"]), + "billing_interval": payload.billing_interval, + "billing_day": int(payload.billing_day), + "start_date": payload.start_date.isoformat(), + "period_start": payload.start_date.isoformat(), + "notice_period_days": 30, + "line_items": [{ + "product_id": int(product["id"]), + "description": str(product.get("short_description") or product_name), + "quantity": 1, + "unit_price": resolved_price, + }], + "notes": str(payload.notes or "").strip() or None, + }) + + connection_update = ConnectionUpdatePayload( + customer_id=customer_id, + subscription_id=int(subscription["id"]), + value_type="subscription", + value_label=None, + ) + if existing_connection: + connection = await update_connection(int(existing_connection["id"]), connection_update) + else: + connection = await create_connection(ConnectionCreatePayload( + name=case_title, + vendor_id=payload.vendor_id, + customer_id=customer_id, + address=str(payload.address or "").strip() or None, + status="planned", + monthly_cost=float(payload.monthly_cost or 0), + sales_price=resolved_price, + technology=str(payload.technology or "").strip() or None, + circuit_number=str(payload.circuit_number or "").strip() or None, + download_mbps=payload.download_mbps, + upload_mbps=payload.upload_mbps, + contract_start=payload.start_date, + notes=str(payload.notes or "").strip() or None, + allocation_model="shared" if payload.shared_fiber else "dedicated", + value_type="delefiber" if payload.shared_fiber else "subscription", + subscription_id=int(subscription["id"]), + is_manual_shared=payload.shared_fiber, + )) + + return {"success": True, "sag": dict(created_case), "subscription": subscription, "connection": connection} + + @router.post("/internet-connections/{connection_id}/bmcnet-connections") async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload): head_row = execute_query_single( diff --git a/app/modules/internet_connections/templates/index.html b/app/modules/internet_connections/templates/index.html index 1afdfbe..f20b71d 100644 --- a/app/modules/internet_connections/templates/index.html +++ b/app/modules/internet_connections/templates/index.html @@ -137,6 +137,33 @@ padding: 1rem; } + .quick-customer-results { + max-height: 260px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 0 0 12px 12px; + background: var(--bg-card); + } + + .quick-customer-result { + display: block; + width: 100%; + padding: 0.7rem 0.85rem; + border: 0; + border-bottom: 1px solid var(--border-color); + background: transparent; + color: var(--text-primary); + text-align: left; + } + + .quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); } + + .quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; } + .quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; } + .quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); } + .quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); } + .quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; } + .invoice-sync-status { display:inline-flex; align-items:center; @@ -179,8 +206,8 @@ -
@@ -386,6 +413,52 @@
+ + + {% 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) %}
-
{{ contact.contact_name }}
+
+ {{ contact.contact_name }} + {% if external_contact %} + Ekstern kontakt + {% endif %} +
{{ contact.title or '-' }} - {{ contact.customer_name or '-' }} + + {{ contact.customer_name or 'Firma ikke registreret' }} + {% if external_contact %}
Andet firma end sagens kunde{% endif %} +
{% if contact.mobile %} +
+
+
+ Ny kontakt + +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
@@ -15777,6 +15841,8 @@ document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg'; document.getElementById('entitySearchInput').value = ''; document.getElementById('entitySearchResults').innerHTML = ''; + document.getElementById('quickContactCreateBtn').classList.toggle('d-none', type !== 'contact'); + toggleQuickContactCreate(false); const modal = new bootstrap.Modal(document.getElementById('entitySearchModal')); modal.show(); @@ -15784,6 +15850,94 @@ setTimeout(() => document.getElementById('entitySearchInput').focus(), 500); } + function toggleQuickContactCreate(show) { + const panel = document.getElementById('quickContactCreate'); + if (!panel) return; + panel.classList.toggle('d-none', !show); + if (show) { + document.getElementById('quickContactCompanyId').value = {{ case.customer_id or "''" }}; + document.getElementById('quickContactCompanySearch').value = {{ (customer.name if customer else '')|tojson }}; + setTimeout(() => document.getElementById('quickContactFirstName').focus(), 50); + } + } + + function quickCreateFromSearch(encodedQuery) { + const query = decodeURIComponent(encodedQuery || '').trim(); + toggleQuickContactCreate(true); + document.getElementById('quickContactFirstName').value = ''; + document.getElementById('quickContactLastName').value = ''; + document.getElementById('quickContactEmail').value = ''; + document.getElementById('quickContactMobile').value = ''; + if (query.includes('@')) { + document.getElementById('quickContactEmail').value = query; + const localName = query.split('@')[0].replace(/[._-]+/g, ' ').trim(); + document.getElementById('quickContactFirstName').value = localName; + } else { + const nameParts = query.split(/\s+/).filter(Boolean); + document.getElementById('quickContactFirstName').value = nameParts.shift() || ''; + document.getElementById('quickContactLastName').value = nameParts.join(' '); + } + document.getElementById('quickContactStatus').textContent = ''; + document.getElementById('quickContactFirstName').focus(); + } + + let quickCompanyTimer; + document.getElementById('quickContactCompanySearch').addEventListener('input', function(event) { + document.getElementById('quickContactCompanyId').value = ''; + clearTimeout(quickCompanyTimer); + const query = event.target.value.trim(); + const results = document.getElementById('quickContactCompanyResults'); + if (query.length < 2) { results.classList.add('d-none'); return; } + quickCompanyTimer = setTimeout(async () => { + try { + const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`); + const companies = response.ok ? await response.json() : []; + results.innerHTML = companies.map(company => ` + `).join('') || '
Ingen firmaer fundet
'; + results.classList.remove('d-none'); + } catch (_) { results.classList.add('d-none'); } + }, 250); + }); + + function selectQuickContactCompany(id, name) { + document.getElementById('quickContactCompanyId').value = id; + document.getElementById('quickContactCompanySearch').value = name; + document.getElementById('quickContactCompanyResults').classList.add('d-none'); + } + + async function createAndAttachQuickContact() { + const firstName = document.getElementById('quickContactFirstName').value.trim(); + const status = document.getElementById('quickContactStatus'); + const button = document.getElementById('quickContactSaveBtn'); + if (!firstName) { status.className = 'small text-danger'; status.textContent = 'Fornavn er påkrævet.'; return; } + const companyId = Number(document.getElementById('quickContactCompanyId').value || 0); + const payload = { + first_name: firstName, + last_name: document.getElementById('quickContactLastName').value.trim(), + email: document.getElementById('quickContactEmail').value.trim() || null, + mobile: document.getElementById('quickContactMobile').value.trim() || null, + company_id: companyId || null, + is_primary: Boolean(companyId) + }; + button.disabled = true; + status.className = 'small text-muted'; status.textContent = 'Opretter kontakt…'; + try { + const createResponse = await fetch('/api/v1/contacts', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)}); + const contact = await createResponse.json().catch(() => ({})); + if (!createResponse.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes'); + const attachResponse = await fetch(`/api/v1/sag/${caseIds}/contacts`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({contact_id:contact.id, role:'Kontakt'})}); + const attachResult = await attachResponse.json().catch(() => ({})); + if (!attachResponse.ok) throw new Error(attachResult.detail || 'Kontakten blev oprettet, men kunne ikke tilknyttes'); + bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide(); + reloadCasePreservingContext(); + } catch (error) { + status.className = 'small text-danger'; status.textContent = error.message; + button.disabled = false; + } + } + document.getElementById('entitySearchInput').addEventListener('input', function(e) { clearTimeout(searchDebounceIds); const query = e.target.value.trim(); @@ -15809,7 +15963,7 @@ const res = await fetch(url); if (!res.ok) throw new Error('Search failed'); const results = await res.json(); - renderResults(results); + renderResults(results, query); } catch (e) { console.error(e); document.getElementById('entitySearchResults').innerHTML = '
Fejl ved søgning
'; @@ -15819,15 +15973,34 @@ } } - function renderResults(results) { + function renderResults(results, query = '') { const container = document.getElementById('entitySearchResults'); if (results.length === 0) { - container.innerHTML = '
Ingen resultater fundet
'; + if (currentSearchType === 'contact') { + const safeQuery = escapeHtml(query); + const encodedQuery = encodeURIComponent(query).replace(/'/g, '%27'); + container.innerHTML = ` +
+ +
Ingen kontakt fundet for ${safeQuery}
+ +
`; + } else { + container.innerHTML = '
Ingen resultater fundet
'; + } return; } + const caseCustomerIds = [ + {% if case.customer_id %}{{ case.customer_id }},{% endif %} + {% for linked_customer in customers %}{{ linked_customer.customer_id }},{% endfor %} + ].map(Number); container.innerHTML = results.map(item => { let title = '', subtitle = '', icon = '', id = item.id; + let extraClass = ''; + let relationBadge = ''; if (currentSearchType === 'hardware') { title = `${item.brand} ${item.model}`; @@ -15839,8 +16012,14 @@ icon = 'bi-geo-alt'; } else if (currentSearchType === 'contact') { title = `${item.first_name} ${item.last_name}`; - subtitle = item.email; + const companyIds = Array.isArray(item.customer_ids) ? item.customer_ids.map(Number) : []; + const isExternal = !companyIds.some((id) => caseCustomerIds.includes(id)); + subtitle = [item.email, item.user_company].filter(Boolean).join(' • '); icon = 'bi-person'; + if (isExternal) { + extraClass = 'contact-search-external'; + relationBadge = 'Ekstern kontakt'; + } } else if (currentSearchType === 'customer') { title = item.name; subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`; @@ -15848,10 +16027,10 @@ } return ` - diff --git a/app/modules/search/backend/router.py b/app/modules/search/backend/router.py index e898d7e..9865059 100644 --- a/app/modules/search/backend/router.py +++ b/app/modules/search/backend/router.py @@ -7,10 +7,10 @@ router = APIRouter() async def search_customers(q: str = Query(..., min_length=2)): """ Autocomplete search for customers. - Returns list of {id, name, cvr_nummer, email} + Returns customer identity and registered address for address-aware workflows. """ sql = """ - SELECT id, name, cvr_number as cvr_nummer, email + SELECT id, name, cvr_number as cvr_nummer, email, address, city, postal_code FROM customers WHERE (name ILIKE %s OR cvr_number ILIKE %s) AND deleted_at IS NULL @@ -45,6 +45,19 @@ async def search_contacts(q: str = Query(..., min_length=2)): ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC LIMIT 1 ) AS user_company, + ( + SELECT cu.id + FROM contact_companies cc + JOIN customers cu ON cu.id = cc.customer_id + WHERE cc.contact_id = c.id + ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC + LIMIT 1 + ) AS user_company_id, + ARRAY( + SELECT cc.customer_id + FROM contact_companies cc + WHERE cc.contact_id = c.id + ) AS customer_ids, ( SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null') FROM contact_companies cc @@ -61,11 +74,17 @@ async def search_contacts(q: str = Query(..., min_length=2)): OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s OR c.phone ILIKE %s OR c.mobile ILIKE %s + OR EXISTS ( + SELECT 1 + FROM contact_companies cc_search + JOIN customers cu_search ON cu_search.id = cc_search.customer_id + WHERE cc_search.contact_id = c.id AND cu_search.name ILIKE %s + ) ORDER BY c.first_name ASC, c.last_name ASC LIMIT 20 """ term = f"%{q}%" - results = execute_query(sql, (term, term, term, term, term, term)) + results = execute_query(sql, (term, term, term, term, term, term, term)) return results @router.get("/search/hardware") diff --git a/app/services/email_service.py b/app/services/email_service.py index fc272d4..1133c46 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -977,20 +977,26 @@ class EmailService: return {"success": False, "reason": "Email has no message id"} headers = {"Authorization": f"Bearer {access_token}"} - params = { - "$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")), - "$select": "id,subject,hasAttachments", - "$top": 2, - } try: async with ClientSession() as session: url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages" - async with session.get(url, params=params, headers=headers) as response: - if response.status != 200: - detail = await response.text() - logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail) - return {"success": False, "reason": "Email could not be found in Microsoft Graph"} - matches = (await response.json()).get("value", []) + clean_message_id = message_id.strip().strip('<>') + message_id_candidates = list(dict.fromkeys((message_id.strip(), f"<{clean_message_id}>"))) + matches = [] + for candidate in message_id_candidates: + params = { + "$filter": "internetMessageId eq '{}'".format(candidate.replace("'", "''")), + "$select": "id,subject,hasAttachments,internetMessageId", + "$top": 2, + } + async with session.get(url, params=params, headers=headers) as response: + if response.status != 200: + detail = await response.text() + logger.warning("⚠️ Could not resolve Graph email %s using %s: %s %s", email_id, candidate, response.status, detail) + continue + matches = (await response.json()).get("value", []) + if matches: + break if not matches: return {"success": False, "reason": "Email is no longer available in Microsoft Graph"} diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py index b21d5bd..632afda 100644 --- a/app/services/email_workflow_service.py +++ b/app/services/email_workflow_service.py @@ -1859,6 +1859,29 @@ class EmailWorkflowService: attachments = [] elif not isinstance(attachments, list): attachments = [attachments] + + # Older/manual email imports may declare attachments without having saved + # their bytes. Recover the attachment before declaring the workflow failed. + if not attachments: + email_state = execute_query( + "SELECT has_attachments FROM email_messages WHERE id = %s", + (email_id,), + ) + if email_state and email_state[0].get("has_attachments"): + try: + from app.services.email_service import EmailService + recovery = await EmailService().recover_graph_attachments(int(email_id)) + if recovery.get("success"): + attachments = execute_query( + """SELECT filename, file_path, size_bytes, content_type + FROM email_attachments + WHERE email_id = %s + AND (LOWER(COALESCE(content_type, '')) = 'application/pdf' + OR LOWER(filename) LIKE '%%.pdf')""", + (email_id,), + ) or [] + except Exception as recovery_error: + logger.warning("Could not recover attachments for email %s: %s", email_id, recovery_error) if not attachments: logger.warning(f"⚠️ No PDF attachments found for email {email_id}") @@ -1904,11 +1927,17 @@ class EmailWorkflowService: # Check if file already exists existing = execute_query( - "SELECT file_id FROM incoming_files WHERE checksum = %s", + "SELECT file_id, status FROM incoming_files WHERE checksum = %s", (checksum,)) if existing: logger.info(f"⚠️ File already exists: {attachment['filename']}") + uploaded_files.append({ + 'file_id': existing[0]['file_id'], + 'filename': attachment['filename'], + 'existing': True, + 'status': existing[0].get('status'), + }) continue # Create uploads directory if it doesn't exist @@ -1953,12 +1982,43 @@ class EmailWorkflowService: continue if uploaded_files: + processing_results = [] + for uploaded in uploaded_files: + if uploaded.get('existing') and uploaded.get('status') in {'ai_extracted', 'processed'}: + processing_results.append({ + 'file_id': uploaded['file_id'], + 'status': 'already_processed', + }) + continue + try: + # Lazy import avoids coupling router import order to email startup. + from app.billing.backend.supplier_invoices import reprocess_uploaded_file + processing_results.append(await reprocess_uploaded_file(int(uploaded['file_id']))) + except Exception as processing_error: + logger.exception("Immediate invoice processing failed for file %s", uploaded['file_id']) + processing_results.append({ + 'file_id': uploaded['file_id'], + 'status': 'failed', + 'error': str(getattr(processing_error, 'detail', processing_error)), + }) + + failed_processing = [item for item in processing_results if item.get('status') == 'failed'] + if failed_processing: + return { + 'action': 'extract_invoice_data', + 'success': False, + 'files_uploaded': len(uploaded_files), + 'file_ids': [f['file_id'] for f in uploaded_files], + 'processing_results': processing_results, + 'note': failed_processing[0].get('error') or 'Fakturaudtrækning fejlede', + } return { 'action': 'extract_invoice_data', 'success': True, 'files_uploaded': len(uploaded_files), 'file_ids': [f['file_id'] for f in uploaded_files], - 'note': f"{len(uploaded_files)} PDF(er) gemt i 'Mangler Behandling'" + 'processing_results': processing_results, + 'note': f"{len(uploaded_files)} PDF(er) gemt og behandlet" } else: return { diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 48dfb28..4b05fac 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -2006,7 +2006,7 @@ if (bmcOriginalFetch) { - + {% include "shared/frontend/internal_message.html" %} diff --git a/app/shared/frontend/bug_report_modal.html b/app/shared/frontend/bug_report_modal.html index b451e94..060fa2b 100644 --- a/app/shared/frontend/bug_report_modal.html +++ b/app/shared/frontend/bug_report_modal.html @@ -35,12 +35,12 @@
-
Screenshot forsøges automatisk ved klik på bug-ikonet. Hvis det fejler, brug skærmdeling-knappen eller indsæt med Cmd+V.
+
Ved et nyt screenshot skjules denne formular kortvarigt, så fejlen på siden kommer med. Du kan også indsætte et billede med Cmd+V.