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.
This commit is contained in:
parent
13dc1736b4
commit
8e8616c835
@ -18,12 +18,180 @@ from app.services.invoice2data_service import get_invoice2data_service
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_PURCHASE_CASE_TYPE = "indkøb"
|
_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:
|
def _resolve_procurement_customer_id() -> int:
|
||||||
"""Find customer used for internally-owned procurement cases."""
|
"""Find customer used for internally-owned procurement cases."""
|
||||||
@ -94,13 +262,26 @@ def _ensure_case_for_supplier_invoice(
|
|||||||
f"Fil ID: {file_id or '-'}"
|
f"Fil ID: {file_id or '-'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
case_row = execute_query_single(
|
case_row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id)
|
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)
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id)
|
(case_title, case_description, "åben", customer_id, 1)
|
||||||
)
|
)
|
||||||
if not case_row:
|
if not case_row:
|
||||||
return None
|
return None
|
||||||
@ -138,6 +319,196 @@ def _ensure_case_for_supplier_invoice(
|
|||||||
return sag_id
|
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]:
|
def _smart_extract_lines(text: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Universal line extraction using pdfplumber layout mode.
|
Universal line extraction using pdfplumber layout mode.
|
||||||
@ -278,6 +649,7 @@ def _smart_extract_lines(text: str) -> List[Dict]:
|
|||||||
async def list_supplier_invoices(
|
async def list_supplier_invoices(
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
vendor_id: Optional[int] = None,
|
vendor_id: Optional[int] = None,
|
||||||
|
sag_id: Optional[int] = None,
|
||||||
overdue_only: bool = False
|
overdue_only: bool = False
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -298,7 +670,16 @@ async def list_supplier_invoices(
|
|||||||
WHEN si.paid_date IS NOT NULL THEN 'paid'
|
WHEN si.paid_date IS NOT NULL THEN 'paid'
|
||||||
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
|
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
|
||||||
ELSE si.status
|
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
|
FROM supplier_invoices si
|
||||||
LEFT JOIN vendors v ON si.vendor_id = v.id
|
LEFT JOIN vendors v ON si.vendor_id = v.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@ -306,6 +687,21 @@ async def list_supplier_invoices(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if status:
|
if 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"
|
query += " AND si.status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
|
||||||
@ -313,6 +709,10 @@ async def list_supplier_invoices(
|
|||||||
query += " AND si.vendor_id = %s"
|
query += " AND si.vendor_id = %s"
|
||||||
params.append(vendor_id)
|
params.append(vendor_id)
|
||||||
|
|
||||||
|
if sag_id:
|
||||||
|
query += " AND si.sag_id = %s"
|
||||||
|
params.append(sag_id)
|
||||||
|
|
||||||
if overdue_only:
|
if overdue_only:
|
||||||
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
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:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
||||||
|
|
||||||
# Get extraction lines
|
# Get extracted lines from DB, fallback to LLM payload lines.
|
||||||
lines = execute_query(
|
lines = _load_extraction_lines(extraction_data)
|
||||||
"""SELECT * FROM extraction_lines
|
|
||||||
WHERE extraction_id = %s
|
|
||||||
ORDER BY line_number""",
|
|
||||||
(extraction_data['extraction_id'],)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse LLM response JSON if it's a string
|
# Parse LLM response JSON if it's a string
|
||||||
import json
|
import json
|
||||||
@ -1135,8 +1530,8 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
invoice_id = execute_insert(
|
invoice_id = execute_insert(
|
||||||
"""INSERT INTO supplier_invoices (
|
"""INSERT INTO supplier_invoices (
|
||||||
vendor_id, invoice_number, invoice_date, due_date,
|
vendor_id, invoice_number, invoice_date, due_date,
|
||||||
total_amount, currency, status, extraction_id, notes, invoice_type
|
total_amount, currency, status, workflow_status_v2, extraction_id, notes, invoice_type
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
extraction_data['vendor_matched_id'],
|
extraction_data['vendor_matched_id'],
|
||||||
@ -1145,13 +1540,22 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
due_date,
|
due_date,
|
||||||
extraction_data['total_amount'],
|
extraction_data['total_amount'],
|
||||||
extraction_data['currency'],
|
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'],
|
extraction_data['extraction_id'],
|
||||||
f"Oprettet fra AI extraction (file_id: {file_id})",
|
f"Oprettet fra AI extraction (file_id: {file_id})",
|
||||||
invoice_type
|
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(
|
sag_id = _ensure_case_for_supplier_invoice(
|
||||||
invoice_id=invoice_id,
|
invoice_id=invoice_id,
|
||||||
invoice_number=invoice_number,
|
invoice_number=invoice_number,
|
||||||
@ -1164,19 +1568,40 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
# Create invoice lines
|
# Create invoice lines
|
||||||
if lines:
|
if lines:
|
||||||
for line in 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(
|
execute_update(
|
||||||
"""INSERT INTO supplier_invoice_lines (
|
"""INSERT INTO supplier_invoice_lines (
|
||||||
supplier_invoice_id, description, quantity, unit_price,
|
supplier_invoice_id, line_number, description, quantity, unit_price,
|
||||||
line_total, vat_rate, vat_amount
|
line_total, vat_rate, vat_amount, product_id, sku
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
invoice_id,
|
invoice_id,
|
||||||
|
line.get('line_number'),
|
||||||
line['description'],
|
line['description'],
|
||||||
line.get('quantity') or 1,
|
quantity,
|
||||||
line.get('unit_price') or 0,
|
unit_price,
|
||||||
line.get('line_total') or 0,
|
line_total,
|
||||||
line.get('vat_rate') or 25.00, # Default 25% Danish VAT if NULL
|
vat_rate,
|
||||||
line.get('vat_amount')
|
vat_amount,
|
||||||
|
product_id,
|
||||||
|
sku,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1598,8 +2023,9 @@ async def create_supplier_invoice(data: Dict):
|
|||||||
"""INSERT INTO supplier_invoices
|
"""INSERT INTO supplier_invoices
|
||||||
(invoice_number, vendor_id, vendor_name, invoice_date, due_date,
|
(invoice_number, vendor_id, vendor_name, invoice_date, due_date,
|
||||||
total_amount, vat_amount, net_amount, currency, description, notes,
|
total_amount, vat_amount, net_amount, currency, description, notes,
|
||||||
status, created_by, invoice_type)
|
status, workflow_status_v2, created_by, invoice_type)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
data['invoice_number'],
|
data['invoice_number'],
|
||||||
data['vendor_id'],
|
data['vendor_id'],
|
||||||
@ -1612,35 +2038,64 @@ async def create_supplier_invoice(data: Dict):
|
|||||||
data.get('currency', 'DKK'),
|
data.get('currency', 'DKK'),
|
||||||
data.get('description'),
|
data.get('description'),
|
||||||
data.get('notes'),
|
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'),
|
data.get('created_by'),
|
||||||
invoice_type
|
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
|
# Insert lines if provided
|
||||||
if data.get('lines'):
|
if data.get('lines'):
|
||||||
for idx, line in enumerate(data['lines'], start=1):
|
for idx, line in enumerate(data['lines'], start=1):
|
||||||
# Map vat_code: I52 for reverse charge, I25 for standard
|
# Map vat_code: I52 for reverse charge, I25 for standard
|
||||||
vat_code = line.get('vat_code', 'I25')
|
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
|
"""INSERT INTO supplier_invoice_lines
|
||||||
(supplier_invoice_id, line_number, description, quantity, unit_price,
|
(supplier_invoice_id, line_number, description, quantity, unit_price,
|
||||||
line_total, vat_code, vat_rate, vat_amount, contra_account, sku)
|
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)""",
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
invoice_id,
|
invoice_id,
|
||||||
line.get('line_number', idx),
|
line.get('line_number', idx),
|
||||||
line.get('description'),
|
line.get('description'),
|
||||||
line.get('quantity', 1),
|
quantity,
|
||||||
line.get('unit_price', 0),
|
unit_price,
|
||||||
line.get('line_total', 0),
|
line_total,
|
||||||
vat_code,
|
vat_code,
|
||||||
line.get('vat_rate', 25.00),
|
vat_rate,
|
||||||
line.get('vat_amount', 0),
|
vat_amount,
|
||||||
line.get('contra_account', '5810'),
|
line.get('contra_account', '5810'),
|
||||||
line.get('sku')
|
product_id,
|
||||||
|
sku,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1829,33 +2284,44 @@ class ApproveRequest(BaseModel):
|
|||||||
approved_by: str
|
approved_by: str
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
rejected_by: str
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MarkPaidRequest(BaseModel):
|
class MarkPaidRequest(BaseModel):
|
||||||
paid_date: Optional[date] = None
|
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")
|
@router.post("/supplier-invoices/{invoice_id}/approve")
|
||||||
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
||||||
"""Approve supplier invoice for payment"""
|
"""Approve supplier invoice for payment (v2 status flow)."""
|
||||||
try:
|
try:
|
||||||
invoice = execute_query_single(
|
transition = _transition_invoice_status_v2(
|
||||||
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
|
invoice_id=invoice_id,
|
||||||
(invoice_id,))
|
new_status_v2="godkendt",
|
||||||
|
actor=request.approved_by,
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ Approved supplier invoice {invoice['invoice_number']} 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}
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -1864,49 +2330,243 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/supplier-invoices/{invoice_id}/mark-paid")
|
@router.post("/supplier-invoices/{invoice_id}/reject")
|
||||||
async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
|
async def reject_supplier_invoice(invoice_id: int, request: RejectRequest):
|
||||||
"""Mark supplier invoice as paid."""
|
"""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:
|
try:
|
||||||
invoice = execute_query_single(
|
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,)
|
(invoice_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not invoice:
|
if not invoice:
|
||||||
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
||||||
|
|
||||||
if invoice['status'] == 'paid':
|
status_v2 = _get_invoice_status_v2(invoice)
|
||||||
return {"success": True, "invoice_id": invoice_id, "status": "paid"}
|
if status_v2 != 'godkendt':
|
||||||
|
|
||||||
if invoice['status'] not in ('approved', 'sent_to_economic'):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=(
|
detail=(
|
||||||
f"Faktura har status '{invoice['status']}' - "
|
f"Faktura har status '{status_v2}' - "
|
||||||
"kun 'approved' eller 'sent_to_economic' kan markeres som betalt"
|
"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(
|
execute_update(
|
||||||
"""UPDATE supplier_invoices
|
"""
|
||||||
SET status = 'paid', updated_at = CURRENT_TIMESTAMP
|
INSERT INTO supplier_invoice_payments (
|
||||||
WHERE id = %s""",
|
supplier_invoice_id,
|
||||||
(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(
|
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['invoice_number'],
|
||||||
invoice_id,
|
invoice_id,
|
||||||
request.paid_date,
|
amount,
|
||||||
|
max(new_remaining, Decimal('0')),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"invoice_id": invoice_id,
|
"invoice_id": invoice_id,
|
||||||
"status": "paid",
|
"status_v2": status_v2,
|
||||||
"paid_date": request.paid_date,
|
"payment_date": payment_date,
|
||||||
|
"payment_amount": float(amount),
|
||||||
|
"paid_total": float(new_paid_sum),
|
||||||
|
"remaining": float(max(new_remaining, Decimal('0'))),
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@ -3199,8 +3859,8 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
|
|||||||
invoice_id = execute_insert(
|
invoice_id = execute_insert(
|
||||||
"""INSERT INTO supplier_invoices (
|
"""INSERT INTO supplier_invoices (
|
||||||
vendor_id, invoice_number, invoice_date, due_date,
|
vendor_id, invoice_number, invoice_date, due_date,
|
||||||
total_amount, currency, status, notes
|
total_amount, currency, status, workflow_status_v2, notes
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
vendor_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
|
(datetime.now() + timedelta(days=30)).date(), # Due in 30 days
|
||||||
0.00, # Amount to be filled manually
|
0.00, # Amount to be filled manually
|
||||||
'DKK',
|
'DKK',
|
||||||
'unpaid',
|
'pending',
|
||||||
|
'modtaget',
|
||||||
f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})"
|
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(
|
_ensure_case_for_supplier_invoice(
|
||||||
invoice_id=invoice_id,
|
invoice_id=invoice_id,
|
||||||
invoice_number=f"PENDING-{file_id}",
|
invoice_number=f"PENDING-{file_id}",
|
||||||
|
|||||||
@ -23,6 +23,42 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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
|
# Pydantic Models
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
|
|||||||
"supplier": supplier
|
"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")
|
@router.post("/customers")
|
||||||
async def create_customer(customer: CustomerCreate):
|
async def create_customer(customer: CustomerCreate):
|
||||||
"""Create a new customer"""
|
"""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")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
try:
|
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(
|
contact_id = execute_insert(
|
||||||
"""INSERT INTO contacts
|
"""INSERT INTO contacts
|
||||||
(first_name, last_name, email, phone, mobile, title, department)
|
(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.first_name,
|
||||||
contact.last_name,
|
contact.last_name,
|
||||||
contact.email,
|
normalized_email,
|
||||||
contact.phone,
|
contact.phone,
|
||||||
contact.mobile,
|
contact.mobile,
|
||||||
contact.title,
|
contact.title,
|
||||||
@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Link contact to customer
|
# Link contact to customer
|
||||||
execute_insert(
|
execute_update(
|
||||||
"""INSERT INTO contact_companies
|
"""INSERT INTO contact_companies
|
||||||
(contact_id, customer_id, is_primary, role)
|
(contact_id, customer_id, is_primary, role)
|
||||||
VALUES (%s, %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s)
|
||||||
(contact_id, customer_id, contact.is_primary, contact.role)
|
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}")
|
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
||||||
|
|||||||
@ -498,6 +498,32 @@
|
|||||||
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="fw-bold mb-0">Leverandørrelationer</h5>
|
||||||
|
<span class="badge bg-primary" id="customerVendorsCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="customerVendorSearch"
|
||||||
|
placeholder="Søg leverandør (navn, CVR, domæne)"
|
||||||
|
oninput="searchVendorsForCustomer(this.value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-md-end">
|
||||||
|
<small class="text-muted">Knyt kunde til leverandør</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="customerVendorSearchResults" class="list-group mb-3" style="display:none;"></div>
|
||||||
|
<div id="customerVendorLinksContainer" class="list-group mb-2"></div>
|
||||||
|
<div id="customerVendorLinksEmpty" class="text-muted small">Ingen linked leverandører endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1423,6 +1449,7 @@ async function loadCustomer() {
|
|||||||
|
|
||||||
await loadUtilityCompany();
|
await loadUtilityCompany();
|
||||||
await loadCustomerTags();
|
await loadCustomerTags();
|
||||||
|
await loadCustomerVendorLinks();
|
||||||
|
|
||||||
// Check data consistency
|
// Check data consistency
|
||||||
await checkDataConsistency();
|
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) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
|
||||||
|
${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/vendors/${row.vendor_id}">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="unlinkVendorFromCustomer(${row.vendor_id})">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 = '<div class="list-group-item text-muted">Ingen leverandører fundet</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = rows.map((v) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(v.name || '')}</div>
|
||||||
|
<div class="small text-muted">${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="linkVendorToCustomerFromUI(${v.id})">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor search failed:', error);
|
||||||
|
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
|
||||||
|
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) {
|
function displayCustomer(customer) {
|
||||||
// Update page title
|
// Update page title
|
||||||
document.title = `${customer.name} - BMC Hub`;
|
document.title = `${customer.name} - BMC Hub`;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
|||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.services.email_processor_service import EmailProcessorService
|
from app.services.email_processor_service import EmailProcessorService
|
||||||
@ -20,6 +21,218 @@ router = APIRouter()
|
|||||||
|
|
||||||
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
|
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
|
# Pydantic Models
|
||||||
class EmailListItem(BaseModel):
|
class EmailListItem(BaseModel):
|
||||||
@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel):
|
|||||||
context: Optional[str] = None
|
context: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainMappingUpsertRequest(BaseModel):
|
||||||
|
domain: str
|
||||||
|
customer_id: int
|
||||||
|
source: Optional[str] = "manual"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/emails/sag-options")
|
@router.get("/emails/sag-options")
|
||||||
async def get_sag_assignment_options():
|
async def get_sag_assignment_options():
|
||||||
"""Return users and groups for SAG assignment controls in email UI."""
|
"""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))
|
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)
|
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
|
||||||
async def rewrite_email_text(request: RewriteEmailTextRequest):
|
async def rewrite_email_text(request: RewriteEmailTextRequest):
|
||||||
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
|
"""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"
|
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
|
||||||
execute_update(query, tuple(params))
|
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}")
|
logger.info(f"✅ Linked email {email_id}: {payload}")
|
||||||
return {"success": True, "message": "Email linket"}
|
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")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
email_data = email_row[0]
|
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')
|
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:
|
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:
|
||||||
|
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()
|
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 ''
|
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()
|
priority = (payload.priority or 'normal').strip().lower()
|
||||||
|
|
||||||
if priority not in {'low', 'normal', 'high', 'urgent'}:
|
if priority not in {'low', 'normal', 'high', 'urgent'}:
|
||||||
|
|||||||
@ -283,6 +283,62 @@
|
|||||||
overflow-x: hidden;
|
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 {
|
.attachment-chip {
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -301,6 +357,17 @@
|
|||||||
border-color: #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 {
|
.email-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
|
|||||||
<div class="email-preview">${escapeHtml(preview)}</div>
|
<div class="email-preview">${escapeHtml(preview)}</div>
|
||||||
<div class="email-meta">
|
<div class="email-meta">
|
||||||
${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
|
${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
|
||||||
|
${getPriorityBadge(email)}
|
||||||
<span class="classification-badge classification-${classification}">
|
<span class="classification-badge classification-${classification}">
|
||||||
${formatClassification(classification)}
|
${formatClassification(classification)}
|
||||||
</span>
|
</span>
|
||||||
@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) {
|
|||||||
|
|
||||||
renderEmailDetail(email);
|
renderEmailDetail(email);
|
||||||
renderEmailAnalysis(email);
|
renderEmailAnalysis(email);
|
||||||
|
await maybeSuggestCustomerByDomain(email);
|
||||||
|
|
||||||
if (!email.is_read) {
|
if (!email.is_read) {
|
||||||
await markAsRead(emailId);
|
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 = `<span class="spinner-border spinner-border-sm me-1"></span>${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(
|
||||||
|
`<div class="title"><i class="bi bi-check-circle text-success me-1"></i>Firma linket</div>
|
||||||
|
<div class="domain-suggestion-meta">Email er nu linket til ${escapeHtml(customerName || 'kunden')}.</div>`,
|
||||||
|
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(
|
||||||
|
`<div class="title"><i class="bi bi-search me-1"></i>Søger firma på domæne…</div>
|
||||||
|
<div class="domain-suggestion-meta">Domæne: ${escapeHtml(domain)}</div>`,
|
||||||
|
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(
|
||||||
|
`<div class="title"><i class="bi bi-info-circle me-1"></i>Ingen firmamatch</div>
|
||||||
|
<div class="domain-suggestion-meta">Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.</div>`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem';
|
||||||
|
renderDomainCustomerHint(
|
||||||
|
`<div class="title"><i class="bi bi-building me-1"></i>Muligt firma fundet (${confidence} confidence)</div>
|
||||||
|
<div class="domain-suggestion-meta">
|
||||||
|
<strong>${escapeHtml(best.name)}</strong>
|
||||||
|
${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''}
|
||||||
|
${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''}
|
||||||
|
${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="linkEmailToCustomerQuick(${best.id}, '${escapeHtml(best.name).replace(/'/g, "\\'")}')">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link firma
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Opret ny
|
||||||
|
</button>
|
||||||
|
</div>`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
setCaseCustomerSelection(best.id, best.name);
|
||||||
|
} catch (error) {
|
||||||
|
renderDomainCustomerHint(
|
||||||
|
`<div class="title"><i class="bi bi-exclamation-circle me-1"></i>Domæneopslag fejlede</div>
|
||||||
|
<div class="domain-suggestion-meta">${escapeHtml(error.message || 'Ukendt fejl')}</div>`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getClassificationActions(email) {
|
function getClassificationActions(email) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
@ -1918,31 +2157,51 @@ function renderEmailDetail(email) {
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-actions d-flex justify-content-between align-items-center">
|
<div class="email-actions">
|
||||||
<div class="d-flex gap-2">
|
<div class="quick-actions-grid">
|
||||||
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)">
|
<button class="btn btn-sm btn-primary" onclick="createSupplierInvoice(${email.id}, this)" title="Leverandørfaktura">
|
||||||
|
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'support')" title="Kundesag">
|
||||||
|
<i class="bi bi-folder-plus me-1"></i>Kundesag
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'bogholderi', 'Fakturasporgsmal')" title="Fakturaspørgsmål">
|
||||||
|
<i class="bi bi-question-circle me-1"></i>Fakturaspørgsmål
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="markAsSpam(this)" title="Marker som spam">
|
||||||
|
<i class="bi bi-slash-circle me-1"></i>Spam
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')" title="Opret firma/kontakt">
|
||||||
|
<i class="bi bi-building-add me-1"></i>Opret firma/kontakt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-secondary-actions">
|
||||||
|
<div class="left">
|
||||||
|
<button class="btn btn-sm btn-light border" onclick="archiveEmail(this)" title="Arkivér (e)">
|
||||||
<i class="bi bi-archive"></i>
|
<i class="bi bi-archive"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam">
|
<button class="btn btn-sm btn-light border" onclick="reprocessEmail(this)" title="Genbehandl (r)">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
|
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail(this)" title="Kør workflows">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||||
</button>
|
</button>
|
||||||
${email.linked_case_id ? `
|
${email.linked_case_id ? `
|
||||||
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
|
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
|
||||||
<i class="bi bi-box-arrow-up-right me-1"></i>Sag
|
<i class="bi bi-box-arrow-up-right me-1"></i>SAG-${email.linked_case_id}
|
||||||
</a>
|
</a>
|
||||||
` : ''}
|
` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
|
||||||
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button class="btn btn-sm btn-danger-soft" onclick="deleteEmail(this)" title="Slet">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${email.attachments && email.attachments.length > 0 ? `
|
${email.attachments && email.attachments.length > 0 ? `
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top">
|
||||||
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
|
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
|
||||||
${email.attachments.map(att => {
|
${email.attachments.map(att => {
|
||||||
const canPreview = canPreviewFile(att.content_type);
|
const canPreview = canPreviewFile(att.content_type);
|
||||||
@ -2033,7 +2292,7 @@ function renderEmailAnalysis(email) {
|
|||||||
<div class="analysis-card">
|
<div class="analysis-card">
|
||||||
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
|
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
|
||||||
<div class="quick-action-row mb-3">
|
<div class="quick-action-row mb-3">
|
||||||
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
|
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion(this)">
|
||||||
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
|
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
|
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
|
||||||
@ -2068,6 +2327,7 @@ function renderEmailAnalysis(email) {
|
|||||||
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
|
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
||||||
|
<div id="domainCustomerHint" class="domain-suggestion-card" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="suggestion-field">
|
<div class="suggestion-field">
|
||||||
@ -2113,7 +2373,7 @@ function renderEmailAnalysis(email) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quick-action-row quick-action-row-case mt-3">
|
<div class="quick-action-row quick-action-row-case mt-3">
|
||||||
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
|
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm(this)">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
|
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
|
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
|
||||||
@ -2314,8 +2574,8 @@ async function searchSagerForCurrentEmail(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmSuggestion() {
|
function confirmSuggestion(button = null) {
|
||||||
createCaseFromCurrentForm();
|
createCaseFromCurrentForm(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCaseFormPayload() {
|
function getCaseFormPayload() {
|
||||||
@ -2336,8 +2596,11 @@ function getCaseFormPayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCaseFromCurrentForm() {
|
let caseCreateInFlight = false;
|
||||||
|
|
||||||
|
async function createCaseFromCurrentForm(button = null) {
|
||||||
if (!currentEmailId) return;
|
if (!currentEmailId) return;
|
||||||
|
if (caseCreateInFlight) return;
|
||||||
|
|
||||||
const payload = getCaseFormPayload();
|
const payload = getCaseFormPayload();
|
||||||
if (!payload.customer_id) {
|
if (!payload.customer_id) {
|
||||||
@ -2345,6 +2608,9 @@ async function createCaseFromCurrentForm() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
caseCreateInFlight = true;
|
||||||
|
const done = withActionLoading(button, 'Opretter...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
|
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -2358,11 +2624,18 @@ async function createCaseFromCurrentForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
if (result.idempotent) {
|
||||||
|
showInfo(`E-mail var allerede knyttet til SAG-${result.sag.id}`);
|
||||||
|
} else {
|
||||||
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
||||||
|
}
|
||||||
loadEmails();
|
loadEmails();
|
||||||
await loadEmailDetail(currentEmailId);
|
await loadEmailDetail(currentEmailId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.message || 'Kunne ikke oprette sag');
|
showError(error.message || 'Kunne ikke oprette sag');
|
||||||
|
} finally {
|
||||||
|
caseCreateInFlight = false;
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2582,8 +2855,9 @@ function formatEventType(eventType) {
|
|||||||
return labels[eventType] || eventType;
|
return labels[eventType] || eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveEmail() {
|
async function archiveEmail(button = null) {
|
||||||
if (!currentEmailId) return;
|
if (!currentEmailId) return;
|
||||||
|
const done = withActionLoading(button, 'Arkiverer...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
|
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
|
||||||
@ -2599,14 +2873,18 @@ async function archiveEmail() {
|
|||||||
loadEmails();
|
loadEmails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke arkivere email');
|
showError('Kunne ikke arkivere email');
|
||||||
|
} finally {
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAsSpam() {
|
async function markAsSpam(button = null) {
|
||||||
if (!currentEmailId) return;
|
if (!currentEmailId) return;
|
||||||
|
|
||||||
if (!confirm('Marker denne email som spam?')) return;
|
if (!confirm('Marker denne email som spam?')) return;
|
||||||
|
|
||||||
|
const done = withActionLoading(button, 'Spam...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
|
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -2623,17 +2901,16 @@ async function markAsSpam() {
|
|||||||
loadEmails();
|
loadEmails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke markere som spam');
|
showError('Kunne ikke markere som spam');
|
||||||
|
} finally {
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reprocessEmail() {
|
async function reprocessEmail(button = null) {
|
||||||
if (!currentEmailId) return;
|
if (!currentEmailId) return;
|
||||||
|
const done = withActionLoading(button, 'Genbehandler...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const btn = event.target.closest('button');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, {
|
const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
@ -2647,18 +2924,17 @@ async function reprocessEmail() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke genbehandle email');
|
showError('Kunne ikke genbehandle email');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) {
|
done();
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEmail() {
|
async function deleteEmail(button = null) {
|
||||||
if (!currentEmailId) return;
|
if (!currentEmailId) return;
|
||||||
|
|
||||||
if (!confirm('Slet denne email permanent?')) return;
|
if (!confirm('Slet denne email permanent?')) return;
|
||||||
|
|
||||||
|
const done = withActionLoading(button, 'Sletter...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
|
const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@ -2672,11 +2948,86 @@ async function deleteEmail() {
|
|||||||
loadEmails();
|
loadEmails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Kunne ikke slette email');
|
showError('Kunne ikke slette email');
|
||||||
|
} finally {
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createCaseQuick(emailId, primaryType = 'support', secondaryLabel = '') {
|
||||||
|
if (currentEmailId !== emailId) {
|
||||||
|
await selectEmail(emailId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrimaryType(primaryType || 'support');
|
||||||
|
const secondary = document.getElementById('caseSecondaryLabel');
|
||||||
|
if (secondary && secondaryLabel) {
|
||||||
|
secondary.value = secondaryLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEmail = emails.find((item) => Number(item.id) === Number(emailId));
|
||||||
|
if (currentEmail) {
|
||||||
|
await maybeSuggestCustomerByDomain(currentEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomer = !!document.getElementById('caseCustomerId')?.value;
|
||||||
|
if (!hasCustomer) {
|
||||||
|
showInfo('Ingen kunde valgt endnu. Vælg forslag fra domæne eller opret firma/kontakt først.');
|
||||||
|
quickCreateCustomer(emailId, currentEmail?.sender_name || '', currentEmail?.sender_email || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createCaseFromCurrentForm();
|
||||||
|
}
|
||||||
|
|
||||||
// Classification Action Handlers
|
// Classification Action Handlers
|
||||||
async function createSupplierInvoice(emailId) {
|
const supplierInvoiceInFlightByEmail = new Set();
|
||||||
|
|
||||||
|
async function ensureSupplierCaseForEmail(email) {
|
||||||
|
if (!email || !email.id) return null;
|
||||||
|
if (email.linked_case_id) return Number(email.linked_case_id);
|
||||||
|
|
||||||
|
const subject = String(email.subject || '').trim();
|
||||||
|
const sender = String(email.sender_email || '').trim();
|
||||||
|
const payload = {
|
||||||
|
case_type: 'indkoeb',
|
||||||
|
relation_type: 'mail',
|
||||||
|
priority: 'normal',
|
||||||
|
titel: subject ? `Leverandørmail: ${subject}` : `Leverandørmail fra ${sender || 'ukendt afsender'}`,
|
||||||
|
beskrivelse: [
|
||||||
|
'Auto-oprettet fra leverandør email-behandling',
|
||||||
|
`Afsender: ${sender || '-'}`,
|
||||||
|
`Subject: ${subject || '-'}`
|
||||||
|
].join('\n')
|
||||||
|
};
|
||||||
|
|
||||||
|
if (email.customer_id) {
|
||||||
|
payload.customer_id = Number(email.customer_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/emails/${email.id}/create-sag`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorPayload = await response.json().catch(() => ({}));
|
||||||
|
const detail = errorPayload?.detail || 'Kunne ikke oprette relateret sag';
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result?.sag?.id ? Number(result.sag.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSupplierInvoice(emailId, button = null) {
|
||||||
|
if (supplierInvoiceInFlightByEmail.has(Number(emailId))) {
|
||||||
|
showInfo('Leverandørfaktura behandles allerede for denne email...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
supplierInvoiceInFlightByEmail.add(Number(emailId));
|
||||||
|
const done = withActionLoading(button, 'Behandler...');
|
||||||
try {
|
try {
|
||||||
console.log('📧 Behandler email...');
|
console.log('📧 Behandler email...');
|
||||||
|
|
||||||
@ -2753,24 +3104,42 @@ async function createSupplierInvoice(emailId) {
|
|||||||
|
|
||||||
// Show result
|
// Show result
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
showSuccess(`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`);
|
let linkedSagId = null;
|
||||||
|
|
||||||
// Mark email as processed and move to Processed folder
|
try {
|
||||||
|
linkedSagId = await ensureSupplierCaseForEmail(email);
|
||||||
|
if (linkedSagId) {
|
||||||
|
email.linked_case_id = linkedSagId;
|
||||||
|
}
|
||||||
|
} catch (caseError) {
|
||||||
|
console.error('Error creating supplier-related case:', caseError);
|
||||||
|
showError(`Faktura uploadet, men sag-oprettelse fejlede: ${caseError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkedSagId) {
|
||||||
|
// Fallback: keep prior behavior if case link was not created.
|
||||||
try {
|
try {
|
||||||
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (markResponse.ok) {
|
if (!markResponse.ok) {
|
||||||
console.log('✅ Email marked as processed and moved to Processed folder');
|
|
||||||
// Reload email list to reflect changes
|
|
||||||
loadEmails();
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Could not mark email as processed');
|
console.warn('⚠️ Could not mark email as processed');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error marking email as processed:', e);
|
console.error('Error marking email as processed:', e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(
|
||||||
|
`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}` +
|
||||||
|
(linkedSagId ? ` · SAG-${linkedSagId} oprettet/linket` : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
loadEmails();
|
||||||
|
if (currentEmailId === emailId) {
|
||||||
|
await loadEmailDetail(emailId);
|
||||||
|
}
|
||||||
|
|
||||||
// Ask if user wants to go to supplier invoices page
|
// Ask if user wants to go to supplier invoices page
|
||||||
if (confirm(`${successCount} faktura${successCount > 1 ? 'er' : ''} er uploadet og behandlet.\n\nVil du gå til Leverandørfakturaer for at gennemse?`)) {
|
if (confirm(`${successCount} faktura${successCount > 1 ? 'er' : ''} er uploadet og behandlet.\n\nVil du gå til Leverandørfakturaer for at gennemse?`)) {
|
||||||
@ -2783,6 +3152,9 @@ async function createSupplierInvoice(emailId) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating supplier invoice:', error);
|
console.error('Error creating supplier invoice:', error);
|
||||||
showError('Kunne ikke behandle email: ' + error.message);
|
showError('Kunne ikke behandle email: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
supplierInvoiceInFlightByEmail.delete(Number(emailId));
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2818,11 +3190,16 @@ async function linkToCustomer(emailId) {
|
|||||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||||
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
||||||
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
|
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
|
||||||
|
const nameParts = splitSenderName(senderName, senderEmail);
|
||||||
document.getElementById('qcEmailId').value = emailId;
|
document.getElementById('qcEmailId').value = emailId;
|
||||||
document.getElementById('qcCustomerName').value = senderName || '';
|
document.getElementById('qcCustomerName').value = senderName || '';
|
||||||
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
||||||
document.getElementById('qcCustomerDomain').value = senderDomain;
|
document.getElementById('qcCustomerDomain').value = senderDomain;
|
||||||
document.getElementById('qcCustomerPhone').value = '';
|
document.getElementById('qcCustomerPhone').value = '';
|
||||||
|
document.getElementById('qcCreateContact').checked = true;
|
||||||
|
document.getElementById('qcContactFirstName').value = nameParts.firstName || '';
|
||||||
|
document.getElementById('qcContactLastName').value = nameParts.lastName || '';
|
||||||
|
document.getElementById('qcContactEmail').value = senderEmail || '';
|
||||||
document.getElementById('qcCustomerStatus').textContent = '';
|
document.getElementById('qcCustomerStatus').textContent = '';
|
||||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
@ -2834,13 +3211,33 @@ async function submitQuickCustomer() {
|
|||||||
const email = document.getElementById('qcCustomerEmail').value.trim();
|
const email = document.getElementById('qcCustomerEmail').value.trim();
|
||||||
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
|
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
|
||||||
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
||||||
|
const createContact = !!document.getElementById('qcCreateContact').checked;
|
||||||
|
const contactFirstName = document.getElementById('qcContactFirstName').value.trim();
|
||||||
|
const contactLastName = document.getElementById('qcContactLastName').value.trim();
|
||||||
|
const contactEmail = document.getElementById('qcContactEmail').value.trim();
|
||||||
const statusEl = document.getElementById('qcCustomerStatus');
|
const statusEl = document.getElementById('qcCustomerStatus');
|
||||||
|
|
||||||
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
|
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
|
||||||
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
|
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create customer
|
let customer = null;
|
||||||
|
|
||||||
|
// Idempotent fast path: try to reuse existing customer by domain/email before create.
|
||||||
|
if (domain || email) {
|
||||||
|
const probe = domain || email;
|
||||||
|
const searchResp = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(probe)}&limit=20`);
|
||||||
|
if (searchResp.ok) {
|
||||||
|
const matches = await searchResp.json();
|
||||||
|
const exactByDomain = (matches || []).find((row) => normalizeDomain(row.email_domain) === domain);
|
||||||
|
const exactByEmail = !exactByDomain
|
||||||
|
? (matches || []).find((row) => String(row.email || '').trim().toLowerCase() === email.toLowerCase())
|
||||||
|
: null;
|
||||||
|
customer = exactByDomain || exactByEmail || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
const custResp = await fetch('/api/v1/customers', {
|
const custResp = await fetch('/api/v1/customers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -2852,7 +3249,26 @@ async function submitQuickCustomer() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||||
const customer = await custResp.json();
|
customer = await custResp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createContact && contactFirstName && contactLastName) {
|
||||||
|
const contactResp = await fetch(`/api/v1/customers/${customer.id}/contacts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: contactFirstName,
|
||||||
|
last_name: contactLastName,
|
||||||
|
email: contactEmail || null,
|
||||||
|
is_primary: true,
|
||||||
|
role: 'primary'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contactResp.ok) {
|
||||||
|
console.warn('Contact creation failed for quick flow', await contactResp.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Link email
|
// Link email
|
||||||
await fetch(`/api/v1/emails/${emailId}/link`, {
|
await fetch(`/api/v1/emails/${emailId}/link`, {
|
||||||
@ -2862,7 +3278,7 @@ async function submitQuickCustomer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
||||||
showSuccess(`Kunde "${name}" oprettet og linket`);
|
showSuccess(`Kunde "${customer.name || name}" linket`);
|
||||||
loadEmailDetail(parseInt(emailId));
|
loadEmailDetail(parseInt(emailId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.className = 'text-danger small';
|
statusEl.className = 'text-danger small';
|
||||||
@ -3254,6 +3670,26 @@ function getCaseBadge(email) {
|
|||||||
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
|
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPriorityBadge(email) {
|
||||||
|
const sender = String(email.sender_email || '').toLowerCase();
|
||||||
|
const domain = normalizeDomain(sender.includes('@') ? sender.split('@')[1] : '');
|
||||||
|
const hasCustomer = !!email.customer_id;
|
||||||
|
|
||||||
|
if (!hasCustomer && domain && !['gmail.com', 'hotmail.com', 'outlook.com', 'icloud.com'].includes(domain)) {
|
||||||
|
return '<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Ingen kontakt</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((email.classification || '').toLowerCase() === 'invoice') {
|
||||||
|
return '<span class="badge bg-info-subtle text-info-emphasis ms-1">Mulig leverandørfaktura</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return '<span class="badge bg-warning-subtle text-warning-emphasis ms-1">Ukendt domæne</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getFileIcon(contentType) {
|
function getFileIcon(contentType) {
|
||||||
if (contentType?.includes('pdf')) return 'pdf';
|
if (contentType?.includes('pdf')) return 'pdf';
|
||||||
if (contentType?.includes('image')) return 'image';
|
if (contentType?.includes('image')) return 'image';
|
||||||
@ -4056,7 +4492,7 @@ async function deleteWorkflow(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeWorkflowsForEmail() {
|
async function executeWorkflowsForEmail(button = null) {
|
||||||
if (!currentEmailId) {
|
if (!currentEmailId) {
|
||||||
alert('Ingen email valgt');
|
alert('Ingen email valgt');
|
||||||
return;
|
return;
|
||||||
@ -4064,6 +4500,8 @@ async function executeWorkflowsForEmail() {
|
|||||||
|
|
||||||
if (!confirm('Vil du køre workflows for denne email?')) return;
|
if (!confirm('Vil du køre workflows for denne email?')) return;
|
||||||
|
|
||||||
|
const done = withActionLoading(button, 'Kører...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showNotification('Kører workflows...', 'info');
|
showNotification('Kører workflows...', 'info');
|
||||||
|
|
||||||
@ -4100,6 +4538,8 @@ async function executeWorkflowsForEmail() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error executing workflows:', error);
|
console.error('Error executing workflows:', error);
|
||||||
showNotification('❌ Kunne ikke køre workflows', 'danger');
|
showNotification('❌ Kunne ikke køre workflows', 'danger');
|
||||||
|
} finally {
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4885,6 +5325,26 @@ async function uploadEmailFiles(files) {
|
|||||||
<label class="form-label fw-semibold">Telefon</label>
|
<label class="form-label fw-semibold">Telefon</label>
|
||||||
<input type="text" class="form-control" id="qcCustomerPhone">
|
<input type="text" class="form-control" id="qcCustomerPhone">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="qcCreateContact" checked>
|
||||||
|
<label class="form-check-label" for="qcCreateContact">
|
||||||
|
Opret også primær kontakt
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label fw-semibold">Kontakt fornavn</label>
|
||||||
|
<input type="text" class="form-control" id="qcContactFirstName" placeholder="Fornavn">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label fw-semibold">Kontakt efternavn</label>
|
||||||
|
<input type="text" class="form-control" id="qcContactLastName" placeholder="Efternavn">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Kontakt email</label>
|
||||||
|
<input type="email" class="form-control" id="qcContactEmail" placeholder="kontakt@firma.dk">
|
||||||
|
</div>
|
||||||
<div id="qcCustomerStatus"></div>
|
<div id="qcCustomerStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@ -148,7 +148,7 @@ def get_dashboard_status() -> Dict[str, int]:
|
|||||||
FROM sag_sager
|
FROM sag_sager
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -262,7 +262,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]
|
|||||||
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
||||||
AND (l.status IS NULL OR l.status != 'dismissed')
|
AND (l.status IS NULL OR l.status != 'dismissed')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE LOWER(COALESCE(r.priority, 'normal'))
|
CASE LOWER(COALESCE(r.priority::text, 'normal'))
|
||||||
WHEN 'urgent' THEN 1
|
WHEN 'urgent' THEN 1
|
||||||
WHEN 'high' THEN 2
|
WHEN 'high' THEN 2
|
||||||
WHEN 'normal' THEN 3
|
WHEN 'normal' THEN 3
|
||||||
@ -386,7 +386,7 @@ def build_bottom_bar_state(
|
|||||||
FROM sag_sager
|
FROM sag_sager
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
ORDER BY updated_at DESC NULLS LAST, id DESC
|
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
"""
|
"""
|
||||||
@ -447,7 +447,7 @@ def build_bottom_bar_state(
|
|||||||
u.user_id,
|
u.user_id,
|
||||||
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
COUNT(s.id)::int AS open_cases,
|
COUNT(s.id)::int AS open_cases,
|
||||||
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN sag_sager s
|
LEFT JOIN sag_sager s
|
||||||
ON s.ansvarlig_bruger_id = u.user_id
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
@ -473,7 +473,7 @@ def build_bottom_bar_state(
|
|||||||
json_build_object(
|
json_build_object(
|
||||||
'id', t.id,
|
'id', t.id,
|
||||||
'title', t.titel,
|
'title', t.titel,
|
||||||
'priority', COALESCE(t.priority, 'normal'),
|
'priority', COALESCE(t.priority::text, 'normal'),
|
||||||
'deadline', t.deadline
|
'deadline', t.deadline
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@ -531,7 +531,7 @@ def build_bottom_bar_state(
|
|||||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
|
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
|
||||||
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
|
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
|
||||||
LIMIT 8
|
LIMIT 8
|
||||||
|
|||||||
@ -1104,6 +1104,23 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
if "customer_id" in updates:
|
if "customer_id" in updates:
|
||||||
updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "customer_id")
|
updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "customer_id")
|
||||||
_validate_customer_id(updates["customer_id"])
|
_validate_customer_id(updates["customer_id"])
|
||||||
|
if "supplier_flow_type" in updates:
|
||||||
|
flow_type = str(updates.get("supplier_flow_type") or "").strip().lower()
|
||||||
|
if flow_type and flow_type not in {"varekob", "ydelse"}:
|
||||||
|
raise HTTPException(status_code=400, detail="supplier_flow_type must be varekob or ydelse")
|
||||||
|
updates["supplier_flow_type"] = flow_type or None
|
||||||
|
if "supplier_flow_confidence" in updates:
|
||||||
|
conf = updates.get("supplier_flow_confidence")
|
||||||
|
if conf in (None, ""):
|
||||||
|
updates["supplier_flow_confidence"] = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
conf_num = float(conf)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be a number between 0 and 1")
|
||||||
|
if conf_num < 0 or conf_num > 1:
|
||||||
|
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be between 0 and 1")
|
||||||
|
updates["supplier_flow_confidence"] = conf_num
|
||||||
|
|
||||||
# Build dynamic update query
|
# Build dynamic update query
|
||||||
allowed_fields = [
|
allowed_fields = [
|
||||||
@ -1120,6 +1137,8 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
"deferred_until",
|
"deferred_until",
|
||||||
"deferred_until_case_id",
|
"deferred_until_case_id",
|
||||||
"deferred_until_status",
|
"deferred_until_status",
|
||||||
|
"supplier_flow_type",
|
||||||
|
"supplier_flow_confidence",
|
||||||
]
|
]
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|||||||
@ -2678,6 +2678,11 @@
|
|||||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)">
|
||||||
|
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
|
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
|
||||||
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
|
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
|
||||||
@ -6112,6 +6117,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Leverandør Tab -->
|
||||||
|
<div class="tab-pane fade" id="supplier" role="tabpanel" tabindex="0" data-module="supplier" data-has-content="unknown" style="display:none;">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-xl-8 col-12">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Leverandørflow Type</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierFlowType()">
|
||||||
|
<i class="bi bi-save me-1"></i>Gem type
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select" id="supplierFlowTypeSelect" onchange="toggleSupplierTypePanels()">
|
||||||
|
<option value="">Vælg type...</option>
|
||||||
|
<option value="varekob" {% if case.supplier_flow_type == 'varekob' %}selected{% endif %}>Varekøb</option>
|
||||||
|
<option value="ydelse" {% if case.supplier_flow_type == 'ydelse' %}selected{% endif %}>Købt ydelse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">AI confidence</label>
|
||||||
|
<input type="number" class="form-control" id="supplierFlowConfidenceInput" min="0" max="1" step="0.01" value="{{ case.supplier_flow_confidence if case.supplier_flow_confidence is not none else '' }}" placeholder="0.00 - 1.00">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Forslag</label>
|
||||||
|
<div id="supplierFlowSuggestion" class="small text-muted border rounded p-2">Ingen forslag endnu</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3" id="supplierVarekobPanel" style="display:none;">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-box-seam me-2"></i>VAREKØB handlinger pr. linje</h6>
|
||||||
|
<span class="badge bg-light text-dark border" id="supplierPurchaseLinesCount">0 linjer</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Beskrivelse</th>
|
||||||
|
<th>Beløb</th>
|
||||||
|
<th>Handling</th>
|
||||||
|
<th class="text-end pe-3">Gem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="supplierPurchaseLinesBody">
|
||||||
|
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3" id="supplierYdelsePanel" style="display:none;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-tools me-2"></i>YDELSE handling</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Ydelsestype</label>
|
||||||
|
<select class="form-select" id="supplierServiceType">
|
||||||
|
<option value="husleje">Husleje</option>
|
||||||
|
<option value="rengoering">Rengøring</option>
|
||||||
|
<option value="konsulent">Konsulent</option>
|
||||||
|
<option value="abonnement">Abonnement</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Periode fra</label>
|
||||||
|
<input type="date" class="form-control" id="supplierServiceFrom">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Periode til</label>
|
||||||
|
<input type="date" class="form-control" id="supplierServiceTo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Beløb</label>
|
||||||
|
<input type="number" min="0" step="0.01" class="form-control" id="supplierServiceAmount" placeholder="0,00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('direkte_omkostning')">
|
||||||
|
<i class="bi bi-cash-stack me-1"></i>Direkte omkostning
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('fordel_kunder')">
|
||||||
|
<i class="bi bi-diagram-2 me-1"></i>Fordel på kunder
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('gentagende_ydelse')">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Gentagende ydelse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-4 col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-receipt me-2"></i>Leverandørfakturaer</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadSupplierModule()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Faktura</th>
|
||||||
|
<th>Beløb</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end pe-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="supplierInvoicesBody">
|
||||||
|
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tidsforbrug Tab -->
|
<!-- Tidsforbrug Tab -->
|
||||||
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown" style="display:none;">
|
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown" style="display:none;">
|
||||||
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
|
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
|
||||||
@ -7527,6 +7662,313 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const supplierCaseId = {{ case.id }};
|
||||||
|
|
||||||
|
function supplierStatusBadge(status) {
|
||||||
|
const value = String(status || '').toLowerCase();
|
||||||
|
if (value === 'godkendt') return '<span class="badge bg-primary-subtle text-primary-emphasis">Godkendt</span>';
|
||||||
|
if (value === 'betalt') return '<span class="badge bg-success-subtle text-success-emphasis">Betalt</span>';
|
||||||
|
if (value === 'afvist') return '<span class="badge bg-danger-subtle text-danger-emphasis">Afvist</span>';
|
||||||
|
return '<span class="badge bg-warning-subtle text-warning-emphasis">Modtaget</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSupplierFlowSuggestion() {
|
||||||
|
const purchaseItems = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
||||||
|
if (purchaseItems.length > 0) {
|
||||||
|
return { type: 'varekob', confidence: 0.87, reason: 'Sag har indkøbslinjer fra leverandørflow' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseText = (saleItemsCache || [])
|
||||||
|
.filter((item) => (item.type || '').toLowerCase() === 'purchase')
|
||||||
|
.map((item) => String(item.description || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (/(abonnement|hosting|licens|service|konsulent|rengoering|husleje)/.test(purchaseText)) {
|
||||||
|
return { type: 'ydelse', confidence: 0.76, reason: 'Beskrivelser matcher ydelsesmønstre' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupplierSuggestion() {
|
||||||
|
const suggestionEl = document.getElementById('supplierFlowSuggestion');
|
||||||
|
if (!suggestionEl) return;
|
||||||
|
|
||||||
|
const suggestion = inferSupplierFlowSuggestion();
|
||||||
|
if (!suggestion) {
|
||||||
|
suggestionEl.innerHTML = '<span class="text-muted">Ingen sikker AI-indikation endnu</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestionEl.innerHTML = `
|
||||||
|
<div><strong>Forslag:</strong> ${suggestion.type === 'varekob' ? 'Varekøb' : 'Købt ydelse'}</div>
|
||||||
|
<div class="small text-muted">Confidence: ${(suggestion.confidence * 100).toFixed(0)}% · ${suggestion.reason}</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary mt-2" onclick="applySupplierSuggestion('${suggestion.type}', ${suggestion.confidence})">
|
||||||
|
<i class="bi bi-magic me-1"></i>Anvend forslag
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySupplierSuggestion(type, confidence) {
|
||||||
|
const typeEl = document.getElementById('supplierFlowTypeSelect');
|
||||||
|
const confEl = document.getElementById('supplierFlowConfidenceInput');
|
||||||
|
if (typeEl) typeEl.value = type;
|
||||||
|
if (confEl) confEl.value = Number(confidence).toFixed(2);
|
||||||
|
toggleSupplierTypePanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSupplierTypePanels() {
|
||||||
|
const type = document.getElementById('supplierFlowTypeSelect')?.value || '';
|
||||||
|
const varekobPanel = document.getElementById('supplierVarekobPanel');
|
||||||
|
const ydelsePanel = document.getElementById('supplierYdelsePanel');
|
||||||
|
if (varekobPanel) varekobPanel.style.display = type === 'varekob' ? '' : 'none';
|
||||||
|
if (ydelsePanel) ydelsePanel.style.display = type === 'ydelse' ? '' : 'none';
|
||||||
|
if (type === 'varekob') {
|
||||||
|
renderSupplierPurchasePurposeRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSupplierFlowType() {
|
||||||
|
const type = document.getElementById('supplierFlowTypeSelect')?.value || null;
|
||||||
|
const confidenceRaw = document.getElementById('supplierFlowConfidenceInput')?.value || null;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
supplier_flow_type: type,
|
||||||
|
supplier_flow_confidence: confidenceRaw === null || confidenceRaw === '' ? null : Number(confidenceRaw)
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Kunne ikke gemme leverandørflow type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Leverandørflow type gemt');
|
||||||
|
toggleSupplierTypePanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupplierPurchasePurposeRows() {
|
||||||
|
const body = document.getElementById('supplierPurchaseLinesBody');
|
||||||
|
const countBadge = document.getElementById('supplierPurchaseLinesCount');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const purchases = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
||||||
|
if (countBadge) countBadge.textContent = `${purchases.length} linjer`;
|
||||||
|
|
||||||
|
if (!purchases.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen indkøbslinjer fundet på denne sag</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = purchases.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">${item.description || '-'}</td>
|
||||||
|
<td>${formatCurrency(item.amount || 0)}</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm" id="supplierPurpose-${item.id}">
|
||||||
|
<option value="">Vælg...</option>
|
||||||
|
<option value="salg" ${item.purchase_purpose === 'salg' ? 'selected' : ''}>Videresalg</option>
|
||||||
|
<option value="asset" ${item.purchase_purpose === 'asset' ? 'selected' : ''}>Opret som Asset</option>
|
||||||
|
<option value="intern_brug" ${item.purchase_purpose === 'intern_brug' ? 'selected' : ''}>Intern brug</option>
|
||||||
|
<option value="projekt_omkostning" ${item.purchase_purpose === 'projekt_omkostning' ? 'selected' : ''}>Forbrug/omkostning</option>
|
||||||
|
<option value="lager" ${item.purchase_purpose === 'lager' ? 'selected' : ''}>Lager</option>
|
||||||
|
<option value="retur_reklamation" ${item.purchase_purpose === 'retur_reklamation' ? 'selected' : ''}>Retur/reklamation</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierPurchasePurpose(${item.id})">
|
||||||
|
<i class="bi bi-save"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSupplierPurchasePurpose(itemId) {
|
||||||
|
const purpose = document.getElementById(`supplierPurpose-${itemId}`)?.value || null;
|
||||||
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}/sale-items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'purchase',
|
||||||
|
purchase_purpose: purpose
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Kunne ikke gemme linjehandling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadVarekobSalg();
|
||||||
|
renderSupplierPurchasePurposeRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerSupplierServiceHandling(actionCode) {
|
||||||
|
const serviceType = document.getElementById('supplierServiceType')?.value || '';
|
||||||
|
const fromDate = document.getElementById('supplierServiceFrom')?.value || '';
|
||||||
|
const toDate = document.getElementById('supplierServiceTo')?.value || '';
|
||||||
|
const amount = document.getElementById('supplierServiceAmount')?.value || '';
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'Supplier ydelse-handling registreret',
|
||||||
|
`Handling: ${actionCode}`,
|
||||||
|
`Type: ${serviceType}`,
|
||||||
|
`Periode: ${fromDate || '-'} -> ${toDate || '-'}`,
|
||||||
|
`Beløb: ${amount || '-'} DKK`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}/kommentarer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
indhold: content,
|
||||||
|
er_system_besked: true,
|
||||||
|
forfatter: 'Supplier Flow'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Kunne ikke registrere ydelseshandling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Ydelseshandling registreret på sagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supplierApproveInvoice(invoiceId) {
|
||||||
|
const approvedBy = prompt('Hvem godkender?', 'System');
|
||||||
|
if (!approvedBy) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ approved_by: approvedBy })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
alert(`Kunne ikke godkende faktura: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadSupplierModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supplierRejectInvoice(invoiceId) {
|
||||||
|
const rejectedBy = prompt('Hvem afviser?', 'System');
|
||||||
|
if (!rejectedBy) return;
|
||||||
|
const reason = prompt('Årsag til afvisning (valgfri):', '') || null;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rejected_by: rejectedBy, reason })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
alert(`Kunne ikke afvise faktura: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadSupplierModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supplierMarkPaid(invoiceId) {
|
||||||
|
const amountRaw = prompt('Betalingsbeløb (tom = restbeløb):', '');
|
||||||
|
const amount = amountRaw && amountRaw.trim() !== '' ? Number(amountRaw) : null;
|
||||||
|
if (amountRaw && (!Number.isFinite(amount) || amount <= 0)) {
|
||||||
|
alert('Ugyldigt beløb');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = prompt('Betalingsmetode (fx bank):', '') || null;
|
||||||
|
const reference = prompt('Betalingsreference (valgfri):', '') || null;
|
||||||
|
const paidBy = prompt('Registreret af:', 'System') || 'System';
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount,
|
||||||
|
payment_method: method,
|
||||||
|
payment_reference: reference,
|
||||||
|
paid_by: paidBy
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
alert(`Kunne ikke registrere betaling: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSupplierModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSupplierInvoices(items) {
|
||||||
|
const body = document.getElementById('supplierInvoicesBody');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (!items || !items.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen leverandørfakturaer på sagen</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = items.map((inv) => {
|
||||||
|
const statusV2 = inv.status_v2 || 'modtaget';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-semibold">${inv.invoice_number || `ID-${inv.id}`}</div>
|
||||||
|
<div class="small text-muted">${inv.invoice_date || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatCurrency(inv.total_amount || 0)}</td>
|
||||||
|
<td>${supplierStatusBadge(statusV2)}</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="supplierApproveInvoice(${inv.id})" title="Godkend"><i class="bi bi-check2"></i></button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="supplierRejectInvoice(${inv.id})" title="Afvis"><i class="bi bi-x"></i></button>
|
||||||
|
<button class="btn btn-outline-success" onclick="supplierMarkPaid(${inv.id})" title="Registrer betaling"><i class="bi bi-cash"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSupplierModule() {
|
||||||
|
const body = document.getElementById('supplierInvoicesBody');
|
||||||
|
if (body) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/supplier-invoices?sag_id=${supplierCaseId}`);
|
||||||
|
if (!res.ok) throw new Error('Kunne ikke hente leverandørfakturaer');
|
||||||
|
const items = await res.json();
|
||||||
|
renderSupplierInvoices(items || []);
|
||||||
|
renderSupplierSuggestion();
|
||||||
|
toggleSupplierTypePanels();
|
||||||
|
setModuleContentState('supplier', (items || []).length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (body) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Kunne ikke hente leverandørfakturaer</td></tr>';
|
||||||
|
}
|
||||||
|
setModuleContentState('supplier', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadSupplierModule();
|
||||||
|
toggleSupplierTypePanels();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const timeCaseId = {{ case.id }};
|
const timeCaseId = {{ case.id }};
|
||||||
let timeV1EntriesById = {};
|
let timeV1EntriesById = {};
|
||||||
|
|||||||
@ -49,6 +49,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sag-table tbody tr:nth-child(even) {
|
||||||
|
background: rgba(15, 76, 117, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.sag-table tbody tr:hover {
|
.sag-table tbody tr:hover {
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
}
|
}
|
||||||
@ -264,30 +268,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 2rem;
|
gap: 0.4rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0.8rem;
|
||||||
padding: 1rem;
|
padding: 0.25rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.5rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.62rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-avatar {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@ -322,13 +357,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h1 style="margin: 0; color: var(--accent);">
|
<h1 style="margin: 0; color: var(--accent);">
|
||||||
<i class="bi bi-list-check me-2"></i>Sager
|
<i class="bi bi-list-check"></i>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
||||||
@ -350,10 +385,6 @@
|
|||||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
||||||
<div class="stat-label">Åbne</div>
|
<div class="stat-label">Åbne</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'lukket')|list|length }}</div>
|
|
||||||
<div class="stat-label">Lukkede</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Filters -->
|
<!-- Search & Filters -->
|
||||||
@ -461,8 +492,19 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||||
{{ sag.priority if sag.priority else 'normal' }}
|
{{ sag.priority if sag.priority else 'normal' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||||
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
{% if sag.ansvarlig_navn %}
|
||||||
|
{% set owner_name = sag.ansvarlig_navn.strip() %}
|
||||||
|
{% set owner_parts = owner_name.split() %}
|
||||||
|
<div class="owner-cell">
|
||||||
|
<span class="owner-avatar">
|
||||||
|
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||||
|
</span>
|
||||||
|
<span class="owner-name">{{ owner_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||||
@ -528,8 +570,19 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||||
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||||
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
{% if related_sag.ansvarlig_navn %}
|
||||||
|
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
|
||||||
|
{% set owner_parts = owner_name.split() %}
|
||||||
|
<div class="owner-cell">
|
||||||
|
<span class="owner-avatar">
|
||||||
|
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||||
|
</span>
|
||||||
|
<span class="owner-name">{{ owner_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||||
|
|||||||
@ -279,6 +279,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-detail-line {
|
.global-bottom-bar .bb-detail-line {
|
||||||
|
display: none;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
@ -287,12 +288,19 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
}
|
||||||
.global-bottom-bar.is-expanded .bb-detail-line {
|
.global-bottom-bar.is-expanded .bb-detail-line {
|
||||||
opacity: 0;
|
display: block;
|
||||||
pointer-events: none;
|
}
|
||||||
position: absolute;
|
|
||||||
|
.global-bottom-bar:not(.is-expanded) .bb-header {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-bottom-bar:not(.is-expanded) .bb-header::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-sheet-panel {
|
.global-bottom-bar .bb-sheet-panel {
|
||||||
@ -419,14 +427,14 @@
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-header {
|
.global-bottom-bar.is-expanded .bb-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
row-gap: 0.4rem;
|
row-gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-zone-left,
|
.global-bottom-bar.is-expanded .bb-zone-left,
|
||||||
.global-bottom-bar .bb-zone-center,
|
.global-bottom-bar.is-expanded .bb-zone-center,
|
||||||
.global-bottom-bar .bb-zone-right {
|
.global-bottom-bar.is-expanded .bb-zone-right {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,6 +145,7 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
|
|||||||
if hub_customer_id is None:
|
if hub_customer_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_existing_mapping() -> Optional[int]:
|
||||||
mapped = execute_query_single(
|
mapped = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
@ -162,6 +163,121 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
mapped_id = _get_existing_mapping()
|
||||||
|
if mapped_id is not None:
|
||||||
|
return mapped_id
|
||||||
|
|
||||||
|
# Auto-link fallback: try targeted linking by economic number/name before failing.
|
||||||
|
try:
|
||||||
|
hub_customer = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, name, economic_customer_number
|
||||||
|
FROM customers
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(hub_customer_id,),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
economic_number = hub_customer.get("economic_customer_number")
|
||||||
|
customer_name = str(hub_customer.get("name") or "").strip()
|
||||||
|
|
||||||
|
if economic_number not in (None, ""):
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tmodule_customers
|
||||||
|
SET hub_customer_id = %s,
|
||||||
|
economic_customer_number = COALESCE(economic_customer_number, %s),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE hub_customer_id IS NULL
|
||||||
|
AND economic_customer_number = %s
|
||||||
|
""",
|
||||||
|
(hub_customer_id, economic_number, economic_number),
|
||||||
|
)
|
||||||
|
|
||||||
|
if customer_name:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tmodule_customers
|
||||||
|
SET hub_customer_id = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE hub_customer_id IS NULL
|
||||||
|
AND LOWER(TRIM(COALESCE(name, ''))) = LOWER(TRIM(%s))
|
||||||
|
""",
|
||||||
|
(hub_customer_id, customer_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Last fallback: run global relink function if available.
|
||||||
|
try:
|
||||||
|
execute_query("SELECT * FROM link_tmodule_customers_to_hub()")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as link_error:
|
||||||
|
logger.warning("⚠️ Auto-link fallback for hub customer %s failed: %s", hub_customer_id, link_error)
|
||||||
|
|
||||||
|
mapped_id = _get_existing_mapping()
|
||||||
|
if mapped_id is not None:
|
||||||
|
return mapped_id
|
||||||
|
|
||||||
|
# Last-resort fallback: create a local tmodule customer stub from Hub customer.
|
||||||
|
# This keeps timer registration operational even if vTiger sync/linking has not run yet.
|
||||||
|
try:
|
||||||
|
hub_customer = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, name, email, economic_customer_number
|
||||||
|
FROM customers
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(hub_customer_id,),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
hub_name = str(hub_customer.get("name") or "").strip()
|
||||||
|
if hub_name:
|
||||||
|
synthetic_vtiger_id = f"HUB-{hub_customer_id}"
|
||||||
|
created = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO tmodule_customers (
|
||||||
|
vtiger_id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
hub_customer_id,
|
||||||
|
economic_customer_number,
|
||||||
|
vtiger_data,
|
||||||
|
sync_hash,
|
||||||
|
updated_at,
|
||||||
|
last_synced_at
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s::jsonb,
|
||||||
|
%s,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (vtiger_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
email = COALESCE(EXCLUDED.email, tmodule_customers.email),
|
||||||
|
hub_customer_id = EXCLUDED.hub_customer_id,
|
||||||
|
economic_customer_number = COALESCE(EXCLUDED.economic_customer_number, tmodule_customers.economic_customer_number),
|
||||||
|
updated_at = NOW(),
|
||||||
|
last_synced_at = NOW()
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
synthetic_vtiger_id,
|
||||||
|
hub_name,
|
||||||
|
hub_customer.get("email"),
|
||||||
|
hub_customer_id,
|
||||||
|
hub_customer.get("economic_customer_number"),
|
||||||
|
'{"source":"hub_fallback"}',
|
||||||
|
f"hub-fallback-{hub_customer_id}",
|
||||||
|
),
|
||||||
|
) or []
|
||||||
|
if created and created[0].get("id") is not None:
|
||||||
|
return int(created[0]["id"])
|
||||||
|
except Exception as create_error:
|
||||||
|
logger.warning("⚠️ Could not create tmodule customer fallback for hub customer %s: %s", hub_customer_id, create_error)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SYNC ENDPOINTS
|
# SYNC ENDPOINTS
|
||||||
|
|||||||
116
app/vendors/backend/router.py
vendored
116
app/vendors/backend/router.py
vendored
@ -5,14 +5,56 @@ Endpoints for managing suppliers and vendors
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
|
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class VendorCustomerLinkCreate(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
relationship_type: Optional[str] = "supplier"
|
||||||
|
|
||||||
|
|
||||||
@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"),
|
||||||
@ -172,3 +214,75 @@ async def delete_vendor(vendor_id: int):
|
|||||||
|
|
||||||
logger.info(f"✅ Deleted vendor: {vendor_id}")
|
logger.info(f"✅ Deleted vendor: {vendor_id}")
|
||||||
return {"message": "Vendor deleted successfully"}
|
return {"message": "Vendor deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vendors/{vendor_id}/customers", tags=["Vendors"])
|
||||||
|
async def list_vendor_customers(vendor_id: int):
|
||||||
|
"""List customers linked to a vendor."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.customer_id,
|
||||||
|
l.vendor_id,
|
||||||
|
l.relationship_type,
|
||||||
|
l.created_at,
|
||||||
|
l.updated_at,
|
||||||
|
c.name AS customer_name,
|
||||||
|
c.email AS customer_email,
|
||||||
|
c.cvr_number AS customer_cvr
|
||||||
|
FROM customer_vendor_links l
|
||||||
|
JOIN customers c ON c.id = l.customer_id
|
||||||
|
WHERE l.vendor_id = %s
|
||||||
|
ORDER BY c.name ASC, l.id ASC
|
||||||
|
""",
|
||||||
|
(vendor_id,),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vendors/{vendor_id}/customers", tags=["Vendors"])
|
||||||
|
async def link_vendor_to_customer(vendor_id: int, payload: VendorCustomerLinkCreate):
|
||||||
|
"""Create link between vendor and customer."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
relationship_type = str(payload.relationship_type or "supplier").strip().lower()
|
||||||
|
if relationship_type 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
|
||||||
|
""",
|
||||||
|
(payload.customer_id, vendor_id, relationship_type),
|
||||||
|
)
|
||||||
|
_ensure_customer_supplier_tag(int(payload.customer_id))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vendors/{vendor_id}/customers/{customer_id}", tags=["Vendors"])
|
||||||
|
async def unlink_vendor_from_customer(vendor_id: int, customer_id: int):
|
||||||
|
"""Delete link between vendor and customer."""
|
||||||
|
deleted = execute_update(
|
||||||
|
"DELETE FROM customer_vendor_links WHERE vendor_id = %s AND customer_id = %s",
|
||||||
|
(vendor_id, customer_id),
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
return {"success": True, "vendor_id": vendor_id, "customer_id": customer_id}
|
||||||
|
|||||||
164
app/vendors/frontend/vendor_detail.html
vendored
164
app/vendors/frontend/vendor_detail.html
vendored
@ -131,6 +131,9 @@
|
|||||||
<a class="nav-link" href="#fakturaer" data-tab="fakturaer">
|
<a class="nav-link" href="#fakturaer" data-tab="fakturaer">
|
||||||
<i class="bi bi-receipt me-2"></i>Fakturaer
|
<i class="bi bi-receipt me-2"></i>Fakturaer
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#kunder" data-tab="kunder">
|
||||||
|
<i class="bi bi-building me-2"></i>Kunder
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#aktivitet" data-tab="aktivitet">
|
<a class="nav-link" href="#aktivitet" data-tab="aktivitet">
|
||||||
<i class="bi bi-clock-history me-2"></i>Aktivitet
|
<i class="bi bi-clock-history me-2"></i>Aktivitet
|
||||||
</a>
|
</a>
|
||||||
@ -224,6 +227,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kunder Tab -->
|
||||||
|
<div class="tab-pane fade" id="kunder">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0 fw-bold">Linked kunder</h5>
|
||||||
|
<span class="badge bg-primary" id="vendorCustomersCount">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="vendorCustomerSearch"
|
||||||
|
placeholder="Søg kunde (navn, email, CVR)"
|
||||||
|
oninput="searchCustomersForVendor(this.value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-md-end">
|
||||||
|
<small class="text-muted">Link leverandør til kunde</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vendorCustomerSearchResults" class="list-group mb-3" style="display:none;"></div>
|
||||||
|
<div id="vendorCustomersList" class="list-group mb-2"></div>
|
||||||
|
<div id="vendorCustomersEmpty" class="text-muted">Ingen linked kunder endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Aktivitet Tab -->
|
<!-- Aktivitet Tab -->
|
||||||
<div class="tab-pane fade" id="aktivitet">
|
<div class="tab-pane fade" id="aktivitet">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -340,12 +372,144 @@ async function loadVendor() {
|
|||||||
}
|
}
|
||||||
const vendor = await response.json();
|
const vendor = await response.json();
|
||||||
displayVendor(vendor);
|
displayVendor(vendor);
|
||||||
|
await loadVendorCustomers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading vendor:', error);
|
console.error('Error loading vendor:', error);
|
||||||
document.getElementById('vendorName').textContent = 'Fejl ved indlæsning';
|
document.getElementById('vendorName').textContent = 'Fejl ved indlæsning';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVendorCustomers() {
|
||||||
|
const listEl = document.getElementById('vendorCustomersList');
|
||||||
|
const emptyEl = document.getElementById('vendorCustomersEmpty');
|
||||||
|
const countEl = document.getElementById('vendorCustomersCount');
|
||||||
|
if (!listEl || !emptyEl || !countEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`);
|
||||||
|
if (!response.ok) throw new Error('Kunne ikke hente linked kunder');
|
||||||
|
const links = await response.json();
|
||||||
|
const rows = Array.isArray(links) ? links : [];
|
||||||
|
|
||||||
|
countEl.textContent = String(rows.length);
|
||||||
|
if (!rows.length) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyEl.classList.add('d-none');
|
||||||
|
listEl.innerHTML = rows.map((row) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(row.customer_name || `Kunde #${row.customer_id}`)}</div>
|
||||||
|
<div class="small text-muted">${row.customer_cvr ? `CVR ${escapeHtml(row.customer_cvr)} · ` : ''}${row.customer_email ? escapeHtml(row.customer_email) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/customers/${row.customer_id}">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="unlinkCustomerFromVendor(${row.customer_id})">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading vendor customers:', error);
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerSearchDebounce = null;
|
||||||
|
async function searchCustomersForVendor(query) {
|
||||||
|
const resultsEl = document.getElementById('vendorCustomerSearchResults');
|
||||||
|
if (!resultsEl) return;
|
||||||
|
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
resultsEl.style.display = 'none';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customerSearchDebounce) window.clearTimeout(customerSearchDebounce);
|
||||||
|
customerSearchDebounce = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(q)}&limit=10&offset=0`);
|
||||||
|
if (!response.ok) throw new Error('Søgning fejlede');
|
||||||
|
const payload = await response.json();
|
||||||
|
const customers = Array.isArray(payload?.customers) ? payload.customers : [];
|
||||||
|
|
||||||
|
if (!customers.length) {
|
||||||
|
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen kunder fundet</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = customers.map((c) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(c.name || '')}</div>
|
||||||
|
<div class="small text-muted">${c.cvr_number ? `CVR ${escapeHtml(c.cvr_number)} · ` : ''}${c.email ? escapeHtml(c.email) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="linkCustomerToVendorFromUI(${c.id})">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Customer search failed:', error);
|
||||||
|
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkCustomerToVendorFromUI(customerId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customer_id: customerId, relationship_type: 'supplier' })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.detail || 'Kunne ikke linke kunde');
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.getElementById('vendorCustomerSearch');
|
||||||
|
const results = document.getElementById('vendorCustomerSearchResults');
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (results) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
results.style.display = 'none';
|
||||||
|
}
|
||||||
|
await loadVendorCustomers();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Kunne ikke linke kunde');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkCustomerFromVendor(customerId) {
|
||||||
|
if (!confirm('Fjern link mellem leverandør og kunde?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/customers/${customerId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.detail || 'Kunne ikke fjerne link');
|
||||||
|
}
|
||||||
|
await loadVendorCustomers();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Kunne ikke fjerne link');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function displayVendor(vendor) {
|
function displayVendor(vendor) {
|
||||||
// Header
|
// Header
|
||||||
document.getElementById('vendorName').textContent = vendor.name;
|
document.getElementById('vendorName').textContent = vendor.name;
|
||||||
|
|||||||
@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS user_module_preferences (
|
|||||||
|
|
||||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
VALUES
|
VALUES
|
||||||
('bottom_bar_enabled', 'false', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
('bottom_bar_enabled', 'true', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
-- Default role access: admins, managers and technicians enabled. viewers disabled.
|
-- Default role access: admins, managers and technicians enabled. viewers disabled.
|
||||||
|
|||||||
33
migrations/170_email_domain_customer_mappings.sql
Normal file
33
migrations/170_email_domain_customer_mappings.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
-- Migration 170: Trusted sender-domain to customer mappings for email triage
|
||||||
|
-- Created: 2026-04-12
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_domain_customer_mappings (
|
||||||
|
domain VARCHAR(255) PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT 'manual',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_customer_id
|
||||||
|
ON email_domain_customer_mappings(customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_source
|
||||||
|
ON email_domain_customer_mappings(source);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_email_domain_customer_mappings_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trigger_email_domain_customer_mappings_updated_at ON email_domain_customer_mappings;
|
||||||
|
CREATE TRIGGER trigger_email_domain_customer_mappings_updated_at
|
||||||
|
BEFORE UPDATE ON email_domain_customer_mappings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_email_domain_customer_mappings_updated_at();
|
||||||
|
|
||||||
|
COMMENT ON TABLE email_domain_customer_mappings IS 'Trusted mappings from sender email domain to customer for fast auto-linking in email triage';
|
||||||
|
COMMENT ON COLUMN email_domain_customer_mappings.source IS 'manual, auto_link, import, etc.';
|
||||||
84
migrations/171_supplier_invoice_v2_lifecycle.sql
Normal file
84
migrations/171_supplier_invoice_v2_lifecycle.sql
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
-- Migration 171: Supplier invoice v2 lifecycle (status, approval, split payments, event log)
|
||||||
|
-- Created: 2026-04-12
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS workflow_status_v2 VARCHAR(20),
|
||||||
|
ADD COLUMN IF NOT EXISTS rejected_by VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS payment_method VARCHAR(100);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoices_workflow_status_v2'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD CONSTRAINT chk_supplier_invoices_workflow_status_v2
|
||||||
|
CHECK (workflow_status_v2 IN ('modtaget', 'godkendt', 'betalt', 'afvist'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Backfill v2 statuses from legacy status values.
|
||||||
|
UPDATE supplier_invoices
|
||||||
|
SET workflow_status_v2 = CASE
|
||||||
|
WHEN status IN ('approved', 'sent_to_economic') THEN 'godkendt'
|
||||||
|
WHEN status = 'paid' THEN 'betalt'
|
||||||
|
WHEN status IN ('cancelled', 'credited', 'rejected') THEN 'afvist'
|
||||||
|
ELSE 'modtaget'
|
||||||
|
END
|
||||||
|
WHERE workflow_status_v2 IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ALTER COLUMN workflow_status_v2 SET DEFAULT 'modtaget';
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ALTER COLUMN workflow_status_v2 SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_workflow_status_v2
|
||||||
|
ON supplier_invoices(workflow_status_v2);
|
||||||
|
|
||||||
|
-- Split payments on supplier invoices.
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_invoice_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
amount DECIMAL(15, 2) NOT NULL CHECK (amount > 0),
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
|
||||||
|
payment_method VARCHAR(100),
|
||||||
|
payment_reference VARCHAR(100),
|
||||||
|
notes TEXT,
|
||||||
|
paid_by VARCHAR(255),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_invoice_id
|
||||||
|
ON supplier_invoice_payments(supplier_invoice_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_date
|
||||||
|
ON supplier_invoice_payments(payment_date);
|
||||||
|
|
||||||
|
-- Event log / outbox-style table for later webhook activation.
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_invoice_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||||
|
event_type VARCHAR(60) NOT NULL,
|
||||||
|
from_status VARCHAR(20),
|
||||||
|
to_status VARCHAR(20),
|
||||||
|
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
webhook_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_invoice_id
|
||||||
|
ON supplier_invoice_events(supplier_invoice_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_webhook_status
|
||||||
|
ON supplier_invoice_events(webhook_status);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN supplier_invoices.workflow_status_v2 IS 'v2 lifecycle: modtaget, godkendt, betalt, afvist';
|
||||||
|
COMMENT ON TABLE supplier_invoice_payments IS 'Partial/split payments for supplier invoices';
|
||||||
|
COMMENT ON TABLE supplier_invoice_events IS 'Supplier invoice event log prepared for future webhook/outbox processing';
|
||||||
39
migrations/172_sag_supplier_flow_type.sql
Normal file
39
migrations/172_sag_supplier_flow_type.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-- Migration 172: Supplier flow type fields on cases
|
||||||
|
-- Created: 2026-04-12
|
||||||
|
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
|
||||||
|
ADD COLUMN IF NOT EXISTS supplier_flow_confidence NUMERIC(4,3);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_sag_sager_supplier_flow_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD CONSTRAINT chk_sag_sager_supplier_flow_type
|
||||||
|
CHECK (supplier_flow_type IS NULL OR supplier_flow_type IN ('varekob', 'ydelse'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_sag_sager_supplier_flow_confidence'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD CONSTRAINT chk_sag_sager_supplier_flow_confidence
|
||||||
|
CHECK (supplier_flow_confidence IS NULL OR (supplier_flow_confidence >= 0 AND supplier_flow_confidence <= 1));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_sager_supplier_flow_type
|
||||||
|
ON sag_sager(supplier_flow_type)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sag_sager.supplier_flow_type IS 'Supplier module type selection: varekob or ydelse';
|
||||||
|
COMMENT ON COLUMN sag_sager.supplier_flow_confidence IS 'AI suggestion confidence (0-1) for supplier flow type';
|
||||||
68
migrations/173_supplier_invoice_flow_metadata.sql
Normal file
68
migrations/173_supplier_invoice_flow_metadata.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- Migration 173: Supplier invoice flow metadata
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
|
||||||
|
ADD COLUMN IF NOT EXISTS tags_json JSONB DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS linked_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS linked_order_id BIGINT,
|
||||||
|
ADD COLUMN IF NOT EXISTS linked_order_source VARCHAR(50);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoices_supplier_flow_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD CONSTRAINT chk_supplier_invoices_supplier_flow_type
|
||||||
|
CHECK (
|
||||||
|
supplier_flow_type IS NULL
|
||||||
|
OR supplier_flow_type IN ('varekob', 'ydelse')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoices_linked_order_source'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD CONSTRAINT chk_supplier_invoices_linked_order_source
|
||||||
|
CHECK (
|
||||||
|
linked_order_source IS NULL
|
||||||
|
OR linked_order_source IN ('tmodule_orders', 'webshop_orders')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_supplier_flow_type
|
||||||
|
ON supplier_invoices(supplier_flow_type)
|
||||||
|
WHERE supplier_flow_type IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_source_email_id
|
||||||
|
ON supplier_invoices(source_email_id)
|
||||||
|
WHERE source_email_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_customer_id
|
||||||
|
ON supplier_invoices(linked_customer_id)
|
||||||
|
WHERE linked_customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_order_id
|
||||||
|
ON supplier_invoices(linked_order_id)
|
||||||
|
WHERE linked_order_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_tags_json
|
||||||
|
ON supplier_invoices USING GIN (tags_json);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN supplier_invoices.supplier_flow_type IS 'Flow type: varekob or ydelse';
|
||||||
|
COMMENT ON COLUMN supplier_invoices.tags_json IS 'Tag list as JSON array';
|
||||||
|
COMMENT ON COLUMN supplier_invoices.source_email_id IS 'Source email message id';
|
||||||
|
COMMENT ON COLUMN supplier_invoices.linked_customer_id IS 'Linked customer context';
|
||||||
|
COMMENT ON COLUMN supplier_invoices.linked_order_id IS 'Linked order id';
|
||||||
|
COMMENT ON COLUMN supplier_invoices.linked_order_source IS 'Order table source: tmodule_orders or webshop_orders';
|
||||||
88
migrations/174_supplier_invoice_line_handling.sql
Normal file
88
migrations/174_supplier_invoice_line_handling.sql
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
-- Migration 174: Supplier invoice line handling fields
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoice_lines
|
||||||
|
ADD COLUMN IF NOT EXISTS handling_type VARCHAR(40),
|
||||||
|
ADD COLUMN IF NOT EXISTS target_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS target_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS target_employee_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS requires_serial BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS resale_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_lines_handling_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_lines
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_lines_handling_type
|
||||||
|
CHECK (
|
||||||
|
handling_type IS NULL
|
||||||
|
OR handling_type IN (
|
||||||
|
'fakturer_videre',
|
||||||
|
'asset',
|
||||||
|
'intern_brug',
|
||||||
|
'projekt_omkostning',
|
||||||
|
'lager',
|
||||||
|
'retur_reklamation'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_lines_serial_requirement'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_lines
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_lines_serial_requirement
|
||||||
|
CHECK (
|
||||||
|
requires_serial = FALSE
|
||||||
|
OR (serial_number IS NOT NULL AND btrim(serial_number) <> '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_handling_type
|
||||||
|
ON supplier_invoice_lines(handling_type)
|
||||||
|
WHERE handling_type IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_customer_id
|
||||||
|
ON supplier_invoice_lines(target_customer_id)
|
||||||
|
WHERE target_customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_sag_id
|
||||||
|
ON supplier_invoice_lines(target_sag_id)
|
||||||
|
WHERE target_sag_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_employee_id
|
||||||
|
ON supplier_invoice_lines(target_employee_id)
|
||||||
|
WHERE target_employee_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_asset_id
|
||||||
|
ON supplier_invoice_lines(asset_id)
|
||||||
|
WHERE asset_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_resale_ready
|
||||||
|
ON supplier_invoice_lines(resale_ready)
|
||||||
|
WHERE resale_ready = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_serial_number
|
||||||
|
ON supplier_invoice_lines(serial_number)
|
||||||
|
WHERE serial_number IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.handling_type IS 'Line handling: fakturer_videre, asset, intern_brug, projekt_omkostning, lager, retur_reklamation';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.target_customer_id IS 'Customer target for line allocation';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.target_sag_id IS 'Case target for line allocation';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.target_employee_id IS 'Employee target for line allocation';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.requires_serial IS 'Whether serial number is required';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.serial_number IS 'Serial number when required';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.asset_id IS 'Linked hardware asset id';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_lines.resale_ready IS 'Whether line is ready for resale flow';
|
||||||
48
migrations/175_supplier_invoice_attachments.sql
Normal file
48
migrations/175_supplier_invoice_attachments.sql
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
-- Migration 175: Supplier invoice attachments
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_invoice_attachments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||||
|
source_type VARCHAR(30) NOT NULL DEFAULT 'upload',
|
||||||
|
source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||||
|
source_email_attachment_id INTEGER REFERENCES email_attachments(id) ON DELETE SET NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(120),
|
||||||
|
file_path TEXT,
|
||||||
|
size_bytes BIGINT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_attachments_source_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_attachments
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_attachments_source_type
|
||||||
|
CHECK (source_type IN ('email', 'upload', 'manual'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_supplier_invoice_id
|
||||||
|
ON supplier_invoice_attachments(supplier_invoice_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_id
|
||||||
|
ON supplier_invoice_attachments(source_email_id)
|
||||||
|
WHERE source_email_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_attachment_id
|
||||||
|
ON supplier_invoice_attachments(source_email_attachment_id)
|
||||||
|
WHERE source_email_attachment_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_type
|
||||||
|
ON supplier_invoice_attachments(source_type);
|
||||||
|
|
||||||
|
COMMENT ON TABLE supplier_invoice_attachments IS 'Attachments linked to supplier invoices';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_attachments.source_type IS 'Attachment source: email, upload, or manual';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_attachments.source_email_id IS 'Source email id when attachment came from email';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_attachments.source_email_attachment_id IS 'Source email attachment id when available';
|
||||||
42
migrations/176_supplier_invoice_relations.sql
Normal file
42
migrations/176_supplier_invoice_relations.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
-- Migration 176: Supplier invoice generic relations
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_invoice_relations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||||
|
relation_type VARCHAR(40) NOT NULL,
|
||||||
|
relation_id BIGINT NOT NULL,
|
||||||
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_relations_relation_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_relations
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_relations_relation_type
|
||||||
|
CHECK (
|
||||||
|
relation_type IN ('sag', 'kunde', 'ordre', 'asset', 'reklamation_sag', 'email')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_type_id
|
||||||
|
ON supplier_invoice_relations(supplier_invoice_id, relation_type, relation_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_relations_lookup
|
||||||
|
ON supplier_invoice_relations(relation_type, relation_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_primary_per_type
|
||||||
|
ON supplier_invoice_relations(supplier_invoice_id, relation_type)
|
||||||
|
WHERE is_primary = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE supplier_invoice_relations IS 'Generic relation map from supplier invoices to related entities';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_relations.relation_type IS 'Relation type: sag, kunde, ordre, asset, reklamation_sag, email';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_relations.relation_id IS 'Id of the related entity';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_relations.is_primary IS 'Primary relation flag within relation type';
|
||||||
85
migrations/177_supplier_invoice_reminders.sql
Normal file
85
migrations/177_supplier_invoice_reminders.sql
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
-- Migration 177: Supplier invoice reminders
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplier_invoice_reminders (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||||
|
reminder_type VARCHAR(30) NOT NULL,
|
||||||
|
remind_at TIMESTAMP NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMP,
|
||||||
|
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
|
||||||
|
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_reminders_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_reminders
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_reminders_type
|
||||||
|
CHECK (reminder_type IN ('due_soon', 'overdue', 'manual'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_reminders_status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_reminders
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_reminders_status
|
||||||
|
CHECK (status IN ('pending', 'sent', 'cancelled', 'failed'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_supplier_invoice_reminders_channel'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE supplier_invoice_reminders
|
||||||
|
ADD CONSTRAINT chk_supplier_invoice_reminders_channel
|
||||||
|
CHECK (channel IN ('in_app', 'email'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_supplier_invoice_id
|
||||||
|
ON supplier_invoice_reminders(supplier_invoice_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_remind_at
|
||||||
|
ON supplier_invoice_reminders(remind_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_status
|
||||||
|
ON supplier_invoice_reminders(status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_payload_json
|
||||||
|
ON supplier_invoice_reminders USING GIN (payload_json);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_supplier_invoice_reminders_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_supplier_invoice_reminders_updated_at ON supplier_invoice_reminders;
|
||||||
|
CREATE TRIGGER trg_supplier_invoice_reminders_updated_at
|
||||||
|
BEFORE UPDATE ON supplier_invoice_reminders
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_supplier_invoice_reminders_updated_at();
|
||||||
|
|
||||||
|
COMMENT ON TABLE supplier_invoice_reminders IS 'Scheduled reminders for supplier invoice follow-up';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_reminders.reminder_type IS 'due_soon, overdue, or manual';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_reminders.status IS 'pending, sent, cancelled, or failed';
|
||||||
|
COMMENT ON COLUMN supplier_invoice_reminders.channel IS 'Delivery channel: in_app or email';
|
||||||
81
migrations/178_customer_vendor_links.sql
Normal file
81
migrations/178_customer_vendor_links.sql
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
-- Migration 178: Link customers and vendors (many-to-many)
|
||||||
|
-- Created: 2026-04-13
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_vendor_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||||
|
relationship_type VARCHAR(50) NOT NULL DEFAULT 'supplier',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (customer_id, vendor_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_customer_vendor_links_relationship_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE customer_vendor_links
|
||||||
|
ADD CONSTRAINT chk_customer_vendor_links_relationship_type
|
||||||
|
CHECK (relationship_type IN ('supplier', 'reseller', 'partner'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_customer_id
|
||||||
|
ON customer_vendor_links(customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_vendor_id
|
||||||
|
ON customer_vendor_links(vendor_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_relationship_type
|
||||||
|
ON customer_vendor_links(relationship_type);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_customer_vendor_links_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_customer_vendor_links_updated_at ON customer_vendor_links;
|
||||||
|
CREATE TRIGGER trg_customer_vendor_links_updated_at
|
||||||
|
BEFORE UPDATE ON customer_vendor_links
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_customer_vendor_links_updated_at();
|
||||||
|
|
||||||
|
-- Backfill links by CVR first (most reliable)
|
||||||
|
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||||
|
SELECT c.id, v.id, 'supplier'
|
||||||
|
FROM customers c
|
||||||
|
JOIN vendors v
|
||||||
|
ON regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') <> ''
|
||||||
|
AND regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g') <> ''
|
||||||
|
AND regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') = regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g')
|
||||||
|
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backfill by exact email match
|
||||||
|
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||||
|
SELECT c.id, v.id, 'supplier'
|
||||||
|
FROM customers c
|
||||||
|
JOIN vendors v
|
||||||
|
ON LOWER(TRIM(COALESCE(c.email, ''))) <> ''
|
||||||
|
AND LOWER(TRIM(COALESCE(v.email, ''))) <> ''
|
||||||
|
AND LOWER(TRIM(COALESCE(c.email, ''))) = LOWER(TRIM(COALESCE(v.email, '')))
|
||||||
|
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backfill by exact normalized name match
|
||||||
|
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||||
|
SELECT c.id, v.id, 'supplier'
|
||||||
|
FROM customers c
|
||||||
|
JOIN vendors v
|
||||||
|
ON LOWER(TRIM(COALESCE(c.name, ''))) <> ''
|
||||||
|
AND LOWER(TRIM(COALESCE(v.name, ''))) <> ''
|
||||||
|
AND LOWER(TRIM(COALESCE(c.name, ''))) = LOWER(TRIM(COALESCE(v.name, '')))
|
||||||
|
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE customer_vendor_links IS 'Links customers to vendors to support entities that are both customer and supplier';
|
||||||
|
COMMENT ON COLUMN customer_vendor_links.relationship_type IS 'supplier, reseller, or partner';
|
||||||
Loading…
Reference in New Issue
Block a user