From 8e8616c835b083b1775b5038a2fed04eb51787c4 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Apr 2026 09:34:26 +0200 Subject: [PATCH] feat: Enhance vendor and customer linking functionality - Added endpoints to link and unlink customers to vendors, including validation for relationship types. - Implemented a UI for managing linked customers in the vendor detail view. - Introduced a search feature for customers when linking to vendors. - Updated database schema to support customer-vendor relationships with necessary constraints and indices. - Added migration scripts for new tables and fields related to supplier invoices and customer-vendor links. - Modified bottom bar visibility in the frontend for improved user experience. --- app/billing/backend/supplier_invoices.py | 833 ++++++++++++++++-- app/customers/backend/router.py | 181 +++- app/customers/frontend/customer_detail.html | 162 ++++ app/emails/backend/router.py | 493 ++++++++++- app/emails/frontend/emails.html | 598 +++++++++++-- app/modules/bottom_bar/backend/service.py | 12 +- app/modules/sag/backend/router.py | 19 + app/modules/sag/templates/detail.html | 442 ++++++++++ app/modules/sag/templates/index.html | 93 +- app/shared/frontend/base.html | 24 +- app/timetracking/backend/router.py | 146 ++- app/vendors/backend/router.py | 116 ++- app/vendors/frontend/vendor_detail.html | 164 ++++ migrations/166_bottom_bar_module.sql | 2 +- .../170_email_domain_customer_mappings.sql | 33 + .../171_supplier_invoice_v2_lifecycle.sql | 84 ++ migrations/172_sag_supplier_flow_type.sql | 39 + .../173_supplier_invoice_flow_metadata.sql | 68 ++ .../174_supplier_invoice_line_handling.sql | 88 ++ .../175_supplier_invoice_attachments.sql | 48 + migrations/176_supplier_invoice_relations.sql | 42 + migrations/177_supplier_invoice_reminders.sql | 85 ++ migrations/178_customer_vendor_links.sql | 81 ++ 23 files changed, 3645 insertions(+), 208 deletions(-) create mode 100644 migrations/170_email_domain_customer_mappings.sql create mode 100644 migrations/171_supplier_invoice_v2_lifecycle.sql create mode 100644 migrations/172_sag_supplier_flow_type.sql create mode 100644 migrations/173_supplier_invoice_flow_metadata.sql create mode 100644 migrations/174_supplier_invoice_line_handling.sql create mode 100644 migrations/175_supplier_invoice_attachments.sql create mode 100644 migrations/176_supplier_invoice_relations.sql create mode 100644 migrations/177_supplier_invoice_reminders.sql create mode 100644 migrations/178_customer_vendor_links.sql diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index 4f19e91..aa76d79 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -18,12 +18,180 @@ from app.services.invoice2data_service import get_invoice2data_service import logging import os import re +import json logger = logging.getLogger(__name__) router = APIRouter() _PURCHASE_CASE_TYPE = "indkøb" +SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist") + + +def _v2_status_from_legacy(legacy_status: Optional[str]) -> str: + value = str(legacy_status or "").strip().lower() + if value in {"approved", "sent_to_economic"}: + return "godkendt" + if value == "paid": + return "betalt" + if value in {"cancelled", "credited", "rejected"}: + return "afvist" + return "modtaget" + + +def _legacy_status_from_v2(v2_status: str) -> str: + mapping = { + "modtaget": "pending", + "godkendt": "approved", + "betalt": "paid", + "afvist": "cancelled", + } + return mapping.get(v2_status, "pending") + + +def _normalize_v2_status(value: Optional[str]) -> str: + v2_status = str(value or "").strip().lower() + if not v2_status: + return "modtaget" + if v2_status not in SUPPLIER_STATUS_V2: + raise HTTPException( + status_code=400, + detail=f"Ugyldig v2 status '{v2_status}'. Tilladte: {', '.join(SUPPLIER_STATUS_V2)}", + ) + return v2_status + + +def _record_supplier_invoice_event( + invoice_id: int, + event_type: str, + from_status: Optional[str], + to_status: Optional[str], + payload: Optional[Dict] = None, +) -> None: + try: + execute_update( + """ + INSERT INTO supplier_invoice_events ( + supplier_invoice_id, + event_type, + from_status, + to_status, + payload_json + ) + VALUES (%s, %s, %s, %s, %s::jsonb) + """, + ( + invoice_id, + event_type, + from_status, + to_status, + json.dumps(payload or {}), + ), + ) + except Exception as event_error: + # Keep core invoice transitions operational even before migration rollout. + logger.warning("⚠️ Could not persist supplier invoice event for %s: %s", invoice_id, event_error) + + +def _get_invoice_status_v2(invoice_row: Dict) -> str: + explicit_v2 = invoice_row.get("workflow_status_v2") + if explicit_v2: + return _normalize_v2_status(explicit_v2) + return _v2_status_from_legacy(invoice_row.get("status")) + + +def _transition_invoice_status_v2( + invoice_id: int, + new_status_v2: str, + actor: Optional[str] = None, + reason: Optional[str] = None, +) -> Dict: + invoice = execute_query_single( + """ + SELECT id, invoice_number, status, workflow_status_v2 + FROM supplier_invoices + WHERE id = %s + """, + (invoice_id,), + ) + if not invoice: + raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") + + from_status_v2 = _get_invoice_status_v2(invoice) + to_status_v2 = _normalize_v2_status(new_status_v2) + + if from_status_v2 == to_status_v2: + return { + "invoice_id": invoice_id, + "invoice_number": invoice.get("invoice_number"), + "from_status": from_status_v2, + "to_status": to_status_v2, + "changed": False, + } + + allowed_transitions = { + "modtaget": {"godkendt", "afvist"}, + "godkendt": {"betalt", "afvist"}, + "betalt": set(), + "afvist": set(), + } + if to_status_v2 not in allowed_transitions.get(from_status_v2, set()): + raise HTTPException( + status_code=400, + detail=( + f"Ugyldig statusovergang: {from_status_v2} -> {to_status_v2}. " + "Tilladte overgange følger workflow: modtaget -> godkendt -> betalt eller afvist." + ), + ) + + legacy_status = _legacy_status_from_v2(to_status_v2) + approved_by = actor if to_status_v2 == "godkendt" else None + rejected_by = actor if to_status_v2 == "afvist" else None + + execute_update( + """ + UPDATE supplier_invoices + SET status = %s, + workflow_status_v2 = %s, + approved_by = CASE WHEN %s IS NULL THEN approved_by ELSE %s END, + approved_at = CASE WHEN %s IS NULL THEN approved_at ELSE CURRENT_TIMESTAMP END, + rejected_by = CASE WHEN %s IS NULL THEN rejected_by ELSE %s END, + rejected_at = CASE WHEN %s IS NULL THEN rejected_at ELSE CURRENT_TIMESTAMP END, + rejection_reason = CASE WHEN %s IS NULL THEN rejection_reason ELSE %s END, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + legacy_status, + to_status_v2, + approved_by, + approved_by, + approved_by, + rejected_by, + rejected_by, + rejected_by, + reason, + reason, + invoice_id, + ), + ) + + _record_supplier_invoice_event( + invoice_id=invoice_id, + event_type="status_transition", + from_status=from_status_v2, + to_status=to_status_v2, + payload={"actor": actor, "reason": reason}, + ) + + return { + "invoice_id": invoice_id, + "invoice_number": invoice.get("invoice_number"), + "from_status": from_status_v2, + "to_status": to_status_v2, + "changed": True, + } + def _resolve_procurement_customer_id() -> int: """Find customer used for internally-owned procurement cases.""" @@ -94,14 +262,27 @@ def _ensure_case_for_supplier_invoice( f"Fil ID: {file_id or '-'}" ) - case_row = execute_query_single( - """ - INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id) - VALUES (%s, %s, %s, %s, %s) - RETURNING id - """, - (case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id) - ) + try: + case_row = execute_query_single( + """ + INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id, created_by_user_id) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, + (case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id, 1) + ) + except Exception as insert_error: + # Some environments use sag_sager without a `type` column. + if 'column "type"' not in str(insert_error): + raise + case_row = execute_query_single( + """ + INSERT INTO sag_sager (titel, beskrivelse, status, customer_id, created_by_user_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + (case_title, case_description, "åben", customer_id, 1) + ) if not case_row: return None @@ -138,6 +319,196 @@ def _ensure_case_for_supplier_invoice( return sag_id +def _to_decimal(value, default: Decimal = Decimal("0")) -> Decimal: + if value is None or value == "": + return default + try: + return Decimal(str(value)) + except Exception: + return default + + +def _extract_lines_from_llm_payload(extraction_row: Optional[Dict]) -> List[Dict]: + if not extraction_row: + return [] + + raw_payload = extraction_row.get("llm_response_json") + if not raw_payload: + return [] + + try: + payload = raw_payload if isinstance(raw_payload, dict) else json.loads(str(raw_payload)) + except Exception: + return [] + + lines = payload.get("lines") + if not isinstance(lines, list): + return [] + + normalized = [] + for idx, line in enumerate(lines, start=1): + if not isinstance(line, dict): + continue + normalized.append( + { + "line_number": line.get("line_number") or idx, + "sku": line.get("sku") or line.get("item_number") or line.get("article_number"), + "description": line.get("description") or line.get("name") or "", + "quantity": line.get("quantity") or 1, + "unit_price": line.get("unit_price") or 0, + "line_total": line.get("line_total") or line.get("amount") or 0, + "vat_rate": line.get("vat_rate") or 25.0, + "vat_amount": line.get("vat_amount") or 0, + } + ) + return normalized + + +def _load_extraction_lines(extraction_row: Optional[Dict]) -> List[Dict]: + if not extraction_row: + return [] + extraction_id = extraction_row.get("extraction_id") + if not extraction_id: + return [] + + db_lines = execute_query( + """ + SELECT line_number, sku, description, quantity, unit_price, line_total, vat_rate, vat_amount + FROM extraction_lines + WHERE extraction_id = %s + ORDER BY line_number + """, + (extraction_id,), + ) or [] + + if db_lines: + return db_lines + + return _extract_lines_from_llm_payload(extraction_row) + + +def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]: + sku_value = str(sku or "").strip() + desc_value = str(description or "").strip() + + if vendor_id and sku_value: + row = execute_query_single( + """ + SELECT id + FROM products + WHERE deleted_at IS NULL + AND supplier_id = %s + AND ( + supplier_sku = %s + OR sku_internal = %s + OR manufacturer_sku = %s + ) + ORDER BY id + LIMIT 1 + """, + (vendor_id, sku_value, sku_value, sku_value), + ) + if row: + return int(row["id"]) + + if vendor_id and desc_value: + row = execute_query_single( + """ + SELECT id + FROM products + WHERE deleted_at IS NULL + AND supplier_id = %s + AND LOWER(TRIM(name)) = LOWER(TRIM(%s)) + ORDER BY id + LIMIT 1 + """, + (vendor_id, desc_value), + ) + if row: + return int(row["id"]) + + if desc_value: + row = execute_query_single( + """ + SELECT id + FROM products + WHERE deleted_at IS NULL + AND LOWER(TRIM(name)) = LOWER(TRIM(%s)) + ORDER BY id + LIMIT 1 + """, + (desc_value,), + ) + if row: + return int(row["id"]) + + return None + + +def _ensure_product_for_supplier_line( + vendor_id: Optional[int], + vendor_name: Optional[str], + description: Optional[str], + sku: Optional[str], + unit_price, + currency: Optional[str], + vat_rate, +) -> Optional[int]: + desc_value = str(description or "").strip() + sku_value = str(sku or "").strip() or None + if not desc_value and not sku_value: + return None + + try: + existing_id = _find_existing_product_id(vendor_id, desc_value, sku_value) + if existing_id: + return existing_id + + name = desc_value or f"Vare {sku_value}" + created_id = execute_insert( + """ + INSERT INTO products ( + name, + type, + status, + sku_internal, + supplier_id, + supplier_name, + supplier_sku, + supplier_price, + supplier_currency, + cost_price, + vat_rate, + billable, + created_by + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + name, + "product", + "active", + sku_value, + vendor_id, + vendor_name, + sku_value, + _to_decimal(unit_price), + (currency or "DKK"), + _to_decimal(unit_price), + _to_decimal(vat_rate, Decimal("25.00")), + True, + 1, + ), + ) + logger.info("✅ Auto-created product %s for vendor %s", created_id, vendor_id) + return int(created_id) + except Exception as product_error: + # Keep invoice creation alive even if product auto-create fails. + logger.warning("⚠️ Could not auto-create/find product for supplier line '%s': %s", desc_value, product_error) + return _find_existing_product_id(vendor_id, desc_value, sku_value) + + def _smart_extract_lines(text: str) -> List[Dict]: """ Universal line extraction using pdfplumber layout mode. @@ -278,6 +649,7 @@ def _smart_extract_lines(text: str) -> List[Dict]: async def list_supplier_invoices( status: Optional[str] = None, vendor_id: Optional[int] = None, + sag_id: Optional[int] = None, overdue_only: bool = False ): """ @@ -298,7 +670,16 @@ async def list_supplier_invoices( WHEN si.paid_date IS NOT NULL THEN 'paid' WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue' ELSE si.status - END as computed_status + END as computed_status, + COALESCE( + si.workflow_status_v2, + CASE + WHEN si.status IN ('approved', 'sent_to_economic') THEN 'godkendt' + WHEN si.status = 'paid' THEN 'betalt' + WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN 'afvist' + ELSE 'modtaget' + END + ) as status_v2 FROM supplier_invoices si LEFT JOIN vendors v ON si.vendor_id = v.id WHERE 1=1 @@ -306,12 +687,31 @@ async def list_supplier_invoices( params = [] if status: - query += " AND si.status = %s" - params.append(status) + normalized_filter = str(status).strip().lower() + if normalized_filter in SUPPLIER_STATUS_V2: + query += """ + AND COALESCE( + si.workflow_status_v2, + CASE + WHEN si.status IN ('approved', 'sent_to_economic') THEN 'godkendt' + WHEN si.status = 'paid' THEN 'betalt' + WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN 'afvist' + ELSE 'modtaget' + END + ) = %s + """ + params.append(normalized_filter) + else: + query += " AND si.status = %s" + params.append(status) if vendor_id: query += " AND si.vendor_id = %s" params.append(vendor_id) + + if sag_id: + query += " AND si.sag_id = %s" + params.append(sag_id) if overdue_only: query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL" @@ -1089,13 +1489,8 @@ async def create_invoice_from_extraction(file_id: int): if existing: raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction") - # Get extraction lines - lines = execute_query( - """SELECT * FROM extraction_lines - WHERE extraction_id = %s - ORDER BY line_number""", - (extraction_data['extraction_id'],) - ) + # Get extracted lines from DB, fallback to LLM payload lines. + lines = _load_extraction_lines(extraction_data) # Parse LLM response JSON if it's a string import json @@ -1135,8 +1530,8 @@ async def create_invoice_from_extraction(file_id: int): invoice_id = execute_insert( """INSERT INTO supplier_invoices ( vendor_id, invoice_number, invoice_date, due_date, - total_amount, currency, status, extraction_id, notes, invoice_type - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + total_amount, currency, status, workflow_status_v2, extraction_id, notes, invoice_type + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", ( extraction_data['vendor_matched_id'], @@ -1145,13 +1540,22 @@ async def create_invoice_from_extraction(file_id: int): due_date, extraction_data['total_amount'], extraction_data['currency'], - 'credited' if invoice_type == 'credit_note' else 'unpaid', + 'cancelled' if invoice_type == 'credit_note' else 'pending', + 'afvist' if invoice_type == 'credit_note' else 'modtaget', extraction_data['extraction_id'], f"Oprettet fra AI extraction (file_id: {file_id})", invoice_type ) ) + _record_supplier_invoice_event( + invoice_id=invoice_id, + event_type="invoice_created", + from_status=None, + to_status='afvist' if invoice_type == 'credit_note' else 'modtaget', + payload={"source": "from_extraction", "file_id": file_id, "invoice_type": invoice_type}, + ) + sag_id = _ensure_case_for_supplier_invoice( invoice_id=invoice_id, invoice_number=invoice_number, @@ -1164,19 +1568,40 @@ async def create_invoice_from_extraction(file_id: int): # Create invoice lines if lines: for line in lines: + quantity = _to_decimal(line.get('quantity'), Decimal('1')) + unit_price = _to_decimal(line.get('unit_price')) + line_total = _to_decimal(line.get('line_total')) + if line_total <= 0: + line_total = quantity * unit_price + vat_rate = _to_decimal(line.get('vat_rate'), Decimal('25.00')) + vat_amount = _to_decimal(line.get('vat_amount')) + sku = line.get('sku') or line.get('item_number') + product_id = _ensure_product_for_supplier_line( + vendor_id=extraction_data.get('vendor_matched_id'), + vendor_name=extraction.get('vendor_name'), + description=line.get('description'), + sku=sku, + unit_price=unit_price, + currency=extraction_data.get('currency'), + vat_rate=vat_rate, + ) + execute_update( """INSERT INTO supplier_invoice_lines ( - supplier_invoice_id, description, quantity, unit_price, - line_total, vat_rate, vat_amount - ) VALUES (%s, %s, %s, %s, %s, %s, %s)""", + supplier_invoice_id, line_number, description, quantity, unit_price, + line_total, vat_rate, vat_amount, product_id, sku + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", ( invoice_id, + line.get('line_number'), line['description'], - line.get('quantity') or 1, - line.get('unit_price') or 0, - line.get('line_total') or 0, - line.get('vat_rate') or 25.00, # Default 25% Danish VAT if NULL - line.get('vat_amount') + quantity, + unit_price, + line_total, + vat_rate, + vat_amount, + product_id, + sku, ) ) @@ -1598,8 +2023,9 @@ async def create_supplier_invoice(data: Dict): """INSERT INTO supplier_invoices (invoice_number, vendor_id, vendor_name, invoice_date, due_date, total_amount, vat_amount, net_amount, currency, description, notes, - status, created_by, invoice_type) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + status, workflow_status_v2, created_by, invoice_type) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id""", ( data['invoice_number'], data['vendor_id'], @@ -1612,35 +2038,64 @@ async def create_supplier_invoice(data: Dict): data.get('currency', 'DKK'), data.get('description'), data.get('notes'), - 'credited' if invoice_type == 'credit_note' else 'pending', + 'cancelled' if invoice_type == 'credit_note' else 'pending', + 'afvist' if invoice_type == 'credit_note' else 'modtaget', data.get('created_by'), invoice_type ) ) + + _record_supplier_invoice_event( + invoice_id=invoice_id, + event_type="invoice_created", + from_status=None, + to_status='afvist' if invoice_type == 'credit_note' else 'modtaget', + payload={"source": "manual_create", "invoice_type": invoice_type}, + ) # Insert lines if provided if data.get('lines'): for idx, line in enumerate(data['lines'], start=1): # Map vat_code: I52 for reverse charge, I25 for standard vat_code = line.get('vat_code', 'I25') + quantity = _to_decimal(line.get('quantity'), Decimal('1')) + unit_price = _to_decimal(line.get('unit_price')) + line_total = _to_decimal(line.get('line_total')) + if line_total <= 0: + line_total = quantity * unit_price + vat_rate = _to_decimal(line.get('vat_rate'), Decimal('25.00')) + vat_amount = _to_decimal(line.get('vat_amount')) + sku = line.get('sku') + product_id = line.get('product_id') + if not product_id: + product_id = _ensure_product_for_supplier_line( + vendor_id=data.get('vendor_id'), + vendor_name=data.get('vendor_name'), + description=line.get('description'), + sku=sku, + unit_price=unit_price, + currency=data.get('currency', 'DKK'), + vat_rate=vat_rate, + ) - execute_insert( + execute_update( """INSERT INTO supplier_invoice_lines (supplier_invoice_id, line_number, description, quantity, unit_price, - line_total, vat_code, vat_rate, vat_amount, contra_account, sku) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + line_total, vat_code, vat_rate, vat_amount, contra_account, product_id, sku) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", ( invoice_id, line.get('line_number', idx), line.get('description'), - line.get('quantity', 1), - line.get('unit_price', 0), - line.get('line_total', 0), + quantity, + unit_price, + line_total, vat_code, - line.get('vat_rate', 25.00), - line.get('vat_amount', 0), + vat_rate, + vat_amount, line.get('contra_account', '5810'), - line.get('sku') + product_id, + sku, ) ) @@ -1829,33 +2284,44 @@ class ApproveRequest(BaseModel): approved_by: str +class RejectRequest(BaseModel): + rejected_by: str + reason: Optional[str] = None + + class MarkPaidRequest(BaseModel): paid_date: Optional[date] = None + amount: Optional[Decimal] = None + payment_method: Optional[str] = None + payment_reference: Optional[str] = None + notes: Optional[str] = None + paid_by: Optional[str] = None @router.post("/supplier-invoices/{invoice_id}/approve") async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest): - """Approve supplier invoice for payment""" + """Approve supplier invoice for payment (v2 status flow).""" try: - invoice = execute_query_single( - "SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s", - (invoice_id,)) - - if not invoice: - raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") - - if invoice['status'] != 'pending': - raise HTTPException(status_code=400, detail=f"Faktura har allerede status '{invoice['status']}' - kan kun godkende fakturaer med status 'pending'") - - execute_update( - """UPDATE supplier_invoices - SET status = 'approved', approved_by = %s, approved_at = CURRENT_TIMESTAMP - WHERE id = %s""", - (request.approved_by, invoice_id) + transition = _transition_invoice_status_v2( + invoice_id=invoice_id, + new_status_v2="godkendt", + actor=request.approved_by, ) - - logger.info(f"✅ Approved supplier invoice {invoice['invoice_number']} by {request.approved_by}") - - return {"success": True, "invoice_id": invoice_id, "approved_by": request.approved_by} + + logger.info( + "✅ Approved supplier invoice %s by %s (%s -> %s)", + transition.get("invoice_number"), + request.approved_by, + transition.get("from_status"), + transition.get("to_status"), + ) + + return { + "success": True, + "invoice_id": invoice_id, + "approved_by": request.approved_by, + "status_v2": transition.get("to_status"), + "changed": transition.get("changed", False), + } except HTTPException: raise @@ -1864,49 +2330,243 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/supplier-invoices/{invoice_id}/mark-paid") -async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest): - """Mark supplier invoice as paid.""" +@router.post("/supplier-invoices/{invoice_id}/reject") +async def reject_supplier_invoice(invoice_id: int, request: RejectRequest): + """Reject supplier invoice in v2 workflow.""" + try: + transition = _transition_invoice_status_v2( + invoice_id=invoice_id, + new_status_v2="afvist", + actor=request.rejected_by, + reason=request.reason, + ) + + logger.info( + "❌ Rejected supplier invoice %s by %s (%s -> %s)", + transition.get("invoice_number"), + request.rejected_by, + transition.get("from_status"), + transition.get("to_status"), + ) + + return { + "success": True, + "invoice_id": invoice_id, + "rejected_by": request.rejected_by, + "status_v2": transition.get("to_status"), + "changed": transition.get("changed", False), + } + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Failed to reject invoice {invoice_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/supplier-invoices/{invoice_id}/payments") +async def get_supplier_invoice_payments(invoice_id: int): + """Return all registered payments and remaining balance for a supplier invoice.""" try: invoice = execute_query_single( - "SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s", + "SELECT id, total_amount, currency FROM supplier_invoices WHERE id = %s", + (invoice_id,), + ) + if not invoice: + raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") + + payments = execute_query( + """ + SELECT id, payment_date, amount, currency, payment_method, payment_reference, notes, paid_by, created_at + FROM supplier_invoice_payments + WHERE supplier_invoice_id = %s + ORDER BY payment_date ASC, id ASC + """, + (invoice_id,), + ) or [] + + paid_total = sum(Decimal(str(p.get("amount") or 0)) for p in payments) + total_amount = Decimal(str(invoice.get("total_amount") or 0)) + remaining = total_amount - paid_total + + return { + "invoice_id": invoice_id, + "currency": invoice.get("currency") or "DKK", + "total_amount": float(total_amount), + "paid_total": float(paid_total), + "remaining": float(max(remaining, Decimal("0"))), + "payments": payments, + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Failed to fetch supplier payments for invoice %s: %s", invoice_id, e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/supplier-invoices/{invoice_id}/events") +async def get_supplier_invoice_events(invoice_id: int, limit: int = 100): + """Return lifecycle events for supplier invoice (outbox/event-log view).""" + try: + exists = execute_query_single("SELECT id FROM supplier_invoices WHERE id = %s", (invoice_id,)) + if not exists: + raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") + + rows = execute_query( + """ + SELECT id, event_type, from_status, to_status, payload_json, webhook_status, created_at, processed_at + FROM supplier_invoice_events + WHERE supplier_invoice_id = %s + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (invoice_id, limit), + ) or [] + return rows + except HTTPException: + raise + except Exception as e: + logger.error("❌ Failed to fetch supplier events for invoice %s: %s", invoice_id, e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/supplier-invoices/{invoice_id}/mark-paid") +async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest): + """Register payment (supports split payments) and mark as paid when fully covered.""" + try: + invoice = execute_query_single( + """ + SELECT id, invoice_number, status, workflow_status_v2, total_amount, currency + FROM supplier_invoices + WHERE id = %s + """, (invoice_id,) ) if not invoice: raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") - if invoice['status'] == 'paid': - return {"success": True, "invoice_id": invoice_id, "status": "paid"} - - if invoice['status'] not in ('approved', 'sent_to_economic'): + status_v2 = _get_invoice_status_v2(invoice) + if status_v2 != 'godkendt': raise HTTPException( status_code=400, detail=( - f"Faktura har status '{invoice['status']}' - " - "kun 'approved' eller 'sent_to_economic' kan markeres som betalt" + f"Faktura har status '{status_v2}' - " + "kun godkendte fakturaer kan registrere betaling" ) ) + total_amount = Decimal(str(invoice.get("total_amount") or 0)) + payment_date = request.paid_date or date.today() + + paid_sum_row = execute_query_single( + "SELECT COALESCE(SUM(amount), 0) AS paid_sum FROM supplier_invoice_payments WHERE supplier_invoice_id = %s", + (invoice_id,), + ) + already_paid = Decimal(str((paid_sum_row or {}).get("paid_sum") or 0)) + + remaining = total_amount - already_paid + if remaining <= 0: + transition = _transition_invoice_status_v2( + invoice_id=invoice_id, + new_status_v2="betalt", + actor=request.paid_by, + ) + return { + "success": True, + "invoice_id": invoice_id, + "status_v2": transition.get("to_status"), + "paid_total": float(already_paid), + "remaining": 0.0, + "message": "Faktura er allerede fuldt betalt", + } + + amount = request.amount if request.amount is not None else remaining + amount = Decimal(str(amount)) + if amount <= 0: + raise HTTPException(status_code=400, detail="amount skal være større end 0") + if amount > remaining: + raise HTTPException( + status_code=400, + detail=f"amount ({amount}) overstiger restbeløb ({remaining})", + ) + execute_update( - """UPDATE supplier_invoices - SET status = 'paid', updated_at = CURRENT_TIMESTAMP - WHERE id = %s""", - (invoice_id,) + """ + INSERT INTO supplier_invoice_payments ( + supplier_invoice_id, + payment_date, + amount, + currency, + payment_method, + payment_reference, + notes, + paid_by + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + invoice_id, + payment_date, + amount, + invoice.get("currency") or "DKK", + request.payment_method, + request.payment_reference, + request.notes, + request.paid_by, + ), ) + _record_supplier_invoice_event( + invoice_id=invoice_id, + event_type="payment_registered", + from_status=status_v2, + to_status=status_v2, + payload={ + "amount": str(amount), + "payment_date": str(payment_date), + "payment_method": request.payment_method, + "payment_reference": request.payment_reference, + "paid_by": request.paid_by, + }, + ) + + new_paid_sum = already_paid + amount + new_remaining = total_amount - new_paid_sum + + if new_remaining <= 0: + transition = _transition_invoice_status_v2( + invoice_id=invoice_id, + new_status_v2="betalt", + actor=request.paid_by, + ) + execute_update( + """ + UPDATE supplier_invoices + SET paid_date = %s, + payment_reference = COALESCE(%s, payment_reference), + payment_method = COALESCE(%s, payment_method), + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (payment_date, request.payment_reference, request.payment_method, invoice_id), + ) + status_v2 = transition.get("to_status") or "betalt" + logger.info( - "✅ Marked supplier invoice %s (ID: %s) as paid (date: %s)", + "✅ Registered supplier payment for invoice %s (ID: %s): amount=%s, remaining=%s", invoice['invoice_number'], invoice_id, - request.paid_date, + amount, + max(new_remaining, Decimal('0')), ) return { "success": True, "invoice_id": invoice_id, - "status": "paid", - "paid_date": request.paid_date, + "status_v2": status_v2, + "payment_date": payment_date, + "payment_amount": float(amount), + "paid_total": float(new_paid_sum), + "remaining": float(max(new_remaining, Decimal('0'))), } except HTTPException: @@ -3199,8 +3859,8 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int: invoice_id = execute_insert( """INSERT INTO supplier_invoices ( vendor_id, invoice_number, invoice_date, due_date, - total_amount, currency, status, notes - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + total_amount, currency, status, workflow_status_v2, notes + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", ( vendor_id, @@ -3209,11 +3869,20 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int: (datetime.now() + timedelta(days=30)).date(), # Due in 30 days 0.00, # Amount to be filled manually 'DKK', - 'unpaid', + 'pending', + 'modtaget', f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})" ) ) + _record_supplier_invoice_event( + invoice_id=invoice_id, + event_type="invoice_created", + from_status=None, + to_status="modtaget", + payload={"source": "from_file", "file_id": file_id}, + ) + _ensure_case_for_supplier_invoice( invoice_id=invoice_id, invoice_number=f"PENDING-{file_id}", diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 58be2c4..6365428 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -23,6 +23,42 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _ensure_customer_supplier_tag(customer_id: int) -> None: + """Ensure linked customers are tagged as suppliers.""" + try: + tag = execute_query_single( + "SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1" + ) + if tag and tag.get("id") is not None: + tag_id = int(tag["id"]) + else: + created = execute_query_single( + """ + INSERT INTO tags (name, type, description, color, is_active) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (name, type) + DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + ("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True), + ) + tag_id = int(created["id"]) if created and created.get("id") is not None else None + + if not tag_id: + return + + execute_query( + """ + INSERT INTO entity_tags (entity_type, entity_id, tag_id) + VALUES (%s, %s, %s) + ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING + """, + ("customer", customer_id, tag_id), + ) + except Exception as tag_error: + logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error) + + # Pydantic Models class CustomerBase(BaseModel): name: str @@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int): "supplier": supplier } + +@router.get("/customers/{customer_id}/vendors") +async def list_customer_vendors(customer_id: int): + """List vendors linked to a customer.""" + customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + rows = execute_query( + """ + SELECT + l.id, + l.customer_id, + l.vendor_id, + l.relationship_type, + l.created_at, + l.updated_at, + v.name AS vendor_name, + v.email AS vendor_email, + v.cvr_number AS vendor_cvr + FROM customer_vendor_links l + JOIN vendors v ON v.id = l.vendor_id + WHERE l.customer_id = %s + ORDER BY v.name ASC, l.id ASC + """, + (customer_id,), + ) or [] + return rows + + +@router.post("/customers/{customer_id}/vendors/{vendor_id}") +async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")): + """Create or update a customer-vendor link.""" + customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)) + if not vendor: + raise HTTPException(status_code=404, detail="Vendor not found") + + rel = str(relationship_type or "supplier").strip().lower() + if rel not in {"supplier", "reseller", "partner"}: + raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner") + + row = execute_query_single( + """ + INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type) + VALUES (%s, %s, %s) + ON CONFLICT (customer_id, vendor_id) + DO UPDATE SET + relationship_type = EXCLUDED.relationship_type, + updated_at = CURRENT_TIMESTAMP + RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at + """, + (customer_id, vendor_id, rel), + ) + _ensure_customer_supplier_tag(int(customer_id)) + return row + + +@router.delete("/customers/{customer_id}/vendors/{vendor_id}") +async def unlink_customer_from_vendor(customer_id: int, vendor_id: int): + """Remove customer-vendor link.""" + deleted = execute_update( + "DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s", + (customer_id, vendor_id), + ) + if not deleted: + raise HTTPException(status_code=404, detail="Link not found") + return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id} + @router.post("/customers") async def create_customer(customer: CustomerCreate): """Create a new customer""" @@ -1096,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate): raise HTTPException(status_code=404, detail="Customer not found") try: - # Create contact + normalized_email = (contact.email or "").strip().lower() or None + existing_contact = None + + # Prefer exact email match scoped to this customer, then global email match. + if normalized_email: + existing_contact = execute_query_single( + """ + SELECT c.* + FROM contacts c + JOIN contact_companies cc ON cc.contact_id = c.id + WHERE cc.customer_id = %s + AND LOWER(COALESCE(c.email, '')) = %s + ORDER BY c.id ASC + LIMIT 1 + """, + (customer_id, normalized_email), + ) + if not existing_contact: + existing_contact = execute_query_single( + """ + SELECT c.* + FROM contacts c + WHERE LOWER(COALESCE(c.email, '')) = %s + ORDER BY c.id ASC + LIMIT 1 + """, + (normalized_email,), + ) + + # Fallback dedupe by full name within same customer when email is missing. + if not existing_contact and not normalized_email: + existing_contact = execute_query_single( + """ + SELECT c.* + FROM contacts c + JOIN contact_companies cc ON cc.contact_id = c.id + WHERE cc.customer_id = %s + AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s) + AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s) + ORDER BY c.id ASC + LIMIT 1 + """, + (customer_id, contact.first_name, contact.last_name), + ) + + if existing_contact: + contact_id = int(existing_contact["id"]) + execute_update( + """ + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) + VALUES (%s, %s, %s, %s) + ON CONFLICT (contact_id, customer_id) + DO UPDATE SET + is_primary = contact_companies.is_primary OR EXCLUDED.is_primary, + role = COALESCE(contact_companies.role, EXCLUDED.role) + """, + (contact_id, customer_id, bool(contact.is_primary), contact.role), + ) + + logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id) + return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,)) + + # Create contact when no reusable match exists. contact_id = execute_insert( """INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department) @@ -1105,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate): ( contact.first_name, contact.last_name, - contact.email, + normalized_email, contact.phone, contact.mobile, contact.title, @@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate): ) # Link contact to customer - execute_insert( + execute_update( """INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) - VALUES (%s, %s, %s, %s)""", - (contact_id, customer_id, contact.is_primary, contact.role) + VALUES (%s, %s, %s, %s) + ON CONFLICT (contact_id, customer_id) DO NOTHING""", + (contact_id, customer_id, bool(contact.is_primary), contact.role) ) logger.info(f"✅ Created contact {contact_id} for customer {customer_id}") diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index f51c304..1dd76a4 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -498,6 +498,32 @@
Ingen tags tilføjet endnu.
+ +
+
+
+
Leverandørrelationer
+ 0 +
+
+
+ +
+
+ Knyt kunde til leverandør +
+
+ + +
Ingen linked leverandører endnu.
+
+
@@ -1423,6 +1449,7 @@ async function loadCustomer() { await loadUtilityCompany(); await loadCustomerTags(); + await loadCustomerVendorLinks(); // Check data consistency await checkDataConsistency(); @@ -1433,6 +1460,141 @@ async function loadCustomer() { } } +async function loadCustomerVendorLinks() { + const container = document.getElementById('customerVendorLinksContainer'); + const empty = document.getElementById('customerVendorLinksEmpty'); + const countEl = document.getElementById('customerVendorsCount'); + + if (!container || !empty || !countEl) return; + + try { + const response = await fetch(`/api/v1/customers/${customerId}/vendors`); + if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks'); + const links = await response.json(); + const rows = Array.isArray(links) ? links : []; + + countEl.textContent = String(rows.length); + if (!rows.length) { + container.innerHTML = ''; + empty.classList.remove('d-none'); + return; + } + + empty.classList.add('d-none'); + container.innerHTML = rows.map((row) => ` +
+
+
${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}
+
+ ${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''} + ${row.vendor_email ? escapeHtml(row.vendor_email) : '-'} +
+
+
+ ${escapeHtml(row.relationship_type || 'supplier')} + + + + +
+
+ `).join(''); + } catch (error) { + console.error('Failed to load customer vendor links:', error); + container.innerHTML = ''; + empty.classList.remove('d-none'); + } +} + +let vendorSearchDebounce = null; +async function searchVendorsForCustomer(query) { + const resultsEl = document.getElementById('customerVendorSearchResults'); + if (!resultsEl) return; + + const q = String(query || '').trim(); + if (!q) { + resultsEl.style.display = 'none'; + resultsEl.innerHTML = ''; + return; + } + + if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce); + vendorSearchDebounce = window.setTimeout(async () => { + try { + const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`); + if (!response.ok) throw new Error('Søgning fejlede'); + const vendors = await response.json(); + const rows = Array.isArray(vendors) ? vendors : []; + + if (!rows.length) { + resultsEl.innerHTML = '
Ingen leverandører fundet
'; + resultsEl.style.display = 'block'; + return; + } + + resultsEl.innerHTML = rows.map((v) => ` +
+
+
${escapeHtml(v.name || '')}
+
${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}
+
+ +
+ `).join(''); + resultsEl.style.display = 'block'; + } catch (error) { + console.error('Vendor search failed:', error); + resultsEl.innerHTML = '
Søgning fejlede
'; + resultsEl.style.display = 'block'; + } + }, 220); +} + +async function linkVendorToCustomerFromUI(vendorId) { + try { + const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, { + method: 'POST' + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.detail || 'Kunne ikke linke leverandør'); + } + + const input = document.getElementById('customerVendorSearch'); + const results = document.getElementById('customerVendorSearchResults'); + if (input) input.value = ''; + if (results) { + results.innerHTML = ''; + results.style.display = 'none'; + } + + await loadCustomerVendorLinks(); + await loadCustomerTags(); + } catch (error) { + alert(error.message || 'Kunne ikke linke leverandør'); + } +} + +async function unlinkVendorFromCustomer(vendorId) { + if (!confirm('Fjern link mellem kunde og leverandør?')) return; + try { + const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, { + method: 'DELETE' + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.detail || 'Kunne ikke fjerne link'); + } + await loadCustomerVendorLinks(); + } catch (error) { + alert(error.message || 'Kunne ikke fjerne link'); + } +} + function displayCustomer(customer) { // Update page title document.title = `${customer.name} - BMC Hub`; diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 7abe5f5..1fe4d51 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request from typing import List, Optional, Dict from pydantic import BaseModel from datetime import datetime, date +import unicodedata from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.services.email_processor_service import EmailProcessorService @@ -20,6 +21,218 @@ router = APIRouter() ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"} +IGNORED_SENDER_DOMAINS = { + "bmcnetworks.dk", + "bmchub.local", + "outlook.com", + "hotmail.com", + "gmail.com", + "icloud.com", + "yahoo.com", + "live.com", +} + + +def _normalize_domain(value: Optional[str]) -> str: + domain = str(value or "").strip().lower() + if not domain: + return "" + if domain.startswith("www."): + return domain[4:] + return domain + + +def _extract_sender_domain(sender_email: Optional[str]) -> str: + sender = str(sender_email or "").strip().lower() + if "@" not in sender: + return "" + return _normalize_domain(sender.split("@", 1)[1]) + + +def _is_ignored_sender_domain(domain: str) -> bool: + if not domain: + return True + return domain in IGNORED_SENDER_DOMAINS or "bmc" in domain + + +def _upsert_domain_mapping(domain: str, customer_id: int, source: str = "manual") -> None: + if not domain or not customer_id: + return + try: + execute_update( + """ + INSERT INTO email_domain_customer_mappings (domain, customer_id, source) + VALUES (%s, %s, %s) + ON CONFLICT (domain) + DO UPDATE SET + customer_id = EXCLUDED.customer_id, + source = EXCLUDED.source, + updated_at = CURRENT_TIMESTAMP + """, + (domain, customer_id, source), + ) + except Exception as e: + # Keep linking flow operational even if mapping table is not migrated yet. + logger.warning("⚠️ Could not upsert domain mapping for %s: %s", domain, e) + + +def _resolve_procurement_customer_id() -> Optional[int]: + """Resolve a fallback customer for supplier/procurement case creation.""" + bmc_row = execute_query_single( + """ + SELECT id + FROM customers + WHERE is_active = true + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id + LIMIT 1 + """, + ("%bmc%", "%bmc networks%") + ) + if bmc_row: + return int(bmc_row["id"]) + + fallback = execute_query_single( + "SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1" + ) + if fallback: + return int(fallback["id"]) + + return None + + +def _normalize_case_type(value: Optional[str]) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "support" + normalized = unicodedata.normalize("NFKD", raw) + ascii_value = normalized.encode("ascii", "ignore").decode("ascii").strip().lower() + return ascii_value or raw + + +def _is_supplier_case_type(case_type: Optional[str]) -> bool: + value = _normalize_case_type(case_type) + if value in { + "indkob", + "indkoeb", + "supplier", + "leverandor", + "leverandoer", + "vendor", + "procurement", + "purchase", + }: + return True + return "indk" in value or "leverand" in value or "supplier" in value + + +def _extract_domain_from_email(email: Optional[str]) -> str: + sender = str(email or "").strip().lower() + if "@" not in sender: + return "" + return _normalize_domain(sender.split("@", 1)[1]) + + +def _find_customer_for_vendor(vendor: Dict) -> Optional[int]: + cvr = str(vendor.get("cvr_number") or "").strip() + if cvr: + row = execute_query_single( + "SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1", + (cvr,), + ) + if row: + return int(row["id"]) + + email = str(vendor.get("email") or "").strip().lower() + if email: + row = execute_query_single( + "SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1", + (email,), + ) + if row: + return int(row["id"]) + + domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) + if domain: + row = execute_query_single( + """ + SELECT id + FROM customers + WHERE COALESCE(is_active, true) = true + AND ( + LOWER(TRIM(COALESCE(email_domain, ''))) = %s + OR LOWER(TRIM(COALESCE(email_domain, ''))) = %s + ) + ORDER BY id + LIMIT 1 + """, + (domain, f"www.{domain}"), + ) + if row: + return int(row["id"]) + + name = str(vendor.get("name") or "").strip().lower() + if name: + row = execute_query_single( + "SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1", + (name,), + ) + if row: + return int(row["id"]) + + return None + + +def _ensure_customer_from_vendor(vendor_id: Optional[int]) -> Optional[int]: + if not vendor_id: + return None + + vendor = execute_query_single( + """ + SELECT id, name, email, phone, address, cvr_number, domain, city, postal_code, country, website + FROM vendors + WHERE id = %s AND is_active = true + """, + (vendor_id,), + ) + if not vendor: + return None + + existing_customer_id = _find_customer_for_vendor(vendor) + if existing_customer_id: + return existing_customer_id + + name = str(vendor.get("name") or "").strip() + if not name: + return None + + domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) or None + + try: + created_id = execute_insert( + """ + INSERT INTO customers (name, email, phone, address, cvr_number, email_domain, city, postal_code, country, website, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true) + RETURNING id + """, + ( + name, + vendor.get("email"), + vendor.get("phone"), + vendor.get("address"), + vendor.get("cvr_number"), + domain, + vendor.get("city"), + vendor.get("postal_code"), + vendor.get("country") or "DK", + vendor.get("website"), + ), + ) + return int(created_id) + except Exception: + # Handle potential race/unique conflict by resolving again. + return _find_customer_for_vendor(vendor) + # Pydantic Models class EmailListItem(BaseModel): @@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel): context: Optional[str] = None +class DomainMappingUpsertRequest(BaseModel): + domain: str + customer_id: int + source: Optional[str] = "manual" + + @router.get("/emails/sag-options") async def get_sag_assignment_options(): """Return users and groups for SAG assignment controls in email UI.""" @@ -294,6 +513,222 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query raise HTTPException(status_code=500, detail=str(e)) +@router.get("/emails/{email_id}/domain-customer-suggestion") +async def get_domain_customer_suggestion(email_id: int): + """Suggest customer based on sender domain for mails without known contact/customer.""" + try: + email_row = execute_query_single( + "SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL", + (email_id,), + ) + if not email_row: + raise HTTPException(status_code=404, detail="Email not found") + + if email_row.get("customer_id"): + return { + "email_id": email_id, + "domain": None, + "has_customer": True, + "ignored": False, + "suggestion": None, + } + + sender_email = str(email_row.get("sender_email") or "").strip().lower() + if sender_email: + contact_match = execute_query_single( + """ + SELECT + c.id, + c.name, + c.email_domain, + c.cvr_number, + ct.id AS contact_id, + ct.first_name, + ct.last_name + FROM contacts ct + JOIN contact_companies cc ON cc.contact_id = ct.id + JOIN customers c ON c.id = cc.customer_id + WHERE c.is_active = true + AND LOWER(TRIM(COALESCE(ct.email, ''))) = %s + ORDER BY cc.is_primary DESC, c.id ASC + LIMIT 1 + """, + (sender_email,), + ) + if contact_match: + contact_name = " ".join( + part for part in [contact_match.get("first_name"), contact_match.get("last_name")] if part + ).strip() + return { + "email_id": email_id, + "domain": _extract_sender_domain(sender_email), + "has_customer": False, + "ignored": False, + "suggestion": { + "customer_id": contact_match["id"], + "customer_name": contact_match["name"], + "email_domain": contact_match.get("email_domain"), + "cvr_number": contact_match.get("cvr_number"), + "confidence": "high", + "score": 110, + "source": f"contact_email:{contact_name or sender_email}", + }, + } + + sender_domain = _extract_sender_domain(email_row.get("sender_email")) + if not sender_domain: + return { + "email_id": email_id, + "domain": None, + "has_customer": False, + "ignored": True, + "reason": "no_domain", + "suggestion": None, + } + + if _is_ignored_sender_domain(sender_domain): + return { + "email_id": email_id, + "domain": sender_domain, + "has_customer": False, + "ignored": True, + "reason": "ignored_domain", + "suggestion": None, + } + + mapped = execute_query_single( + """ + SELECT c.id, c.name, c.email_domain, c.cvr_number, m.source + FROM email_domain_customer_mappings m + JOIN customers c ON c.id = m.customer_id + WHERE m.domain = %s + AND c.is_active = true + LIMIT 1 + """, + (sender_domain,), + ) + + if mapped: + return { + "email_id": email_id, + "domain": sender_domain, + "has_customer": False, + "ignored": False, + "suggestion": { + "customer_id": mapped["id"], + "customer_name": mapped["name"], + "email_domain": mapped.get("email_domain"), + "cvr_number": mapped.get("cvr_number"), + "confidence": "high", + "score": 100, + "source": f"mapping:{mapped.get('source') or 'manual'}", + }, + } + + exact = execute_query_single( + """ + SELECT id, name, email_domain, cvr_number + FROM customers + WHERE is_active = true + AND ( + LOWER(TRIM(email_domain)) = %s + OR LOWER(TRIM(email_domain)) = %s + ) + ORDER BY id ASC + LIMIT 1 + """, + (sender_domain, f"www.{sender_domain}"), + ) + + if exact: + return { + "email_id": email_id, + "domain": sender_domain, + "has_customer": False, + "ignored": False, + "suggestion": { + "customer_id": exact["id"], + "customer_name": exact["name"], + "email_domain": exact.get("email_domain"), + "cvr_number": exact.get("cvr_number"), + "confidence": "high", + "score": 95, + "source": "exact_domain", + }, + } + + 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 { + "email_id": email_id, + "domain": sender_domain, + "has_customer": False, + "ignored": False, + "suggestion": None, + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error getting domain customer suggestion: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/emails/domain-customer-mapping") +async def upsert_domain_customer_mapping(payload: DomainMappingUpsertRequest): + """Persist trusted mapping from sender domain to customer.""" + try: + domain = _normalize_domain(payload.domain) + if not domain: + raise HTTPException(status_code=400, detail="domain is required") + + customer = execute_query_single( + "SELECT id FROM customers WHERE id = %s AND is_active = true", + (payload.customer_id,), + ) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + _upsert_domain_mapping(domain, int(payload.customer_id), payload.source or "manual") + return { + "success": True, + "domain": domain, + "customer_id": int(payload.customer_id), + "source": payload.source or "manual", + } + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error upserting domain mapping: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse) async def rewrite_email_text(request: RewriteEmailTextRequest): """Rewrite email/case text via Ollama using the text_rewrite prompt.""" @@ -608,6 +1043,16 @@ async def link_email(email_id: int, payload: Dict): query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" execute_update(query, tuple(params)) + customer_id = payload.get('customer_id') + if customer_id: + email_row = execute_query_single( + "SELECT sender_email FROM email_messages WHERE id = %s", + (email_id,), + ) + sender_domain = _extract_sender_domain((email_row or {}).get("sender_email")) + if sender_domain and not _is_ignored_sender_domain(sender_domain): + _upsert_domain_mapping(sender_domain, int(customer_id), "auto_link") + logger.info(f"✅ Linked email {email_id}: {payload}") return {"success": True, "message": "Email linket"} @@ -630,13 +1075,59 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques raise HTTPException(status_code=404, detail="Email not found") email_data = email_row[0] + + # Idempotent safeguard: repeated clicks should return existing linked case. + existing_sag_id = email_data.get('linked_case_id') + if existing_sag_id: + existing_sag = execute_query_single( + """ + SELECT id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at + FROM sag_sager + WHERE id = %s AND deleted_at IS NULL + """, + (existing_sag_id,), + ) + if existing_sag: + execute_update( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT (sag_id, email_id) DO NOTHING + """, + (existing_sag_id, email_id), + ) + return { + "success": True, + "email_id": email_id, + "sag": existing_sag, + "idempotent": True, + "message": "E-mail er allerede knyttet til eksisterende SAG" + } + + requested_case_type = _normalize_case_type(payload.case_type) + customer_id = payload.customer_id or email_data.get('customer_id') + if not customer_id and _is_supplier_case_type(requested_case_type): + customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id')) + if not customer_id and _is_supplier_case_type(requested_case_type): + customer_id = _resolve_procurement_customer_id() + if not customer_id: 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: + execute_update( + "UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (customer_id, email_id), + ) + + sender_domain = _extract_sender_domain(email_data.get("sender_email")) + if sender_domain and not _is_ignored_sender_domain(sender_domain): + _upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto") + titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' - template_key = (payload.case_type or 'support').strip().lower()[:50] + template_key = requested_case_type[:50] priority = (payload.priority or 'normal').strip().lower() if priority not in {'low', 'normal', 'high', 'urgent'}: diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index e6f643b..6dbd463 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -283,6 +283,62 @@ overflow-x: hidden; } + .quick-actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 0.5rem; + width: 100%; + margin-bottom: 0.65rem; + } + + .email-secondary-actions { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + flex-wrap: wrap; + justify-content: space-between; + } + + .email-secondary-actions .left, + .email-secondary-actions .right { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .domain-suggestion-card { + border: 1px dashed rgba(15, 76, 117, 0.35); + border-radius: 10px; + padding: 0.7rem 0.8rem; + background: rgba(15, 76, 117, 0.04); + margin-top: 0.65rem; + } + + .domain-suggestion-card .title { + font-size: 0.83rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.35rem; + } + + .domain-suggestion-meta { + font-size: 0.78rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + } + + .triage-priority-badge { + border: 1px solid rgba(15, 76, 117, 0.22); + background: rgba(15, 76, 117, 0.06); + color: var(--text-primary); + font-size: 0.75rem; + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-weight: 600; + } + .attachment-chip { max-width: 240px; overflow: hidden; @@ -300,6 +356,17 @@ background: #0a3a5c; border-color: #0a3a5c; } + + .email-actions .btn-danger-soft { + background: rgba(220, 53, 69, 0.08); + color: #a72a37; + border-color: rgba(220, 53, 69, 0.28); + } + + .email-actions .btn-danger-soft:hover { + background: rgba(220, 53, 69, 0.14); + color: #8e1f2d; + } .email-body { flex: 1; @@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
${escapeHtml(preview)}
${!email.is_read ? '' : ''} + ${getPriorityBadge(email)} ${formatClassification(classification)} @@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) { renderEmailDetail(email); renderEmailAnalysis(email); + await maybeSuggestCustomerByDomain(email); if (!email.is_read) { await markAsRead(emailId); @@ -1805,6 +1874,176 @@ async function loadEmailDetail(emailId) { } } +function withActionLoading(button, loadingText = 'Arbejder...') { + const btn = button || null; + if (!btn) { + return () => {}; + } + + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = `${loadingText}`; + + return () => { + btn.disabled = false; + btn.innerHTML = original; + }; +} + +function normalizeDomain(value) { + const domain = String(value || '').trim().toLowerCase(); + if (!domain) return ''; + return domain.startsWith('www.') ? domain.slice(4) : domain; +} + +function splitSenderName(name, email) { + const raw = String(name || '').trim(); + if (!raw) { + const fallback = String(email || '').split('@')[0] || 'Kontakt'; + return { firstName: fallback.slice(0, 40), lastName: 'Fra email' }; + } + + const cleaned = raw.replace(/[<>]/g, '').trim(); + const parts = cleaned.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + return { firstName: parts[0].slice(0, 40), lastName: '-' }; + } + return { + firstName: parts[0].slice(0, 40), + lastName: parts.slice(1).join(' ').slice(0, 60) + }; +} + +function setCaseCustomerSelection(customerId, customerName) { + const hidden = document.getElementById('caseCustomerId'); + const input = document.getElementById('caseCustomerSearch'); + if (hidden) hidden.value = customerId ? String(customerId) : ''; + if (input && customerName) input.value = customerName; +} + +function renderDomainCustomerHint(html, visible = true) { + const hint = document.getElementById('domainCustomerHint'); + if (!hint) return; + hint.innerHTML = html || ''; + hint.style.display = visible ? 'block' : 'none'; +} + +async function linkEmailToCustomerQuick(customerId, customerName) { + if (!currentEmailId || !customerId) return; + + try { + const resp = await fetch(`/api/v1/emails/${currentEmailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer_id: customerId }) + }); + if (!resp.ok) throw new Error('Kunne ikke linke kunden'); + + try { + const senderEmail = String(document.querySelector('.sender-email')?.textContent || '').trim().toLowerCase(); + const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : ''); + if (domain) { + await fetch('/api/v1/emails/domain-customer-mapping', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain, + customer_id: customerId, + source: 'manual' + }) + }); + } + } catch (mappingError) { + console.warn('Could not save domain mapping', mappingError); + } + + setCaseCustomerSelection(customerId, customerName); + renderDomainCustomerHint( + `
Firma linket
+
Email er nu linket til ${escapeHtml(customerName || 'kunden')}.
`, + true + ); + showSuccess(`Linket til ${customerName}`); + await loadEmailDetail(currentEmailId); + } catch (error) { + showError(error.message || 'Kunde-link fejlede'); + } +} + +async function maybeSuggestCustomerByDomain(email) { + if (!email || email.customer_id) { + renderDomainCustomerHint('', false); + return; + } + + const senderEmail = String(email.sender_email || '').trim().toLowerCase(); + const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : ''); + if (!domain) { + renderDomainCustomerHint('', false); + return; + } + + renderDomainCustomerHint( + `
Søger firma på domæne…
+
Domæne: ${escapeHtml(domain)}
`, + true + ); + + try { + const resp = await fetch(`/api/v1/emails/${email.id}/domain-customer-suggestion`); + if (!resp.ok) throw new Error('Domæneopslag fejlede'); + const payload = await resp.json(); + const best = payload?.suggestion + ? { + id: payload.suggestion.customer_id, + name: payload.suggestion.customer_name, + email_domain: payload.suggestion.email_domain, + cvr_number: payload.suggestion.cvr_number, + source: payload.suggestion.source, + score: payload.suggestion.score, + confidence: payload.suggestion.confidence + } + : null; + + if (!best) { + renderDomainCustomerHint( + `
Ingen firmamatch
+
Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.
`, + true + ); + return; + } + + const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem'; + renderDomainCustomerHint( + `
Muligt firma fundet (${confidence} confidence)
+
+ ${escapeHtml(best.name)} + ${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''} + ${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''} + ${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''} +
+
+ + +
`, + true + ); + + setCaseCustomerSelection(best.id, best.name); + } catch (error) { + renderDomainCustomerHint( + `
Domæneopslag fejlede
+
${escapeHtml(error.message || 'Ukendt fejl')}
`, + true + ); + } +} + function getClassificationActions(email) { const actions = []; @@ -1918,31 +2157,51 @@ function renderEmailDetail(email) { ` : ''}
-
-
- - - - - ${email.linked_case_id ? ` - - Sag - - ` : ''} -
+ + + ${email.attachments && email.attachments.length > 0 ? ` -
+
${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''} ${email.attachments.map(att => { const canPreview = canPreviewFile(att.content_type); @@ -2033,7 +2292,7 @@ function renderEmailAnalysis(email) {
System Forslag
-
+
@@ -2113,7 +2373,7 @@ function renderEmailAnalysis(email) {
-
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +