From 07584b1b0c0ccc833e573256019f8752945329f9 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 1 Mar 2026 11:24:06 +0100 Subject: [PATCH] feat: quick-create customer/vendor from unknown email sender --- app/emails/backend/router.py | 169 +++++++++++++++++++++- app/emails/frontend/emails.html | 247 ++++++++++++++++++++++++++++++++ app/models/schemas.py | 10 +- 3 files changed, 421 insertions(+), 5 deletions(-) diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 3263f24..3a7276e 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -5,13 +5,14 @@ API endpoints for email viewing, classification, and rule management import logging from fastapi import APIRouter, HTTPException, Query, UploadFile, File -from typing import List, Optional +from typing import List, Optional, Dict from pydantic import BaseModel from datetime import datetime, date from app.core.database import execute_query, execute_insert, execute_update from app.services.email_processor_service import EmailProcessorService from app.services.email_workflow_service import email_workflow_service +from app.services.ollama_service import ollama_service logger = logging.getLogger(__name__) @@ -76,6 +77,8 @@ class EmailDetail(BaseModel): created_at: datetime updated_at: datetime attachments: List[EmailAttachment] = [] + customer_name: Optional[str] = None + supplier_name: Optional[str] = None class EmailRule(BaseModel): @@ -204,8 +207,13 @@ async def get_email(email_id: int): """Get email detail by ID""" try: query = """ - SELECT * FROM email_messages - WHERE id = %s AND deleted_at IS NULL + SELECT em.*, + c.name AS customer_name, + v.name AS supplier_name + FROM email_messages em + LEFT JOIN customers c ON em.customer_id = c.id + LEFT JOIN vendors v ON em.supplier_id = v.id + WHERE em.id = %s AND em.deleted_at IS NULL """ result = execute_query(query, (email_id,)) logger.info(f"🔍 Query result type: {type(result)}, length: {len(result) if result else 0}") @@ -334,6 +342,161 @@ async def update_email(email_id: int, status: Optional[str] = None): raise HTTPException(status_code=500, detail=str(e)) +@router.patch("/emails/{email_id}/link") +async def link_email(email_id: int, payload: Dict): + """Link email to a customer and/or vendor/supplier""" + try: + updates = [] + params = [] + + if 'customer_id' in payload: + updates.append("customer_id = %s") + params.append(payload['customer_id']) + + if 'supplier_id' in payload: + updates.append("supplier_id = %s") + params.append(payload['supplier_id']) + + if not updates: + raise HTTPException(status_code=400, detail="Ingen felter at opdatere") + + params.append(email_id) + query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" + execute_update(query, tuple(params)) + + logger.info(f"✅ Linked email {email_id}: {payload}") + return {"success": True, "message": "Email linket"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error linking email {email_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/emails/{email_id}/extract-vendor-suggestion") +async def extract_vendor_suggestion(email_id: int): + """ + Forsøger at udtrække leverandørinfo fra email body og vedhæftede fakturaer. + Returnerer forslag til navn, CVR, adresse, telefon, email, domæne. + """ + import re + import os + + try: + # Hent email + email_result = execute_query( + "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", + (email_id,) + ) + if not email_result: + raise HTTPException(status_code=404, detail="Email ikke fundet") + email = email_result[0] + + # Saml tekst fra body + vedhæftede PDF-filer + text_parts = [] + if email.get('body_text'): + text_parts.append(email['body_text']) + + attachments = execute_query( + "SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id", + (email_id,) + ) + for att in (attachments or []): + file_path = att.get('file_path') + if file_path and os.path.exists(file_path): + content_type = att.get('content_type', '') + if 'pdf' in content_type or file_path.lower().endswith('.pdf'): + try: + pdf_text = await ollama_service._extract_text_from_file(file_path) + if pdf_text: + text_parts.append(f"[PDF: {att['filename']}]\n{pdf_text}") + except Exception as e: + logger.warning(f"⚠️ Kunne ikke læse PDF {file_path}: {e}") + + combined_text = "\n\n".join(text_parts) + + suggestion = { + "name": email.get('sender_name') or '', + "email": email.get('sender_email') or '', + "cvr_number": None, + "phone": None, + "address": None, + "domain": None, + "source": "regex" + } + + # Regex fallback: uddrag CVR (8 cifre efter CVR/Momsnr/DK) + cvr_match = re.search( + r'(?:CVR|Cvr|cvr|Momsnr\.?|DK)[\s:.-]*([0-9]{8})', + combined_text + ) + if cvr_match: + suggestion['cvr_number'] = cvr_match.group(1) + + # Regex: telefon (dansk format) + phone_match = re.search( + r'(?:Tlf|Tel|Telefon|Phone)[\s.:]*([+\d][\d\s\-().]{6,15})', + combined_text, re.IGNORECASE + ) + if phone_match: + suggestion['phone'] = phone_match.group(1).strip() + + # Domæne fra sender email + if email.get('sender_email') and '@' in email['sender_email']: + suggestion['domain'] = email['sender_email'].split('@')[1] + + # Brug AI hvis vi har tekst fra PDF + if len(combined_text) > 100: + try: + from app.core.config import settings + own_cvr = getattr(settings, 'OWN_CVR', '') + + prompt = f"""OPGAVE: Udtræk leverandørens firmainfo fra denne tekst. +TEKSTEN er body/footer fra en faktura-email eller vedhæftet faktura. +RETURNER KUN VALID JSON - ingen forklaring! + +{{ + \"name\": \"Firmanavn ApS\", + \"cvr_number\": \"12345678\", + \"address\": \"Vejnavn 1, 2000 By\", + \"phone\": \"12345678\", + \"email\": \"kontakt@firma.dk\", + \"domain\": \"firma.dk\" +}} + +REGLER: +- name: LEVERANDØRENS firmanavn (ikke køber, ikke BMC) +- cvr_number: 8-cifret CVR - IGNORER {own_cvr} (det er køber) +- Sæt null hvis ikke fundet +- KUN JSON output! + +TEKST (max 3000 tegn): +{combined_text[:3000]} + +KUN JSON:""" + + ai_result = await ollama_service.extract_from_text(prompt) + if ai_result and isinstance(ai_result, dict): + # Merge AI resultat ind i suggestion (AI prioriteres over regex) + for field in ('name', 'cvr_number', 'address', 'phone', 'email', 'domain'): + val = ai_result.get(field) + if val and val not in (None, 'null', '', 'N/A'): + suggestion[field] = str(val).strip() + suggestion['source'] = 'ai' + logger.info(f"✅ AI vendor suggestion for email {email_id}: {suggestion}") + except Exception as e: + logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}") + + return suggestion + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ extract-vendor-suggestion fejlede for email {email_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete("/emails/{email_id}") async def delete_email(email_id: int): """Soft delete email""" diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index d5b1dac..308664d 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -1763,6 +1763,34 @@ function renderEmailAnalysis(email) { // Opdater kun AI Analysis tab indholdet - ikke hele sidebar aiAnalysisTab.innerHTML = ` + ${!email.customer_id && !email.supplier_id ? ` +
+
Ukendt Afsender
+
+ ${escapeHtml(email.sender_email || '')} + ${email.sender_name ? `
${escapeHtml(email.sender_name)}` : ''} +
+
+ + +
+
+ ` : ` +
+
Linket Til
+ +
+ `} + ${getClassificationActions(email) ? `
Hurtig Action
@@ -2251,6 +2279,141 @@ async function linkToCustomer(emailId) { showError('Kunde-linking er ikke implementeret endnu'); } +// ─── Quick Create Customer ──────────────────────────────────────────────── +function quickCreateCustomer(emailId, senderName, senderEmail) { + document.getElementById('qcEmailId').value = emailId; + document.getElementById('qcCustomerName').value = senderName || ''; + document.getElementById('qcCustomerEmail').value = senderEmail || ''; + document.getElementById('qcCustomerPhone').value = ''; + document.getElementById('qcCustomerStatus').textContent = ''; + const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal')); + modal.show(); +} + +async function submitQuickCustomer() { + const emailId = document.getElementById('qcEmailId').value; + const name = document.getElementById('qcCustomerName').value.trim(); + const email = document.getElementById('qcCustomerEmail').value.trim(); + const phone = document.getElementById('qcCustomerPhone').value.trim(); + const statusEl = document.getElementById('qcCustomerStatus'); + + if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; } + statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…'; + + try { + // Create customer + const custResp = await fetch('/api/v1/customers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email: email || null, phone: phone || null }) + }); + if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede'); + const customer = await custResp.json(); + + // Link email + await fetch(`/api/v1/emails/${emailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer_id: customer.id }) + }); + + bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide(); + showSuccess(`Kunde "${name}" oprettet og linket`); + loadEmailDetail(parseInt(emailId)); + } catch (e) { + statusEl.className = 'text-danger small'; + statusEl.textContent = e.message; + } +} + +// ─── Quick Create Vendor ────────────────────────────────────────────────── +async function quickCreateVendor(emailId, senderName, senderEmail) { + // Reset form + document.getElementById('qvEmailId').value = emailId; + document.getElementById('qvVendorName').value = senderName || ''; + document.getElementById('qvVendorEmail').value = senderEmail || ''; + document.getElementById('qvVendorCVR').value = ''; + document.getElementById('qvVendorPhone').value = ''; + document.getElementById('qvVendorAddress').value = ''; + document.getElementById('qvVendorDomain').value = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1] : ''; + document.getElementById('qvVendorStatus').textContent = ''; + document.getElementById('qvAiStatus').innerHTML = 'Henter info fra email/bilag…'; + + const modal = new bootstrap.Modal(document.getElementById('quickCreateVendorModal')); + modal.show(); + + // Attempt AI extraction in background + try { + const resp = await fetch(`/api/v1/emails/${emailId}/extract-vendor-suggestion`, { method: 'POST' }); + if (resp.ok) { + const s = await resp.json(); + if (s.name && s.name !== senderName) document.getElementById('qvVendorName').value = s.name; + if (s.cvr_number) document.getElementById('qvVendorCVR').value = s.cvr_number; + if (s.phone) document.getElementById('qvVendorPhone').value = s.phone; + if (s.address) document.getElementById('qvVendorAddress').value = s.address; + if (s.domain) document.getElementById('qvVendorDomain').value = s.domain; + if (s.email && !document.getElementById('qvVendorEmail').value) + document.getElementById('qvVendorEmail').value = s.email; + + const srcLabel = s.source === 'ai' ? '🤖 AI' : '🔍 Regex'; + document.getElementById('qvAiStatus').innerHTML = + `${srcLabel} – felter preudfyldt`; + } else { + document.getElementById('qvAiStatus').innerHTML = + 'Ingen automatisk data fundet'; + } + } catch (e) { + document.getElementById('qvAiStatus').innerHTML = + 'Udfyld manuelt'; + } +} + +async function submitQuickVendor() { + const emailId = document.getElementById('qvEmailId').value; + const name = document.getElementById('qvVendorName').value.trim(); + const email = document.getElementById('qvVendorEmail').value.trim(); + const cvr = document.getElementById('qvVendorCVR').value.trim(); + const phone = document.getElementById('qvVendorPhone').value.trim(); + const address = document.getElementById('qvVendorAddress').value.trim(); + const domain = document.getElementById('qvVendorDomain').value.trim(); + const statusEl = document.getElementById('qvVendorStatus'); + + if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; } + statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…'; + + try { + // Create vendor + const vendResp = await fetch('/api/v1/vendors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + email: email || null, + cvr_number: cvr || null, + phone: phone || null, + address: address || null, + domain: domain || null + }) + }); + if (!vendResp.ok) throw new Error((await vendResp.json()).detail || 'Opret fejlede'); + const vendor = await vendResp.json(); + + // Link email + await fetch(`/api/v1/emails/${emailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supplier_id: vendor.id }) + }); + + bootstrap.Modal.getInstance(document.getElementById('quickCreateVendorModal')).hide(); + showSuccess(`Leverandør "${name}" oprettet og linket`); + loadEmailDetail(parseInt(emailId)); + } catch (e) { + statusEl.className = 'text-danger small'; + statusEl.textContent = e.message; + } +} + async function saveClassification() { if (!currentEmailId) return; @@ -4115,4 +4278,88 @@ async function uploadEmailFiles(files) { } } + + + + + + + {% endblock %} diff --git a/app/models/schemas.py b/app/models/schemas.py index 64d81fb..4c28053 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -74,9 +74,16 @@ class VendorBase(BaseModel): domain: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + email_pattern: Optional[str] = None contact_person: Optional[str] = None category: Optional[str] = None + priority: Optional[int] = 100 notes: Optional[str] = None + is_active: bool = True class VendorCreate(VendorBase): @@ -100,10 +107,9 @@ class VendorUpdate(BaseModel): class Vendor(VendorBase): """Full vendor schema""" id: int - is_active: bool = True created_at: datetime updated_at: Optional[datetime] = None - + class Config: from_attributes = True