feat: Enhance AnyDesk integration and vendor email domain management

- Updated the AnyDesk quick connect modal to improve user experience with new UI elements and functionality.
- Added support for saving and managing multiple AnyDesk IDs associated with cases, including hardware and contact information.
- Implemented backend endpoints for managing vendor email domains, allowing addition, deletion, and retrieval of domains linked to vendors.
- Created a new database table for vendor email domains to support multiple exact domains per vendor.
- Added tests for bankruptcy workflow to ensure correct case creation and alert linking based on exact CVR matches.
This commit is contained in:
Christian 2026-07-30 20:11:43 +02:00
parent d81a8f41b4
commit 56cc1bdcc4
17 changed files with 1931 additions and 205 deletions

View File

@ -135,6 +135,19 @@ class CustomerUpdate(BaseModel):
department: Optional[str] = None department: Optional[str] = None
class EmailDomainCreate(BaseModel):
domain: str
def _normalize_email_domain(value: str) -> str:
domain = str(value or "").strip().lower()
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
domain = domain.split("/", 1)[0].strip(". ")
if not domain or "." not in domain or "@" in domain:
raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne")
return domain
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
first_name: str first_name: str
last_name: str last_name: str
@ -461,6 +474,59 @@ async def verify_customer_linking():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/email-domains")
async def list_customer_email_domains(customer_id: int):
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
rows = execute_query(
"""
SELECT domain, created_at
FROM email_domain_customer_mappings
WHERE customer_id = %s
ORDER BY domain
""",
(customer_id,),
) or []
return rows
@router.post("/customers/{customer_id}/email-domains")
async def add_customer_email_domain(customer_id: int, payload: EmailDomainCreate):
if not execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)):
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
domain = _normalize_email_domain(payload.domain)
existing = execute_query_single(
"SELECT customer_id FROM email_domain_customer_mappings WHERE domain = %s",
(domain,),
)
if existing and int(existing["customer_id"]) != customer_id:
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden kunde")
return execute_query_single(
"""
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
VALUES (%s, %s, 'manual_customer')
ON CONFLICT (domain) DO UPDATE SET
customer_id = EXCLUDED.customer_id,
source = EXCLUDED.source
RETURNING domain, customer_id, created_at
""",
(domain, customer_id),
)
@router.delete("/customers/{customer_id}/email-domains/{domain}")
async def delete_customer_email_domain(customer_id: int, domain: str):
normalized = _normalize_email_domain(domain)
deleted = execute_update(
"DELETE FROM email_domain_customer_mappings WHERE customer_id = %s AND domain = %s",
(customer_id, normalized),
)
if not deleted:
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
return {"success": True}
@router.get("/customers/{customer_id}") @router.get("/customers/{customer_id}")
async def get_customer(customer_id: int): async def get_customer(customer_id: int):
"""Get single customer by ID with contact count and vTiger BMC Låst status""" """Get single customer by ID with contact count and vTiger BMC Låst status"""

View File

@ -1000,6 +1000,16 @@
<span class="info-value" id="wikiLink">-</span> <span class="info-value" id="wikiLink">-</span>
</div> </div>
</div> </div>
<div class="info-card mt-4">
<h5 class="fw-bold mb-2">Emaildomæner</h5>
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
<div class="input-group input-group-sm mb-3">
<input id="customerEmailDomainInput" class="form-control" placeholder="fx firma.dk">
<button class="btn btn-primary" onclick="addCustomerEmailDomain()">Tilføj</button>
</div>
<div id="customerEmailDomains" class="d-flex flex-wrap gap-2"></div>
</div>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
@ -2468,6 +2478,7 @@ async function loadCustomer() {
customerData = await response.json(); customerData = await response.json();
displayCustomer(customerData); displayCustomer(customerData);
await loadCustomerEmailDomains();
await loadUtilityCompany(); await loadUtilityCompany();
await loadCustomerTags(); await loadCustomerTags();
@ -2482,6 +2493,52 @@ async function loadCustomer() {
} }
} }
async function loadCustomerEmailDomains() {
const host = document.getElementById('customerEmailDomains');
if (!host) return;
try {
const response = await fetch(`/api/v1/customers/${customerId}/email-domains`);
if (!response.ok) throw new Error('Kunne ikke hente domæner');
const rows = await response.json();
host.innerHTML = rows.length ? rows.map(row => `
<span class="badge bg-light text-dark border p-2">
${escapeHtml(row.domain)}
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteCustomerEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
</span>
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
} catch (error) {
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
}
}
async function addCustomerEmailDomain() {
const input = document.getElementById('customerEmailDomainInput');
const domain = input.value.trim();
if (!domain) return;
const response = await fetch(`/api/v1/customers/${customerId}/email-domains`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke tilføjes');
return;
}
input.value = '';
await loadCustomerEmailDomains();
}
async function deleteCustomerEmailDomain(encodedDomain) {
const response = await fetch(`/api/v1/customers/${customerId}/email-domains/${encodedDomain}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke fjernes');
return;
}
await loadCustomerEmailDomains();
}
async function loadCustomerVendorLinks() { async function loadCustomerVendorLinks() {
const container = document.getElementById('customerVendorLinksContainer'); const container = document.getElementById('customerVendorLinksContainer');
const empty = document.getElementById('customerVendorLinksEmpty'); const empty = document.getElementById('customerVendorLinksEmpty');

View File

@ -17,6 +17,7 @@ from app.utils.safe_html import sanitize_safe_html
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 from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -388,6 +389,11 @@ class CreateSagFromEmailRequest(BaseModel):
relation_type: str = "mail" relation_type: str = "mail"
class EmailQuickActionRequest(BaseModel):
action: str
titel: Optional[str] = None
class EmailReadStateUpdate(BaseModel): class EmailReadStateUpdate(BaseModel):
is_read: bool is_read: bool
@ -505,6 +511,11 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
steps = wf.get('workflow_steps') steps = wf.get('workflow_steps')
steps_total = len(steps) if isinstance(steps, list) else 0 steps_total = len(steps) if isinstance(steps, list) else 0
actions = [
str(step.get("action") or "").strip()
for step in (steps or [])
if isinstance(step, dict) and str(step.get("action") or "").strip()
]
row = { row = {
'id': wf.get('id'), 'id': wf.get('id'),
'name': wf.get('name'), 'name': wf.get('name'),
@ -515,12 +526,17 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'sender_pattern': sender_pattern, 'sender_pattern': sender_pattern,
'subject_pattern': subject_pattern, 'subject_pattern': subject_pattern,
'steps_total': steps_total, 'steps_total': steps_total,
'actions': actions,
'matches': bool(matches), 'matches': bool(matches),
'reasons': reasons, 'reasons': reasons,
} }
candidates.append(row) candidates.append(row)
if matches: if matches:
matching.append(row) matching.append(row)
# Preview must mirror execution: a completed stop-on-match workflow
# prevents lower-priority workflows from running.
if row["stop_on_match"]:
break
system_matches = [] system_matches = []
if classification == 'bankruptcy': if classification == 'bankruptcy':
@ -529,12 +545,16 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'name': 'System: Bankruptcy Analysis', 'name': 'System: Bankruptcy Analysis',
'matches': True, 'matches': True,
'reason': 'classification == bankruptcy', 'reason': 'classification == bankruptcy',
'effect': 'Ved eksakt CVR-match oprettes en sag og en kritisk Alert Note på kunden',
'automatic': True,
}) })
has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data) has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
supplier_document = classification in {'invoice', 'order_confirmation', 'freight_note'}
hard_skip = {'newsletter', 'spam'} hard_skip = {'newsletter', 'spam'}
should_try_helpdesk = ( should_try_helpdesk = (
classification not in hard_skip not supplier_document
and classification not in hard_skip
and ( and (
classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint or has_hint
@ -545,6 +565,15 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'name': 'System: Helpdesk SAG routing', 'name': 'System: Helpdesk SAG routing',
'matches': bool(should_try_helpdesk), 'matches': bool(should_try_helpdesk),
'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list', 'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list',
'effect': (
'Forsøger at finde en eksisterende sag eller oprette en sag for en kendt kunde'
if should_try_helpdesk
else (
'Ingen supportsag oprettes dokumentet behandles som leverandørbilag'
if supplier_document
else 'Ingen sag oprettes eller tilknyttes automatisk'
)
),
}) })
return { return {
@ -559,7 +588,60 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
'matching_workflows': matching, 'matching_workflows': matching,
'workflow_candidates': candidates, 'workflow_candidates': candidates,
'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)), 'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)),
'automatic_execution': classification == 'bankruptcy',
} }
def _find_existing_vendor_for_email(email_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Match only trusted exact identifiers. Company-name guessing is forbidden."""
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
sender_email = str(email_data.get("sender_email") or "").strip().lower()
sender_domain = sender_email.rsplit("@", 1)[1] if "@" in sender_email else ""
if extracted_cvr:
match = execute_query_single(
"""
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
'exact_cvr'::text AS source
FROM vendors
WHERE is_active = true
AND TRIM(COALESCE(cvr_number, '')) = %s
ORDER BY id ASC LIMIT 1
""",
(extracted_cvr,),
)
if match:
return match
if sender_email:
match = execute_query_single(
"""
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
'exact_email'::text AS source
FROM vendors
WHERE is_active = true
AND LOWER(TRIM(COALESCE(email, ''))) = %s
ORDER BY id ASC LIMIT 1
""",
(sender_email,),
)
if match:
return match
if not sender_domain:
return None
return execute_query_single(
"""
SELECT v.id, v.name, v.cvr_number, v.domain, v.email,
100 AS match_score, 'exact_domain'::text AS source
FROM vendor_email_domains d
JOIN vendors v ON v.id = d.vendor_id
WHERE v.is_active = true AND LOWER(TRIM(d.domain)) = %s
LIMIT 1
""",
(sender_domain,),
)
context: Optional[str] = None context: Optional[str] = None
@ -782,35 +864,6 @@ async def get_domain_customer_suggestion(email_id: int):
}, },
} }
partial = execute_query_single(
"""
SELECT id, name, email_domain, cvr_number
FROM customers
WHERE is_active = true
AND COALESCE(email_domain, '') ILIKE %s
ORDER BY name ASC
LIMIT 1
""",
(f"%{sender_domain}%",),
)
if partial:
return {
"email_id": email_id,
"domain": sender_domain,
"has_customer": False,
"ignored": False,
"suggestion": {
"customer_id": partial["id"],
"customer_name": partial["name"],
"email_domain": partial.get("email_domain"),
"cvr_number": partial.get("cvr_number"),
"confidence": "medium",
"score": 70,
"source": "partial_domain",
},
}
return { return {
"email_id": email_id, "email_id": email_id,
"domain": sender_domain, "domain": sender_domain,
@ -1252,15 +1305,22 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
requested_case_type = _normalize_case_type(payload.case_type) requested_case_type = _normalize_case_type(payload.case_type)
customer_id = payload.customer_id or email_data.get('customer_id') customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id and _is_supplier_case_type(requested_case_type): # A vendor is not a customer. Supplier-originated cases are owned by
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id')) # the internal procurement customer, while the email keeps only its
# supplier_id relation.
if not customer_id and email_data.get('supplier_id'):
customer_id = _resolve_procurement_customer_id()
if not customer_id and _is_supplier_case_type(requested_case_type): if not customer_id and _is_supplier_case_type(requested_case_type):
customer_id = _resolve_procurement_customer_id() customer_id = _resolve_procurement_customer_id()
if not customer_id: if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)") raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
if not email_data.get('customer_id') and customer_id: if (
not _is_supplier_case_type(requested_case_type)
and not email_data.get('customer_id')
and customer_id
):
execute_update( execute_update(
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", "UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(customer_id, email_id), (customer_id, email_id),
@ -1268,7 +1328,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
sender_domain = _extract_sender_domain(email_data.get("sender_email")) sender_domain = _extract_sender_domain(email_data.get("sender_email"))
if sender_domain and not _is_ignored_sender_domain(sender_domain): if sender_domain and not _is_ignored_sender_domain(sender_domain):
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto") _upsert_domain_mapping(sender_domain, int(customer_id), "case_creation")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
@ -1377,6 +1437,121 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/quick-action")
async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest):
"""Run one explicit, identity-safe action from the email decision panel."""
action = str(payload.action or "").strip().lower()
allowed_actions = {
"create_customer_case",
"create_supplier_invoice",
"create_accounting_case",
"archive_entity_mail",
}
if action not in allowed_actions:
raise HTTPException(status_code=400, detail="Ukendt emailhandling")
email_data = execute_query_single(
"""
SELECT id, subject, sender_email, body_text, body_html, classification,
confidence_score, customer_id, supplier_id, linked_case_id
FROM email_messages
WHERE id = %s AND deleted_at IS NULL
""",
(email_id,),
)
if not email_data:
raise HTTPException(status_code=404, detail="Email blev ikke fundet")
customer_id = email_data.get("customer_id")
supplier_id = email_data.get("supplier_id")
title = str(payload.titel or email_data.get("subject") or "Sag fra email").strip()
if action in {"create_customer_case", "create_accounting_case"}:
if not customer_id:
raise HTTPException(
status_code=409,
detail="Fastslå og tilknyt kunden, før du opretter en kundesag",
)
group_id = None
case_type = "support"
if action == "create_accounting_case":
group = execute_query_single(
"""
SELECT id
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id
LIMIT 1
"""
)
if not group:
raise HTTPException(
status_code=409,
detail="Bogholderi-gruppen mangler i Indstillinger → Grupper",
)
group_id = int(group["id"])
case_type = "bogholderi"
return await create_sag_from_email(
email_id,
CreateSagFromEmailRequest(
titel=title,
customer_id=int(customer_id),
case_type=case_type,
assigned_group_id=group_id,
),
)
if action == "create_supplier_invoice":
if not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå og tilknyt leverandøren, før fakturaen behandles",
)
# This button is an explicit user decision and therefore authoritative.
email_data["classification"] = "invoice"
email_data["confidence_score"] = 1.0
execute_update(
"""
UPDATE email_messages
SET classification = 'invoice', confidence_score = 1.0,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,),
)
result = await email_workflow_service.execute_workflows(email_data)
return {
"success": True,
"action": action,
"result": result,
"message": "Leverandørfaktura sendt til behandling",
}
if not customer_id and not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå kunde eller leverandør, før mailen arkiveres",
)
execute_update(
"""
UPDATE email_messages
SET status = 'archived', folder = 'Archive', updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,),
)
return {
"success": True,
"action": action,
"message": "Mailen er arkiveret under den fastsatte kunde eller leverandør",
}
@router.post("/emails/{email_id}/link-sag") @router.post("/emails/{email_id}/link-sag")
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest): async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
"""Link an email to an existing SAG and optionally append a system note.""" """Link an email to an existing SAG and optionally append a system note."""
@ -2240,12 +2415,29 @@ async def upload_emails(files: List[UploadFile] = File(...)):
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Classification failed for uploaded email: {e}") logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
# Execute workflows # Manual uploads follow the same approval policy as mailbox imports:
try: # classify and preview now; execute only after explicit user approval.
logger.info(f"⚙️ Executing workflows for email {email_id}...") if classification == "bankruptcy":
await email_workflow_service.execute_workflows_for_email(email_id) try:
except Exception as e: logger.info("⚙️ Kører automatisk konkursflow for email %s", email_id)
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}") await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning("⚠️ Automatisk konkursflow fejlede for email %s: %s", email_id, e)
elif getattr(settings, "EMAIL_REQUIRE_MANUAL_APPROVAL", True):
execute_update(
"""
UPDATE email_messages
SET status = 'awaiting_user_action'
WHERE id = %s
""",
(email_id,),
)
else:
try:
logger.info(f"⚙️ Executing workflows for email {email_id}...")
await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
results.append({ results.append({
"filename": file.filename, "filename": file.filename,
@ -2779,7 +2971,7 @@ async def execute_workflows_for_email(email_id: int):
query = """ query = """
SELECT id, message_id, subject, sender_email, sender_name, body_text, SELECT id, message_id, subject, sender_email, sender_name, body_text,
body_html, in_reply_to, email_references, thread_key, body_html, in_reply_to, email_references, thread_key,
classification, confidence_score, status classification, confidence_score, status, customer_id, supplier_id
FROM email_messages FROM email_messages
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
""" """
@ -2789,6 +2981,22 @@ async def execute_workflows_for_email(email_id: int):
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0] # Get first row as dict email_data = email_result[0] # Get first row as dict
if not str(email_data.get("classification") or "").strip():
classified = simple_classifier.classify(email_data)
email_data["classification"] = classified["classification"]
email_data["confidence_score"] = classified["confidence"]
execute_update(
"""
UPDATE email_messages
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
classified["classification"],
classified["confidence"],
email_id,
),
)
# Execute workflows # Execute workflows
result = await email_workflow_service.execute_workflows(email_data) result = await email_workflow_service.execute_workflows(email_data)
@ -2807,11 +3015,16 @@ async def preview_workflows_for_email(email_id: int):
"""Preview which workflows would match an email without executing them.""" """Preview which workflows would match an email without executing them."""
try: try:
query = """ query = """
SELECT id, message_id, subject, sender_email, sender_name, body_text, SELECT em.id, em.message_id, em.subject, em.sender_email, em.sender_name,
body_html, in_reply_to, email_references, thread_key, em.body_text, em.body_html, em.in_reply_to, em.email_references,
classification, confidence_score, status em.thread_key, em.classification, em.confidence_score, em.status,
FROM email_messages em.customer_id, em.supplier_id, em.extracted_vendor_name,
WHERE id = %s AND deleted_at IS NULL em.extracted_vendor_cvr, c.name AS customer_name,
v.name AS supplier_name, v.cvr_number AS supplier_cvr
FROM email_messages em
LEFT JOIN customers c ON c.id = em.customer_id
LEFT JOIN vendors v ON v.id = em.supplier_id
WHERE em.id = %s AND em.deleted_at IS NULL
""" """
email_result = execute_query(query, (email_id,)) email_result = execute_query(query, (email_id,))
@ -2819,7 +3032,80 @@ async def preview_workflows_for_email(email_id: int):
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0] email_data = email_result[0]
return _compute_workflow_preview(email_data) if not str(email_data.get("classification") or "").strip():
classified = simple_classifier.classify(email_data)
email_data["classification"] = classified["classification"]
email_data["confidence_score"] = classified["confidence"]
execute_update(
"""
UPDATE email_messages
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
classified["classification"],
classified["confidence"],
email_id,
),
)
preview = _compute_workflow_preview(email_data)
supplier_document = str(email_data.get("classification") or "").lower() in {
"invoice",
"order_confirmation",
"freight_note",
}
customer_identity = (
{
"has_customer": False,
"not_applicable": True,
"reason": "supplier_document",
"domain": _extract_sender_domain(email_data.get("sender_email")),
"suggestion": None,
}
if supplier_document
else await get_domain_customer_suggestion(email_id)
)
vendor_identity = {
"has_vendor": bool(email_data.get("supplier_id")),
"suggestion": None,
}
if email_data.get("supplier_id"):
vendor_identity["suggestion"] = {
"vendor_id": email_data.get("supplier_id"),
"name": email_data.get("supplier_name"),
"cvr_number": email_data.get("supplier_cvr"),
"match_score": 100,
"source": "linked_email",
}
else:
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
extracted_name = str(email_data.get("extracted_vendor_name") or "").strip()
matched_vendor = _find_existing_vendor_for_email(email_data)
if matched_vendor:
vendor_identity["suggestion"] = {
"vendor_id": matched_vendor.get("id"),
"name": matched_vendor.get("name"),
"cvr_number": matched_vendor.get("cvr_number"),
"domain": matched_vendor.get("domain"),
"email": matched_vendor.get("email"),
"match_score": matched_vendor.get("match_score"),
"source": matched_vendor.get("source"),
}
elif extracted_name or extracted_cvr:
vendor_identity["suggestion"] = {
"vendor_id": None,
"name": extracted_name or None,
"cvr_number": extracted_cvr or None,
"match_score": 0,
"source": "extracted_email_data",
}
preview["identity"] = {
"customer": customer_identity,
"vendor": vendor_identity,
}
return preview
except HTTPException: except HTTPException:
raise raise

View File

@ -77,6 +77,42 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card)); background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
} }
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
padding: 0.9rem;
margin-bottom: 0.85rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.email-ai-type {
font-size: 1.15rem;
font-weight: 750;
}
.email-ai-confidence {
height: 7px;
border-radius: 99px;
overflow: hidden;
background: var(--border-color);
}
.email-ai-confidence > span {
display: block;
height: 100%;
background: var(--accent);
}
.email-ai-actions {
margin: 0;
padding-left: 1.2rem;
font-size: 0.86rem;
}
.email-ai-actions li {
margin-bottom: 0.32rem;
}
.emails-shortcuts { .emails-shortcuts {
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-secondary); color: var(--text-secondary);
@ -200,6 +236,27 @@
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-quick-toolbar {
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.emails-v2-quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.emails-v2-quick-grid .btn {
min-height: 38px;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-mail-body { .emails-v2-mail-body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@ -428,6 +485,8 @@
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div> <div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
<div id="v2QuickActions"></div>
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div> <div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div> <div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
@ -700,7 +759,9 @@
const rows = await apiFetch(url); const rows = await apiFetch(url);
let emails = Array.isArray(rows) ? rows : []; let emails = Array.isArray(rows) ? rows : [];
if (state.filter === 'active' && !state.query) { // Processed/archived emails must never reappear in the active
// inbox, including when the user searches.
if (state.filter === 'active') {
emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase())); emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase()));
} }
@ -713,6 +774,13 @@
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId)); const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
if (stillExists) { if (stillExists) {
await selectEmail(state.selectedEmailId, { silentListRefresh: true }); await selectEmail(state.selectedEmailId, { silentListRefresh: true });
} else {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
}
} }
} else if (emails.length) { } else if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true }); await selectEmail(Number(emails[0].id), { silentListRefresh: true });
@ -823,6 +891,15 @@
try { try {
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`); const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
state.workflowPreview = preview || null; state.workflowPreview = preview || null;
if (preview?.identity) {
state.domainCustomerSuggestion = preview.identity.customer || {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
renderDomainCustomerSuggestion();
renderVendorSuggestion();
}
renderWorkflowPreview(); renderWorkflowPreview();
} catch (error) { } catch (error) {
state.workflowPreview = null; state.workflowPreview = null;
@ -845,18 +922,236 @@
} }
} }
const CLASSIFICATION_LABELS = {
invoice: 'Leverandørfaktura',
freight_note: 'Fragtbrev',
order_confirmation: 'Ordrebekræftelse',
time_confirmation: 'Tidsbekræftelse',
case_notification: 'Sagsnotifikation',
customer_email: 'Kundehenvendelse',
bankruptcy: 'Konkursmeddelelse',
general: 'Almindelig email',
spam: 'Spam',
newsletter: 'Nyhedsbrev',
unknown: 'Ukendt type',
};
const WORKFLOW_ACTION_LABELS = {
create_ticket: 'Opretter en sag/opgave',
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
create_time_entry: 'Opretter en tidsregistrering',
link_to_vendor: 'Knytter emailen til leverandøren',
link_to_customer: 'Knytter emailen til kunden',
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
extract_tracking_number: 'Udtrækker trackingnummer',
regex_extract_and_link: 'Finder reference og opretter tilknytning',
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
send_slack_notification: 'Sender en Slack-notifikation',
send_email_notification: 'Sender en emailnotifikation',
mark_as_processed: 'Markerer emailen som behandlet',
flag_for_review: 'Markerer emailen til manuel kontrol',
};
function renderIdentityAssessment() {
const email = state.selectedEmail || {};
const customerData = state.domainCustomerSuggestion;
const customerSuggestion = customerData?.suggestion;
const vendorSuggestion = state.vendorSuggestion;
const senderDomain = customerData?.domain
|| String(email.sender_email || '').split('@')[1]
|| '';
let customerHtml;
if (customerData?.not_applicable) {
customerHtml = `
<div class="fw-semibold">Ikke relevant</div>
<div class="small text-muted">Dette er et leverandørdokument. Afsenderen matches kun mod leverandører.</div>
`;
} else if (email.customer_id) {
customerHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.customer_name || `Kunde #${email.customer_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (customerSuggestion?.customer_id) {
customerHtml = `
<div class="fw-semibold">${escapeHtml(customerSuggestion.customer_name || `Kunde #${customerSuggestion.customer_id}`)}</div>
<div class="small text-muted">${escapeHtml(customerSuggestion.confidence || 'ukendt')} sikkerhed · ${escapeHtml(customerSuggestion.source || 'domænematch')}</div>
<button id="v2DecisionLinkCustomer" class="btn btn-sm btn-outline-primary mt-2">Tilknyt kunden</button>
`;
} else if (customerData) {
const proposedName = email.sender_name || senderDomain || 'Ny kunde';
customerHtml = `
<div class="fw-semibold">${escapeHtml(proposedName)}</div>
<div class="small text-muted">Ingen eksisterende kunde fundet${senderDomain ? ` for ${escapeHtml(senderDomain)}` : ''}</div>
<button id="v2DecisionCreateCustomer" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt kunde
</button>
`;
} else {
customerHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder kunde…</div>';
}
let vendorHtml;
if (email.supplier_id) {
vendorHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.supplier_name || `Leverandør #${email.supplier_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (vendorSuggestion?.vendor_id) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name || `Leverandør #${vendorSuggestion.vendor_id}`)}</div>
<div class="small text-muted">Matchscore ${Number(vendorSuggestion.match_score || 0)}${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionLinkVendor" class="btn btn-sm btn-outline-primary mt-2">Tilknyt leverandøren</button>
`;
} else if (vendorSuggestion?.name) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name)}</div>
<div class="small text-muted">Ny leverandør${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt leverandør
</button>
`;
} else if (vendorSuggestion) {
vendorHtml = `
<div class="small text-muted">Systemet fandt ikke en sikker leverandør.</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-outline-primary mt-2">
Opret leverandør fra afsender
</button>
`;
} else {
vendorHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder leverandør…</div>';
}
return `
<div class="row g-2 mb-3">
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-building me-1"></i>Systemet tror kunden er</div>
${customerHtml}
</div>
</div>
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-truck me-1"></i>Systemet tror leverandøren er</div>
${vendorHtml}
</div>
</div>
</div>
`;
}
function bindIdentityDecisionActions() {
document.getElementById('v2DecisionLinkCustomer')?.addEventListener('click', applyDomainCustomerSuggestion);
document.getElementById('v2DecisionCreateCustomer')?.addEventListener('click', createCustomerFromSuggestion);
document.getElementById('v2DecisionLinkVendor')?.addEventListener('click', linkVendorSuggestion);
document.getElementById('v2DecisionCreateVendor')?.addEventListener('click', createVendorFromSuggestion);
}
function renderClassificationDecision(errorMessage) {
const host = document.getElementById('v2DecisionCard');
if (!host) return;
if (errorMessage) {
host.innerHTML = `
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
`;
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
return;
}
const meta = preview.email || {};
const classification = String(meta.classification || 'unknown').toLowerCase();
const typeLabel = CLASSIFICATION_LABELS[classification] || classification;
const rawConfidence = Number(meta.confidence_score || 0);
const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100);
const confidenceLabel = confidencePercent >= 80
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const effects = [];
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<div class="d-flex justify-content-between small mt-2 mb-1">
<span>${escapeHtml(confidenceLabel)}</span>
<strong>${confidencePercent}%</strong>
</div>
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-3">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : `
<button id="v2ApproveActions" class="btn btn-success w-100">
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
</button>`}
` : `
<div class="alert alert-secondary py-2 small mb-0">
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
</div>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div>
`;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions();
}
async function approveSuggestedActions() {
const button = document.getElementById('v2ApproveActions');
if (!button || !state.selectedEmailId) return;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
await executeWorkflowsCurrent();
} finally {
if (document.body.contains(button)) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
}
}
}
function renderWorkflowPreview(errorMessage) { function renderWorkflowPreview(errorMessage) {
const host = document.getElementById('v2WorkflowPreview'); const host = document.getElementById('v2WorkflowPreview');
if (!host) return; if (!host) return;
if (errorMessage) { if (errorMessage) {
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`; host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
renderClassificationDecision(errorMessage);
return; return;
} }
const preview = state.workflowPreview; const preview = state.workflowPreview;
if (!preview) { if (!preview) {
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>'; host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
renderClassificationDecision();
return; return;
} }
@ -898,6 +1193,7 @@
${matchingHtml} ${matchingHtml}
</div> </div>
`; `;
renderClassificationDecision();
const autoRunBtn = document.getElementById('v2AutoRun'); const autoRunBtn = document.getElementById('v2AutoRun');
if (autoRunBtn) { if (autoRunBtn) {
@ -931,6 +1227,7 @@
const caseTypeEl = document.getElementById('v2CaseType'); const caseTypeEl = document.getElementById('v2CaseType');
const titelEl = document.getElementById('v2CaseTitle'); const titelEl = document.getElementById('v2CaseTitle');
const createButton = document.getElementById('v2CreateCase');
const payload = { const payload = {
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(), titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
case_type: String(caseTypeEl?.value || 'support'), case_type: String(caseTypeEl?.value || 'support'),
@ -941,6 +1238,10 @@
} }
try { try {
if (createButton) {
createButton.disabled = true;
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
}
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, { const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -952,6 +1253,49 @@
await selectEmail(state.selectedEmailId); await selectEmail(state.selectedEmailId);
} catch (error) { } catch (error) {
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`); setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
} finally {
if (createButton && document.body.contains(createButton)) {
createButton.disabled = false;
createButton.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Opret';
}
}
}
async function runQuickAction(action, button) {
if (!state.selectedEmailId || !state.selectedEmail || !action) return;
const originalHtml = button?.innerHTML || '';
try {
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Udfører…';
}
const title = String(document.getElementById('v2CaseTitle')?.value
|| state.selectedEmail.subject
|| '').trim();
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, titel: title }),
});
if (action === 'archive_entity_mail') {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
await loadEmails();
} else {
await selectEmail(state.selectedEmailId);
}
setDetailStatus(result?.message || 'Handlingen er udført');
} catch (error) {
setDetailStatus(`Handlingen fejlede: ${error.message}`);
window.alert(`Handlingen kunne ikke udføres:\n${error.message}`);
} finally {
if (button && document.body.contains(button)) {
button.disabled = false;
button.innerHTML = originalHtml;
}
} }
} }
@ -1007,8 +1351,11 @@
}); });
state.vendorSuggestion = suggestion || null; state.vendorSuggestion = suggestion || null;
renderVendorSuggestion(); renderVendorSuggestion();
renderClassificationDecision();
setSupplierStatus('Forslag opdateret', 'success'); setSupplierStatus('Forslag opdateret', 'success');
} catch (error) { } catch (error) {
state.vendorSuggestion = {};
renderClassificationDecision();
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error'); setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
} }
} }
@ -1063,8 +1410,14 @@
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`); const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
state.domainCustomerSuggestion = suggestion || null; state.domainCustomerSuggestion = suggestion || null;
renderDomainCustomerSuggestion(); renderDomainCustomerSuggestion();
renderClassificationDecision();
setDomainStatus('Forslag opdateret', 'success'); setDomainStatus('Forslag opdateret', 'success');
} catch (error) { } catch (error) {
state.domainCustomerSuggestion = {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
renderClassificationDecision();
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error'); setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
} }
} }
@ -1088,6 +1441,98 @@
} }
} }
async function createCustomerFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const domain = state.domainCustomerSuggestion?.domain
|| String(email.sender_email || '').split('@')[1]
|| null;
const name = String(email.sender_name || domain || '').trim();
if (!name) {
setDetailStatus('Kunden kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter kunden ${name}…`);
const customer = await apiFetch('/api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email.sender_email || null,
email_domain: domain,
is_active: true,
country: 'DK',
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(customer.id) }),
});
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
}
}
async function linkVendorSuggestion() {
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
if (!state.selectedEmailId || !vendorId) return;
try {
setDetailStatus('Tilknytter leverandøren…');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: vendorId }),
});
setDetailStatus('Leverandøren blev tilknyttet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
}
}
async function createVendorFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const suggestion = state.vendorSuggestion || {};
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
if (!name) {
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter leverandøren ${name}…`);
const vendor = await apiFetch('/api/v1/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
cvr_number: suggestion.cvr_number || null,
email: suggestion.email || email.sender_email || null,
phone: suggestion.phone || null,
address: suggestion.address || null,
domain: suggestion.domain || senderDomain,
category: 'supplier',
notes: `Oprettet fra email #${state.selectedEmailId}`,
is_active: true,
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
});
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
}
}
async function searchSager(query) { async function searchSager(query) {
const host = document.getElementById('v2SagResults'); const host = document.getElementById('v2SagResults');
if (!host) return; if (!host) return;
@ -1126,12 +1571,14 @@
function renderDetail(email) { function renderDetail(email) {
const mailHeader = document.getElementById('v2MailHeader'); const mailHeader = document.getElementById('v2MailHeader');
const quickActions = document.getElementById('v2QuickActions');
const mailBody = document.getElementById('v2MailBody'); const mailBody = document.getElementById('v2MailBody');
const sideActions = document.getElementById('v2SideActions'); const sideActions = document.getElementById('v2SideActions');
if (!mailHeader || !mailBody || !sideActions) return; if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
if (!email) { if (!email) {
mailHeader.innerHTML = 'Vælg en email for at se info'; mailHeader.innerHTML = 'Vælg en email for at se info';
quickActions.innerHTML = '';
mailBody.className = 'emails-v2-detail-empty'; mailBody.className = 'emails-v2-detail-empty';
mailBody.textContent = 'Vælg en email fra listen'; mailBody.textContent = 'Vælg en email fra listen';
sideActions.className = 'emails-v2-detail-empty'; sideActions.className = 'emails-v2-detail-empty';
@ -1154,6 +1601,31 @@
</div> </div>
`; `;
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
<i class="bi bi-person-workspace me-1"></i>Kundesag
</button>
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
</button>
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
</button>
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
<i class="bi bi-archive me-1"></i>Arkivér mail
</button>
</div>
`;
mailBody.className = 'emails-v2-mail-body'; mailBody.className = 'emails-v2-mail-body';
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0; const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
const rawHtml = String(email.body_html || '').trim(); const rawHtml = String(email.body_html || '').trim();
@ -1182,6 +1654,13 @@
sideActions.className = 'emails-v2-actions-pane'; sideActions.className = 'emails-v2-actions-pane';
sideActions.innerHTML = ` sideActions.innerHTML = `
<div id="v2DecisionCard" class="email-ai-decision">
<div class="d-flex align-items-center gap-2 text-muted">
<span class="spinner-border spinner-border-sm"></span>
Systemet vurderer mailtype og handlinger…
</div>
</div>
${email.linked_case_id ? ` ${email.linked_case_id ? `
<div class="emails-v2-card emails-primary-action"> <div class="emails-v2-card emails-primary-action">
<h6>Allerede knyttet til sag</h6> <h6>Allerede knyttet til sag</h6>
@ -1197,9 +1676,9 @@
<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">
<option value="support">Support</option> <option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
<option value="bogholderi">Bogholderi</option> <option value="bogholderi">Bogholderi</option>
<option value="leverandor">Leverandør</option> <option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave">Projekt/helhedsopgave</option> <option value="helhedsopgave">Projekt/helhedsopgave</option>
<option value="andet">Andet</option> <option value="andet">Andet</option>
</select> </select>
@ -1288,6 +1767,11 @@
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent); document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent); document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent); document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
button.addEventListener('click', () => {
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
});
});
state.vendorSuggestion = null; state.vendorSuggestion = null;
state.domainCustomerSuggestion = null; state.domainCustomerSuggestion = null;
@ -1296,6 +1780,14 @@
renderDomainCustomerSuggestion(); renderDomainCustomerSuggestion();
renderWorkflowPreview(); renderWorkflowPreview();
loadWorkflowPreviewCurrent(); loadWorkflowPreviewCurrent();
const vendorClassifications = new Set(['invoice', 'order_confirmation', 'freight_note']);
if (email.supplier_id || vendorClassifications.has(String(email.classification || '').toLowerCase())
|| email.extracted_vendor_name || email.extracted_vendor_cvr) {
extractVendorSuggestionCurrent();
} else {
state.vendorSuggestion = {};
renderClassificationDecision();
}
setMailStatus(`Email #${email.id} vises`); setMailStatus(`Email #${email.id} vises`);
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => { document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {

View File

@ -853,9 +853,124 @@ def _replace_switch_port_if_confirmed(
) )
def _validate_outlet_port_references(
*,
location_id: int,
cross_field_port_id: Optional[int],
switch_hardware_id: Optional[int],
switch_port: Optional[str],
) -> None:
"""Only allow active, explicitly created ports belonging to this location."""
if cross_field_port_id is not None:
cross_field_port = execute_query(
'''SELECT p.id
FROM locations_cross_field_ports p
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
WHERE p.id = %s
AND p.is_active = TRUE
AND cf.location_id = %s
AND cf.is_active = TRUE
AND cf.deleted_at IS NULL''',
(cross_field_port_id, location_id),
) or []
if not cross_field_port:
raise HTTPException(
status_code=400,
detail='Den valgte krydsfelt-port findes ikke eller er ikke aktiv på lokationen',
)
if switch_hardware_id is None:
return
hardware = execute_query(
'''SELECT id, hardware_specs
FROM hardware_assets
WHERE id = %s
AND current_location_id = %s
AND deleted_at IS NULL
AND LOWER(COALESCE(asset_type, '')) = 'netværk' ''',
(switch_hardware_id, location_id),
) or []
if not hardware:
raise HTTPException(status_code=400, detail='Den valgte switch findes ikke på lokationen')
if switch_port:
specs = hardware[0].get('hardware_specs') or {}
if isinstance(specs, str):
try:
specs = json.loads(specs)
except (TypeError, ValueError):
specs = {}
port_count = int((specs or {}).get('port_count') or 0)
valid_ports = {str(number) for number in range(1, port_count + 1)}
if str(switch_port).strip() not in valid_ports:
raise HTTPException(
status_code=400,
detail='Den valgte switch-port findes ikke blandt switchens oprettede porte',
)
def _validate_single_customer_wan(
*,
location_id: int,
customer_id: Optional[int],
is_wan: bool,
exclude_outlet_id: Optional[int] = None,
) -> None:
if not is_wan:
return
effective_customer = customer_id
if effective_customer is None:
location = execute_query(
'''SELECT customer_id
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL''',
(location_id,),
) or []
effective_customer = location[0].get('customer_id') if location else None
if effective_customer is None:
raise HTTPException(status_code=400, detail='Vælg en kunde, før porten markeres som WAN')
params = [effective_customer]
exclude_sql = ''
if exclude_outlet_id is not None:
exclude_sql = 'AND o.id <> %s'
params.append(exclude_outlet_id)
existing = execute_query(
f'''SELECT o.id, o.outlet_number
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id
WHERE COALESCE(o.customer_id, l.customer_id) = %s
AND o.is_wan = TRUE
AND o.is_active = TRUE
AND o.deleted_at IS NULL
{exclude_sql}
LIMIT 1''',
tuple(params),
) or []
if existing:
outlet_name = existing[0].get('outlet_number') or f"#{existing[0]['id']}"
raise HTTPException(
status_code=409,
detail=f'Kunden har allerede WAN-porten {outlet_name}. Der kan kun være én WAN-port pr. kunde',
)
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201) @router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
async def create_wall_outlet(data: WallOutletCreate): async def create_wall_outlet(data: WallOutletCreate):
_outlet_location(data.location_id) _outlet_location(data.location_id)
_validate_outlet_port_references(
location_id=data.location_id,
cross_field_port_id=data.cross_field_port_id,
switch_hardware_id=data.switch_hardware_id,
switch_port=data.switch_port,
)
_validate_single_customer_wan(
location_id=data.location_id,
customer_id=data.customer_id,
is_wan=data.is_wan,
)
_replace_switch_port_if_confirmed( _replace_switch_port_if_confirmed(
switch_hardware_id=data.switch_hardware_id, switch_hardware_id=data.switch_hardware_id,
switch_name=data.switch_name, switch_name=data.switch_name,
@ -888,12 +1003,24 @@ async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
if 'outlet_number' in changes: if 'outlet_number' in changes:
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
current = execute_query( current = execute_query(
'''SELECT switch_hardware_id, switch_name, switch_port '''SELECT location_id, customer_id, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''', FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
(outlet_id,), (outlet_id,),
) or [] ) or []
if not current: if not current:
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet') raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
_validate_outlet_port_references(
location_id=changes.get('location_id', current[0]['location_id']),
cross_field_port_id=changes.get('cross_field_port_id', current[0].get('cross_field_port_id')),
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_port=changes.get('switch_port', current[0].get('switch_port')),
)
_validate_single_customer_wan(
location_id=changes.get('location_id', current[0]['location_id']),
customer_id=changes.get('customer_id', current[0].get('customer_id')),
is_wan=changes.get('is_wan', current[0].get('is_wan', False)),
exclude_outlet_id=outlet_id,
)
_replace_switch_port_if_confirmed( _replace_switch_port_if_confirmed(
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')), switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
switch_name=changes.get('switch_name', current[0].get('switch_name')), switch_name=changes.get('switch_name', current[0].get('switch_name')),

View File

@ -788,7 +788,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
FROM locations_cross_field_ports p FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
LEFT JOIN locations_locations l ON l.id = o.location_id LEFT JOIN locations_locations l ON l.id = o.location_id
WHERE p.cross_field_id = %s ORDER BY p.port_order""", WHERE p.cross_field_id = %s AND p.is_active = TRUE ORDER BY p.port_order""",
(cross_field["id"],), (cross_field["id"],),
) or [] ) or []
for port in cross_field["ports"]: for port in cross_field["ports"]:

View File

@ -1138,11 +1138,11 @@
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div> <div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div> <div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
<div class="col-md-3"><label class="form-label">Fra portposition</label><input class="form-control" id="bulkFromPort" type="number" min="1" value="1" required></div> <div class="col-md-3"><label class="form-label">Fra krydsfelt-port</label><select class="form-select" id="bulkFromPort" required></select></div>
<div class="col-md-3"><label class="form-label">Til portposition</label><input class="form-control" id="bulkToPort" type="number" min="1" value="24" required></div> <div class="col-md-3"><label class="form-label">Til krydsfelt-port</label><select class="form-select" id="bulkToPort" required></select></div>
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div> <div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
<div class="col-md-3"><label class="form-label">Start switch-port</label><input class="form-control" id="bulkSwitchStart" type="number" min="1" value="1" required></div> <div class="col-md-3"><label class="form-label">Start switch-port</label><select class="form-select" id="bulkSwitchStart" required></select></div>
<div class="col-md-3 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" id="bulkIsWan" type="checkbox"><label class="form-check-label fw-semibold" for="bulkIsWan">WAN</label></div></div> <div class="col-md-3"><label class="form-label">WAN-port i området</label><select class="form-select" id="bulkWanPort"><option value="">Ingen WAN-port</option></select></div>
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div> <div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div> <div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
</div> </div>
@ -1591,67 +1591,171 @@ document.addEventListener('DOMContentLoaded', function() {
const bulkFieldSelect = document.getElementById('bulkCrossField'); const bulkFieldSelect = document.getElementById('bulkCrossField');
const bulkSwitchSelect = document.getElementById('bulkSwitch'); const bulkSwitchSelect = document.getElementById('bulkSwitch');
const bulkCustomerSelect = document.getElementById('bulkCustomer'); const bulkCustomerSelect = document.getElementById('bulkCustomer');
let bulkCustomers = [];
function updateBulkCustomerOptions(query = '') {
const normalizedQuery = query.trim().toLocaleLowerCase('da');
const matches = normalizedQuery
? bulkCustomers.filter(customer =>
String(customer.name || customer.navn || '').toLocaleLowerCase('da').includes(normalizedQuery)
).slice(0, 25)
: [];
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + matches
.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`)
.join('');
}
function selectedBulkField() { function selectedBulkField() {
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value)); return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
} }
function selectableBulkFieldPorts(field) {
return (field?.ports || []).filter(port => port.is_active !== false);
}
function selectableSwitchPorts(hardware) {
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined);
}
function populateBulkFieldPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
document.getElementById('bulkFromPort').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`)
.join('');
document.getElementById('bulkFromPort').value = ports.length ? '0' : '';
populateBulkEndPorts(Math.min(23, ports.length - 1));
}
function populateBulkEndPorts(preferredIndex = null) {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const firstIndex = Number.isInteger(from) && from >= 0 ? from : 0;
const endSelect = document.getElementById('bulkToPort');
endSelect.innerHTML = ports
.map((port, index) => ({port, index}))
.filter(item => item.index >= firstIndex)
.map(item => `<option value="${item.index}">${item.port.port_number}</option>`)
.join('');
const requested = Number(preferredIndex);
const target = Number.isInteger(requested) && requested >= firstIndex && requested < ports.length
? requested
: firstIndex;
endSelect.value = ports.length ? String(target) : '';
}
function populateBulkSwitchPorts() {
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const ports = selectableSwitchPorts(hardware);
document.getElementById('bulkSwitchStart').innerHTML = ports
.map((port, index) => `<option value="${index}">${port.port_number}</option>`)
.join('');
}
function populateBulkWanPorts() {
const ports = selectableBulkFieldPorts(selectedBulkField());
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const selectedPorts = Number.isInteger(from) && Number.isInteger(to) && to >= from
? ports.slice(from, to + 1)
: [];
const wanSelect = document.getElementById('bulkWanPort');
const previous = wanSelect.value;
wanSelect.innerHTML = '<option value="">Ingen WAN-port</option>' + selectedPorts
.map(port => `<option value="${port.id}">${port.port_number}</option>`)
.join('');
const existingWan = selectedPorts.find(port => port.is_wan);
wanSelect.value = selectedPorts.some(port => String(port.id) === previous)
? previous
: (existingWan ? String(existingWan.id) : '');
}
function updateBulkPreview() { function updateBulkPreview() {
const field = selectedBulkField(); const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value)); const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
const from = Number(document.getElementById('bulkFromPort').value); const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value); const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value); const switchStart = Number(document.getElementById('bulkSwitchStart').value);
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0; const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
const firstPort = field?.ports?.[from - 1]?.port_number || '—'; const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count);
const lastPort = field?.ports?.[to - 1]?.port_number || '—'; const firstPort = fieldPorts[from]?.port_number || '—';
document.getElementById('bulkPatchPreview').innerHTML = count const lastPort = fieldPorts[to]?.port_number || '—';
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}${switchStart + count - 1}` const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}${lastSwitchPort}`
: count
? 'Det valgte område går ud over de oprettede switch-porte.'
: 'Vælg et gyldigt portområde.'; : 'Vælg et gyldigt portområde.';
} }
async function openBulkPatch() { async function openBulkPatch() {
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join(''); const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length);
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk'); bulkFieldSelect.innerHTML = fields.map(field => {
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join(''); const ports = selectableBulkFieldPorts(field);
const firstPort = ports[0]?.port_number || '—';
const lastPort = ports[ports.length - 1]?.port_number || '—';
return `<option value="${field.id}">${field.name} · ${firstPort}${lastPort} · ${ports.length} porte</option>`;
}).join('');
const switches = (locationHardware || []).filter(item =>
String(item.asset_type || '').toLowerCase() === 'netværk' && selectableSwitchPorts(item).length
);
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${selectableSwitchPorts(item).length} oprettede porte</option>`).join('');
populateBulkFieldPorts();
populateBulkSwitchPorts();
populateBulkWanPorts();
const response = await fetch('/api/v1/customers?limit=1000&offset=0'); const response = await fetch('/api/v1/customers?limit=1000&offset=0');
const data = response.ok ? await response.json() : []; const data = response.ok ? await response.json() : [];
const customers = Array.isArray(data) ? data : (data.customers || []); bulkCustomers = Array.isArray(data) ? data : (data.customers || []);
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + customers.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`).join(''); document.getElementById('bulkCustomerSearch').value = '';
const firstField = selectedBulkField(); updateBulkCustomerOptions();
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
updateBulkPreview(); updateBulkPreview();
bulkPatchModal?.show(); bulkPatchModal?.show();
} }
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch); document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview)); bulkFieldSelect?.addEventListener('change', () => { populateBulkFieldPorts(); populateBulkWanPorts(); updateBulkPreview(); });
bulkSwitchSelect?.addEventListener('change', () => { populateBulkSwitchPorts(); updateBulkPreview(); });
document.getElementById('bulkFromPort')?.addEventListener('change', () => {
populateBulkEndPorts(Number(document.getElementById('bulkToPort').value));
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkToPort')?.addEventListener('change', () => {
populateBulkWanPorts();
updateBulkPreview();
});
document.getElementById('bulkSwitchStart')?.addEventListener('change', updateBulkPreview);
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => { document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
const query = event.target.value.trim().toLocaleLowerCase('da'); updateBulkCustomerOptions(event.target.value);
Array.from(bulkCustomerSelect.options).forEach((option, index) => {
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
});
const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden);
if (query && match) bulkCustomerSelect.value = match.value;
}); });
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => { document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
event.preventDefault(); event.preventDefault();
const field = selectedBulkField(); const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value)); const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const fieldPorts = selectableBulkFieldPorts(field);
const switchPorts = selectableSwitchPorts(hardware);
const from = Number(document.getElementById('bulkFromPort').value); const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value); const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value); const switchStart = Number(document.getElementById('bulkSwitchStart').value);
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) { if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to >= fieldPorts.length) {
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.'); alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
return; return;
} }
const ports = field.ports.slice(from - 1, to); const ports = fieldPorts.slice(from, to + 1);
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) { const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length);
alert('Portområdet går ud over switchens registrerede antal porte.'); if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) {
alert('Portområdet går ud over switchens oprettede porte.');
return; return;
} }
const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean); const conflicts = ports.map((port, index) => switchPortConflict(
hardware,
switchDisplayName(hardware),
selectedSwitchPorts[index].port_number,
port.outlet_id || null
)).filter(Boolean);
if (conflicts.length) { if (conflicts.length) {
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`); alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
return; return;
@ -1662,8 +1766,15 @@ document.addEventListener('DOMContentLoaded', function() {
submit.disabled = true; submit.disabled = true;
submit.textContent = 'Gemmer…'; submit.textContent = 'Gemmer…';
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null; const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
const isWan = document.getElementById('bulkIsWan').checked; const wanPortId = document.getElementById('bulkWanPort').value
const results = await Promise.all(ports.map(async (port, index) => { ? Number(document.getElementById('bulkWanPort').value)
: null;
const orderedPorts = [
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) !== wanPortId),
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) === wanPortId)
];
const results = [];
for (const {port, index} of orderedPorts) {
const payload = { const payload = {
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`, outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
customer_id: customerId, customer_id: customerId,
@ -1673,8 +1784,8 @@ document.addEventListener('DOMContentLoaded', function() {
cross_field_port_id: Number(port.id), cross_field_port_id: Number(port.id),
switch_hardware_id: Number(hardware.id), switch_hardware_id: Number(hardware.id),
switch_name: switchDisplayName(hardware), switch_name: switchDisplayName(hardware),
switch_port: String(switchStart + index), switch_port: String(selectedSwitchPorts[index].port_number),
is_wan: isWan, is_wan: Number(port.id) === wanPortId,
status: 'active', status: 'active',
notes: port.outlet_notes || null notes: port.outlet_notes || null
}; };
@ -1684,8 +1795,8 @@ document.addEventListener('DOMContentLoaded', function() {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
return response.ok; results.push(response.ok);
})); }
const failed = results.filter(ok => !ok).length; const failed = results.filter(ok => !ok).length;
if (failed) { if (failed) {
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`); alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);

View File

@ -660,7 +660,34 @@ async def sag_detaljer(request: Request, sag_id: int):
customer = None customer = None
hovedkontakt = None hovedkontakt = None
if sag.get('customer_id'): if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s" customer_query = """
SELECT
c.*,
(vendor_link.vendor_id IS NOT NULL) AS is_vendor,
vendor_link.vendor_id,
vendor_link.vendor_name,
vendor_link.relationship_type AS vendor_relationship_type
FROM customers c
LEFT JOIN LATERAL (
SELECT
cvl.vendor_id,
v.name AS vendor_name,
cvl.relationship_type
FROM customer_vendor_links cvl
JOIN vendors v ON v.id = cvl.vendor_id
WHERE cvl.customer_id = c.id
AND v.is_active IS NOT FALSE
ORDER BY
CASE cvl.relationship_type
WHEN 'supplier' THEN 0
WHEN 'reseller' THEN 1
ELSE 2
END,
cvl.id
LIMIT 1
) vendor_link ON TRUE
WHERE c.id = %s
"""
customer_result = execute_query(customer_query, (sag['customer_id'],)) customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result: if customer_result:
customer = customer_result[0] customer = customer_result[0]

View File

@ -2550,6 +2550,69 @@
background: transparent; background: transparent;
} }
.anydesk-connect-modal .modal-content {
overflow: hidden;
border: 0;
border-radius: 14px;
box-shadow: 0 24px 70px rgba(15, 42, 65, 0.28);
}
.anydesk-connect-modal .modal-header {
color: #fff;
border: 0;
padding: 0.8rem 1rem;
background: linear-gradient(135deg, #0f4c75, #1677a8);
}
.anydesk-connect-modal .modal-header .btn-close {
filter: invert(1);
}
.anydesk-id-entry {
border: 1px solid color-mix(in srgb, var(--border-color) 80%, #1677a8);
border-radius: 9px;
background: color-mix(in srgb, var(--bg-card) 94%, #dff4ff);
}
.anydesk-id-display {
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
}
.anydesk-saved-device {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
min-height: 42px;
padding: 0.35rem 0.45rem 0.35rem 0.65rem;
border-bottom: 1px solid var(--border-color);
}
.anydesk-saved-device:last-child {
border-bottom: 0;
}
.anydesk-saved-device .device-name {
overflow: hidden;
font-size: 0.82rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.anydesk-saved-list {
max-height: 180px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 9px;
}
.anydesk-saved-device .device-id {
color: var(--text-secondary);
font-size: 0.74rem;
}
[data-bs-theme="dark"] .module-title { [data-bs-theme="dark"] .module-title {
color: #e5edf5; color: #e5edf5;
} }
@ -3439,6 +3502,11 @@
</div> </div>
<p class="text-white-50 mb-0 case-header-meta-line"> <p class="text-white-50 mb-0 case-header-meta-line">
<i class="bi bi-building me-1"></i> <a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift kunde">{{ customer.name if customer else 'Ingen kunde valgt' }}</a> <i class="bi bi-building me-1"></i> <a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift kunde">{{ customer.name if customer else 'Ingen kunde valgt' }}</a>
{% if customer and customer.is_vendor %}
<span class="badge rounded-pill bg-warning text-dark ms-1" title="Denne kunde er også registreret som leverandør{% if customer.vendor_name %}: {{ customer.vendor_name }}{% endif %}">
<i class="bi bi-truck me-1"></i>Leverandør
</span>
{% endif %}
<span class="meta-divider">|</span> <span class="meta-divider">|</span>
<i class="bi bi-person me-1"></i> <a href="#" onclick="event.preventDefault(); showKontaktModal()" class="text-white text-decoration-none" title="Se/Rediger Kontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</a> <i class="bi bi-person me-1"></i> <a href="#" onclick="event.preventDefault(); showKontaktModal()" class="text-white text-decoration-none" title="Se/Rediger Kontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</a>
<span class="meta-divider">|</span> <span class="meta-divider">|</span>
@ -4652,60 +4720,72 @@
</div> </div>
<!-- AnyDesk quick-connect modal --> <!-- AnyDesk quick-connect modal -->
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true"> <div class="modal fade anydesk-connect-modal" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered" style="max-width:560px;">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5> <div>
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Forbind med AnyDesk</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body p-3">
<div class="mb-3"> <section class="mb-3">
<label class="form-label">AnyDesk ID</label> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="input-group"> <label class="form-label fw-semibold mb-0">Gemte enheder</label>
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" /> <span class="badge text-bg-light border" id="caseAnyDeskSavedCount"></span>
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
</a>
</div> </div>
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div> <div id="caseAnyDeskSavedIds" class="anydesk-saved-list">
</div>
<div class="mb-3">
<label class="form-label">Gemte IDs på sagen</label>
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
<span class="text-muted small">Indlæser...</span> <span class="text-muted small">Indlæser...</span>
</div> </div>
</div> </section>
<div class="mb-3"> <section class="anydesk-id-entry p-2 mb-2">
<label class="form-label">Relatér til hardware (valgfri)</label> <label class="form-label small fw-semibold mb-1">Andet AnyDesk-ID</label>
<select class="form-select" id="caseAnydeskHardwareSelect"> <div class="input-group">
<option value="">Ingen hardware valgt</option> <span class="input-group-text"><i class="bi bi-hash"></i></span>
</select> <input type="text" inputmode="numeric" autocomplete="off" class="form-control anydesk-id-display" id="caseAnydeskIdInput" placeholder="123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div> <a id="caseAnyDeskConnectBtn" class="btn btn-primary px-4 disabled" href="#" role="button" aria-disabled="true" onclick="registerCaseAnyDeskSession()">
</div> <i class="bi bi-plug-fill me-1"></i>Forbind
</a>
</div>
</section>
<div class="mb-3"> <div id="caseAnyDeskStatus" class="small text-muted mb-2" role="status"></div>
<label class="form-label">Kontakt (valgfri)</label>
<select class="form-select" id="caseAnydeskContactSelect"> <details class="border rounded-3 p-2">
<option value="">Ingen kontakt</option> <summary class="small fw-semibold" style="cursor:pointer;">
{% for contact in contacts %} <i class="bi bi-sliders me-1"></i>Hardware, kontakt og notat
<option value="{{ contact.contact_id }}">{{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}</option> </summary>
{% endfor %} <div class="mt-3">
</select> <label class="form-label">Relatér til hardware</label>
</div> <select class="form-select" id="caseAnydeskHardwareSelect">
<div class="mb-1"> <option value="">Ingen hardware valgt</option>
<label class="form-label">Notat (valgfri)</label> </select>
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea> <div class="form-text">Sagens hardware vises først. Derefter kundens øvrige hardware.</div>
</div> </div>
<div class="small text-muted mt-2">
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync. <div class="mt-3">
</div> <label class="form-label">Kontakt</label>
<select class="form-select" id="caseAnydeskContactSelect">
<option value="">Ingen kontakt</option>
{% for contact in contacts %}
<option value="{{ contact.contact_id }}">{{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="mt-3">
<label class="form-label">Notat</label>
<textarea class="form-control" id="caseAnydeskNoteInput" rows="2" placeholder="Fx installation, fejlsøgning eller brugerhjælp"></textarea>
</div>
</details>
</div> </div>
<div class="modal-footer"> <div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button> <a href="/anydesk/sessions" class="btn btn-link text-decoration-none me-auto">
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button> <i class="bi bi-clock-history me-1"></i>Se historik
</a>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
</div> </div>
</div> </div>
</div> </div>
@ -9613,11 +9693,9 @@
} }
if (subjectInput && !subjectInput.value.trim()) { if (subjectInput && !subjectInput.value.trim()) {
subjectInput.value = escapeHtmlForInput( subjectInput.value = /^re:\s*/i.test(header.emne || '')
/^re:\s*/i.test(header.emne || '') ? (header.emne || `Sag #${caseIds}`)
? (header.emne || `Sag #${caseIds}`) : `Re: ${header.emne || `Sag #${caseIds}`}`;
: `Re: ${header.emne || `Sag #${caseIds}`}`
);
} }
if (bodyInput && !bodyInput.value.trim()) { if (bodyInput && !bodyInput.value.trim()) {
@ -13414,21 +13492,42 @@
return digits || raw; return digits || raw;
} }
function formatAnyDeskIdClient(rawValue) {
const id = normalizeAnyDeskIdClient(rawValue);
return id.replace(/(\d{3})(?=\d)/g, '$1 ').trim();
}
function setCaseAnyDeskStatus(message, type = 'muted') {
const status = document.getElementById('caseAnyDeskStatus');
if (!status) return;
status.className = `small mb-3 text-${type}`;
status.textContent = message || '';
}
function onCaseAnyDeskIdInputChange() { function onCaseAnyDeskIdInputChange() {
const input = document.getElementById('caseAnydeskIdInput'); const input = document.getElementById('caseAnydeskIdInput');
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn'); const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
if (!input || !linkBtn) return; if (!input || !connectBtn) return;
const id = normalizeAnyDeskIdClient(input.value); const id = normalizeAnyDeskIdClient(input.value);
if (!id) { if (!id) {
linkBtn.style.display = 'none'; connectBtn.classList.add('disabled');
linkBtn.setAttribute('href', '#'); connectBtn.setAttribute('aria-disabled', 'true');
connectBtn.setAttribute('href', '#');
setCaseAnyDeskStatus('Indtast et AnyDesk-ID eller vælg en gemt enhed.');
return; return;
} }
input.value = id; input.value = id;
linkBtn.style.display = ''; const valid = /^\d{6,12}$/.test(id);
linkBtn.setAttribute('href', `anydesk:${id}`); const enabled = valid && !caseAnyDeskConnectInFlight;
connectBtn.classList.toggle('disabled', !enabled);
connectBtn.setAttribute('aria-disabled', enabled ? 'false' : 'true');
connectBtn.setAttribute('href', enabled ? `anydesk:${id}` : '#');
setCaseAnyDeskStatus(
valid ? `Klar til at forbinde til ${formatAnyDeskIdClient(id)}` : 'AnyDesk-ID skal være 612 cifre.',
valid ? 'success' : 'danger'
);
} }
function setCaseAnyDeskInputFromSaved(anydeskId) { function setCaseAnyDeskInputFromSaved(anydeskId) {
@ -13440,21 +13539,63 @@
function renderCaseAnyDeskSavedIds(entries) { function renderCaseAnyDeskSavedIds(entries) {
const container = document.getElementById('caseAnyDeskSavedIds'); const container = document.getElementById('caseAnyDeskSavedIds');
const count = document.getElementById('caseAnyDeskSavedCount');
if (!container) return; if (!container) return;
if (count) count.textContent = String(entries?.length || 0);
if (!entries?.length) { if (!entries?.length) {
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>'; container.innerHTML = '<div class="border rounded-3 p-3 text-muted small"><i class="bi bi-info-circle me-1"></i>Ingen gemte enheder endnu. Det første ID gemmes automatisk.</div>';
return; return;
} }
container.innerHTML = entries.map((entry) => { container.innerHTML = entries.map((entry) => {
const primary = entry?.is_primary ? ' border-primary text-primary' : ''; const id = normalizeAnyDeskIdClient(entry?.anydesk_id);
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : ''; const hardwareId = Number(entry?.hardware_asset_id || 0);
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : ''; const sourceLabels = {
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`; case: 'Tidligere på sagen',
contact: 'Kontakt',
hardware: 'Kundens hardware',
history: 'Tidligere brugt'
};
const name = entry?.hardware_label
|| entry?.contact_name
|| sourceLabels[entry?.source]
|| 'AnyDesk-enhed';
const sourceLabel = sourceLabels[entry?.source] || 'Gemt';
const badge = entry?.is_primary
? '<i class="bi bi-star-fill text-warning ms-1" title="Primær"></i>'
: '';
const lastUsed = entry?.last_used_at
? new Date(entry.last_used_at).toLocaleDateString('da-DK')
: '';
const tooltip = [
sourceLabel,
entry?.contact_name ? `Kontakt: ${entry.contact_name}` : '',
`Senest brugt: ${lastUsed}`
].filter(Boolean).join(' · ');
return `
<div class="anydesk-saved-device" title="${escapeHtml(tooltip)}">
<button type="button" class="btn btn-link text-start text-decoration-none p-0 text-body" onclick="setCaseAnyDeskInputFromSaved('${id}')">
<div class="device-name">${escapeHtml(name)}${badge}</div>
<div class="device-id anydesk-id-display">${escapeHtml(formatAnyDeskIdClient(id))}</div>
</button>
<a class="btn btn-sm btn-primary" href="anydesk:${id}" onclick="connectSavedCaseAnyDesk('${id}', ${hardwareId})">
<i class="bi bi-plug-fill me-1"></i>Forbind
</a>
</div>
`;
}).join(''); }).join('');
} }
async function connectSavedCaseAnyDesk(anydeskId, hardwareAssetId) {
setCaseAnyDeskInputFromSaved(anydeskId);
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
if (hardwareSelect && Number(hardwareAssetId || 0) > 0) {
hardwareSelect.value = String(Number(hardwareAssetId));
}
await registerCaseAnyDeskSession();
}
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) { function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
const select = document.getElementById('caseAnydeskHardwareSelect'); const select = document.getElementById('caseAnydeskHardwareSelect');
if (!select) return; if (!select) return;
@ -13519,6 +13660,7 @@
const noteInput = document.getElementById('caseAnydeskNoteInput'); const noteInput = document.getElementById('caseAnydeskNoteInput');
if (noteInput) noteInput.value = ''; if (noteInput) noteInput.value = '';
setCaseAnyDeskStatus('Henter gemte enheder…');
const saved = document.getElementById('caseAnyDeskSavedIds'); const saved = document.getElementById('caseAnyDeskSavedIds');
if (saved) { if (saved) {
@ -13538,8 +13680,14 @@
const connectBtn = document.getElementById('caseAnyDeskConnectBtn'); const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
if (connectBtn) { if (connectBtn) {
connectBtn.disabled = false; connectBtn.classList.add('disabled');
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem'; connectBtn.setAttribute('aria-disabled', 'true');
connectBtn.setAttribute('href', '#');
connectBtn.innerHTML = '<i class="bi bi-plug-fill me-1"></i>Forbind';
}
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
caseAnyDeskModal.show();
} }
try { try {
@ -13549,10 +13697,7 @@
if (saved) { if (saved) {
saved.innerHTML = `<span class="text-danger small">${message}</span>`; saved.innerHTML = `<span class="text-danger small">${message}</span>`;
} }
} setCaseAnyDeskStatus(message, 'danger');
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
caseAnyDeskModal.show();
} }
} }
@ -13567,13 +13712,13 @@
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim(); const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
if (!anydeskId) { if (!anydeskId) {
alert('Udfyld AnyDesk ID'); setCaseAnyDeskStatus('Udfyld et AnyDesk-ID.', 'danger');
return; return;
} }
const customerId = {{ customer.id if customer else 'null' }}; const customerId = {{ customer.id if customer else 'null' }};
if (!customerId) { if (!customerId) {
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session'); setCaseAnyDeskStatus('Sagen har ingen kunde og kan derfor ikke starte en session.', 'danger');
return; return;
} }
@ -13592,9 +13737,11 @@
const connectBtn = document.getElementById('caseAnyDeskConnectBtn'); const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
const openBtn = document.getElementById('caseAnyDeskOpenBtn'); const openBtn = document.getElementById('caseAnyDeskOpenBtn');
if (connectBtn) { if (connectBtn) {
connectBtn.disabled = true; connectBtn.classList.add('disabled');
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...'; connectBtn.setAttribute('aria-disabled', 'true');
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Forbinder…';
} }
setCaseAnyDeskStatus(`Opretter session til ${formatAnyDeskIdClient(anydeskId)}…`);
if (openBtn) { if (openBtn) {
openBtn.disabled = true; openBtn.disabled = true;
} }
@ -13617,22 +13764,17 @@
caseAnyDeskModal.hide(); caseAnyDeskModal.hide();
} }
if (result?.deep_link) {
window.location.href = result.deep_link;
}
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
if (typeof loadComments === 'function') { if (typeof loadComments === 'function') {
loadComments(); loadComments();
} }
} catch (e) { } catch (e) {
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl')); setCaseAnyDeskStatus(`Forbindelsen kunne ikke startes: ${e.message || 'Ukendt fejl'}`, 'danger');
} finally { } finally {
caseAnyDeskConnectInFlight = false; caseAnyDeskConnectInFlight = false;
if (connectBtn) { if (connectBtn) {
connectBtn.disabled = false; connectBtn.innerHTML = '<i class="bi bi-plug-fill me-1"></i>Forbind';
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
} }
onCaseAnyDeskIdInputChange();
if (openBtn) { if (openBtn) {
openBtn.disabled = false; openBtn.disabled = false;
} }
@ -13644,6 +13786,7 @@
window.registerCaseAnyDeskSession = registerCaseAnyDeskSession; window.registerCaseAnyDeskSession = registerCaseAnyDeskSession;
window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange; window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange;
window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved; window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved;
window.connectSavedCaseAnyDesk = connectSavedCaseAnyDesk;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const adjustCaseTopbarOffset = () => { const adjustCaseTopbarOffset = () => {
@ -13674,8 +13817,10 @@
if (connectBtn) { if (connectBtn) {
connectBtn.removeAttribute('onclick'); connectBtn.removeAttribute('onclick');
connectBtn.addEventListener('click', (event) => { connectBtn.addEventListener('click', (event) => {
event.preventDefault(); if (connectBtn.getAttribute('aria-disabled') === 'true') {
event.stopPropagation(); event.preventDefault();
return;
}
registerCaseAnyDeskSession(); registerCaseAnyDeskSession();
}); });
} }
@ -15377,15 +15522,6 @@
.filter(Boolean); .filter(Boolean);
} }
function escapeHtmlForInput(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
let rewriteReviewState = null; let rewriteReviewState = null;
function extractRewriteBody(rawText, context) { function extractRewriteBody(rawText, context) {
@ -15624,7 +15760,7 @@
if (subjectInput && !subjectInput.value.trim()) { if (subjectInput && !subjectInput.value.trim()) {
const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN'; const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN';
subjectInput.value = escapeHtmlForInput(`(Sag:${caseIds}) - "${title}"`); subjectInput.value = `(Sag:${caseIds}) - "${title}"`;
} }
} }
@ -15670,7 +15806,7 @@
const replySubject = /^re:\s*/i.test(originalSubject) const replySubject = /^re:\s*/i.test(originalSubject)
? originalSubject ? originalSubject
: `Re: ${originalSubject || `Sag #${caseIds}`}`; : `Re: ${originalSubject || `Sag #${caseIds}`}`;
subjectInput.value = escapeHtmlForInput(replySubject); subjectInput.value = replySubject;
} }
if (bodyInput && !bodyInput.value.trim()) { if (bodyInput && !bodyInput.value.trim()) {

View File

@ -363,9 +363,10 @@ async def register_manual_session(data: dict):
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"]) @router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
async def list_case_anydesk_ids(sag_id: int): async def list_case_anydesk_ids(sag_id: int):
"""List saved AnyDesk IDs for a case (multi-ID support).""" """List all useful AnyDesk targets for a case, deduplicated by ID."""
try: try:
_ensure_case_exists(sag_id) case_row = _ensure_case_exists(sag_id)
customer_id = case_row.get("customer_id")
rows = execute_query( rows = execute_query(
""" """
@ -400,8 +401,126 @@ async def list_case_anydesk_ids(sag_id: int):
if serial: if serial:
fragments.append(f"SN: {serial}") fragments.append(f"SN: {serial}")
row["hardware_label"] = " - ".join(fragments) if fragments else None row["hardware_label"] = " - ".join(fragments) if fragments else None
row["source"] = "case"
return {"ids": rows} history_rows = execute_query(
"""
SELECT
COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
) AS anydesk_id,
s.hardware_asset_id,
MAX(s.started_at) AS last_used_at,
MAX(
NULLIF(
TRIM(CONCAT_WS(' ', c.first_name, c.last_name)),
''
)
) AS contact_name,
MAX(
NULLIF(
TRIM(CONCAT_WS(' ', h.brand, h.model)),
''
)
) AS hardware_label
FROM anydesk_sessions s
LEFT JOIN contacts c ON c.id = s.contact_id
LEFT JOIN hardware_assets h ON h.id = s.hardware_asset_id
WHERE (
s.sag_id = %s
OR (%s IS NOT NULL AND s.customer_id = %s)
OR s.contact_id IN (
SELECT sk.contact_id
FROM sag_kontakter sk
WHERE sk.sag_id = %s
AND sk.deleted_at IS NULL
)
)
AND COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
) IS NOT NULL
GROUP BY
COALESCE(
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
NULLIF(TRIM(s.device_info->>'to_id'), '')
),
s.hardware_asset_id
ORDER BY MAX(s.started_at) DESC NULLS LAST
""",
(sag_id, customer_id, customer_id, sag_id),
) or []
hardware_rows = execute_query(
"""
SELECT DISTINCT
h.id AS hardware_asset_id,
h.anydesk_id,
NULLIF(
TRIM(CONCAT_WS(
' - ',
NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), ''),
CASE
WHEN NULLIF(TRIM(h.serial_number), '') IS NOT NULL
THEN 'SN: ' || TRIM(h.serial_number)
ELSE NULL
END
)),
''
) AS hardware_label
FROM hardware_assets h
LEFT JOIN sag_hardware sh
ON sh.hardware_id = h.id
AND sh.sag_id = %s
AND sh.deleted_at IS NULL
WHERE h.deleted_at IS NULL
AND NULLIF(TRIM(h.anydesk_id), '') IS NOT NULL
AND (
sh.id IS NOT NULL
OR (%s IS NOT NULL AND h.current_owner_customer_id = %s)
)
ORDER BY hardware_label NULLS LAST
""",
(sag_id, customer_id, customer_id),
) or []
merged = {}
def add_target(target: dict, source: str) -> None:
anydesk_id = _normalize_anydesk_id(target.get("anydesk_id"))
if not anydesk_id:
return
target = dict(target)
target["anydesk_id"] = anydesk_id
target["source"] = source
existing = merged.get(anydesk_id)
if not existing:
merged[anydesk_id] = target
return
# A manually saved case target remains authoritative, but enrich it
# with contact/hardware/history information from other sources.
for key in ("hardware_asset_id", "hardware_label", "contact_name", "last_used_at"):
if not existing.get(key) and target.get(key):
existing[key] = target[key]
for row in rows:
add_target(row, "case")
for row in hardware_rows:
add_target(row, "hardware")
for row in history_rows:
add_target(row, "contact" if row.get("contact_name") else "history")
targets = list(merged.values())
targets.sort(
key=lambda item: (
0 if item.get("is_primary") else 1,
0 if item.get("source") == "case" else 1,
str(item.get("hardware_label") or item.get("contact_name") or item.get("anydesk_id")),
)
)
return {"ids": targets}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -1411,4 +1530,3 @@ async def anydesk_hardware_list():
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@ -159,12 +159,20 @@ class EmailProcessorService:
email_id, email_id,
) )
if require_manual_approval and not has_helpdesk_hint: # Bankruptcy notices are a safety-critical system workflow and run
# automatically, but only act when an explicit CVR matches exactly.
is_bankruptcy = classification == 'bankruptcy'
if require_manual_approval and not has_helpdesk_hint and not is_bankruptcy:
await self._set_awaiting_user_action(email_id, reason='manual_approval_required') await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
stats['awaiting_user_action'] = True stats['awaiting_user_action'] = True
return stats return stats
if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint: if (
(not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD)
and not has_helpdesk_hint
and not is_bankruptcy
):
await self._set_awaiting_user_action(email_id, reason='low_confidence') await self._set_awaiting_user_action(email_id, reason='low_confidence')
stats['awaiting_user_action'] = True stats['awaiting_user_action'] = True
return stats return stats

View File

@ -184,7 +184,8 @@ class EmailWorkflowService:
query = f""" query = f"""
SELECT id, name, cvr_number SELECT id, name, cvr_number
FROM customers FROM customers
WHERE cvr_number IN ({format_strings}) WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ({format_strings})
AND is_active = true
""" """
matching_customers = execute_query(query, tuple(unique_cvrs)) matching_customers = execute_query(query, tuple(unique_cvrs))
@ -200,28 +201,112 @@ class EmailWorkflowService:
) )
return {'status': 'completed', 'action': 'marked_processed_no_match'} return {'status': 'completed', 'action': 'marked_processed_no_match'}
logger.warning(f"⚠️ FOUND BANKRUPTCY MATCHES: {[c['name'] for c in matching_customers]}") logger.warning("⚠️ FOUND BANKRUPTCY MATCHES: %s", [c['name'] for c in matching_customers])
email_id = int(email_data["id"])
subject = str(email_data.get("subject") or "Konkursmeddelelse").strip()
body_text = self._extract_primary_email_body(email_data)
created_cases = []
alert_ids = []
for customer in matching_customers:
customer_id = int(customer["id"])
existing_case = execute_query(
"""
SELECT s.id, s.titel
FROM sag_sager s
JOIN sag_emails se ON se.sag_id = s.id
WHERE se.email_id = %s
AND s.customer_id = %s
AND s.deleted_at IS NULL
ORDER BY s.id ASC LIMIT 1
""",
(email_id, customer_id),
)
if existing_case:
case = existing_case[0]
else:
case_rows = execute_query(
"""
INSERT INTO sag_sager (
titel, beskrivelse, template_key, status, customer_id, created_by_user_id
)
VALUES (%s, %s, 'ticket', 'åben', %s, 1)
RETURNING id, titel, customer_id
""",
(
f"KONKURSVARSEL {customer['name']}: {subject}"[:255],
(
"Automatisk oprettet fra eksakt CVR-match i konkursmail.\n"
f"Kunde: {customer['name']}\n"
f"CVR: {customer['cvr_number']}\n"
f"Email-ID: {email_id}\n\n{body_text}"
),
customer_id,
),
)
if not case_rows:
raise ValueError(f"Kunne ikke oprette konkurssag for kunde {customer_id}")
case = case_rows[0]
self._add_helpdesk_comment(int(case["id"]), email_data)
self._link_email_to_sag(int(case["id"]), email_id)
created_cases.append(int(case["id"]))
alert_marker = f"Konkursmail Email-ID: {email_id}"
existing_alert = execute_query(
"""
SELECT id FROM alert_notes
WHERE entity_type = 'customer'
AND entity_id = %s
AND active = true
AND message LIKE %s
ORDER BY id ASC LIMIT 1
""",
(customer_id, f"%{alert_marker}%"),
)
if existing_alert:
alert_id = int(existing_alert[0]["id"])
else:
alert_rows = execute_query(
"""
INSERT INTO alert_notes (
entity_type, entity_id, title, message, severity,
requires_acknowledgement, active, created_by_user_id
)
VALUES ('customer', %s, %s, %s, 'critical', true, true, 1)
RETURNING id
""",
(
customer_id,
"KONKURSVARSEL kræver handling",
(
f"{alert_marker}\n"
f"Der er modtaget en konkursmeddelelse med eksakt CVR-match.\n"
f"CVR: {customer['cvr_number']}\n"
f"Sag: #{case['id']} {case['titel']}"
),
),
)
alert_id = int(alert_rows[0]["id"])
alert_ids.append(alert_id)
# Link to the first customer found (limitation of 1:1 schema)
first_match = matching_customers[0] first_match = matching_customers[0]
execute_update( execute_update(
"""UPDATE email_messages """
SET customer_id = %s, status = 'processed', folder = 'Processed', UPDATE email_messages
processed_at = CURRENT_TIMESTAMP, auto_processed = true SET customer_id = %s, linked_case_id = %s,
WHERE id = %s""", status = 'processed', folder = 'Processed',
(first_match['id'], email_data['id']) processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s
""",
(first_match["id"], created_cases[0], email_id),
) )
logger.info(f"🔗 Linked bankruptcy email {email_data['id']} to customer {first_match['name']} ({first_match['id']}) and marked as processed")
if len(matching_customers) > 1:
logger.warning(f"❗ Email contained multiple customer matches! Only linked to first one.")
return { return {
'status': 'completed', "status": "completed",
'action': 'linked_customer', "action": "created_bankruptcy_cases_and_alerts",
'customer_name': first_match['name'] "customer_ids": [int(c["id"]) for c in matching_customers],
"sag_ids": created_cases,
"alert_note_ids": alert_ids,
} }
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]: def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:

View File

@ -55,6 +55,61 @@ class VendorCustomerLinkCreate(BaseModel):
relationship_type: Optional[str] = "supplier" relationship_type: Optional[str] = "supplier"
class EmailDomainCreate(BaseModel):
domain: str
def _normalize_email_domain(value: str) -> str:
domain = str(value or "").strip().lower()
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
domain = domain.split("/", 1)[0].strip(". ")
if not domain or "." not in domain or "@" in domain:
raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne")
return domain
@router.get("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
async def list_vendor_email_domains(vendor_id: int):
return execute_query(
"SELECT domain, created_at FROM vendor_email_domains WHERE vendor_id = %s ORDER BY domain",
(vendor_id,),
) or []
@router.post("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
async def add_vendor_email_domain(vendor_id: int, payload: EmailDomainCreate):
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
domain = _normalize_email_domain(payload.domain)
existing = execute_query_single(
"SELECT vendor_id FROM vendor_email_domains WHERE domain = %s",
(domain,),
)
if existing and int(existing["vendor_id"]) != vendor_id:
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden leverandør")
return execute_query_single(
"""
INSERT INTO vendor_email_domains (domain, vendor_id)
VALUES (%s, %s)
ON CONFLICT (domain) DO UPDATE SET vendor_id = EXCLUDED.vendor_id
RETURNING domain, vendor_id, created_at
""",
(domain, vendor_id),
)
@router.delete("/vendors/{vendor_id}/email-domains/{domain}", tags=["Vendors"])
async def delete_vendor_email_domain(vendor_id: int, domain: str):
normalized = _normalize_email_domain(domain)
deleted = execute_update(
"DELETE FROM vendor_email_domains WHERE vendor_id = %s AND domain = %s",
(vendor_id, normalized),
)
if not deleted:
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
return {"success": True}
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"]) @router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
async def list_vendors( async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"), search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),

View File

@ -184,6 +184,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<h5 class="mb-2 fw-bold">Emaildomæner</h5>
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
<div class="input-group input-group-sm mb-3">
<input id="vendorEmailDomainInput" class="form-control" placeholder="fx cloudfactorygroup.com">
<button class="btn btn-primary" onclick="addVendorEmailDomain()">Tilføj</button>
</div>
<div id="vendorEmailDomains" class="d-flex flex-wrap gap-2"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -372,6 +382,7 @@ async function loadVendor() {
} }
const vendor = await response.json(); const vendor = await response.json();
displayVendor(vendor); displayVendor(vendor);
await loadVendorEmailDomains();
await loadVendorCustomers(); await loadVendorCustomers();
} catch (error) { } catch (error) {
console.error('Error loading vendor:', error); console.error('Error loading vendor:', error);
@ -379,6 +390,52 @@ async function loadVendor() {
} }
} }
async function loadVendorEmailDomains() {
const host = document.getElementById('vendorEmailDomains');
if (!host) return;
try {
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains`);
if (!response.ok) throw new Error('Kunne ikke hente domæner');
const rows = await response.json();
host.innerHTML = rows.length ? rows.map(row => `
<span class="badge bg-light text-dark border p-2">
${escapeHtml(row.domain)}
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteVendorEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
</span>
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
} catch (error) {
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
}
}
async function addVendorEmailDomain() {
const input = document.getElementById('vendorEmailDomainInput');
const domain = input.value.trim();
if (!domain) return;
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke tilføjes');
return;
}
input.value = '';
await loadVendorEmailDomains();
}
async function deleteVendorEmailDomain(encodedDomain) {
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains/${encodedDomain}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
alert(error.detail || 'Domænet kunne ikke fjernes');
return;
}
await loadVendorEmailDomains();
}
async function loadVendorCustomers() { async function loadVendorCustomers() {
const listEl = document.getElementById('vendorCustomersList'); const listEl = document.getElementById('vendorCustomersList');
const emptyEl = document.getElementById('vendorCustomersEmpty'); const emptyEl = document.getElementById('vendorCustomersEmpty');

View File

@ -0,0 +1,26 @@
-- Multiple exact sender domains per vendor.
CREATE TABLE IF NOT EXISTS vendor_email_domains (
domain TEXT PRIMARY KEY,
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_vendor_email_domains_vendor_id
ON vendor_email_domains(vendor_id);
INSERT INTO vendor_email_domains (domain, vendor_id)
SELECT LOWER(TRIM(domain)), id
FROM vendors
WHERE NULLIF(TRIM(domain), '') IS NOT NULL
ON CONFLICT (domain) DO NOTHING;
-- Also promote the legacy single customer domain into the existing
-- multi-domain mapping table.
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
SELECT LOWER(TRIM(email_domain)), id, 'legacy_customer_domain'
FROM customers
WHERE NULLIF(TRIM(email_domain), '') IS NOT NULL
ON CONFLICT (domain) DO NOTHING;
COMMENT ON TABLE vendor_email_domains IS
'Exact trusted sender domains belonging to vendors; no fuzzy matching.';

View File

@ -0,0 +1,9 @@
INSERT INTO groups (name, description)
SELECT
'Bogholderi',
'Kundehenvendelser og opgaver vedrørende bogholderi og økonomi'
WHERE NOT EXISTS (
SELECT 1
FROM groups
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
);

View File

@ -0,0 +1,66 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.services import email_workflow_service as workflow_module
def test_bankruptcy_exact_cvr_creates_case_alert_and_links_email(monkeypatch):
queries = []
updates = []
def fake_query(sql, params=()):
queries.append((sql, params))
if "FROM customers" in sql:
return [{"id": 42, "name": "Testkunde ApS", "cvr_number": "12345678"}]
if "FROM sag_sager s" in sql:
return []
if "INSERT INTO sag_sager" in sql:
return [{"id": 700, "titel": "KONKURSVARSEL", "customer_id": 42}]
if "SELECT id FROM alert_notes" in sql:
return []
if "INSERT INTO alert_notes" in sql:
return [{"id": 900}]
return []
monkeypatch.setattr(workflow_module, "execute_query", fake_query)
monkeypatch.setattr(
workflow_module,
"execute_update",
lambda sql, params=(): updates.append((sql, params)) or 1,
)
service = workflow_module.EmailWorkflowService()
monkeypatch.setattr(service, "_add_helpdesk_comment", lambda *args: None)
monkeypatch.setattr(service, "_link_email_to_sag", lambda *args: None)
result = asyncio.run(service._handle_bankruptcy_analysis({
"id": 55,
"subject": "Konkursdekret",
"body_text": "Vedrørende CVR-nr.: 12345678",
"body_html": "",
"sender_email": "statstidende@example.dk",
}))
assert result["action"] == "created_bankruptcy_cases_and_alerts"
assert result["customer_ids"] == [42]
assert result["sag_ids"] == [700]
assert result["alert_note_ids"] == [900]
assert any("linked_case_id" in sql for sql, _ in updates)
assert any("INSERT INTO alert_notes" in sql for sql, _ in queries)
def test_bankruptcy_without_explicit_cvr_does_nothing(monkeypatch):
monkeypatch.setattr(
workflow_module,
"execute_query",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("DB må ikke kaldes")),
)
service = workflow_module.EmailWorkflowService()
result = asyncio.run(service._handle_bankruptcy_analysis({
"id": 56,
"subject": "Mulig konkurs",
"body_text": "Ingen CVR-oplysning i teksten",
"body_html": "",
}))
assert result == {"status": "skipped", "reason": "no_cvr_found"}