release: v2.8.1 planner and system observability
This commit is contained in:
parent
9ed709c9e8
commit
a3b8057fa8
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
@ -0,0 +1,39 @@
|
||||
# BMC Hub v2.8.1
|
||||
|
||||
## Planner
|
||||
|
||||
- Nyt Planner-modul til fordeling af sager og overblik over teamets kapacitet.
|
||||
- Dag-, uge- og månedsvisning med planlagte blokke, registreret tid og ekstern travl tid.
|
||||
- Drag-and-drop af sager med kompakt forhåndsvisning, 15-minutters placering og håndterbare standardblokke.
|
||||
- Bredere sagsoversigt og forbedret pladsudnyttelse i kalenderen.
|
||||
- Kalender og Planlægger er samlet i en Kalender-undermenu.
|
||||
|
||||
## Systemlog og drift
|
||||
|
||||
- Ny administrativ systemlog til telefoni, e-mail og øvrige integrationshændelser.
|
||||
- Udvidet hændelseslogning og adgangskontrol i indstillingerne.
|
||||
|
||||
## AI og fakturaskabeloner
|
||||
|
||||
- Rettet AI Auto-generer i Template Builder.
|
||||
- Tilføjet manglende `OWN_CVR`-konfiguration og validering af Ollama-fejl.
|
||||
- Ollama fakturaudtræk tvinges til struktureret JSON.
|
||||
- GUI'en viser nu den konkrete AI-fejl i stedet for en generisk fejlbesked.
|
||||
|
||||
## Integrationer og brugerflade
|
||||
|
||||
- Forbedringer til telefoni, e-mail og ALSO Cloud-integrationerne.
|
||||
- Mindre rettelser til login, indstillinger og den fælles navigation.
|
||||
- Fremtidige udviklingsplaner har fået et fast dokumentationsområde.
|
||||
|
||||
## Database
|
||||
|
||||
- Kør `migrations/240_planner.sql`.
|
||||
- Kør `migrations/241_system_event_log.sql`.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Planner: 8 tests bestået.
|
||||
- Samlet testsuite: 271 bestået, 1 sprunget over.
|
||||
- Fire eksisterende tests kræver databaseinitialisering eller har forældede invoice-history mocks.
|
||||
- Python- og JavaScript-syntakskontrol bestået.
|
||||
@ -87,6 +87,11 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const requestedNext = new URLSearchParams(window.location.search).get('next');
|
||||
const loginRedirectTarget = requestedNext && requestedNext.startsWith('/') && !requestedNext.startsWith('//')
|
||||
? requestedNext
|
||||
: '/';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -131,8 +136,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Show error
|
||||
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
|
||||
@ -169,8 +173,7 @@ if (token) {
|
||||
d.setTime(d.getTime() + (24*60*60*1000));
|
||||
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Token invalid, clear storage
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
@ -3700,10 +3700,16 @@ RETURNER KUN JSON - intet andet!"""
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
|
||||
if result.get("error"):
|
||||
# extract_from_text returns operational Ollama failures as a dict.
|
||||
# Do not expose that dict as a successful analysis to the browser.
|
||||
raise HTTPException(status_code=502, detail=str(result["error"]))
|
||||
|
||||
logger.info(f"✅ AI analyse gennemført: {result}")
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI analyse fejlede: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")
|
||||
|
||||
@ -1370,10 +1370,20 @@ async function autoGenerateTemplate() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('AI analyse fejlede');
|
||||
let detail = `AI analyse fejlede (HTTP ${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload?.detail) detail = payload.detail;
|
||||
} catch (_) {
|
||||
// Keep the HTTP status when the server did not return JSON.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// Helper to extract value (handles both nested {value, pattern} and flat formats)
|
||||
const getValue = (field) => field?.value || field;
|
||||
@ -1468,7 +1478,7 @@ async function autoGenerateTemplate() {
|
||||
console.error('AI generation failed:', error);
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
alert('AI analyse fejlede. Prøv manuel indtastning.');
|
||||
alert(`AI analyse fejlede: ${error.message || 'Ukendt fejl'}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -123,6 +123,9 @@ class Settings(BaseSettings):
|
||||
# Ollama LLM
|
||||
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2"
|
||||
# Primary company CVR used to prevent AI invoice extraction from treating
|
||||
# the buyer (BMC) as the supplier.
|
||||
OWN_CVR: str = "29522790"
|
||||
|
||||
# Email System Configuration
|
||||
# IMAP Settings
|
||||
|
||||
@ -488,6 +488,11 @@
|
||||
|
||||
const fetchJson = async (url, options = {}) => {
|
||||
const res = await fetch(url, options);
|
||||
if (res.status === 401) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
|
||||
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let message = text || `HTTP ${res.status}`;
|
||||
@ -525,7 +530,7 @@
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
const config = await fetchJson('/api/v1/also/config');
|
||||
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
|
||||
const label = !config.enabled
|
||||
? 'ALSO deaktiveret'
|
||||
: config.read_only
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Query, Request, Response, UploadFile
|
||||
|
||||
from app.modules.also.backend.service import also_service
|
||||
from app.modules.also.models.schemas import (
|
||||
@ -34,7 +34,8 @@ def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
|
||||
|
||||
@router.get("/also/config")
|
||||
async def also_config() -> dict:
|
||||
async def also_config(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return also_service.get_config()
|
||||
|
||||
|
||||
|
||||
1
app/modules/planner/__init__.py
Normal file
1
app/modules/planner/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Case planning and capacity module."""
|
||||
1
app/modules/planner/backend/__init__.py
Normal file
1
app/modules/planner/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner API package."""
|
||||
34
app/modules/planner/backend/providers.py
Normal file
34
app/modules/planner/backend/providers.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Provider-neutral, read-only busy-time interface."""
|
||||
from typing import Protocol
|
||||
|
||||
from app.core.database import execute_query
|
||||
|
||||
|
||||
class BusyTimeProvider(Protocol):
|
||||
def get_busy(self, user_ids: list[int], starts_at, ends_at) -> list[dict]: ...
|
||||
def statuses(self, user_ids: list[int]) -> list[dict]: ...
|
||||
|
||||
|
||||
class DatabaseBusyTimeProvider:
|
||||
"""Cache adapter used by Outlook/Google sync jobs without exposing meeting data."""
|
||||
|
||||
def get_busy(self, user_ids, starts_at, ends_at):
|
||||
if not user_ids:
|
||||
return []
|
||||
return execute_query(
|
||||
"""SELECT user_id, starts_at, ends_at, provider FROM planner_external_busy
|
||||
WHERE user_id = ANY(%s) AND starts_at < %s AND ends_at > %s""",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
|
||||
def statuses(self, user_ids):
|
||||
if not user_ids:
|
||||
return []
|
||||
return execute_query(
|
||||
"""SELECT user_id, provider, status, last_success_at, last_error_at
|
||||
FROM planner_integrations WHERE user_id = ANY(%s)""",
|
||||
(user_ids,),
|
||||
) or []
|
||||
|
||||
|
||||
busy_time_provider: BusyTimeProvider = DatabaseBusyTimeProvider()
|
||||
460
app/modules/planner/backend/router.py
Normal file
460
app/modules/planner/backend/router.py
Normal file
@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2.extras import RealDictCursor, Json
|
||||
|
||||
from app.core.auth_dependencies import get_current_user
|
||||
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
||||
from .providers import busy_time_provider
|
||||
|
||||
router = APIRouter(prefix="/planner")
|
||||
TZ = ZoneInfo("Europe/Copenhagen")
|
||||
CLOSED_STATUSES = {"afsluttet", "lukket", "closed", "done", "arkiveret", "archived"}
|
||||
|
||||
|
||||
class AllocationInput(BaseModel):
|
||||
sag_id: int
|
||||
user_id: int
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
source: str = Field(default="manual", max_length=40)
|
||||
confirm_conflicts: bool = False
|
||||
locked: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_period(self):
|
||||
if self.ends_at <= self.starts_at:
|
||||
raise ValueError("Sluttid skal ligge efter starttid")
|
||||
return self
|
||||
|
||||
|
||||
class AllocationUpdate(BaseModel):
|
||||
user_id: int | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
version: int
|
||||
confirm_conflicts: bool = False
|
||||
locked: bool | None = None
|
||||
|
||||
|
||||
class CaseEstimateInput(BaseModel):
|
||||
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
|
||||
|
||||
|
||||
class PlannerSettingsInput(BaseModel):
|
||||
warning_percent: int = Field(ge=1, le=99)
|
||||
full_percent: int = Field(ge=100, le=300)
|
||||
default_allocation_minutes: int = Field(ge=15, le=1440)
|
||||
|
||||
|
||||
def _permissions(user: dict) -> set[str]:
|
||||
return set(user.get("permissions") or [])
|
||||
|
||||
|
||||
def _can_view(user: dict) -> bool:
|
||||
return bool(user.get("is_superadmin") or "planner.view" in _permissions(user))
|
||||
|
||||
|
||||
def _can_edit(user: dict, target_id: int) -> bool:
|
||||
if user.get("is_superadmin") or "planner.admin" in _permissions(user):
|
||||
return True
|
||||
if target_id == int(user["id"]) and "planner.edit_own" in _permissions(user):
|
||||
return True
|
||||
if "planner.edit_team" not in _permissions(user):
|
||||
return False
|
||||
return bool(execute_query_single(
|
||||
"SELECT 1 FROM planner_team_scope WHERE editor_user_id=%s AND target_user_id=%s",
|
||||
(user["id"], target_id),
|
||||
))
|
||||
|
||||
|
||||
def _require_view(user: dict):
|
||||
if not _can_view(user):
|
||||
raise HTTPException(403, "Du har ikke adgang til Planlæggeren")
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=TZ) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def _minutes(start, end) -> int:
|
||||
return max(0, round((end - start).total_seconds() / 60))
|
||||
|
||||
|
||||
def _merge_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
|
||||
clipped = sorted((max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
|
||||
merged: list[list[datetime]] = []
|
||||
for a, b in clipped:
|
||||
if not merged or a > merged[-1][1]:
|
||||
merged.append([a, b])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], b)
|
||||
return sum(_minutes(a, b) for a, b in merged)
|
||||
|
||||
|
||||
def _sum_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
|
||||
"""Count every allocation; overlapping jobs each consume assigned capacity."""
|
||||
return sum(_minutes(max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
|
||||
|
||||
|
||||
def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, allocations=None) -> list[dict]:
|
||||
settings = execute_query_single("SELECT * FROM planner_settings WHERE id=1") or {
|
||||
"warning_percent": 80, "full_percent": 100
|
||||
}
|
||||
schedules = execute_query(
|
||||
"""SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active
|
||||
AND (valid_from IS NULL OR valid_from <= %s) AND (valid_to IS NULL OR valid_to >= %s)""",
|
||||
(user_ids, ends_at.date(), starts_at.date()),
|
||||
) or []
|
||||
absences = execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
external = busy_time_provider.get_busy(user_ids, starts_at, ends_at)
|
||||
registered = execute_query(
|
||||
"""SELECT t.medarbejder_id AS user_id,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS registered_at,
|
||||
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes
|
||||
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
|
||||
WHERE t.medarbejder_id=ANY(%s)
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
|
||||
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)""",
|
||||
(user_ids, starts_at, ends_at, list(CLOSED_STATUSES)),
|
||||
) or []
|
||||
if allocations is None:
|
||||
allocations = execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
result = []
|
||||
current = starts_at.astimezone(TZ).date()
|
||||
final = (ends_at - timedelta(microseconds=1)).astimezone(TZ).date()
|
||||
schedule_map = {(r["user_id"], r["weekday"]): r for r in schedules}
|
||||
while current <= final:
|
||||
for uid in user_ids:
|
||||
row = schedule_map.get((uid, current.weekday()))
|
||||
if row:
|
||||
day_start = datetime.combine(current, row["start_time"], TZ)
|
||||
day_end = datetime.combine(current, row["end_time"], TZ)
|
||||
gross = _minutes(day_start, day_end)
|
||||
unavailable = _merge_minutes(
|
||||
[( _aware(r["starts_at"]), _aware(r["ends_at"]) ) for r in absences + external if r["user_id"] == uid],
|
||||
day_start, day_end,
|
||||
)
|
||||
available = max(0, gross - int(row["break_minutes"] or 0) - unavailable)
|
||||
else:
|
||||
day_start = datetime.combine(current, time(0), TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
available = 0
|
||||
allocated = _sum_minutes(
|
||||
[(_aware(r["starts_at"]), _aware(r["ends_at"])) for r in allocations if r["user_id"] == uid],
|
||||
day_start, day_end,
|
||||
)
|
||||
percent = round(allocated * 100 / available) if available else (100 if allocated else 0)
|
||||
registered_minutes = sum(
|
||||
int(r.get("minutes") or 0) for r in registered
|
||||
if r["user_id"] == uid and _aware(r["registered_at"]).astimezone(TZ).date() == current
|
||||
)
|
||||
state = "gray" if available == 0 else (
|
||||
"red" if percent >= settings["full_percent"] else
|
||||
"yellow" if percent >= settings["warning_percent"] else "green"
|
||||
)
|
||||
result.append({"user_id": uid, "date": current.isoformat(), "available_minutes": available,
|
||||
"allocated_minutes": allocated, "percent": percent, "state": state,
|
||||
"registered_minutes": registered_minutes,
|
||||
"label": f"{allocated / 60:g} af {available / 60:g} timer planlagt",
|
||||
"registered_label": f"{registered_minutes / 60:g} timer registreret"})
|
||||
current += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
def _conflicts(user_id, starts_at, ends_at, exclude_id=None) -> tuple[str, dict]:
|
||||
query = """SELECT id FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s
|
||||
AND starts_at < %s AND ends_at > %s"""
|
||||
params: list = [user_id, ends_at, starts_at]
|
||||
if exclude_id:
|
||||
query += " AND id <> %s"
|
||||
params.append(exclude_id)
|
||||
overlaps = execute_query(query, tuple(params)) or []
|
||||
prospective = (execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s AND starts_at<%s AND ends_at>%s" + (" AND id<>%s" if exclude_id else ""),
|
||||
tuple([user_id, ends_at, starts_at] + ([exclude_id] if exclude_id else [])),
|
||||
) or []) + [{"user_id": user_id, "starts_at": starts_at, "ends_at": ends_at}]
|
||||
cap = _capacity([user_id], starts_at, ends_at, prospective)
|
||||
overbooked = any(x["state"] == "red" and x["allocated_minutes"] > x["available_minutes"] for x in cap)
|
||||
status = "overlap_and_overbooked" if overlaps and overbooked else "overlap" if overlaps else "overbooked" if overbooked else "none"
|
||||
return status, {"overlapping_ids": [r["id"] for r in overlaps], "capacity": cap}
|
||||
|
||||
|
||||
def _serialize(row: dict | None):
|
||||
if not row: return None
|
||||
return {k: (v.isoformat() if isinstance(v, (datetime, date, time)) else v) for k, v in row.items()}
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
def metadata(current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
users = execute_query("SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name") or []
|
||||
settings = execute_query_single("SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1")
|
||||
departments = execute_query("SELECT id, name FROM groups ORDER BY name") or []
|
||||
statuses = busy_time_provider.statuses([r["id"] for r in users])
|
||||
editable_user_ids = [r["id"] for r in users if _can_edit(current_user, r["id"])]
|
||||
return {"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
|
||||
"current_user_id": current_user["id"], "permissions": list(_permissions(current_user)),
|
||||
"editable_user_ids": editable_user_ids}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(get_current_user)):
|
||||
if not (current_user.get("is_superadmin") or "planner.admin" in _permissions(current_user)):
|
||||
raise HTTPException(403, "Kun administratorer kan ændre kapacitetsgrænser")
|
||||
if payload.warning_percent >= payload.full_percent:
|
||||
raise HTTPException(422, "Gul-grænsen skal ligge under rød-grænsen")
|
||||
return execute_query_single(
|
||||
"""UPDATE planner_settings SET warning_percent=%s,full_percent=%s,default_allocation_minutes=%s,
|
||||
updated_by=%s,updated_at=NOW() WHERE id=1 RETURNING *""",
|
||||
(payload.warning_percent,payload.full_percent,payload.default_allocation_minutes,current_user["id"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cases")
|
||||
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
|
||||
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
|
||||
department_id: int | None = None,
|
||||
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
|
||||
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
|
||||
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
sql = """SELECT s.id, s.titel AS title, s.status, s.type, s.priority::text, s.ansvarlig_bruger_id AS responsible_id,
|
||||
(LOWER(COALESCE(s.status,'')) = ANY(%s)) AS is_closed,
|
||||
COALESCE(u.full_name,u.username) AS responsible_name, c.name AS customer_name,
|
||||
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
|
||||
COALESCE(used.used_minutes, 0) AS used_minutes,
|
||||
used.latest_registration_at,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
|
||||
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
|
||||
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
|
||||
LEFT JOIN groups department ON department.id=s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(COALESCE(t.faktisk_tid_min,
|
||||
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))),0)::int AS used_minutes,
|
||||
MAX(COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at)) AS latest_registration_at
|
||||
FROM tmodule_times t WHERE t.sag_id=s.id
|
||||
) used ON TRUE
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)"""
|
||||
params: list = [list(CLOSED_STATUSES), list(CLOSED_STATUSES)]
|
||||
if q:
|
||||
sql += " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username,'') ILIKE %s)"
|
||||
params += [f"%{q}%"] * 4
|
||||
if status: sql += " AND s.status = ANY(%s)"; params.append(status)
|
||||
if priority: sql += " AND s.priority::text = ANY(%s)"; params.append(priority)
|
||||
if case_type: sql += " AND s.type = ANY(%s)"; params.append(case_type)
|
||||
if responsible_id: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(responsible_id)
|
||||
if department_id: sql += " AND s.assigned_group_id=%s"; params.append(department_id)
|
||||
if mine: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(current_user["id"])
|
||||
if urgent: sql += " AND s.priority::text IN ('urgent','high')"
|
||||
sql += " GROUP BY s.id,u.full_name,u.username,c.name,department.name,used.used_minutes,used.latest_registration_at"
|
||||
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
|
||||
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
|
||||
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
|
||||
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
|
||||
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
|
||||
"updated": "s.updated_at DESC"}[sort]
|
||||
rows = execute_query(sql + f" ORDER BY {order} LIMIT %s", tuple(params + [limit])) or []
|
||||
for row in rows: row["remaining_minutes"] = max(0, row["estimated_minutes"] - row["planned_minutes"])
|
||||
return {"items": rows}
|
||||
|
||||
|
||||
@router.get("/cases/{sag_id}/time-entries")
|
||||
def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Show actual registered time separately from planned allocations."""
|
||||
_require_view(current_user)
|
||||
rows = execute_query(
|
||||
"""SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
|
||||
COALESCE(t.faktisk_tid_min, ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
|
||||
t.description, t.entry_status, t.status,
|
||||
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS employee_name
|
||||
FROM tmodule_times t LEFT JOIN users u ON u.user_id=t.medarbejder_id
|
||||
WHERE t.sag_id=%s ORDER BY COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at) DESC, t.id DESC""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
|
||||
|
||||
|
||||
@router.patch("/cases/{sag_id}/estimate")
|
||||
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
||||
permissions = _permissions(current_user)
|
||||
allowed = {"planner.edit_own", "planner.edit_team", "planner.admin", "cases.edit"}
|
||||
if not (current_user.get("is_superadmin") or permissions.intersection(allowed)):
|
||||
raise HTTPException(403, "Du må ikke ændre forventet tid")
|
||||
old = execute_query_single("SELECT id,titel,estimated_minutes FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
|
||||
if not old:
|
||||
raise HTTPException(404, "Sagen findes ikke")
|
||||
row = execute_query_single(
|
||||
"UPDATE sag_sager SET estimated_minutes=%s,updated_at=NOW() WHERE id=%s RETURNING id,estimated_minutes",
|
||||
(payload.estimated_minutes, sag_id),
|
||||
)
|
||||
execute_query(
|
||||
"""INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
|
||||
VALUES(%s,%s,'planner.estimate.update','case',%s,%s,%s,%s)""",
|
||||
(current_user["id"], current_user.get("username"), sag_id, old.get("titel"),
|
||||
str(old.get("estimated_minutes")), str(payload.estimated_minutes)),
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/period")
|
||||
def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(default=[]), current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
if ends_at <= starts_at or ends_at - starts_at > timedelta(days=370): raise HTTPException(400, "Ugyldig periode")
|
||||
ids = user_id or [current_user["id"]]
|
||||
allocations = execute_query(
|
||||
"""SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
|
||||
COALESCE(u.full_name,u.username) AS user_name
|
||||
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id JOIN customers c ON c.id=s.customer_id
|
||||
JOIN users u ON u.user_id=a.user_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s)
|
||||
AND a.starts_at<%s AND a.ends_at>%s ORDER BY a.starts_at""", (ids, ends_at, starts_at)) or []
|
||||
time_entries = execute_query(
|
||||
"""SELECT t.id, t.sag_id, t.medarbejder_id AS user_id,
|
||||
(t.start_tid IS NOT NULL) AS has_time,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS starts_at,
|
||||
COALESCE(t.slut_tid,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)
|
||||
+ make_interval(mins => GREATEST(COALESCE(t.faktisk_tid_min,
|
||||
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,15))) AS ends_at,
|
||||
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
|
||||
t.description, s.titel AS case_title, c.name AS customer_name,
|
||||
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS user_name
|
||||
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
|
||||
JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=t.medarbejder_id
|
||||
WHERE t.medarbejder_id=ANY(%s)
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
|
||||
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)
|
||||
ORDER BY starts_at""",
|
||||
(ids, starts_at, ends_at, list(CLOSED_STATUSES)),
|
||||
) or []
|
||||
return {"allocations": allocations, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
|
||||
"time_entries": time_entries,
|
||||
"external_busy": busy_time_provider.get_busy(ids, starts_at, ends_at),
|
||||
"integration_statuses": busy_time_provider.statuses(ids)}
|
||||
|
||||
|
||||
@router.post("/allocations", status_code=201)
|
||||
def create_allocation(payload: AllocationInput, current_user: dict = Depends(get_current_user)):
|
||||
if not _can_edit(current_user, payload.user_id): raise HTTPException(403, "Du må ikke redigere denne medarbejders plan")
|
||||
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s", (payload.sag_id,))
|
||||
if not case or case["deleted_at"] or str(case["status"]).lower() in CLOSED_STATUSES: raise HTTPException(409, "Lukkede eller arkiverede sager skal genåbnes før planlægning")
|
||||
conflict, details = _conflicts(payload.user_id, payload.starts_at, payload.ends_at)
|
||||
if conflict != "none" and not payload.confirm_conflicts: raise HTTPException(409, detail={"message": "Tiden overlapper eller overbooker", "conflict_status": conflict, **details})
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""INSERT INTO planner_allocations(sag_id,user_id,starts_at,ends_at,note,source,conflict_status,locked,created_by,updated_by)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
|
||||
(payload.sag_id,payload.user_id,payload.starts_at,payload.ends_at,payload.note,payload.source,conflict,payload.locked,current_user["id"],current_user["id"]))
|
||||
row=cur.fetchone(); cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,after_value) VALUES(%s,%s,'created',%s,%s)", (row["id"],row["sag_id"],current_user["id"],Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.patch("/allocations/{allocation_id}")
|
||||
def update_allocation(allocation_id: int, payload: AllocationUpdate, current_user: dict = Depends(get_current_user)):
|
||||
conn=get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
target=payload.user_id or old["user_id"]
|
||||
if not _can_edit(current_user,target): raise HTTPException(403,"Du må ikke redigere denne medarbejders plan")
|
||||
if old["version"] != payload.version: raise HTTPException(409,detail={"message":"Allokeringen er ændret af en anden bruger","current":_serialize(old)})
|
||||
moving = bool({"user_id", "starts_at", "ends_at"}.intersection(payload.model_fields_set))
|
||||
if old.get("locked") and moving and payload.locked is not False:
|
||||
raise HTTPException(409, "Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres")
|
||||
start=payload.starts_at or old["starts_at"]; end=payload.ends_at or old["ends_at"]
|
||||
if end<=start: raise HTTPException(422,"Sluttid skal ligge efter starttid")
|
||||
conflict,details=_conflicts(target,start,end,allocation_id)
|
||||
if conflict!="none" and not payload.confirm_conflicts: raise HTTPException(409,detail={"message":"Tiden overlapper eller overbooker","conflict_status":conflict,**details})
|
||||
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,conflict_status=%s,locked=%s,
|
||||
version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s AND version=%s RETURNING *""",
|
||||
(target,start,end,payload.note if "note" in payload.model_fields_set else old["note"],conflict,payload.locked if payload.locked is not None else old.get("locked",False),current_user["id"],allocation_id,payload.version))
|
||||
row=cur.fetchone()
|
||||
if not row: raise HTTPException(409,"Allokeringen blev ændret samtidigt")
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'updated',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.delete("/allocations/{allocation_id}")
|
||||
def delete_allocation(allocation_id:int, version:int, current_user:dict=Depends(get_current_user)):
|
||||
conn=get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
if not _can_edit(current_user,old["user_id"]): raise HTTPException(403,"Du må ikke slette denne allokering")
|
||||
if old["version"]!=version: raise HTTPException(409,"Allokeringen er ændret af en anden bruger")
|
||||
if old.get("locked"): raise HTTPException(409,"Tidspunktet er låst. Fjern låsen før blokken slettes")
|
||||
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s AND version=%s RETURNING *",(current_user["id"],allocation_id,version)); row=cur.fetchone()
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'deleted',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
|
||||
conn.commit(); return {"deleted":True,"allocation":row}
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.post("/allocations/{allocation_id}/undo")
|
||||
def undo_allocation(allocation_id: int, version: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Undo the latest persisted allocation mutation, guarded by its current version."""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s FOR UPDATE", (allocation_id,))
|
||||
current = cur.fetchone()
|
||||
if not current: raise HTTPException(404, "Allokeringen findes ikke")
|
||||
if not _can_edit(current_user, current["user_id"]): raise HTTPException(403, "Du må ikke fortryde denne ændring")
|
||||
if current["version"] != version: raise HTTPException(409, "Kan ikke fortryde: allokeringen er ændret siden")
|
||||
cur.execute("SELECT * FROM planner_allocation_history WHERE allocation_id=%s ORDER BY id DESC LIMIT 1", (allocation_id,))
|
||||
history = cur.fetchone()
|
||||
if not history: raise HTTPException(409, "Der er ingen ændring at fortryde")
|
||||
before = history.get("before_value")
|
||||
if history["action"] == "created":
|
||||
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s RETURNING *", (current_user["id"],allocation_id))
|
||||
elif before:
|
||||
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,
|
||||
conflict_status=%s,locked=%s,deleted_at=%s,version=version+1,updated_by=%s,updated_at=NOW()
|
||||
WHERE id=%s RETURNING *""",
|
||||
(before["user_id"],before["starts_at"],before["ends_at"],before.get("note"),before.get("conflict_status","none"),before.get("locked",False),before.get("deleted_at"),current_user["id"],allocation_id))
|
||||
else: raise HTTPException(409, "Ændringen kan ikke fortrydes")
|
||||
row=cur.fetchone()
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'undo',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(current)),Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.post("/allocations/{allocation_id}/copy", status_code=201)
|
||||
def copy_allocation(allocation_id:int, starts_at:datetime,user_id:int|None=None,confirm_conflicts:bool=False,current_user:dict=Depends(get_current_user)):
|
||||
old=execute_query_single("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL",(allocation_id,))
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
duration=old["ends_at"]-old["starts_at"]
|
||||
return create_allocation(AllocationInput(sag_id=old["sag_id"],user_id=user_id or old["user_id"],starts_at=starts_at,ends_at=starts_at+duration,note=old["note"],source="copy",confirm_conflicts=confirm_conflicts),current_user)
|
||||
|
||||
|
||||
@router.get("/cases/{sag_id}/history")
|
||||
def case_history(sag_id:int,current_user:dict=Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
return {"items":execute_query("SELECT * FROM planner_allocation_history WHERE sag_id=%s ORDER BY created_at DESC",(sag_id,)) or []}
|
||||
1
app/modules/planner/frontend/__init__.py
Normal file
1
app/modules/planner/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner frontend package."""
|
||||
11
app/modules/planner/frontend/views.py
Normal file
11
app/modules/planner/frontend/views.py
Normal file
@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from app.core.auth_dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
@router.get("/planner", response_class=HTMLResponse)
|
||||
async def planner(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("modules/planner/templates/index.html", {"request": request, "current_user": current_user})
|
||||
26
app/modules/planner/templates/index.html
Normal file
26
app/modules/planner/templates/index.html
Normal file
File diff suppressed because one or more lines are too long
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_permission
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.services.sms_service import SmsService
|
||||
from app.services.system_event_logger import log_system_event
|
||||
|
||||
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
|
||||
from .service import TelefoniService
|
||||
@ -380,6 +381,13 @@ async def yealink_established(
|
||||
raw_payload=json.dumps(payload),
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.established", status="success",
|
||||
summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}",
|
||||
correlation_id=resolved_callid, user_id=primary_user_id,
|
||||
details={"call_row_id": row.get("id"), "extension": local_extension,
|
||||
"external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload},
|
||||
)
|
||||
|
||||
# Send websocket notification to ALL users with this extension
|
||||
if user_ids:
|
||||
@ -440,10 +448,19 @@ async def yealink_terminated(
|
||||
if duration_value is None:
|
||||
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
|
||||
|
||||
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,))
|
||||
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
||||
if not updated:
|
||||
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
|
||||
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup",
|
||||
status="success" if existed else "warning",
|
||||
summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event",
|
||||
correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"),
|
||||
details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id,
|
||||
"client_ip": _get_client_ip(request)},
|
||||
)
|
||||
return {"status": "ok", "time_entry_id": time_entry_id}
|
||||
|
||||
|
||||
@ -843,6 +860,7 @@ async def list_calls(
|
||||
limit: int = Query(200, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
TelefoniService.reconcile_stale_calls()
|
||||
where = []
|
||||
params = []
|
||||
|
||||
|
||||
@ -9,6 +9,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelefoniService:
|
||||
@staticmethod
|
||||
def reconcile_stale_calls(max_age_hours: int = 12) -> int:
|
||||
"""Close the lifecycle state for calls whose phone never sent hangup."""
|
||||
rows = execute_query(
|
||||
"""WITH stale AS (
|
||||
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW()
|
||||
WHERE ended_at IS NULL AND lifecycle_status='active'
|
||||
AND started_at < NOW()-(%s * INTERVAL '1 hour')
|
||||
RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at
|
||||
), logged AS (
|
||||
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
|
||||
jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW()
|
||||
FROM stale RETURNING id
|
||||
) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,),
|
||||
) or []
|
||||
return int(rows[0].get("count") or 0) if rows else 0
|
||||
|
||||
@staticmethod
|
||||
def find_user_by_extension(extension: Optional[str]) -> list[int]:
|
||||
"""Find all users with the given extension - returns list of user_ids."""
|
||||
@ -183,6 +201,7 @@ class TelefoniService:
|
||||
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
|
||||
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
|
||||
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
|
||||
, lifecycle_status = 'active', last_event_at = NOW()
|
||||
RETURNING *
|
||||
"""
|
||||
rows = execute_query(
|
||||
@ -208,9 +227,9 @@ class TelefoniService:
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
|
||||
VALUES
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW())
|
||||
ON CONFLICT (callid)
|
||||
DO UPDATE SET
|
||||
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
|
||||
@ -222,7 +241,7 @@ class TelefoniService:
|
||||
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(callid, duration_sec),
|
||||
|
||||
@ -34,6 +34,7 @@ from aiohttp import ClientSession, BasicAuth
|
||||
import msal
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.system_event_logger import log_system_event
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -1765,6 +1766,11 @@ class EmailService:
|
||||
)
|
||||
if graph_ok:
|
||||
logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt",
|
||||
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
|
||||
)
|
||||
return True, graph_message
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1825,11 +1831,21 @@ class EmailService:
|
||||
)
|
||||
|
||||
logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}")
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt",
|
||||
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
|
||||
)
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_system_event(
|
||||
source="email", event_type="email.send_failed", status="error",
|
||||
summary=subject or "E-mail kunne ikke sendes",
|
||||
details={"provider": "smtp", "to": to_addresses, "error": str(e)},
|
||||
)
|
||||
if graph_failure_message:
|
||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
|
||||
return False, error_msg
|
||||
@ -1891,6 +1907,11 @@ class EmailService:
|
||||
subject,
|
||||
provider_thread_key,
|
||||
)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
|
||||
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
|
||||
)
|
||||
return True, graph_message, generated_message_id, provider_thread_key
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1970,11 +1991,21 @@ class EmailService:
|
||||
len(to_addresses),
|
||||
subject,
|
||||
)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
|
||||
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
|
||||
)
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error (attachments): {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_system_event(
|
||||
source="email", event_type="email.send_failed", status="error",
|
||||
summary=subject or "E-mail kunne ikke sendes", correlation_id=generated_message_id,
|
||||
details={"provider": "smtp", "to": to_addresses, "attachments": len(attachments or []), "error": str(e)},
|
||||
)
|
||||
if graph_failure_message:
|
||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key
|
||||
return False, error_msg, generated_message_id, provider_thread_key
|
||||
|
||||
@ -413,6 +413,7 @@ REGLER FOR title:
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"top_p": 0.9,
|
||||
|
||||
22
app/services/system_event_logger.py
Normal file
22
app/services/system_event_logger.py
Normal file
@ -0,0 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import execute_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_system_event(*, source: str, event_type: str, summary: str, status: str = "info",
|
||||
correlation_id: str | None = None, user_id: int | None = None,
|
||||
details: dict[str, Any] | None = None, occurred_at: datetime | None = None) -> None:
|
||||
"""Best-effort operational event logging; logging must never break the source flow."""
|
||||
try:
|
||||
execute_query(
|
||||
"""INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s::jsonb,COALESCE(%s,NOW()))""",
|
||||
(source,event_type,status,summary,correlation_id,user_id,json.dumps(details or {},default=str),occurred_at),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist system event %s: %s", event_type, exc)
|
||||
@ -2,13 +2,13 @@
|
||||
Settings and User Management API Router
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.core.config import settings
|
||||
from app.core.auth_dependencies import require_superadmin
|
||||
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
||||
from app.core.auth_service import AuthService
|
||||
import argparse
|
||||
import asyncio
|
||||
@ -1288,3 +1288,76 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||
|
||||
|
||||
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
|
||||
|
||||
|
||||
def _event_sort_value(event: Dict) -> float:
|
||||
"""Return one comparable timestamp for naive and timezone-aware DB values."""
|
||||
value = event.get("occurred_at")
|
||||
if not value:
|
||||
return 0.0
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.timestamp()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/settings/system-events/list", dependencies=[Depends(system_event_log_access)], tags=["Settings"])
|
||||
async def list_system_events(
|
||||
source: Optional[str] = Query(None), event_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None), q: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||
user_id: Optional[int] = Query(None), limit: int = Query(250, ge=1, le=2000),
|
||||
):
|
||||
"""Unified operational log over persisted system, VoIP and email events."""
|
||||
clauses, params = [], []
|
||||
if source: clauses.append("l.source=%s"); params.append(source)
|
||||
if event_type: clauses.append("l.event_type=%s"); params.append(event_type)
|
||||
if status: clauses.append("l.status=%s"); params.append(status)
|
||||
if user_id: clauses.append("l.user_id=%s"); params.append(user_id)
|
||||
if date_from: clauses.append("l.occurred_at>=%s"); params.append(date_from)
|
||||
if date_to: clauses.append("l.occurred_at<=%s"); params.append(date_to)
|
||||
if q:
|
||||
clauses.append("(l.summary ILIKE %s OR l.correlation_id ILIKE %s OR l.details::text ILIKE %s)")
|
||||
params.extend([f"%{q}%"] * 3)
|
||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
events = execute_query(
|
||||
f"""SELECT l.id,l.source,l.event_type,l.status,l.summary,l.correlation_id,l.user_id,
|
||||
l.details,l.occurred_at,COALESCE(u.full_name,u.username) AS user_name
|
||||
FROM system_event_log l LEFT JOIN users u ON u.user_id=l.user_id {where}
|
||||
ORDER BY l.occurred_at DESC LIMIT %s""", tuple(params + [limit]),
|
||||
) or []
|
||||
|
||||
# Existing mail records remain searchable even before producers adopt system_event_log.
|
||||
if source in (None, "email"):
|
||||
email_clauses, email_params = [], []
|
||||
if date_from: email_clauses.append("em.received_date>=%s"); email_params.append(date_from)
|
||||
if date_to: email_clauses.append("em.received_date<=%s"); email_params.append(date_to)
|
||||
if q:
|
||||
email_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.recipient_email ILIKE %s)")
|
||||
email_params.extend([f"%{q}%"] * 3)
|
||||
email_where = "WHERE " + " AND ".join(email_clauses) if email_clauses else ""
|
||||
emails = execute_query(
|
||||
f"""SELECT em.id,'email' AS source,
|
||||
CASE WHEN LOWER(COALESCE(em.folder,'')) LIKE '%%sent%%' THEN 'email.sent' ELSE 'email.received' END AS event_type,
|
||||
'success'::text AS status,COALESCE(em.subject,'(intet emne)') AS summary,
|
||||
em.message_id AS correlation_id,NULL::integer AS user_id,
|
||||
jsonb_build_object('from',em.sender_email,'to',em.recipient_email,'folder',em.folder) AS details,
|
||||
em.received_date AS occurred_at,NULL::text AS user_name
|
||||
FROM email_messages em {email_where} ORDER BY em.received_date DESC LIMIT %s""",
|
||||
tuple(email_params + [limit]),
|
||||
) or []
|
||||
if event_type: emails = [x for x in emails if x["event_type"] == event_type]
|
||||
if status: emails = [x for x in emails if x["status"] == status]
|
||||
events.extend(emails)
|
||||
events.sort(key=_event_sort_value, reverse=True)
|
||||
return {"items": events[:limit], "sources": ["voip", "email", "system"], "count": len(events[:limit])}
|
||||
|
||||
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_any_permission
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
sql_console_access = require_any_permission("users.manage", "system.admin")
|
||||
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
|
||||
|
||||
CREATE_TABLE_RE = re.compile(
|
||||
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
||||
@ -265,6 +266,11 @@ async def settings_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/settings/event-log", response_class=HTMLResponse, tags=["Frontend"], dependencies=[Depends(system_event_log_access)])
|
||||
async def system_event_log_page(request: Request):
|
||||
return templates.TemplateResponse("settings/frontend/event_log.html", {"request": request, "title": "Systemlog"})
|
||||
|
||||
|
||||
@router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"])
|
||||
async def migrations_page(request: Request):
|
||||
"""Render database migrations page"""
|
||||
|
||||
20
app/settings/frontend/event_log.html
Normal file
20
app/settings/frontend/event_log.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
{% block title %}Systemlog · BMC Hub{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="mb-1">Systemlog</h2><p class="text-muted mb-0">VoIP, e-mail og øvrige integrationshændelser</p></div><a href="/settings" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> Indstillinger</a></div>
|
||||
<div class="card p-3 mb-3"><div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label">Kilde</label><select id="eventSource" class="form-select"><option value="">Alle</option><option value="voip">VoIP</option><option value="email">E-mail</option><option value="system">System</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Hændelse</label><select id="eventType" class="form-select"><option value="">Alle</option><option value="voip.established">VoIP etableret</option><option value="voip.terminated">VoIP hangup</option><option value="voip.orphan_hangup">Hangup uden start</option><option value="voip.stale_call">Mangler hangup</option><option value="email.received">E-mail modtaget</option><option value="email.sent">E-mail sendt</option><option value="email.send_failed">E-mail sendefejl</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Status</label><select id="eventStatus" class="form-select"><option value="">Alle</option><option value="success">OK</option><option value="warning">Advarsel</option><option value="error">Fejl</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Fra</label><input id="eventFrom" type="datetime-local" class="form-control"></div><div class="col-md-2"><label class="form-label">Til</label><input id="eventTo" type="datetime-local" class="form-control"></div>
|
||||
<div class="col-md-2"><label class="form-label">Søg</label><input id="eventQuery" class="form-control" placeholder="Nummer, call-id, emne…"></div>
|
||||
</div></div>
|
||||
<div class="card overflow-hidden"><div class="table-responsive"><table class="table table-hover mb-0"><thead><tr><th>Tid</th><th>Kilde</th><th>Hændelse</th><th>Status</th><th>Beskrivelse</th><th>Bruger</th><th>Korrelations-id</th></tr></thead><tbody id="eventRows"><tr><td colspan="7" class="text-center p-4">Henter log…</td></tr></tbody></table></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}<script>
|
||||
const escapeEvent=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function loadSystemEvents(){const p=new URLSearchParams();const values={source:eventSource.value,event_type:eventType.value,status:eventStatus.value,q:eventQuery.value};Object.entries(values).forEach(([k,v])=>v&&p.set(k,v));if(eventFrom.value)p.set('date_from',new Date(eventFrom.value).toISOString());if(eventTo.value)p.set('date_to',new Date(eventTo.value).toISOString());const r=await fetch('/api/v1/settings/system-events/list?'+p,{credentials:'include'}),b=await r.json();if(!r.ok)throw new Error(b.detail||'Kunne ikke hente log');eventRows.innerHTML=b.items.length?b.items.map(x=>`<tr><td class="text-nowrap">${new Date(x.occurred_at).toLocaleString('da-DK')}</td><td><span class="badge text-bg-secondary">${escapeEvent(x.source)}</span></td><td>${escapeEvent(x.event_type)}</td><td><span class="badge ${x.status==='error'?'text-bg-danger':x.status==='warning'?'text-bg-warning':'text-bg-success'}">${escapeEvent(x.status)}</span></td><td>${escapeEvent(x.summary)}</td><td>${escapeEvent(x.user_name||'–')}</td><td><code>${escapeEvent(x.correlation_id||'–')}</code></td></tr>`).join(''):'<tr><td colspan="7" class="text-center p-4 text-muted">Ingen hændelser matcher filtrene</td></tr>'}
|
||||
let eventTimer;document.querySelectorAll('#eventSource,#eventType,#eventStatus,#eventFrom,#eventTo').forEach(x=>x.addEventListener('change',loadSystemEvents));eventQuery.addEventListener('input',()=>{clearTimeout(eventTimer);eventTimer=setTimeout(loadSystemEvents,300)});loadSystemEvents().catch(e=>eventRows.innerHTML=`<tr><td colspan="7" class="text-danger p-4">${escapeEvent(e.message)}</td></tr>`);
|
||||
</script>{% endblock %}
|
||||
@ -208,6 +208,9 @@
|
||||
<a class="nav-link" href="#system" data-tab="system">
|
||||
<i class="bi bi-gear me-2"></i>System
|
||||
</a>
|
||||
<a class="nav-link" href="/settings/event-log">
|
||||
<i class="bi bi-journal-text me-2"></i>Systemlog
|
||||
</a>
|
||||
<a class="nav-link" href="/settings/migrations">
|
||||
<i class="bi bi-database me-2"></i>DB Migrationer
|
||||
</a>
|
||||
@ -6796,6 +6799,13 @@ const MENU_VISIBILITY_GROUPS = [
|
||||
{ key: 'menu-datamigration', label: 'Data migration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Kalender underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-kalender-overview', label: 'Kalenderoversigt' },
|
||||
{ key: 'menu-planner', label: 'Planlægger' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'CRM underpunkter',
|
||||
items: [
|
||||
|
||||
@ -1486,10 +1486,14 @@
|
||||
<i class="bi bi-list-check me-2"></i>Sager
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" data-menu-key="menu-kalender">
|
||||
<a class="nav-link" href="/calendar">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-kalender">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-calendar3 me-2"></i>Kalender
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li data-menu-key="menu-kalender-overview"><a class="dropdown-item py-2" href="/calendar"><i class="bi bi-calendar3 me-2"></i>Kalenderoversigt</a></li>
|
||||
<li data-menu-key="menu-planner"><a class="dropdown-item py-2" href="/planner"><i class="bi bi-calendar2-week me-2"></i>Planlægger</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" data-menu-key="menu-support">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@ -3080,6 +3084,8 @@ if (bmcOriginalFetch) {
|
||||
{ key: 'menu-crm', label: 'CRM' },
|
||||
{ key: 'menu-sager', label: 'Sager' },
|
||||
{ key: 'menu-kalender', label: 'Kalender' },
|
||||
{ key: 'menu-kalender-overview', label: 'Kalender: Kalenderoversigt' },
|
||||
{ key: 'menu-planner', label: 'Planlægger' },
|
||||
{ key: 'menu-support', label: 'Support' },
|
||||
{ key: 'menu-salg', label: 'Salg' },
|
||||
{ key: 'menu-okonomi', label: 'Økonomi' },
|
||||
|
||||
27
fremtidige planer/README.md
Normal file
27
fremtidige planer/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Fremtidige planer
|
||||
|
||||
Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men endnu ikke implementeret.
|
||||
|
||||
## Aktive planer
|
||||
|
||||
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
|
||||
|
||||
## Arbejdsgang
|
||||
|
||||
- Nye fremtidige planer oprettes som selvstændige Markdown-filer i denne mappe.
|
||||
- Planens status skal stå øverst i filen.
|
||||
- En plan skal beskrive formål, afgrænsning, løsningsforslag, test og leveringsrækkefølge.
|
||||
- Når udviklingen starter, opdateres status til `Under udvikling`.
|
||||
- Når løsningen er leveret, flyttes planen til undermappen `arkiv` eller markeres tydeligt som implementeret.
|
||||
|
||||
## Ældre plandokumenter uden for denne mappe
|
||||
|
||||
Følgende ældre planer blev fundet i projektet, men deres tilhørende funktioner ser allerede ud til at være implementeret. De er derfor ikke registreret som aktive fremtidsplaner:
|
||||
|
||||
- `MDfile/NEXTCLOUD_MODULE_PLAN.md`
|
||||
- `MDfile/SALES_AND_AGGREGATION_PLAN.md`
|
||||
- `docs/ORDRE_SYSTEM_IMPLEMENTATION.md`
|
||||
- `plan-websiteContentAdministration.prompt.md`
|
||||
- `static/js/## Plan: Manualmodul MVP i BMC Hub.prompt.md`
|
||||
|
||||
De bør gennemgås og eventuelt arkiveres separat, men ikke blandes sammen med endnu ikke implementerede planer.
|
||||
201
fremtidige planer/kundestemning-ai.md
Normal file
201
fremtidige planer/kundestemning-ai.md
Normal file
@ -0,0 +1,201 @@
|
||||
# Kundestemning på indgående sager og mails
|
||||
|
||||
## Status
|
||||
|
||||
Fremtidig udviklingsplan. Ikke implementeret.
|
||||
|
||||
## Formål
|
||||
|
||||
BMC Hub skal bruge AI til semantisk analyse af kundens stemning i indgående mails og sager. Løsningen skal give medarbejderen et rådgivende signal om stemning, hast, kunderisiko og udvikling gennem en mailtråd.
|
||||
|
||||
Analysen må ikke baseres på simpel matching af bestemte ord, og den må i første version ikke automatisk ændre prioritet, SLA eller ansvarlig medarbejder.
|
||||
|
||||
## Analysemodel
|
||||
|
||||
AI-resultatet gemmes pr. indgående mail i den eksisterende `email_analysis`-tabel med `analysis_type = 'customer_sentiment'`.
|
||||
|
||||
Foreslået JSON-resultat:
|
||||
|
||||
```json
|
||||
{
|
||||
"sentiment": "frustrated",
|
||||
"sentiment_score": -0.65,
|
||||
"urgency": "high",
|
||||
"customer_risk": "medium",
|
||||
"requires_attention": true,
|
||||
"confidence": 0.88,
|
||||
"reason": "Kunden har rykket flere gange og oplever fortsat driftsstop.",
|
||||
"signals": [
|
||||
"gentagen henvendelse",
|
||||
"driftspåvirkning",
|
||||
"manglende løsning"
|
||||
],
|
||||
"text_scope": "latest_customer_message",
|
||||
"model": "qwen3.5:9b",
|
||||
"prompt_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Kontrollerede værdier:
|
||||
|
||||
- `sentiment`: `positive`, `neutral`, `concerned`, `frustrated`, `angry`
|
||||
- `urgency`: `normal`, `high`, `critical`
|
||||
- `customer_risk`: `low`, `medium`, `high`
|
||||
- `sentiment_score`: tal fra `-1.0` til `1.0`
|
||||
|
||||
## Tekstforberedelse
|
||||
|
||||
Før teksten sendes til AI, skal systemet så vidt muligt fjerne:
|
||||
|
||||
- Medarbejdernes tidligere svar
|
||||
- Citerede mails
|
||||
- Standardsignaturer
|
||||
- Fortrolighedstekster og disclaimere
|
||||
- Autosvar
|
||||
- HTML, tracking-links og anden teknisk støj
|
||||
|
||||
AI'en skal som udgangspunkt analysere kundens seneste indgående tekst. Emnefeltet kan medtages som kontekst. Tekstrensningen må gerne være regelbaseret, men selve vurderingen skal foretages semantisk af AI.
|
||||
|
||||
## Promptkrav
|
||||
|
||||
Prompten skal holde følgende dimensioner adskilt:
|
||||
|
||||
1. Kundens følelsesmæssige stemning
|
||||
2. Henvendelsens praktiske hast
|
||||
3. Kommerciel risiko eller risiko for eskalation
|
||||
|
||||
Eksempel: En kunde kan være venlig eller neutral, selvom et teknisk problem er kritisk.
|
||||
|
||||
Modellen skal instrueres i at:
|
||||
|
||||
- Forstå indirekte og høfligt formuleret utilfredshed
|
||||
- Undgå at overfortolke almindelig høflighed
|
||||
- Skelne kundens nye tekst fra citeret historik
|
||||
- Undgå vurderinger eller diagnoser af personen
|
||||
- Give en kort begrundelse, der er understøttet af teksten
|
||||
- Bruge lav `confidence`, når teksten er tvetydig
|
||||
- Returnere ren og validerbar JSON
|
||||
|
||||
## Afvikling
|
||||
|
||||
Første version skal:
|
||||
|
||||
- Køre asynkront ved modtagelse af en ekstern mail
|
||||
- Kun analysere indgående kundekommunikation
|
||||
- Springe spam, nyhedsbreve, fakturaer og automatiske systemmails over
|
||||
- Genbruge den eksisterende mailklassifikation til frasortering
|
||||
- Cache resultatet, så samme mail ikke analyseres flere gange
|
||||
- Understøtte manuel genanalyse
|
||||
- Lade mailimporten fortsætte, hvis AI er utilgængelig
|
||||
|
||||
## Samlet stemning på en sag
|
||||
|
||||
Sagens aktuelle stemning bør bygge på den seneste relevante indgående kundemail og ikke den værste historiske vurdering.
|
||||
|
||||
Sagen kan vise:
|
||||
|
||||
- Aktuel stemning fra seneste indgående mail
|
||||
- Trend baseret på de seneste 2-3 indgående mails
|
||||
- Aktuel kunderisiko ved tilstrækkelig høj sikkerhed
|
||||
- `forværret`, hvis scoren falder mærkbart gennem tråden
|
||||
- `forbedret`, hvis scoren stiger efter dialog med kunden
|
||||
|
||||
## GUI
|
||||
|
||||
### Mailoversigt
|
||||
|
||||
Vis en diskret badge:
|
||||
|
||||
- Positiv
|
||||
- Neutral
|
||||
- Bekymret
|
||||
- Frustreret
|
||||
- Vred/kritisk
|
||||
|
||||
Ved klik eller hover vises hast, kunderisiko, AI-begrundelse, sikkerhed, model og analysetidspunkt.
|
||||
|
||||
### Sagsvisning
|
||||
|
||||
Vis et mindre panel med:
|
||||
|
||||
- Aktuel kundestemning
|
||||
- Trend
|
||||
- Seneste begrundelse
|
||||
- Link til den mail, vurderingen kommer fra
|
||||
|
||||
### Senere dashboardfunktioner
|
||||
|
||||
- Filteret `Kræver opmærksomhed`
|
||||
- Filter efter stemning, hast og kunderisiko
|
||||
- Oversigt over forværret kundestemning
|
||||
- Visning af kritiske henvendelser
|
||||
|
||||
## Manuel korrektion
|
||||
|
||||
Medarbejderen skal kunne:
|
||||
|
||||
- Markere vurderingen som korrekt eller forkert
|
||||
- Vælge en anden stemning
|
||||
- Tilføje en kort kommentar
|
||||
|
||||
Den manuelle vurdering skal gemmes separat fra det oprindelige AI-resultat. Det giver sporbarhed og et evalueringsdatasæt til senere forbedring af prompt og model.
|
||||
|
||||
## Automatik og sikkerhed
|
||||
|
||||
I første version er `requires_attention` kun et visuelt signal.
|
||||
|
||||
Mulige senere funktioner:
|
||||
|
||||
- Intern notifikation ved høj risiko
|
||||
- Fremhævelse på dashboardet
|
||||
- Besked til ansvarlig medarbejder
|
||||
- Eskalation af kritiske beskeder uden for normal arbejdstid
|
||||
|
||||
Systemet må ikke automatisk sende svar til kunden eller ændre sagsprioritet uden menneskelig godkendelse.
|
||||
|
||||
## Test og evaluering
|
||||
|
||||
Opret et anonymiseret testsæt på cirka 50-100 realistiske mails, herunder:
|
||||
|
||||
- Positive og neutrale mails
|
||||
- Direkte og indirekte utilfredshed
|
||||
- Vrede mails
|
||||
- Kritisk drift uden vred tone
|
||||
- Ironi og tvetydige formuleringer
|
||||
- Meget korte beskeder
|
||||
- Lange tråde med citeret historik
|
||||
- Autosvar og systemmails
|
||||
- Danske og engelske henvendelser
|
||||
|
||||
Mål især:
|
||||
|
||||
- Hvor ofte kritiske mails overses
|
||||
- Hvor ofte neutrale mails fejlmarkeres
|
||||
- Om hast og stemning adskilles korrekt
|
||||
- Om begrundelsen kan dokumenteres ud fra teksten
|
||||
- Svartid og stabilitet
|
||||
- Forskelle mellem de installerede modeller
|
||||
|
||||
## Leveringsrækkefølge
|
||||
|
||||
1. AI-analyse og lagring pr. indgående mail
|
||||
2. Admin-testside med råt AI-resultat
|
||||
3. Evaluering mod menneskeligt mærkede testmails
|
||||
4. Badge i mailoversigten
|
||||
5. Detaljepanel på mails og sager
|
||||
6. Manuel korrektion og feedback
|
||||
7. Trendberegning på mailtråde
|
||||
8. Dashboard og filtre
|
||||
9. Eventuelle notifikationer
|
||||
|
||||
## Anbefalet lancering
|
||||
|
||||
Start med en stille pilot, hvor nye mails analyseres og gemmes, men resultaterne kun vises på en admin-testside. Aktivér først badges og alarmer bredt, når AI-vurderingerne er sammenlignet med menneskelige vurderinger.
|
||||
|
||||
## Beslutninger før implementering
|
||||
|
||||
- Vælg den endelige AI-model ud fra modeltesten
|
||||
- Fastlæg grænser for `requires_attention`
|
||||
- Beslut hvem der kan se og korrigere vurderinger
|
||||
- Beslut hvor længe analyser og manuelle rettelser skal gemmes
|
||||
- Aftal om engelsk og andre sprog skal understøttes fra første version
|
||||
4
main.py
4
main.py
@ -133,6 +133,8 @@ from app.modules.telefoni.backend import router as telefoni_api
|
||||
from app.modules.telefoni.frontend import views as telefoni_views
|
||||
from app.modules.calendar.backend import router as calendar_api
|
||||
from app.modules.calendar.frontend import views as calendar_views
|
||||
from app.modules.planner.backend import router as planner_api
|
||||
from app.modules.planner.frontend import views as planner_views
|
||||
from app.modules.orders.backend import router as orders_api
|
||||
from app.modules.orders.frontend import views as orders_views
|
||||
from app.modules.fedex.backend import router as fedex_api
|
||||
@ -513,6 +515,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
|
||||
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
|
||||
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||
app.include_router(planner_api.router, prefix="/api/v1", tags=["Planner"])
|
||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
|
||||
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])
|
||||
@ -560,6 +563,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
|
||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||
app.include_router(telefoni_views.router, tags=["Frontend"])
|
||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||
app.include_router(planner_views.router, tags=["Frontend"])
|
||||
app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
|
||||
134
migrations/240_planner.sql
Normal file
134
migrations/240_planner.sql
Normal file
@ -0,0 +1,134 @@
|
||||
-- Planner: independent case allocations, capacity sources and permissions.
|
||||
ALTER TABLE sag_sager
|
||||
ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER;
|
||||
ALTER TABLE sag_sager
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'ticket';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_settings (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
warning_percent INTEGER NOT NULL DEFAULT 80 CHECK (warning_percent BETWEEN 1 AND 99),
|
||||
full_percent INTEGER NOT NULL DEFAULT 100 CHECK (full_percent >= 100),
|
||||
default_allocation_minutes INTEGER NOT NULL DEFAULT 60 CHECK (default_allocation_minutes > 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by INTEGER REFERENCES users(user_id)
|
||||
);
|
||||
INSERT INTO planner_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_work_schedules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
weekday SMALLINT NOT NULL CHECK (weekday BETWEEN 0 AND 6),
|
||||
start_time TIME NOT NULL DEFAULT '08:00',
|
||||
end_time TIME NOT NULL DEFAULT '16:00',
|
||||
break_minutes INTEGER NOT NULL DEFAULT 30 CHECK (break_minutes >= 0),
|
||||
valid_from DATE,
|
||||
valid_to DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CHECK (end_time > start_time)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_work_user ON planner_work_schedules(user_id, weekday);
|
||||
INSERT INTO planner_work_schedules (user_id, weekday, start_time, end_time, break_minutes)
|
||||
SELECT u.user_id, d.weekday, '08:00', '16:00', 30
|
||||
FROM users u CROSS JOIN (VALUES (0),(1),(2),(3),(4)) AS d(weekday)
|
||||
WHERE u.is_active AND NOT EXISTS (
|
||||
SELECT 1 FROM planner_work_schedules w WHERE w.user_id=u.user_id AND w.weekday=d.weekday
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
kind VARCHAR(40) NOT NULL DEFAULT 'absence',
|
||||
note TEXT,
|
||||
CHECK (ends_at > starts_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_absence_period ON planner_absences(user_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_external_busy (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
provider VARCHAR(40) NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK (ends_at > starts_at),
|
||||
UNIQUE(provider, external_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_external_period ON planner_external_busy(user_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_integrations (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
provider VARCHAR(40),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'not_connected',
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
CHECK (status IN ('connected', 'degraded', 'not_connected'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_team_scope (
|
||||
editor_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
target_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (editor_user_id, target_user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_allocations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
note TEXT,
|
||||
source VARCHAR(40) NOT NULL DEFAULT 'manual',
|
||||
conflict_status VARCHAR(30) NOT NULL DEFAULT 'none',
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
updated_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CHECK (ends_at > starts_at),
|
||||
CHECK (conflict_status IN ('none', 'overlap', 'overbooked', 'overlap_and_overbooked'))
|
||||
);
|
||||
ALTER TABLE planner_allocations ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_alloc_period ON planner_allocations(user_id, starts_at, ends_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_alloc_case ON planner_allocations(sag_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_allocation_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
allocation_id BIGINT NOT NULL REFERENCES planner_allocations(id),
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
|
||||
action VARCHAR(20) NOT NULL,
|
||||
changed_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
before_value JSONB,
|
||||
after_value JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_history_case ON planner_allocation_history(sag_id, created_at DESC);
|
||||
|
||||
INSERT INTO permissions (code, description, category) VALUES
|
||||
('planner.view', 'Se relevante planer', 'planner'),
|
||||
('planner.edit_own', 'Redigér egen plan', 'planner'),
|
||||
('planner.edit_team', 'Redigér tilladte medarbejderes planer', 'planner'),
|
||||
('planner.admin', 'Administrér kapacitet og grænser', 'planner')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name = 'Administrators' AND p.category = 'planner'
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name = 'Managers' AND p.code IN ('planner.view', 'planner.edit_own', 'planner.edit_team')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name = 'Technicians' AND p.code IN ('planner.view', 'planner.edit_own')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO group_permissions (group_id, permission_id)
|
||||
SELECT g.id, p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name = 'Viewers' AND p.code = 'planner.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
37
migrations/241_system_event_log.sql
Normal file
37
migrations/241_system_event_log.sql
Normal file
@ -0,0 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS system_event_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source VARCHAR(40) NOT NULL,
|
||||
event_type VARCHAR(80) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'info',
|
||||
summary TEXT NOT NULL,
|
||||
correlation_id VARCHAR(255),
|
||||
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_time ON system_event_log(occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_source ON system_event_log(source, event_type, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_correlation ON system_event_log(correlation_id);
|
||||
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS lifecycle_status VARCHAR(30) NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS termination_source VARCHAR(40);
|
||||
CREATE INDEX IF NOT EXISTS idx_telefoni_open_calls ON telefoni_opkald(started_at) WHERE ended_at IS NULL;
|
||||
|
||||
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
|
||||
jsonb_build_object('call_id',id,'extension',intern_extension,'external_number',ekstern_nummer),started_at
|
||||
FROM telefoni_opkald
|
||||
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours'
|
||||
AND NOT EXISTS (SELECT 1 FROM system_event_log l WHERE l.event_type='voip.stale_call' AND l.correlation_id=telefoni_opkald.callid);
|
||||
|
||||
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup'
|
||||
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours';
|
||||
|
||||
INSERT INTO permissions(code,description,category) VALUES
|
||||
('system.event_log.view','Se samlet systemhændelseslog','system') ON CONFLICT(code) DO NOTHING;
|
||||
INSERT INTO group_permissions(group_id,permission_id)
|
||||
SELECT g.id,p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name IN ('Administrators','Managers') AND p.code='system.event_log.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
44
static/js/planner.js
Normal file
44
static/js/planner.js
Normal file
@ -0,0 +1,44 @@
|
||||
(() => {
|
||||
const root=document.querySelector('.planner-page'); if(!root)return;
|
||||
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
|
||||
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
|
||||
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
|
||||
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
|
||||
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
|
||||
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
|
||||
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
|
||||
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
|
||||
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
|
||||
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
|
||||
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
|
||||
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
|
||||
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
|
||||
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||
function render(a,b){const opts={day:'numeric',month:'short'};$('#periodLabel').textContent=state.view==='month'?a.toLocaleDateString('da-DK',{month:'long',year:'numeric'}):`${a.toLocaleDateString('da-DK',opts)} – ${new Date(b-1).toLocaleDateString('da-DK',{...opts,year:'numeric'})}`;state.view==='month'?renderMonth(a):renderTimeline(a,b)}
|
||||
function cap(uid,key){return state.data.capacity.find(c=>c.user_id===uid&&c.date===key)||{state:'gray',label:'Ingen arbejdstid'}}
|
||||
function canEdit(uid){return (state.meta.editable_user_ids||[]).includes(Number(uid))}
|
||||
function integrationWarning(){return (state.data.integration_statuses||[]).some(x=>x.status==='degraded')?'<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>':''}
|
||||
function blockStyle(start,end,lane={index:0,count:1}){const first=7*60,last=18*60,total=last-first,s=new Date(start),e=new Date(end),sm=s.getHours()*60+s.getMinutes(),em=e.getHours()*60+e.getMinutes(),top=Math.max(0,(sm-first)/total*660),height=Math.max(24,(Math.min(last,em)-Math.max(first,sm))/total*660),left=lane.index*(100/lane.count),leftPx=26-lane.index*(29/lane.count),width=100/lane.count,widthPx=29/lane.count;return `top:${top}px;height:${height}px;left:calc(${left}% + ${leftPx}px);right:auto;width:calc(${width}% - ${widthPx}px)`}
|
||||
function overlapLayout(planned,registered){const all=[...planned.map(item=>({key:`p:${item.id}`,item})),...registered.map(item=>({key:`r:${item.id}`,item}))].sort((a,b)=>new Date(a.item.starts_at)-new Date(b.item.starts_at));const result=new Map();let group=[],groupEnd=0;const flush=()=>{if(!group.length)return;const laneEnds=[];for(const event of group){const start=+new Date(event.item.starts_at);let lane=laneEnds.findIndex(end=>end<=start);if(lane<0)lane=laneEnds.length;laneEnds[lane]=+new Date(event.item.ends_at);event.lane=lane}for(const event of group)result.set(event.key,{index:event.lane,count:laneEnds.length});group=[];groupEnd=0};for(const event of all){const start=+new Date(event.item.starts_at),end=+new Date(event.item.ends_at);if(group.length&&start>=groupEnd)flush();group.push(event);groupEnd=Math.max(groupEnd,end)}flush();return result}
|
||||
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
|
||||
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
|
||||
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
|
||||
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout(items,registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
|
||||
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
|
||||
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
|
||||
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
|
||||
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
|
||||
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
|
||||
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
|
||||
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
|
||||
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}
|
||||
async function mutate(url,method,body,close=false){try{const result=await api(url,{method,body:body?JSON.stringify(body):undefined});if(close)$('#allocationDialog').close();toast('Planen er gemt','Fortryd',result?.id&&result?.version?()=>undoMutation(result.id,result.version):null);await refresh()}catch(e){if(e.status===409&&e.body?.detail?.conflict_status&&confirm(`${e.message}. Vil du gemme alligevel?`)){body.confirm_conflicts=true;return mutate(url,method,body,close)}toast(e.message);await loadPeriod()}}
|
||||
async function undoMutation(id,version){try{await api(`/api/v1/planner/allocations/${id}/undo?version=${version}`,{method:'POST'});toast('Ændringen blev fortrudt');await refresh()}catch(e){toast(e.message);await loadPeriod()}}
|
||||
async function refresh(){await Promise.all([loadCases(),loadPeriod()])}
|
||||
function toast(message,action,fn){const el=document.createElement('div');el.className='planner-toast';el.textContent=message;if(action&&fn){const b=document.createElement('button');b.textContent=action;b.onclick=()=>{fn();el.remove()};el.append(b)}$('#toastStack').append(el);setTimeout(()=>el.remove(),6000)}
|
||||
init().catch(e=>{$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`});
|
||||
})();
|
||||
113
tests/test_planner.py
Normal file
113
tests/test_planner.py
Normal file
@ -0,0 +1,113 @@
|
||||
from datetime import datetime, time, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.modules.planner.backend import router as planner
|
||||
|
||||
|
||||
def test_planner_migration_has_independent_versioned_allocations_and_capacity_sources():
|
||||
sql = Path("migrations/240_planner.sql").read_text(encoding="utf-8")
|
||||
for table in (
|
||||
"planner_allocations", "planner_allocation_history", "planner_work_schedules",
|
||||
"planner_absences", "planner_external_busy", "planner_integrations", "planner_settings",
|
||||
):
|
||||
assert f"CREATE TABLE IF NOT EXISTS {table}" in sql
|
||||
assert "version INTEGER NOT NULL DEFAULT 1" in sql
|
||||
assert "ALTER TABLE sag_sager" in sql
|
||||
assert "estimated_minutes" in sql
|
||||
assert "planner.edit_team" in sql
|
||||
|
||||
|
||||
def test_allocation_input_rejects_zero_or_negative_duration():
|
||||
with pytest.raises(Exception):
|
||||
planner.AllocationInput(
|
||||
sag_id=1, user_id=2,
|
||||
starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 10),
|
||||
)
|
||||
|
||||
|
||||
def test_capacity_uses_work_time_less_break_absence_and_external_busy(monkeypatch):
|
||||
def fake_single(query, _params=None):
|
||||
if "planner_settings" in query:
|
||||
return {"warning_percent": 80, "full_percent": 100}
|
||||
return None
|
||||
|
||||
def fake_query(query, _params=None):
|
||||
if "planner_work_schedules" in query:
|
||||
return [{"user_id": 7, "weekday": 1, "start_time": time(8), "end_time": time(16), "break_minutes": 30}]
|
||||
if "planner_absences" in query:
|
||||
return [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 13, tzinfo=planner.TZ)}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(planner, "execute_query_single", fake_single)
|
||||
monkeypatch.setattr(planner, "execute_query", fake_query)
|
||||
monkeypatch.setattr(planner.busy_time_provider, "get_busy", lambda *_: [{
|
||||
"user_id": 7, "starts_at": datetime(2026, 9, 8, 14, tzinfo=planner.TZ),
|
||||
"ends_at": datetime(2026, 9, 8, 15, tzinfo=planner.TZ), "provider": "outlook",
|
||||
}])
|
||||
allocations = [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 8, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ)}]
|
||||
result = planner._capacity(
|
||||
[7], datetime(2026, 9, 8, tzinfo=planner.TZ),
|
||||
datetime(2026, 9, 9, tzinfo=planner.TZ), allocations,
|
||||
)[0]
|
||||
assert result["available_minutes"] == 330 # 8h - 30m break - 2h unavailable
|
||||
assert result["allocated_minutes"] == 240
|
||||
assert result["state"] == "green"
|
||||
assert result["label"] == "4 af 5.5 timer planlagt"
|
||||
assert result["registered_minutes"] == 0
|
||||
assert result["registered_label"] == "0 timer registreret"
|
||||
|
||||
|
||||
def test_own_and_team_edit_permissions_are_separate(monkeypatch):
|
||||
own = {"id": 4, "permissions": ["planner.edit_own"]}
|
||||
assert planner._can_edit(own, 4)
|
||||
assert not planner._can_edit(own, 5)
|
||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"allowed": 1})
|
||||
assert planner._can_edit({"id": 4, "permissions": ["planner.edit_team"]}, 5)
|
||||
|
||||
|
||||
def test_overlapping_allocations_each_consume_capacity():
|
||||
start = datetime(2026, 9, 8, 8, tzinfo=planner.TZ)
|
||||
end = datetime(2026, 9, 8, 12, tzinfo=planner.TZ)
|
||||
assert planner._sum_minutes([(start, end), (start, end)], start, end) == 480
|
||||
|
||||
|
||||
def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
|
||||
html = Path("app/modules/planner/templates/index.html").read_text(encoding="utf-8")
|
||||
js = Path("static/js/planner.js").read_text(encoding="utf-8")
|
||||
for text in ("Min plan", "Team", "Dag", "Uge", "Måned", "Ikke planlagt", "Delvist planlagt", "Mine sager", "Haster"):
|
||||
assert text in html
|
||||
for feature in ("application/x-case", "application/x-allocation", "confirm_conflicts", "undoMutation", "time-entries", "used_minutes", "estimated_minutes", "dialogLocked", "hourMarks", "blockStyle", "overlapLayout"):
|
||||
assert feature in js
|
||||
assert "Fastlås tidspunkt" in html
|
||||
assert "planner.estimate.update" in Path("app/modules/planner/backend/router.py").read_text()
|
||||
assert "registered-time" in html
|
||||
assert "registeredHtml" in js
|
||||
assert "time_entries" in js
|
||||
assert "Ekstern travl tid" in js
|
||||
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
|
||||
|
||||
|
||||
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
||||
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
||||
payload = planner.AllocationInput(
|
||||
sag_id=9, user_id=2, starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 11)
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
planner.create_allocation(payload, {"id": 2, "permissions": ["planner.edit_own"]})
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_active_case_list_only_hides_closed_cases_that_already_have_time():
|
||||
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
|
||||
cases_query = source.split('@router.get("/cases")', 1)[1].split('@router.get("/cases/{sag_id}/time-entries")', 1)[0]
|
||||
assert "NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)" in cases_query
|
||||
assert "AS is_closed" in cases_query
|
||||
Loading…
Reference in New Issue
Block a user