1395 lines
55 KiB
Python
1395 lines
55 KiB
Python
import json
|
|
import logging
|
|
from typing import Any, Dict, Optional
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MissionService:
|
|
MISSION_CASE_TYPES = {"ticket", "opgave", "ordre", "projekt", "service"}
|
|
|
|
@staticmethod
|
|
def _safe(label: str, func, default):
|
|
try:
|
|
return func()
|
|
except Exception as exc:
|
|
logger.error("❌ Mission state component failed: %s (%s)", label, exc)
|
|
return default
|
|
|
|
@staticmethod
|
|
def _table_exists(table_name: str) -> bool:
|
|
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
|
|
return bool(row and row.get("table_name"))
|
|
|
|
@staticmethod
|
|
def get_ring_timeout_seconds() -> int:
|
|
raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
value = 180
|
|
return max(30, min(value, 3600))
|
|
|
|
@staticmethod
|
|
def expire_stale_ringing_calls() -> None:
|
|
if not MissionService._table_exists("mission_call_state"):
|
|
return
|
|
|
|
timeout_seconds = MissionService.get_ring_timeout_seconds()
|
|
execute_query(
|
|
"""
|
|
UPDATE mission_call_state
|
|
SET state = 'hangup',
|
|
ended_at = COALESCE(ended_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE state = 'ringing'
|
|
AND started_at < (NOW() - make_interval(secs => %s))
|
|
""",
|
|
(timeout_seconds,),
|
|
)
|
|
|
|
@staticmethod
|
|
def get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
|
|
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
|
|
if not row:
|
|
return default
|
|
value = row.get("value")
|
|
if value is None or value == "":
|
|
return default
|
|
return str(value)
|
|
|
|
@staticmethod
|
|
def parse_json_setting(key: str, default: Any) -> Any:
|
|
raw = MissionService.get_setting_value(key, None)
|
|
if raw is None:
|
|
return default
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return default
|
|
|
|
@staticmethod
|
|
def build_alert_key(service_name: str, customer_name: Optional[str]) -> str:
|
|
customer_part = (customer_name or "").strip().lower() or "global"
|
|
return f"{service_name.strip().lower()}::{customer_part}"
|
|
|
|
@staticmethod
|
|
def resolve_contact_context(caller_number: Optional[str]) -> Dict[str, Optional[str]]:
|
|
if not caller_number:
|
|
return {"contact_name": None, "company_name": None, "customer_tag": None}
|
|
|
|
query = """
|
|
SELECT
|
|
c.id,
|
|
c.first_name,
|
|
c.last_name,
|
|
(
|
|
SELECT cu.name
|
|
FROM contact_companies cc
|
|
JOIN customers cu ON cu.id = cc.customer_id
|
|
WHERE cc.contact_id = c.id
|
|
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
|
LIMIT 1
|
|
) AS company_name,
|
|
(
|
|
SELECT t.name
|
|
FROM entity_tags et
|
|
JOIN tags t ON t.id = et.tag_id
|
|
WHERE et.entity_type = 'contact'
|
|
AND et.entity_id = c.id
|
|
AND LOWER(t.name) IN ('vip', 'serviceaftale', 'service agreement')
|
|
ORDER BY t.name
|
|
LIMIT 1
|
|
) AS customer_tag
|
|
FROM contacts c
|
|
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
|
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8)
|
|
ORDER BY (
|
|
SELECT COUNT(*)
|
|
FROM sag_kontakter sk
|
|
JOIN sag_sager s ON s.id = sk.sag_id
|
|
WHERE sk.contact_id = c.id
|
|
AND sk.deleted_at IS NULL
|
|
AND s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) <> 'lukket'
|
|
) DESC,
|
|
(
|
|
SELECT MAX(t.started_at)
|
|
FROM telefoni_opkald t
|
|
WHERE t.kontakt_id = c.id
|
|
) DESC NULLS LAST,
|
|
c.id ASC
|
|
LIMIT 1
|
|
"""
|
|
row = execute_query_single(query, (caller_number, caller_number))
|
|
if not row:
|
|
return {"contact_name": None, "company_name": None, "customer_tag": None}
|
|
|
|
contact_name = f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip() or None
|
|
return {
|
|
"contact_name": contact_name,
|
|
"company_name": row.get("company_name"),
|
|
"customer_tag": row.get("customer_tag"),
|
|
}
|
|
|
|
@staticmethod
|
|
def insert_event(
|
|
*,
|
|
event_type: str,
|
|
title: str,
|
|
severity: str = "info",
|
|
source: Optional[str] = None,
|
|
customer_name: Optional[str] = None,
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
if not MissionService._table_exists("mission_events"):
|
|
logger.warning("Mission table missing: mission_events (event skipped)")
|
|
return {}
|
|
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload)
|
|
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
|
RETURNING id, event_type, severity, title, source, customer_name, payload, created_at
|
|
""",
|
|
(event_type, severity, title, source, customer_name, json.dumps(payload or {})),
|
|
)
|
|
return rows[0] if rows else {}
|
|
|
|
@staticmethod
|
|
def get_kpis() -> Dict[str, int]:
|
|
query = """
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet') AS open_cases,
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) = 'åben' AND ansvarlig_bruger_id IS NULL) AS new_cases,
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND ansvarlig_bruger_id IS NULL) AS unassigned_cases,
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS deadlines_today,
|
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_deadlines
|
|
FROM sag_sager
|
|
"""
|
|
row = execute_query_single(query) or {}
|
|
return {
|
|
"open_cases": int(row.get("open_cases") or 0),
|
|
"new_cases": int(row.get("new_cases") or 0),
|
|
"unassigned_cases": int(row.get("unassigned_cases") or 0),
|
|
"deadlines_today": int(row.get("deadlines_today") or 0),
|
|
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
|
|
}
|
|
|
|
@staticmethod
|
|
def get_employee_deadlines() -> list[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
COALESCE(u.full_name, u.username, 'Ukendt') AS employee_name,
|
|
COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) AS deadlines_today,
|
|
COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) AS overdue_deadlines
|
|
FROM sag_sager s
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
WHERE s.deleted_at IS NULL
|
|
AND LOWER(s.status) <> 'afsluttet'
|
|
AND s.deadline IS NOT NULL
|
|
GROUP BY COALESCE(u.full_name, u.username, 'Ukendt')
|
|
HAVING COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) > 0
|
|
OR COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) > 0
|
|
ORDER BY overdue_deadlines DESC, deadlines_today DESC, employee_name ASC
|
|
"""
|
|
) or []
|
|
return [
|
|
{
|
|
"employee_name": row.get("employee_name"),
|
|
"deadlines_today": int(row.get("deadlines_today") or 0),
|
|
"overdue_deadlines": int(row.get("overdue_deadlines") or 0),
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
@staticmethod
|
|
def get_active_calls() -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_call_state"):
|
|
logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
|
|
return []
|
|
|
|
MissionService.expire_stale_ringing_calls()
|
|
rows = execute_query(
|
|
"""
|
|
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at
|
|
FROM mission_call_state
|
|
WHERE state = 'ringing'
|
|
ORDER BY started_at DESC
|
|
"""
|
|
)
|
|
return rows or []
|
|
|
|
@staticmethod
|
|
def get_active_alerts() -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_uptime_alerts"):
|
|
logger.warning("Mission table missing: mission_uptime_alerts (active alerts unavailable)")
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at
|
|
FROM mission_uptime_alerts
|
|
WHERE is_active = TRUE
|
|
ORDER BY started_at ASC NULLS LAST
|
|
"""
|
|
)
|
|
return rows or []
|
|
|
|
@staticmethod
|
|
def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_events"):
|
|
logger.warning("Mission table missing: mission_events (live feed unavailable)")
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT id, event_type, severity, title, source, customer_name, payload, created_at
|
|
FROM mission_events
|
|
ORDER BY created_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return rows or []
|
|
|
|
@staticmethod
|
|
def _normalize_case_type(template_key: Optional[str]) -> str:
|
|
key = (template_key or "").strip().lower()
|
|
if not key:
|
|
return "opgave"
|
|
|
|
if "ticket" in key:
|
|
return "ticket"
|
|
if "ordre" in key or "order" in key:
|
|
return "ordre"
|
|
if "projekt" in key or "project" in key or "pipeline" in key:
|
|
return "projekt"
|
|
if "service" in key or "support" in key:
|
|
return "service"
|
|
if "opgave" in key or "task" in key:
|
|
return "opgave"
|
|
return "opgave"
|
|
|
|
@staticmethod
|
|
def get_important_cases(limit: int = 80) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("sag_sager"):
|
|
logger.warning("Mission table missing: sag_sager (important cases unavailable)")
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.priority,
|
|
s.deadline,
|
|
s.template_key,
|
|
s.created_at,
|
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
|
FROM sag_sager s
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
WHERE s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
|
ORDER BY
|
|
CASE LOWER(COALESCE(s.priority::text, ''))
|
|
WHEN 'kritisk' THEN 5
|
|
WHEN 'critical' THEN 5
|
|
WHEN 'høj' THEN 4
|
|
WHEN 'hoj' THEN 4
|
|
WHEN 'high' THEN 4
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'lav' THEN 1
|
|
WHEN 'low' THEN 1
|
|
ELSE 2
|
|
END DESC,
|
|
s.deadline ASC NULLS LAST,
|
|
s.created_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
|
|
result: list[Dict[str, Any]] = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
normalized_type = MissionService._normalize_case_type(item.get("template_key"))
|
|
if normalized_type not in MissionService.MISSION_CASE_TYPES:
|
|
normalized_type = "opgave"
|
|
item["case_type"] = normalized_type
|
|
result.append(item)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _project_risk_level_from_score(score: int) -> str:
|
|
if score <= 29:
|
|
return "low"
|
|
if score <= 69:
|
|
return "medium"
|
|
if score <= 119:
|
|
return "high"
|
|
return "critical"
|
|
|
|
@staticmethod
|
|
def _compute_project_risk(project_row: Dict[str, Any]) -> Dict[str, Any]:
|
|
score = int(project_row.get("score") or 0)
|
|
factors: list[str] = []
|
|
|
|
overdue_tasks = int(project_row.get("overdue_tasks") or 0)
|
|
open_blockers = int(project_row.get("open_blockers") or 0)
|
|
overdue_milestones = int(project_row.get("overdue_milestones") or 0)
|
|
|
|
if overdue_tasks > 0:
|
|
score += min(overdue_tasks * 8, 40)
|
|
factors.append("overdue_tasks")
|
|
if overdue_milestones > 0:
|
|
score += min(overdue_milestones * 10, 40)
|
|
factors.append("overdue_milestones")
|
|
if open_blockers > 0:
|
|
score += min(open_blockers * 15, 60)
|
|
factors.append("open_blockers")
|
|
|
|
return {
|
|
"risk_score": score,
|
|
"risk_level": MissionService._project_risk_level_from_score(score),
|
|
"risk_factors": factors,
|
|
}
|
|
|
|
@staticmethod
|
|
def get_project_workload(limit: int = 100) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects"):
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
COUNT(*) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS open_tasks,
|
|
COUNT(*) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND s.deadline IS NOT NULL
|
|
AND s.deadline::date < CURRENT_DATE
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS overdue_tasks,
|
|
COUNT(*) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND s.ansvarlig_bruger_id IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS unassigned_tasks
|
|
FROM mission_projects p
|
|
LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
|
|
GROUP BY p.id, p.name
|
|
ORDER BY overdue_tasks DESC, unassigned_tasks DESC, open_tasks DESC, p.id DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
@staticmethod
|
|
def _get_projects_from_cases(limit: int = 120) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("sag_sager"):
|
|
return []
|
|
|
|
case_rows = execute_query(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
s.titel AS name,
|
|
s.beskrivelse AS description,
|
|
LOWER(COALESCE(s.status, 'active')) AS status,
|
|
0 AS score,
|
|
s.start_date AS started_at,
|
|
s.deadline AS ended_at,
|
|
s.created_at AS updated_at,
|
|
0 AS active_milestones,
|
|
0 AS overdue_milestones,
|
|
0 AS open_blockers,
|
|
1 AS open_tasks,
|
|
CASE WHEN s.deadline IS NOT NULL AND s.deadline::date = CURRENT_DATE THEN 1 ELSE 0 END AS due_today_tasks,
|
|
CASE
|
|
WHEN s.deadline IS NOT NULL
|
|
AND s.deadline::date < CURRENT_DATE
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
THEN 1
|
|
ELSE 0
|
|
END AS overdue_tasks
|
|
FROM sag_sager s
|
|
WHERE s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
AND (
|
|
LOWER(COALESCE(s.template_key, '')) IN ('projekt', 'project')
|
|
OR LOWER(COALESCE(s.project_task_type, '')) = 'project'
|
|
OR s.project_id IS NOT NULL
|
|
)
|
|
ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
|
|
fallback: list[Dict[str, Any]] = []
|
|
for row in case_rows:
|
|
item = dict(row)
|
|
item.update(MissionService._compute_project_risk(item))
|
|
fallback.append(item)
|
|
return fallback
|
|
|
|
@staticmethod
|
|
def get_projects(limit: int = 120) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects"):
|
|
return MissionService._get_projects_from_cases(limit)
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
p.description,
|
|
p.status,
|
|
p.score,
|
|
p.started_at,
|
|
p.ended_at,
|
|
p.updated_at,
|
|
COUNT(DISTINCT m.id) FILTER (
|
|
WHERE m.status NOT IN ('completed', 'cancelled')
|
|
) AS active_milestones,
|
|
COUNT(DISTINCT m.id) FILTER (
|
|
WHERE m.target_date IS NOT NULL
|
|
AND m.target_date < CURRENT_DATE
|
|
AND m.status NOT IN ('completed', 'cancelled')
|
|
) AS overdue_milestones,
|
|
COUNT(DISTINCT b.id) FILTER (
|
|
WHERE b.status NOT IN ('resolved', 'cancelled')
|
|
) AS open_blockers,
|
|
COUNT(DISTINCT s.id) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS open_tasks,
|
|
COUNT(DISTINCT s.id) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND s.deadline IS NOT NULL
|
|
AND s.deadline::date = CURRENT_DATE
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS due_today_tasks,
|
|
COUNT(DISTINCT s.id) FILTER (
|
|
WHERE s.id IS NOT NULL
|
|
AND s.deadline IS NOT NULL
|
|
AND s.deadline::date < CURRENT_DATE
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
) AS overdue_tasks
|
|
FROM mission_projects p
|
|
LEFT JOIN mission_project_milestones m ON m.project_id = p.id
|
|
LEFT JOIN mission_project_blockers b ON b.project_id = p.id
|
|
LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
|
|
GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at
|
|
ORDER BY p.updated_at DESC, p.id DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
|
|
result: list[Dict[str, Any]] = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item.update(MissionService._compute_project_risk(item))
|
|
result.append(item)
|
|
|
|
# Important fallback: migration may have created mission_projects but with zero rows.
|
|
if not result:
|
|
return MissionService._get_projects_from_cases(limit)
|
|
return result
|
|
|
|
@staticmethod
|
|
def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects"):
|
|
return None
|
|
|
|
rows = MissionService.get_projects(limit=200)
|
|
project = next((item for item in rows if int(item.get("id") or 0) == int(project_id)), None)
|
|
if not project:
|
|
return None
|
|
|
|
milestones = execute_query(
|
|
"""
|
|
SELECT id, project_id, title, description, status, target_date, created_at, updated_at
|
|
FROM mission_project_milestones
|
|
WHERE project_id = %s
|
|
ORDER BY target_date ASC NULLS LAST, id DESC
|
|
""",
|
|
(project_id,),
|
|
) or []
|
|
|
|
blockers = execute_query(
|
|
"""
|
|
SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
|
|
FROM mission_project_blockers
|
|
WHERE project_id = %s
|
|
ORDER BY
|
|
CASE severity
|
|
WHEN 'critical' THEN 4
|
|
WHEN 'high' THEN 3
|
|
WHEN 'medium' THEN 2
|
|
WHEN 'low' THEN 1
|
|
ELSE 0
|
|
END DESC,
|
|
id DESC
|
|
""",
|
|
(project_id,),
|
|
) or []
|
|
|
|
tasks = execute_query(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.priority,
|
|
s.deadline,
|
|
s.ansvarlig_bruger_id,
|
|
s.project_milestone_id,
|
|
s.is_project_blocker,
|
|
s.project_task_type,
|
|
s.created_at,
|
|
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
|
|
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
|
|
FROM sag_sager s
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
) AS open_todo_count,
|
|
ARRAY_REMOVE(
|
|
ARRAY_AGG(
|
|
CASE
|
|
WHEN t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
THEN t.title
|
|
END
|
|
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
|
),
|
|
NULL
|
|
) AS open_todo_titles
|
|
FROM sag_todo_steps t
|
|
WHERE t.sag_id = s.id
|
|
) ts ON TRUE
|
|
WHERE s.deleted_at IS NULL
|
|
AND s.project_id = %s
|
|
ORDER BY
|
|
s.deadline ASC NULLS LAST,
|
|
s.created_at DESC
|
|
LIMIT 200
|
|
""",
|
|
(project_id,),
|
|
) or []
|
|
|
|
project_open_todo_count = 0
|
|
project_open_todo_titles: list[str] = []
|
|
if MissionService._table_exists("sag_todo_steps"):
|
|
project_todo_row = execute_query_single(
|
|
"""
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
) AS open_todo_count,
|
|
ARRAY_REMOVE(
|
|
ARRAY_AGG(
|
|
CASE
|
|
WHEN t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
THEN t.title
|
|
END
|
|
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
|
),
|
|
NULL
|
|
) AS open_todo_titles
|
|
FROM sag_todo_steps t
|
|
WHERE t.sag_id = %s
|
|
""",
|
|
(project_id,),
|
|
) or {}
|
|
project_open_todo_count = int(project_todo_row.get("open_todo_count") or 0)
|
|
titles_raw = project_todo_row.get("open_todo_titles") or []
|
|
if isinstance(titles_raw, list):
|
|
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
|
|
|
|
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
|
|
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
|
if not tasks and MissionService._table_exists("sag_relationer"):
|
|
tasks = execute_query(
|
|
"""
|
|
WITH related AS (
|
|
SELECT
|
|
sr.målsag_id AS task_id,
|
|
sr.relationstype AS relation_type
|
|
FROM sag_relationer sr
|
|
WHERE sr.deleted_at IS NULL
|
|
AND sr.kilde_sag_id = %s
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
sr.kilde_sag_id AS task_id,
|
|
sr.relationstype AS relation_type
|
|
FROM sag_relationer sr
|
|
WHERE sr.deleted_at IS NULL
|
|
AND sr.målsag_id = %s
|
|
)
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.priority,
|
|
s.deadline,
|
|
s.ansvarlig_bruger_id,
|
|
s.project_milestone_id,
|
|
s.is_project_blocker,
|
|
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
|
|
s.created_at,
|
|
COALESCE(ts.open_todo_count, 0) AS open_todo_count,
|
|
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles
|
|
FROM related r
|
|
JOIN sag_sager s ON s.id = r.task_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
) AS open_todo_count,
|
|
ARRAY_REMOVE(
|
|
ARRAY_AGG(
|
|
CASE
|
|
WHEN t.deleted_at IS NULL
|
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
THEN t.title
|
|
END
|
|
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
|
),
|
|
NULL
|
|
) AS open_todo_titles
|
|
FROM sag_todo_steps t
|
|
WHERE t.sag_id = s.id
|
|
) ts ON TRUE
|
|
WHERE s.deleted_at IS NULL
|
|
AND s.id <> %s
|
|
ORDER BY
|
|
s.deadline ASC NULLS LAST,
|
|
s.created_at DESC
|
|
LIMIT 200
|
|
""",
|
|
(project_id, project_id, project_id),
|
|
) or []
|
|
|
|
return {
|
|
**project,
|
|
"milestones": [dict(row) for row in milestones],
|
|
"blockers": [dict(row) for row in blockers],
|
|
"tasks": [dict(row) for row in tasks],
|
|
"project_open_todo_count": project_open_todo_count,
|
|
"project_open_todo_titles": project_open_todo_titles,
|
|
}
|
|
|
|
@staticmethod
|
|
def get_projects_state_payload(limit: int = 120) -> Dict[str, Any]:
|
|
projects = MissionService.get_projects(limit)
|
|
workload = MissionService.get_project_workload(limit)
|
|
|
|
details: Dict[str, Any] = {}
|
|
for project in projects:
|
|
project_id = int(project.get("id") or 0)
|
|
if project_id <= 0:
|
|
continue
|
|
detail = MissionService.get_project_detail(project_id)
|
|
if detail:
|
|
details[str(project_id)] = detail
|
|
|
|
total = len(projects)
|
|
active = len([p for p in projects if str(p.get("status") or "").lower() == "active"])
|
|
high_risk = len([p for p in projects if p.get("risk_level") in {"high", "critical"}])
|
|
blocked = len([p for p in projects if int(p.get("open_blockers") or 0) > 0])
|
|
due_today = sum(int(p.get("due_today_tasks") or 0) for p in projects)
|
|
|
|
return {
|
|
"summary": {
|
|
"total": total,
|
|
"active": active,
|
|
"high_risk": high_risk,
|
|
"blocked": blocked,
|
|
"due_today": due_today,
|
|
},
|
|
"projects": projects,
|
|
"workload": workload,
|
|
"details": details,
|
|
}
|
|
|
|
@staticmethod
|
|
def create_project(payload: Dict[str, Any], actor_user_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects"):
|
|
return None
|
|
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
return None
|
|
|
|
description = str(payload.get("description") or "").strip() or None
|
|
status = str(payload.get("status") or "planned").strip().lower()
|
|
if status not in {"planned", "active", "paused", "completed", "cancelled"}:
|
|
status = "planned"
|
|
|
|
try:
|
|
score = int(payload.get("score") or 0)
|
|
except (TypeError, ValueError):
|
|
score = 0
|
|
|
|
started_at = payload.get("started_at")
|
|
ended_at = payload.get("ended_at")
|
|
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO mission_projects (
|
|
name,
|
|
description,
|
|
status,
|
|
score,
|
|
started_at,
|
|
ended_at,
|
|
created_by,
|
|
payload,
|
|
updated_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
|
RETURNING id
|
|
""",
|
|
(
|
|
name,
|
|
description,
|
|
status,
|
|
score,
|
|
started_at,
|
|
ended_at,
|
|
actor_user_id,
|
|
json.dumps(payload.get("payload") or {}),
|
|
),
|
|
) or []
|
|
|
|
if not rows:
|
|
return None
|
|
return MissionService.get_project_detail(int(rows[0].get("id")))
|
|
|
|
@staticmethod
|
|
def update_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects"):
|
|
return None
|
|
|
|
updates: list[str] = []
|
|
values: list[Any] = []
|
|
|
|
if "name" in payload:
|
|
name = str(payload.get("name") or "").strip()
|
|
if name:
|
|
updates.append("name = %s")
|
|
values.append(name)
|
|
|
|
if "description" in payload:
|
|
updates.append("description = %s")
|
|
values.append(str(payload.get("description") or "").strip() or None)
|
|
|
|
if "status" in payload:
|
|
status = str(payload.get("status") or "").strip().lower()
|
|
if status in {"planned", "active", "paused", "completed", "cancelled"}:
|
|
updates.append("status = %s")
|
|
values.append(status)
|
|
|
|
if "score" in payload:
|
|
try:
|
|
updates.append("score = %s")
|
|
values.append(int(payload.get("score") or 0))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
if "started_at" in payload:
|
|
updates.append("started_at = %s")
|
|
values.append(payload.get("started_at"))
|
|
|
|
if "ended_at" in payload:
|
|
updates.append("ended_at = %s")
|
|
values.append(payload.get("ended_at"))
|
|
|
|
if not updates:
|
|
return MissionService.get_project_detail(project_id)
|
|
|
|
updates.append("updated_at = NOW()")
|
|
values.append(project_id)
|
|
|
|
execute_query(
|
|
f"""
|
|
UPDATE mission_projects
|
|
SET {', '.join(updates)}
|
|
WHERE id = %s
|
|
""",
|
|
tuple(values),
|
|
)
|
|
|
|
return MissionService.get_project_detail(project_id)
|
|
|
|
@staticmethod
|
|
def add_project_milestone(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_project_milestones"):
|
|
return None
|
|
|
|
title = str(payload.get("title") or "").strip()
|
|
if not title:
|
|
return None
|
|
|
|
description = str(payload.get("description") or "").strip() or None
|
|
status = str(payload.get("status") or "active").strip().lower()
|
|
if status not in {"active", "completed", "cancelled"}:
|
|
status = "active"
|
|
target_date = payload.get("target_date")
|
|
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO mission_project_milestones (project_id, title, description, status, target_date, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, NOW())
|
|
RETURNING id, project_id, title, description, status, target_date, created_at, updated_at
|
|
""",
|
|
(project_id, title, description, status, target_date),
|
|
) or []
|
|
|
|
return dict(rows[0]) if rows else None
|
|
|
|
@staticmethod
|
|
def update_project_milestone(project_id: int, milestone_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_project_milestones"):
|
|
return None
|
|
|
|
updates: list[str] = []
|
|
values: list[Any] = []
|
|
|
|
if "title" in payload:
|
|
title = str(payload.get("title") or "").strip()
|
|
if title:
|
|
updates.append("title = %s")
|
|
values.append(title)
|
|
|
|
if "description" in payload:
|
|
updates.append("description = %s")
|
|
values.append(str(payload.get("description") or "").strip() or None)
|
|
|
|
if "status" in payload:
|
|
status = str(payload.get("status") or "").strip().lower()
|
|
if status in {"active", "completed", "cancelled"}:
|
|
updates.append("status = %s")
|
|
values.append(status)
|
|
|
|
if "target_date" in payload:
|
|
updates.append("target_date = %s")
|
|
values.append(payload.get("target_date"))
|
|
|
|
if not updates:
|
|
return execute_query_single(
|
|
"""
|
|
SELECT id, project_id, title, description, status, target_date, created_at, updated_at
|
|
FROM mission_project_milestones
|
|
WHERE id = %s AND project_id = %s
|
|
""",
|
|
(milestone_id, project_id),
|
|
)
|
|
|
|
updates.append("updated_at = NOW()")
|
|
values.extend([milestone_id, project_id])
|
|
rows = execute_query(
|
|
f"""
|
|
UPDATE mission_project_milestones
|
|
SET {', '.join(updates)}
|
|
WHERE id = %s AND project_id = %s
|
|
RETURNING id, project_id, title, description, status, target_date, created_at, updated_at
|
|
""",
|
|
tuple(values),
|
|
) or []
|
|
|
|
return dict(rows[0]) if rows else None
|
|
|
|
@staticmethod
|
|
def add_project_blocker(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_project_blockers"):
|
|
return None
|
|
|
|
title = str(payload.get("title") or "").strip()
|
|
if not title:
|
|
return None
|
|
|
|
description = str(payload.get("description") or "").strip() or None
|
|
status = str(payload.get("status") or "open").strip().lower()
|
|
if status not in {"open", "in_progress", "resolved", "cancelled"}:
|
|
status = "open"
|
|
|
|
severity = str(payload.get("severity") or "medium").strip().lower()
|
|
if severity not in {"low", "medium", "high", "critical"}:
|
|
severity = "medium"
|
|
|
|
resolved_at = payload.get("resolved_at")
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO mission_project_blockers (project_id, title, description, status, severity, resolved_at, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
|
|
""",
|
|
(project_id, title, description, status, severity, resolved_at),
|
|
) or []
|
|
|
|
return dict(rows[0]) if rows else None
|
|
|
|
@staticmethod
|
|
def update_project_blocker(project_id: int, blocker_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_project_blockers"):
|
|
return None
|
|
|
|
updates: list[str] = []
|
|
values: list[Any] = []
|
|
|
|
if "title" in payload:
|
|
title = str(payload.get("title") or "").strip()
|
|
if title:
|
|
updates.append("title = %s")
|
|
values.append(title)
|
|
|
|
if "description" in payload:
|
|
updates.append("description = %s")
|
|
values.append(str(payload.get("description") or "").strip() or None)
|
|
|
|
if "status" in payload:
|
|
status = str(payload.get("status") or "").strip().lower()
|
|
if status in {"open", "in_progress", "resolved", "cancelled"}:
|
|
updates.append("status = %s")
|
|
values.append(status)
|
|
if status == "resolved" and "resolved_at" not in payload:
|
|
updates.append("resolved_at = NOW()")
|
|
|
|
if "severity" in payload:
|
|
severity = str(payload.get("severity") or "").strip().lower()
|
|
if severity in {"low", "medium", "high", "critical"}:
|
|
updates.append("severity = %s")
|
|
values.append(severity)
|
|
|
|
if "resolved_at" in payload:
|
|
updates.append("resolved_at = %s")
|
|
values.append(payload.get("resolved_at"))
|
|
|
|
if not updates:
|
|
return execute_query_single(
|
|
"""
|
|
SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
|
|
FROM mission_project_blockers
|
|
WHERE id = %s AND project_id = %s
|
|
""",
|
|
(blocker_id, project_id),
|
|
)
|
|
|
|
updates.append("updated_at = NOW()")
|
|
values.extend([blocker_id, project_id])
|
|
rows = execute_query(
|
|
f"""
|
|
UPDATE mission_project_blockers
|
|
SET {', '.join(updates)}
|
|
WHERE id = %s AND project_id = %s
|
|
RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at
|
|
""",
|
|
tuple(values),
|
|
) or []
|
|
|
|
return dict(rows[0]) if rows else None
|
|
|
|
@staticmethod
|
|
def link_case_to_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
if not MissionService._table_exists("mission_projects") or not MissionService._table_exists("sag_sager"):
|
|
return None
|
|
|
|
sag_id = payload.get("sag_id")
|
|
if sag_id is None:
|
|
return None
|
|
|
|
project_exists = execute_query_single("SELECT id FROM mission_projects WHERE id = %s", (project_id,))
|
|
if not project_exists:
|
|
return None
|
|
|
|
milestone_id = payload.get("project_milestone_id")
|
|
is_project_blocker = bool(payload.get("is_project_blocker") or False)
|
|
project_task_type = str(payload.get("project_task_type") or "").strip() or None
|
|
|
|
rows = execute_query(
|
|
"""
|
|
UPDATE sag_sager
|
|
SET
|
|
project_id = %s,
|
|
project_milestone_id = %s,
|
|
is_project_blocker = %s,
|
|
project_task_type = %s
|
|
WHERE id = %s
|
|
AND deleted_at IS NULL
|
|
RETURNING id, titel, status, project_id, project_milestone_id, is_project_blocker, project_task_type
|
|
""",
|
|
(project_id, milestone_id, is_project_blocker, project_task_type, sag_id),
|
|
) or []
|
|
|
|
return dict(rows[0]) if rows else None
|
|
|
|
@staticmethod
|
|
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
u.user_id,
|
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', u.user_id::text)) AS display_name
|
|
FROM users u
|
|
WHERE COALESCE(u.is_active, TRUE) = TRUE
|
|
ORDER BY display_name ASC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
return [
|
|
{
|
|
"user_id": int(row.get("user_id") or 0),
|
|
"display_name": row.get("display_name") or "Ukendt",
|
|
}
|
|
for row in rows
|
|
if row.get("user_id") is not None
|
|
]
|
|
|
|
@staticmethod
|
|
def get_assignment_groups(limit: int = 200) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("groups"):
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT id, COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name
|
|
FROM groups
|
|
ORDER BY name ASC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
return [
|
|
{
|
|
"id": int(row.get("id") or 0),
|
|
"name": row.get("name") or "Ukendt gruppe",
|
|
}
|
|
for row in rows
|
|
if row.get("id") is not None
|
|
]
|
|
|
|
@staticmethod
|
|
def get_day_unassigned_cases(limit: int = 120) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("sag_sager"):
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.beskrivelse,
|
|
s.status,
|
|
s.priority,
|
|
s.start_date,
|
|
s.deadline,
|
|
s.created_at,
|
|
s.ansvarlig_bruger_id,
|
|
s.assigned_group_id,
|
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS ansvarlig_navn,
|
|
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS assigned_group_name,
|
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
|
FROM sag_sager s
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
|
WHERE s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
|
AND (s.ansvarlig_bruger_id IS NULL OR s.assigned_group_id IS NULL)
|
|
ORDER BY
|
|
CASE
|
|
WHEN s.ansvarlig_bruger_id IS NULL AND s.assigned_group_id IS NULL THEN 0
|
|
ELSE 1
|
|
END ASC,
|
|
CASE LOWER(COALESCE(s.priority::text, ''))
|
|
WHEN 'kritisk' THEN 5
|
|
WHEN 'critical' THEN 5
|
|
WHEN 'høj' THEN 4
|
|
WHEN 'hoj' THEN 4
|
|
WHEN 'high' THEN 4
|
|
WHEN 'urgent' THEN 4
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'normal' THEN 2
|
|
WHEN 'lav' THEN 1
|
|
WHEN 'low' THEN 1
|
|
ELSE 2
|
|
END DESC,
|
|
s.deadline ASC NULLS LAST,
|
|
s.created_at ASC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
return [dict(row) for row in rows]
|
|
|
|
@staticmethod
|
|
def get_day_agent_workloads(limit_agents: int = 60, limit_cases_per_agent: int = 20) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("sag_sager"):
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
WITH active_cases AS (
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.priority,
|
|
s.start_date,
|
|
s.deadline,
|
|
s.created_at,
|
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
|
s.ansvarlig_bruger_id,
|
|
s.assigned_group_id,
|
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS assignee_name,
|
|
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS group_name
|
|
FROM sag_sager s
|
|
LEFT JOIN customers c ON c.id = s.customer_id
|
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
|
WHERE s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
|
AND (
|
|
(s.start_date IS NOT NULL AND s.start_date::date <= CURRENT_DATE)
|
|
OR
|
|
(s.deadline IS NOT NULL AND s.deadline::date <= CURRENT_DATE)
|
|
)
|
|
),
|
|
grouped AS (
|
|
SELECT
|
|
COALESCE(
|
|
CASE
|
|
WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text)
|
|
WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text)
|
|
ELSE 'unassigned'
|
|
END,
|
|
'unassigned'
|
|
) AS assignee_key,
|
|
COALESCE(assignee_name, group_name, 'Ufordelt') AS assignee_name,
|
|
COUNT(*) AS total_cases,
|
|
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_cases,
|
|
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS due_today_cases,
|
|
COUNT(*) FILTER (WHERE start_date IS NOT NULL AND start_date::date <= CURRENT_DATE) AS started_cases,
|
|
JSONB_AGG(
|
|
JSONB_BUILD_OBJECT(
|
|
'id', id,
|
|
'titel', titel,
|
|
'status', status,
|
|
'priority', priority,
|
|
'customer_name', customer_name,
|
|
'start_date', start_date,
|
|
'deadline', deadline,
|
|
'created_at', created_at
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN deadline IS NOT NULL AND deadline::date < CURRENT_DATE THEN 0
|
|
WHEN deadline IS NOT NULL AND deadline::date = CURRENT_DATE THEN 1
|
|
ELSE 2
|
|
END,
|
|
deadline ASC NULLS LAST,
|
|
created_at ASC
|
|
) AS case_list
|
|
FROM active_cases
|
|
GROUP BY
|
|
COALESCE(
|
|
CASE
|
|
WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text)
|
|
WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text)
|
|
ELSE 'unassigned'
|
|
END,
|
|
'unassigned'
|
|
),
|
|
COALESCE(assignee_name, group_name, 'Ufordelt')
|
|
)
|
|
SELECT
|
|
assignee_key,
|
|
assignee_name,
|
|
total_cases,
|
|
overdue_cases,
|
|
due_today_cases,
|
|
started_cases,
|
|
CASE
|
|
WHEN case_list IS NULL THEN '[]'::jsonb
|
|
ELSE case_list
|
|
END AS case_list
|
|
FROM grouped
|
|
ORDER BY overdue_cases DESC, due_today_cases DESC, total_cases DESC, assignee_name ASC
|
|
LIMIT %s
|
|
""",
|
|
(limit_agents,),
|
|
) or []
|
|
|
|
result: list[Dict[str, Any]] = []
|
|
for row in rows:
|
|
case_list = row.get("case_list")
|
|
if isinstance(case_list, list):
|
|
trimmed_cases = case_list[: max(1, int(limit_cases_per_agent))]
|
|
else:
|
|
trimmed_cases = []
|
|
|
|
result.append(
|
|
{
|
|
"assignee_key": row.get("assignee_key") or "unassigned",
|
|
"assignee_name": row.get("assignee_name") or "Ufordelt",
|
|
"total_cases": int(row.get("total_cases") or 0),
|
|
"overdue_cases": int(row.get("overdue_cases") or 0),
|
|
"due_today_cases": int(row.get("due_today_cases") or 0),
|
|
"started_cases": int(row.get("started_cases") or 0),
|
|
"case_list": trimmed_cases,
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
|
|
if not MissionService._table_exists("email_messages"):
|
|
logger.warning("Mission table missing: email_messages (recent emails unavailable)")
|
|
return []
|
|
|
|
rows = execute_query(
|
|
"""
|
|
SELECT
|
|
id,
|
|
subject,
|
|
sender_name,
|
|
sender_email,
|
|
classification,
|
|
status,
|
|
linked_case_id,
|
|
received_date
|
|
FROM email_messages
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY received_date DESC NULLS LAST, created_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
) or []
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
@staticmethod
|
|
def get_environment_readings(limit: int = 12) -> list[Dict[str, Any]]:
|
|
readings = MissionService.parse_json_setting("mission_environment_readings", [])
|
|
if not isinstance(readings, list):
|
|
return []
|
|
|
|
result: list[Dict[str, Any]] = []
|
|
for raw in readings:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
item = {
|
|
"sensor_id": str(raw.get("sensor_id") or "").strip() or None,
|
|
"sensor_name": str(raw.get("sensor_name") or "").strip() or "Sensor",
|
|
"temperature": raw.get("temperature"),
|
|
"unit": str(raw.get("unit") or "°C").strip() or "°C",
|
|
"timestamp": raw.get("timestamp"),
|
|
"payload": raw.get("payload") if isinstance(raw.get("payload"), dict) else {},
|
|
}
|
|
result.append(item)
|
|
|
|
return result[: max(1, int(limit))]
|
|
|
|
@staticmethod
|
|
def get_state() -> Dict[str, Any]:
|
|
kpis_default = {
|
|
"open_cases": 0,
|
|
"new_cases": 0,
|
|
"unassigned_cases": 0,
|
|
"deadlines_today": 0,
|
|
"overdue_deadlines": 0,
|
|
}
|
|
|
|
return {
|
|
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
|
|
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
|
|
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
|
|
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
|
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
|
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
|
|
"projects": MissionService._safe("projects", lambda: MissionService.get_projects_state_payload(120), {"summary": {}, "projects": [], "workload": []}),
|
|
"day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []),
|
|
"day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []),
|
|
"assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []),
|
|
"assignment_groups": MissionService._safe("assignment_groups", lambda: MissionService.get_assignment_groups(200), []),
|
|
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
|
|
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
|
|
"config": {
|
|
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
|
|
"sound_enabled": MissionService._safe(
|
|
"config.sound_enabled",
|
|
lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
|
True,
|
|
),
|
|
"sound_volume": MissionService._safe(
|
|
"config.sound_volume",
|
|
lambda: int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
|
|
70,
|
|
),
|
|
"sound_events": MissionService._safe(
|
|
"config.sound_events",
|
|
lambda: MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
|
|
["incoming_call", "uptime_down", "critical_event"],
|
|
),
|
|
"kpi_visible": MissionService._safe(
|
|
"config.kpi_visible",
|
|
lambda: MissionService.parse_json_setting(
|
|
"mission_kpi_visible",
|
|
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
|
),
|
|
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
|
),
|
|
"customer_filter": MissionService._safe(
|
|
"config.customer_filter",
|
|
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
|
|
"",
|
|
),
|
|
"camera_enabled": MissionService._safe(
|
|
"config.camera_enabled",
|
|
lambda: str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true",
|
|
False,
|
|
),
|
|
"camera_name": MissionService._safe(
|
|
"config.camera_name",
|
|
lambda: MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera",
|
|
"Mission Kamera",
|
|
),
|
|
"camera_feed_url": MissionService._safe(
|
|
"config.camera_feed_url",
|
|
lambda: MissionService.get_setting_value("mission_camera_feed_url", "") or "",
|
|
"",
|
|
),
|
|
"camera_spotlight_seconds": MissionService._safe(
|
|
"config.camera_spotlight_seconds",
|
|
lambda: max(5, min(int(MissionService.get_setting_value("mission_camera_spotlight_seconds", "20") or 20), 120)),
|
|
20,
|
|
),
|
|
},
|
|
}
|