"""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 deadlinefree 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