152 lines
9.7 KiB
Python
152 lines
9.7 KiB
Python
|
|
"""One interval-based capacity calculation for the unified work surface."""
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from typing import Literal
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
|
|
||
|
|
from app.core.auth_dependencies import get_current_user
|
||
|
|
from app.core.database import execute_query, execute_query_single
|
||
|
|
from app.core.case_visibility import require_case,hidden_case_ids,filter_data
|
||
|
|
from .router import TZ, _aware, _merge_minutes
|
||
|
|
from .work import require_target, remaining, FINISHED, require_schema,db_utc
|
||
|
|
|
||
|
|
router = APIRouter(dependencies=[Depends(require_schema)])
|
||
|
|
|
||
|
|
|
||
|
|
def working_capacity(schedules, occupied, start, end):
|
||
|
|
normal = busy = 0
|
||
|
|
day = start.astimezone(TZ).date()
|
||
|
|
last_day=end.astimezone(TZ).date()
|
||
|
|
start,end=start.astimezone(timezone.utc),end.astimezone(timezone.utc)
|
||
|
|
occupied=[(a.astimezone(timezone.utc),b.astimezone(timezone.utc)) for a,b in occupied]
|
||
|
|
while day <= last_day:
|
||
|
|
for schedule in schedules:
|
||
|
|
if schedule['weekday'] != day.weekday():
|
||
|
|
continue
|
||
|
|
if schedule.get('valid_from') and day < schedule['valid_from'] or schedule.get('valid_to') and day > schedule['valid_to']:
|
||
|
|
continue
|
||
|
|
a,b = datetime.combine(day,schedule['start_time'],TZ),datetime.combine(day,schedule['end_time'],TZ)
|
||
|
|
a,b=a.astimezone(timezone.utc),b.astimezone(timezone.utc)
|
||
|
|
lo,hi = max(a,start),min(b,end)
|
||
|
|
if hi <= lo:
|
||
|
|
continue
|
||
|
|
gross = (b-a).total_seconds()/60
|
||
|
|
fraction = max(0,gross-int(schedule['break_minutes'] or 0))/gross
|
||
|
|
net=round((hi-lo).total_seconds()/60*fraction)
|
||
|
|
normal += net
|
||
|
|
busy += min(net,_merge_minutes(occupied,lo,hi))
|
||
|
|
day += timedelta(days=1)
|
||
|
|
return {'normal_minutes':normal,'busy_minutes':busy,'free_minutes':max(0,normal-busy)}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/capacity')
|
||
|
|
def capacity(starts_at:datetime,ends_at:datetime,user_id:list[int]=Query(default=[]),user:dict=Depends(get_current_user)):
|
||
|
|
start,end = _aware(starts_at),_aware(ends_at)
|
||
|
|
if end<=start or end-start>timedelta(days=370):
|
||
|
|
raise HTTPException(400,'Ugyldig periode')
|
||
|
|
ids = sorted(set(user_id or [int(user['id'])]))
|
||
|
|
for uid in ids:
|
||
|
|
require_target(user,uid)
|
||
|
|
schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or []
|
||
|
|
occupied = execute_query('''SELECT user_id,starts_at,ends_at FROM planner_allocations
|
||
|
|
WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT user_id,starts_at,ends_at FROM planner_absences
|
||
|
|
WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT user_id,starts_at,ends_at FROM planner_external_busy
|
||
|
|
WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s
|
||
|
|
UNION ALL SELECT user_id,starts_at,ends_at FROM quick_time_items
|
||
|
|
WHERE user_id=ANY(%s) AND state='planned' AND starts_at<%s AND ends_at>%s''',
|
||
|
|
(ids,end,start,ids,end,start,ids,end,start,ids,end,start)) or []
|
||
|
|
assignments = execute_query('''SELECT a.*,s.deadline,s.estimated_minutes AS case_estimated_minutes,
|
||
|
|
COALESCE(t.minutes,0) AS actual_minutes,COALESCE(p.minutes,0) AS future_planned_minutes,p.after_deadline
|
||
|
|
FROM sag_case_assignees a JOIN sag_sager s ON s.id=a.sag_id
|
||
|
|
LEFT JOIN LATERAL (SELECT SUM(COALESCE(faktisk_tid_min,ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes
|
||
|
|
FROM tmodule_times WHERE sag_id=a.sag_id AND medarbejder_id=a.user_id AND status<>'rejected') t ON TRUE
|
||
|
|
LEFT JOIN LATERAL (SELECT SUM(EXTRACT(EPOCH FROM (ends_at-GREATEST(starts_at,NOW())))/60)::int AS minutes,
|
||
|
|
BOOL_OR(ends_at>(s.deadline AT TIME ZONE 'UTC')) AS after_deadline
|
||
|
|
FROM planner_allocations WHERE sag_id=a.sag_id AND user_id=a.user_id AND deleted_at IS NULL AND ends_at>NOW()) p ON TRUE
|
||
|
|
WHERE a.user_id=ANY(%s) AND s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s) AND a.work_status<>'done' ''',(ids,list(FINISHED))) or []
|
||
|
|
names = {r['id']:r['name'] for r in execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users WHERE user_id=ANY(%s)',(ids,)) or []}
|
||
|
|
items = []
|
||
|
|
for uid in ids:
|
||
|
|
rows = [r for r in assignments if r['user_id']==uid]
|
||
|
|
configured=any(r['user_id']==uid for r in schedules)
|
||
|
|
values = working_capacity([r for r in schedules if r['user_id']==uid],
|
||
|
|
[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in occupied if r['user_id']==uid],start,end)
|
||
|
|
queued = sum(max(0,remaining(r)-r['future_planned_minutes']) for r in rows if remaining(r) is not None)
|
||
|
|
risks=[]
|
||
|
|
for row in rows:
|
||
|
|
if not row['deadline']:
|
||
|
|
continue
|
||
|
|
deadline=db_utc(row['deadline'])
|
||
|
|
if deadline>end:
|
||
|
|
continue
|
||
|
|
horizon_start=max(start,datetime.now(TZ))
|
||
|
|
free=working_capacity([r for r in schedules if r['user_id']==uid],
|
||
|
|
[(_aware(r['starts_at']),_aware(r['ends_at'])) for r in occupied if r['user_id']==uid],horizon_start,deadline)['free_minutes'] if deadline>horizon_start else 0
|
||
|
|
rest=remaining(row)
|
||
|
|
kind=('overdue' if deadline<datetime.now(TZ) else 'booking_after_deadline' if row['after_deadline'] else
|
||
|
|
'unallocated_estimate' if rest is None else 'insufficient_capacity' if configured and max(0,rest-row['future_planned_minutes'])>free else None)
|
||
|
|
if kind:
|
||
|
|
risks.append({'sag_id':row['sag_id'],'kind':kind,'deadline':deadline,'available_minutes':free})
|
||
|
|
items.append(dict(values,user_id=uid,name=names.get(uid,''),norm_configured=configured,queue_minutes=queued,
|
||
|
|
overload_minutes=max(0,queued-values['free_minutes']) if configured else None,
|
||
|
|
unestimated_count=sum(remaining(r) is None for r in rows),deadline_risks=risks))
|
||
|
|
return filter_data({'items':items,'starts_at':start,'ends_at':end,'queue_horizon':'all_open_work'},hidden_case_ids(user))
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/unassigned')
|
||
|
|
def unassigned(limit:int=Query(100,ge=1,le=500),offset:int=Query(0,ge=0),user:dict=Depends(get_current_user),q:str=''):
|
||
|
|
require_target(user,int(user['id']))
|
||
|
|
if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.allocate','planner.admin'}):
|
||
|
|
raise HTTPException(403,'Du må ikke se ikke-allokerede sager')
|
||
|
|
from app.modules.sag.frontend.views import _fetch_closed_case_statuses
|
||
|
|
search=q.strip()[:200]
|
||
|
|
rows=execute_query('''SELECT s.id,s.titel,s.priority,s.deadline,s.start_date,s.start_time,s.estimated_minutes,s.updated_at AS case_updated_at,
|
||
|
|
c.name AS customer_name,COALESCE(u.full_name,u.username) AS responsible_name
|
||
|
|
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
|
||
|
|
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
|
||
|
|
WHERE s.deleted_at IS NULL AND LOWER(s.status)<>ALL(%s)
|
||
|
|
AND NOT(s.id=ANY(%s))
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id)
|
||
|
|
AND (%s='' OR CONCAT_WS(' ',s.id::text,s.titel,c.name,u.full_name,u.username) ILIKE %s)
|
||
|
|
ORDER BY s.deadline NULLS LAST,s.id LIMIT %s OFFSET %s''',
|
||
|
|
(list(FINISHED|set(_fetch_closed_case_statuses())),list(hidden_case_ids(user)),search,'%'+search+'%',limit+1,offset)) or []
|
||
|
|
for row in rows:
|
||
|
|
row['case_updated_at']=db_utc(row['case_updated_at'])
|
||
|
|
if row['deadline']:
|
||
|
|
row['deadline']=db_utc(row['deadline']).astimezone(TZ)
|
||
|
|
return {'items':rows[:limit],'has_more':len(rows)>limit,'offset':offset,'limit':limit}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/cases/{sag_id}/summary')
|
||
|
|
def case_summary(sag_id:int,include_children:bool=True,user:dict=Depends(get_current_user),scope:Literal['own','children','all']|None=None):
|
||
|
|
require_case(user,sag_id)
|
||
|
|
require_target(user,int(user['id']))
|
||
|
|
if not execute_query_single('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):
|
||
|
|
raise HTTPException(404,'Sagen findes ikke')
|
||
|
|
# UNION deduplicates IDs, including repeated relations and accidental cycles.
|
||
|
|
selected=scope or ('all' if include_children else 'own')
|
||
|
|
result=execute_query_single('''WITH RECURSIVE family(id) AS (
|
||
|
|
SELECT %s::integer UNION SELECT r.målsag_id FROM sag_relationer r JOIN family f ON f.id=r.kilde_sag_id
|
||
|
|
WHERE %s AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL
|
||
|
|
), cases AS (SELECT s.* FROM sag_sager s JOIN family f ON f.id=s.id
|
||
|
|
WHERE s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND (%s OR s.id<>%s)),
|
||
|
|
actual AS (SELECT t.sag_id,SUM(COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))) AS minutes
|
||
|
|
FROM tmodule_times t JOIN cases c ON c.id=t.sag_id WHERE t.status<>'rejected' GROUP BY t.sag_id)
|
||
|
|
SELECT (SELECT COUNT(*) FROM cases) AS case_count,
|
||
|
|
(SELECT SUM(estimated_minutes) FROM cases) AS estimated_minutes,
|
||
|
|
(SELECT COUNT(*) FROM cases WHERE estimated_minutes IS NULL) AS unestimated_cases,
|
||
|
|
(SELECT COALESCE(SUM(minutes),0) FROM actual) AS actual_minutes,
|
||
|
|
(SELECT SUM(GREATEST(0,c.estimated_minutes-COALESCE(a.minutes,0)))
|
||
|
|
FROM cases c LEFT JOIN actual a ON a.sag_id=c.id WHERE c.estimated_minutes IS NOT NULL) AS remaining_minutes,
|
||
|
|
(SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60),0)::int
|
||
|
|
FROM planner_allocations a JOIN cases c ON c.id=a.sag_id WHERE a.deleted_at IS NULL) AS planned_person_minutes,
|
||
|
|
(SELECT MIN(a.starts_at) FROM planner_allocations a JOIN cases c ON c.id=a.sag_id
|
||
|
|
WHERE a.deleted_at IS NULL AND a.ends_at>NOW()) AS next_activity,
|
||
|
|
(SELECT MAX(deadline) FROM cases) AS latest_deadline''',
|
||
|
|
(sag_id,selected!='own',list(hidden_case_ids(user)),selected!='children',sag_id))
|
||
|
|
if result and result['latest_deadline']:
|
||
|
|
result['latest_deadline']=db_utc(result['latest_deadline']).astimezone(TZ)
|
||
|
|
return result
|