feat: quick-create customer/vendor from unknown email sender

This commit is contained in:
Christian 2026-03-01 11:24:06 +01:00
parent 14b13b8239
commit 07584b1b0c
3 changed files with 421 additions and 5 deletions

View File

@ -5,13 +5,14 @@ API endpoints for email viewing, classification, and rule management
import logging import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from typing import List, Optional from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -76,6 +77,8 @@ class EmailDetail(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
attachments: List[EmailAttachment] = [] attachments: List[EmailAttachment] = []
customer_name: Optional[str] = None
supplier_name: Optional[str] = None
class EmailRule(BaseModel): class EmailRule(BaseModel):
@ -204,8 +207,13 @@ async def get_email(email_id: int):
"""Get email detail by ID""" """Get email detail by ID"""
try: try:
query = """ query = """
SELECT * FROM email_messages SELECT em.*,
WHERE id = %s AND deleted_at IS NULL 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,)) result = execute_query(query, (email_id,))
logger.info(f"🔍 Query result type: {type(result)}, length: {len(result) if result else 0}") 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)) 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}") @router.delete("/emails/{email_id}")
async def delete_email(email_id: int): async def delete_email(email_id: int):
"""Soft delete email""" """Soft delete email"""

View File

@ -1763,6 +1763,34 @@ function renderEmailAnalysis(email) {
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar // Opdater kun AI Analysis tab indholdet - ikke hele sidebar
aiAnalysisTab.innerHTML = ` aiAnalysisTab.innerHTML = `
${!email.customer_id && !email.supplier_id ? `
<div class="analysis-card border border-warning">
<h6 class="text-warning"><i class="bi bi-person-question-fill me-2"></i>Ukendt Afsender</h6>
<div class="text-muted small mb-2">
<i class="bi bi-envelope me-1"></i>${escapeHtml(email.sender_email || '')}
${email.sender_name ? `<br><i class="bi bi-person me-1"></i>${escapeHtml(email.sender_name)}` : ''}
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-primary w-100"
onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-person-plus me-2"></i>Opret som Kunde
</button>
<button class="btn btn-sm btn-outline-secondary w-100"
onclick="quickCreateVendor(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-shop me-2"></i>Opret som Leverandør
</button>
</div>
</div>
` : `
<div class="analysis-card">
<h6 class="text-success"><i class="bi bi-person-check-fill me-2"></i>Linket Til</h6>
<ul class="metadata-list">
${email.customer_id ? `<li class="metadata-item"><div class="metadata-label">Kunde</div><div class="metadata-value">${escapeHtml(email.customer_name || '#' + email.customer_id)}</div></li>` : ''}
${email.supplier_id ? `<li class="metadata-item"><div class="metadata-label">Leverandør</div><div class="metadata-value">${escapeHtml(email.supplier_name || '#' + email.supplier_id)}</div></li>` : ''}
</ul>
</div>
`}
${getClassificationActions(email) ? ` ${getClassificationActions(email) ? `
<div class="analysis-card"> <div class="analysis-card">
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6> <h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6>
@ -2251,6 +2279,141 @@ async function linkToCustomer(emailId) {
showError('Kunde-linking er ikke implementeret endnu'); 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 = '<span class="text-muted small"><i class="bi bi-hourglass-split me-1"></i>Henter info fra email/bilag…</span>';
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 =
`<span class="text-success small"><i class="bi bi-check-circle me-1"></i>${srcLabel} felter preudfyldt</span>`;
} else {
document.getElementById('qvAiStatus').innerHTML =
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Ingen automatisk data fundet</span>';
}
} catch (e) {
document.getElementById('qvAiStatus').innerHTML =
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Udfyld manuelt</span>';
}
}
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() { async function saveClassification() {
if (!currentEmailId) return; if (!currentEmailId) return;
@ -4115,4 +4278,88 @@ async function uploadEmailFiles(files) {
} }
} }
</script> </script>
<!-- Quick Create Customer Modal -->
<div class="modal fade" id="quickCreateCustomerModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="qcEmailId">
<div class="mb-3">
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="qcCustomerName" placeholder="Firmanavn eller kontaktperson">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="qcCustomerEmail">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qcCustomerPhone">
</div>
<div id="qcCustomerStatus"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button class="btn btn-primary" onclick="submitQuickCustomer()">
<i class="bi bi-person-plus me-2"></i>Opret & Link
</button>
</div>
</div>
</div>
</div>
<!-- Quick Create Vendor Modal -->
<div class="modal fade" id="quickCreateVendorModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-shop me-2"></i>Opret Leverandør</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="qvEmailId">
<div id="qvAiStatus" class="mb-3"></div>
<div class="mb-3">
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="qvVendorName" placeholder="Firmanavn">
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">CVR-nummer</label>
<input type="text" class="form-control" id="qvVendorCVR" maxlength="8" placeholder="12345678">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qvVendorPhone">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="qvVendorEmail">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Adresse</label>
<input type="text" class="form-control" id="qvVendorAddress" placeholder="Vejnavn 1, 1234 By">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Domæne</label>
<input type="text" class="form-control" id="qvVendorDomain" placeholder="firma.dk">
</div>
<div id="qvVendorStatus"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button class="btn btn-primary" onclick="submitQuickVendor()">
<i class="bi bi-shop me-2"></i>Opret & Link
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -74,9 +74,16 @@ class VendorBase(BaseModel):
domain: Optional[str] = None domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: 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 contact_person: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
priority: Optional[int] = 100
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True
class VendorCreate(VendorBase): class VendorCreate(VendorBase):
@ -100,7 +107,6 @@ class VendorUpdate(BaseModel):
class Vendor(VendorBase): class Vendor(VendorBase):
"""Full vendor schema""" """Full vendor schema"""
id: int id: int
is_active: bool = True
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None