461 lines
28 KiB
Python
461 lines
28 KiB
Python
|
|
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 []}
|