"""Shared bookings and absence on the existing planner/time models.""" from datetime import date, datetime, time, timedelta from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator from app.core.auth_dependencies import get_current_user from app.core.database import atomic_transaction, 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 audit, require_target, require_schema,notify_case,db_utc,FINISHED router = APIRouter(dependencies=[Depends(require_schema)]) class EventInput(BaseModel): title: str = Field(min_length=1, max_length=255) description: str | None = Field(default=None, max_length=10000) sag_id: int | None = None user_ids: list[int] = Field(min_length=1, max_length=100) starts_at: datetime ends_at: datetime kind: Literal['work', 'meeting', 'internal'] = 'work' confirm_conflicts: bool = False version: int | None = Field(default=None, ge=1) @model_validator(mode='after') def validate_period(self): if self.starts_at.tzinfo is None or self.ends_at.tzinfo is None: raise ValueError('Tidspunkter skal indeholde tidszone') if self.ends_at <= self.starts_at or self.ends_at-self.starts_at > timedelta(days=370): raise ValueError('Ugyldig periode') if not self.title.strip(): raise ValueError('Titel skal udfyldes') return self def employee_locks(ids): for uid in sorted(set(ids)): execute_query('SELECT pg_advisory_xact_lock(%s,%s)', (2521, uid)) def check_event_conflicts(ids, start, end, event_id=None): rows=execute_query('''SELECT user_id FROM planner_allocations WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s AND (%s IS NULL OR event_id IS DISTINCT FROM %s) UNION SELECT user_id FROM planner_absences WHERE user_id=ANY(%s) AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s UNION SELECT user_id FROM quick_time_items WHERE user_id=ANY(%s) AND state='planned' AND starts_at<%s AND ends_at>%s UNION SELECT user_id FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s''', (ids,end,start,event_id,event_id,ids,end,start,ids,end,start,ids,end,start)) or [] result=[{'user_id':r['user_id'],'type':'time_conflict'} for r in rows] schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or [] for uid in ids: if outside_work_hours(uid,start,end,schedules): result.append({'user_id':uid,'type':'outside_work_hours'}) return result def outside_work_hours(uid,start,end,schedules): from datetime import timezone rows=[r for r in schedules if r['user_id']==uid] if not rows: return False # Unknown norm is shown in capacity, rather than inferred. a,b=_aware(start),_aware(end) day=a.astimezone(TZ).date() windows=[] while day<=b.astimezone(TZ).date(): for row in rows: if row['weekday']!=day.weekday() or (row.get('valid_from') and dayrow['valid_to']): continue windows.append((datetime.combine(day,row['start_time'],TZ).astimezone(timezone.utc), datetime.combine(day,row['end_time'],TZ).astimezone(timezone.utc))) day+=timedelta(days=1) start_utc,end_utc=a.astimezone(timezone.utc),b.astimezone(timezone.utc) return _merge_minutes(windows,start_utc,end_utc)_aware(row['starts_at'])] for old in current: if row['_booking']: flags.add(row['id']) if old['_booking']: flags.add(old['id']) current.append(row) active[uid]=current for row in bookings: row['has_conflict']=row['id'] in flags def save_event(payload, user, event_id=None): if payload.sag_id: require_case(user,payload.sag_id) ids = sorted(set(payload.user_ids)) for uid in ids: require_target(user, uid, True) with atomic_transaction(): old = None old_ids = [] if event_id: old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,)) if not old: raise HTTPException(404, 'Aftalen findes ikke') if old['sag_id']: require_case(user,old['sag_id']) if old['sag_id']!=payload.sag_id: private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(old['sag_id'],)) if private and private['restricted'] and not (user.get('is_superadmin') or 'cases.manage_confidential' in set(user.get('permissions') or [])): raise HTTPException(403,'Fortrolige aftalers sagstilknytning kræver rettighed til fortrolighed') if payload.version != old['version']: raise HTTPException(409, 'Aftalen er ændret. Genindlæs.') old_rows = execute_query('SELECT * FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or [] old_ids = [r['user_id'] for r in old_rows] for uid in old_ids: require_target(user, uid, True) if any(r['locked'] for r in old_rows): raise HTTPException(409, 'Fjern tidslås før aftalen ændres') employee_locks(ids + old_ids) active = execute_query('SELECT user_id FROM users WHERE user_id=ANY(%s) AND is_active', (ids,)) or [] if len(active) != len(ids): raise HTTPException(400, 'Vælg aktive medarbejdere') if payload.sag_id: case=execute_query_single('SELECT id,status FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(payload.sag_id,)) if not case: raise HTTPException(404,'Sagen findes ikke') from app.modules.sag.frontend.views import _fetch_closed_case_statuses if str(case['status']).lower() in FINISHED|set(_fetch_closed_case_statuses()): raise HTTPException(409,'Genåbn sagen før den planlægges') conflicts = check_event_conflicts(ids,payload.starts_at,payload.ends_at,event_id) if conflicts and not payload.confirm_conflicts: message='Aftalen ligger uden for arbejdstiden' if all(r['type']=='outside_work_hours' for r in conflicts) else 'Aftalen overlapper med en anden reservation' raise HTTPException(409, {'message': message, 'conflicts': conflicts, 'confirm_conflicts': True}) values = (payload.sag_id,payload.title.strip(),payload.description,payload.kind,payload.starts_at,payload.ends_at,user['id']) if event_id: event = execute_query_single('''UPDATE planner_events SET sag_id=%s,title=%s,description=%s,kind=%s, starts_at=%s,ends_at=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s RETURNING *''', (*values,event_id)) else: event = execute_query_single('''INSERT INTO planner_events(sag_id,title,description,kind,starts_at,ends_at,updated_by,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''', (*values,user['id'])) event_id = event['id'] execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL AND NOT(user_id=ANY(%s))', (user['id'],event_id,ids),fetch=False) for uid in ids: existing = execute_query_single('SELECT id FROM planner_allocations WHERE event_id=%s AND user_id=%s AND deleted_at IS NULL', (event_id,uid)) if existing: execute_query('''UPDATE planner_allocations SET sag_id=%s,starts_at=%s,ends_at=%s, note=%s,updated_by=%s,updated_at=NOW(),version=version+1 WHERE id=%s''', (payload.sag_id,payload.starts_at,payload.ends_at,payload.description,user['id'],existing['id']),fetch=False) else: execute_query('''INSERT INTO planner_allocations(event_id,sag_id,user_id,starts_at,ends_at,note,created_by,updated_by) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)''', (event_id,payload.sag_id,uid,payload.starts_at,payload.ends_at,payload.description,user['id'],user['id']),fetch=False) audit(user,'event_update' if old else 'event_create','event',event_id,old,event,payload.sag_id) notify_case(user,payload.sag_id,f'event:{event_id}:{event["version"]}', 'En fast aftale er oprettet eller ændret.',ids+old_ids) return {'event': event, 'conflicts': conflicts} @router.post('/events', status_code=201) def create_event(payload: EventInput, user: dict = Depends(get_current_user)): return save_event(payload,user) @router.patch('/events/{event_id}') def update_event(event_id: int, payload: EventInput, user: dict = Depends(get_current_user)): return save_event(payload,user,event_id) @router.delete('/events/{event_id}') def cancel_event(event_id: int, version: int, user: dict = Depends(get_current_user)): with atomic_transaction(): old = execute_query_single('SELECT * FROM planner_events WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (event_id,)) if not old: raise HTTPException(404, 'Aftalen findes ikke') if old['sag_id']: require_case(user,old['sag_id']) if old['version'] != version: raise HTTPException(409, 'Aftalen er ændret') participants = execute_query('SELECT user_id,locked FROM planner_allocations WHERE event_id=%s AND deleted_at IS NULL', (event_id,)) or [] for row in participants: require_target(user,row['user_id'],True) if row['locked']: raise HTTPException(409,'Aftalen er låst') employee_locks([r['user_id'] for r in participants]) execute_query('UPDATE planner_events SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s', (user['id'],event_id),fetch=False) execute_query('UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE event_id=%s AND deleted_at IS NULL', (user['id'],event_id),fetch=False) audit(user,'event_cancel','event',event_id,old,{'cancelled':True},old['sag_id']) notify_case(user,old['sag_id'],f'event:{event_id}:cancel:{version}', 'En fast aftale er annulleret.',[r['user_id'] for r in participants]) return {'cancelled': True} class AbsenceInput(BaseModel): user_id: int = Field(gt=0) starts_at: datetime ends_at: datetime kind: str | None = Field(default=None,max_length=40) note: str | None = Field(default=None,max_length=2000) version: int | None = Field(default=None,ge=1) whole_day_start: date | None = None whole_day_end: date | None = None @model_validator(mode='after') def validate_period(self): if self.whole_day_start is not None or self.whole_day_end is not None: if self.whole_day_start is None or self.whole_day_end is None or self.whole_day_end timedelta(days=370): raise ValueError('Fravær må højst dække 370 dage') return self def require_absence(user, target): require_target(user,target,True) permission = 'planner.absence_own' if target == int(user['id']) else 'planner.absence_team' if not (user.get('is_superadmin') or set(user.get('permissions') or []) & {permission,'planner.admin'}): raise HTTPException(403,'Du må ikke registrere dette fravær') def can_see_absence_reason(user,target): return bool(user.get('is_superadmin') or target==int(user['id']) or set(user.get('permissions') or []) & {'planner.absence_reason','planner.admin'}) def visible_absence(row,user): result=dict(row) if not can_see_absence_reason(user,row['user_id']): result['kind']='unavailable' result['note']=None start,end=(_aware(row[k]).astimezone(TZ) for k in ('starts_at','ends_at')) result['whole_days']=start.time()==time.min and end.time()==time.min result['all_day']=start.date()!=end.date() or result['whole_days'] result['display_start']=start.date().isoformat() result['display_end']=(end.date()+timedelta(days=bool(end.time()!=time.min))).isoformat() return result def save_absence(payload,user,absence_id=None): require_absence(user,payload.user_id) with atomic_transaction(): employee_locks([payload.user_id]) if not execute_query_single('SELECT user_id FROM users WHERE user_id=%s',(payload.user_id,)): raise HTTPException(400,'Medarbejderen findes ikke') old = None if absence_id: old = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,)) if not old: raise HTTPException(404,'Fravær findes ikke') require_absence(user,old['user_id']) if old['user_id'] != payload.user_id: raise HTTPException(400,'Fravær kan ikke overdrages') if old['version'] != payload.version: raise HTTPException(409,'Fravær er ændret') if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)): raise HTTPException(409,'Fraværet er allerede tidsregistreret') if not can_see_absence_reason(user,old['user_id']) and (payload.kind is not None or 'note' in payload.model_fields_set): raise HTTPException(403,'Du må kun ændre perioden, ikke fraværsårsagen') kind=payload.kind if payload.kind is not None else old['kind'] if old else None note=payload.note if 'note' in payload.model_fields_set or not old else old['note'] if not execute_query_single('SELECT code FROM planner_activity_types WHERE code=%s AND is_absence AND (active OR %s)',(kind,bool(old and kind==old['kind']))): raise HTTPException(400,'Ukendt fraværstype') # The employee advisory lock serializes create/update checks, including concurrent requests. # Legacy duplicate periods are retained; new/edited periods cannot introduce the same type twice. if execute_query_single('''SELECT id FROM planner_absences WHERE user_id=%s AND kind=%s AND deleted_at IS NULL AND id IS DISTINCT FROM %s AND starts_at<%s AND ends_at>%s LIMIT 1''', (payload.user_id,kind,absence_id,payload.ends_at,payload.starts_at)): raise HTTPException(409,{'code':'absence_overlap', 'message':'Der findes allerede fravær af denne type i perioden. Ret den eksisterende periode i stedet.'}) values = (payload.user_id,payload.starts_at,payload.ends_at,kind,note) if absence_id: row = execute_query_single('UPDATE planner_absences SET user_id=%s,starts_at=%s,ends_at=%s,kind=%s,note=%s,version=version+1 WHERE id=%s RETURNING *',(*values,absence_id)) else: row = execute_query_single('INSERT INTO planner_absences(user_id,starts_at,ends_at,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s) RETURNING *',(*values,user['id'])) affected = execute_query('SELECT id,sag_id FROM planner_allocations WHERE user_id=%s AND deleted_at IS NULL AND starts_at<%s AND ends_at>%s',(payload.user_id,payload.ends_at,payload.starts_at)) or [] audit(user,'absence_update' if old else 'absence_create','absence',row['id'],old,row) for sag_id in {r['sag_id'] for r in affected if r['sag_id']}: notify_case(user,sag_id,f'absence:{row["id"]}:{row["version"]}:{sag_id}', 'Fravær påvirker en planlagt aftale.',[payload.user_id]) return {'absence':visible_absence(row,user),'affected_allocations':affected} @router.post('/absences',status_code=201) def create_absence(payload:AbsenceInput,user:dict=Depends(get_current_user)): return save_absence(payload,user) @router.patch('/absences/{absence_id}') def update_absence(absence_id:int,payload:AbsenceInput,user:dict=Depends(get_current_user)): return save_absence(payload,user,absence_id) @router.delete('/absences/{absence_id}') def cancel_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)): with atomic_transaction(): old=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,)) if not old: raise HTTPException(404,'Fravær findes ikke') require_absence(user,old['user_id']) employee_locks([old['user_id']]) row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,)) if not row or row['version']!=version: raise HTTPException(409,'Fraværet er ændret') if execute_query_single('SELECT id FROM tmodule_times WHERE planner_absence_id=%s',(absence_id,)): raise HTTPException(409,'Fraværet er allerede tidsregistreret og kan ikke annulleres her') execute_query('UPDATE planner_absences SET deleted_at=NOW(),version=version+1 WHERE id=%s',(absence_id,),fetch=False) audit(user,'absence_cancel','absence',absence_id,old,{'cancelled':True}) return {'cancelled':True} @router.post('/absences/{absence_id}/register') def register_absence(absence_id:int,version:int,user:dict=Depends(get_current_user)): """Explicit, replay-safe conversion to nonbillable actual time.""" with atomic_transaction(): row = execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL',(absence_id,)) if not row: raise HTTPException(404,'Fravær findes ikke') require_absence(user,row['user_id']) employee_locks([row['user_id']]) row=execute_query_single('SELECT * FROM planner_absences WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(absence_id,)) if not row: raise HTTPException(409,'Fraværet er ændret') existing = execute_query('SELECT id FROM tmodule_times WHERE planner_absence_id=%s ORDER BY worked_date',(absence_id,)) or [] if existing: return {'time_entry_ids':[r['id'] for r in existing],'already_registered':True} if row['version'] != version: raise HTTPException(409,'Fravær er ændret') if _aware(row['ends_at']) > datetime.now(TZ): raise HTTPException(400,'Fremtidigt fravær kan ikke registreres som afholdt') if execute_query_single('''SELECT id FROM tmodule_times WHERE medarbejder_id=%s AND planner_absence_id IS NOT NULL AND planner_absence_id<>%s AND start_tid<%s AND slut_tid>%s AND status<>'rejected' LIMIT 1''', (row['user_id'],absence_id,db_utc(row['ends_at']).replace(tzinfo=None), db_utc(row['starts_at']).replace(tzinfo=None))): raise HTTPException(409,'Der er allerede registreret fravær i en del af perioden') # Only scheduled working minutes become actual absence, never nights/weekends. schedules = execute_query('SELECT * FROM planner_work_schedules WHERE user_id=%s AND active',(row['user_id'],)) or [] days = absence_work_days(row,schedules) if not days: raise HTTPException(400,'Fraværet indeholder ingen planlagt arbejdstid') name = execute_query_single('SELECT COALESCE(full_name,username) AS name FROM users WHERE user_id=%s',(row['user_id'],))['name'] entry_ids = [] for day,start,end,minutes in days: entry = execute_query_single('''INSERT INTO tmodule_times(description,original_hours,worked_date,user_name,status, billable,billing_method,faktisk_tid_min,fakturerbar_tid_min,entry_type,kilde,entry_status,medarbejder_id, aktiv_timer,round_block_min,ikke_placeret,work_type,start_tid,slut_tid,planner_absence_id,planner_activity_code) VALUES(%s,%s,%s,%s,'pending',false,'draft',%s,0,'manuel','manuel','kladde',%s,false,1,false,'other',%s,%s,%s,%s) RETURNING id''', ('Fravær',minutes/60,day,name,minutes,row['user_id'], db_utc(start).replace(tzinfo=None),db_utc(end).replace(tzinfo=None),absence_id,row['kind'])) entry_ids.append(entry['id']) audit(user,'absence_register','absence',absence_id,None,{'time_entry_ids':entry_ids}) return {'time_entry_ids':entry_ids,'already_registered':False} def absence_work_minutes(absence,schedules): return sum(row[3] for row in absence_work_days(absence,schedules)) def absence_work_days(absence,schedules): start,end = _aware(absence['starts_at']).astimezone(TZ),_aware(absence['ends_at']).astimezone(TZ) day = start.date() last_day=end.date() start,end=db_utc(start),db_utc(end) result = [] while day <= last_day: total = 0 intervals = [] 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=db_utc(a),db_utc(b) overlap = max(0,(min(end,b)-max(start,a)).total_seconds()/60) gross = (b-a).total_seconds()/60 total += round(max(0,overlap-int(schedule['break_minutes'] or 0)) if overlap>=gross else overlap) if overlap: intervals.append((max(start,a),min(end,b))) if total: result.append((day,min(a for a,b in intervals),max(b for a,b in intervals),total)) day += timedelta(days=1) return result @router.get('/activity-types') def activity_types(user:dict=Depends(get_current_user)): require_target(user,int(user['id'])) return execute_query('SELECT * FROM planner_activity_types ORDER BY label') or [] @router.get('/schedule') def schedule(starts_at:datetime,ends_at:datetime,user_id:list[int]=Query(default=[]),user:dict=Depends(get_current_user),include_unassigned:bool=False): if ends_at<=starts_at or ends_at-starts_at>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) bookings = execute_query('''SELECT a.id,a.event_id,a.user_id,a.sag_id,a.starts_at,a.ends_at,a.version,a.locked, COALESCE(e.title,s.titel,'Optaget') AS title,e.kind,COALESCE(e.description,a.note) AS description,e.version AS event_version, ARRAY(SELECT p.user_id FROM planner_allocations p WHERE p.event_id=a.event_id AND p.deleted_at IS NULL ORDER BY p.user_id) AS participants FROM planner_allocations a LEFT JOIN planner_events e ON e.id=a.event_id LEFT JOIN sag_sager s ON s.id=a.sag_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or [] absences = execute_query('''SELECT a.*,EXISTS(SELECT 1 FROM tmodule_times t WHERE t.planner_absence_id=a.id) AS registered FROM planner_absences a WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s''',(ids,ends_at,starts_at)) or [] absences=[visible_absence(row,user) for row in absences] quick = execute_query("SELECT id,user_id,description AS title,starts_at,ends_at FROM quick_time_items WHERE state='planned' AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",(ids,ends_at,starts_at)) or [] hidden=hidden_case_ids(user) include_inbox=include_unassigned and bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.allocate'}) cases=execute_query('''SELECT s.id,s.titel,s.start_date,s.start_time,s.deadline,s.deferred_until,s.updated_at FROM sag_sager s WHERE s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND (s.ansvarlig_bruger_id=ANY(%s) OR EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id AND a.user_id=ANY(%s)) OR (%s AND NOT EXISTS(SELECT 1 FROM sag_case_assignees a WHERE a.sag_id=s.id))) AND ((s.start_date BETWEEN %s AND %s) OR (s.deadline BETWEEN %s AND %s) OR (s.deferred_until BETWEEN %s AND %s))''', (list(hidden),ids,ids,include_inbox,starts_at.date(),ends_at.date(),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None),db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or [] milestones=[] for case in cases: if case['start_date']: value=datetime.combine(case['start_date'],case['start_time'],TZ).isoformat() if case['start_time'] else case['start_date'].isoformat() milestones.append({'id':f'start-{case["id"]}','sag_id':case['id'],'title':'Start: '+case['titel'],'start':value,'allDay':not bool(case['start_time']),'kind':'case_start'}) if case['deadline']: milestones.append({'id':f'deadline-{case["id"]}','sag_id':case['id'],'title':'Deadline: '+case['titel'], 'start':db_utc(case['deadline']).astimezone(TZ).isoformat(),'kind':'deadline'}) if case['deferred_until']: milestones.append({'id':f'deferred-{case["id"]}','sag_id':case['id'],'title':'Udsat: '+case['titel'], 'start':db_utc(case['deferred_until']).astimezone(TZ).isoformat(),'kind':'deferred'}) versions={case['id']:db_utc(case['updated_at']) for case in cases} for milestone in milestones: milestone['case_updated_at']=versions[milestone['sag_id']] reminders=execute_query('''SELECT r.id,r.sag_id,r.title,COALESCE(r.next_check_at,r.scheduled_at) AS moment FROM sag_reminders r JOIN sag_sager s ON s.id=r.sag_id WHERE r.deleted_at IS NULL AND r.is_active AND s.deleted_at IS NULL AND NOT(s.id=ANY(%s)) AND (r.recipient_user_ids && %s::integer[] OR r.created_by_user_id=ANY(%s)) AND COALESCE(r.next_check_at,r.scheduled_at)>=%s AND COALESCE(r.next_check_at,r.scheduled_at)<%s''', (list(hidden),ids,ids,starts_at,ends_at)) or [] for reminder in reminders: milestones.append({'id':f'reminder-{reminder["id"]}','sag_id':reminder['sag_id'], 'title':'Påmindelse: '+reminder['title'],'start':_aware(reminder['moment']).isoformat(),'kind':'reminder'}) registered=execute_query('''SELECT t.id,t.sag_id,t.medarbejder_id AS user_id, COALESCE(s.titel,t.description,'Registreret tid') AS title, 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,1))) AS ends_at FROM tmodule_times t LEFT JOIN sag_sager s ON s.id=t.sag_id WHERE t.medarbejder_id=ANY(%s) AND t.status<>'rejected' AND t.planner_absence_id IS NULL 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''', (ids,db_utc(_aware(starts_at)).replace(tzinfo=None),db_utc(_aware(ends_at)).replace(tzinfo=None))) or [] for entry in registered: entry['starts_at']=db_utc(entry['starts_at']) entry['ends_at']=db_utc(entry['ends_at']) # Converted standalone work is already represented by its canonical time entry above. external=execute_query('SELECT id,user_id,starts_at,ends_at FROM planner_external_busy WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s',(ids,ends_at,starts_at)) or [] mark_conflicts(bookings,absences+quick+external) schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or [] for row in bookings: row['outside_work_hours']=outside_work_hours(row['user_id'],row['starts_at'],row['ends_at'],schedules) return filter_data({'bookings':bookings,'absences':absences,'standalone_plans':quick, 'milestones':milestones,'registered':registered,'external_busy':external},hidden) def can_view_absence_report(user): return bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_report'}) def require_absence_report(user): from .router import _require_view _require_view(user) if not can_view_absence_report(user): raise HTTPException(403,'Du har ikke adgang til fraværsrapporter') def merged_absence_totals(rows,schedules,start,end): """Union overlapping absence before counting scheduled minutes and day fractions.""" spans=sorted((max(_aware(r['starts_at']),start),min(_aware(r['ends_at']),end)) for r in rows) merged=[] for a,b in spans: if b<=a: continue if merged and a<=merged[-1][1]: merged[-1]=(merged[-1][0],max(b,merged[-1][1])) else: merged.append((a,b)) minutes_by_day={} for a,b in merged: for day,_,_,minutes in absence_work_days({'starts_at':a,'ends_at':b},schedules): minutes_by_day[day]=minutes_by_day.get(day,0)+minutes days=0 for day,minutes in minutes_by_day.items(): norm=absence_work_minutes({'starts_at':datetime.combine(day,time.min,TZ), 'ends_at':datetime.combine(day+timedelta(days=1),time.min,TZ)},schedules) minutes_by_day[day]=min(minutes,norm) days+=min(1,minutes/norm) if norm else 0 return {'planned_minutes':sum(minutes_by_day.values()),'planned_days':round(days,2)} @router.get('/absence-report') def absence_report(start_date:date,end_date:date,user_id:int|None=None, limit:int=Query(50,ge=1,le=500),offset:int=Query(0,ge=0), user:dict=Depends(get_current_user),group_by:Literal['date','type']='date'): require_absence_report(user) if end_date366: raise HTTPException(400,'Vælg en periode på højst 367 dage') from .router import _can_view_target employees=execute_query('SELECT user_id AS id,COALESCE(full_name,username) AS name FROM users ORDER BY name,user_id') or [] if user_id is not None: require_target(user,user_id) employees=[r for r in employees if r['id']==user_id] else: employees=[r for r in employees if _can_view_target(user,r['id'])] # Reporting grants no additional employee scope or case access. for employee in employees: require_target(user,employee['id']) ids=[r['id'] for r in employees] names={r['id']:r['name'] for r in employees} start=datetime.combine(start_date,time.min,TZ) end=datetime.combine(end_date+timedelta(days=1),time.min,TZ) rows=execute_query("""SELECT a.*,COALESCE(t.minutes,0)::int AS actual_minutes, COALESCE(t.entries,0)::int AS actual_entries,COALESCE(k.label,a.kind) AS kind_label FROM planner_absences a LEFT JOIN planner_activity_types k ON k.code=a.kind LEFT JOIN (SELECT planner_absence_id,SUM(COALESCE(faktisk_tid_min, ROUND(COALESCE(approved_hours,original_hours,0)*60))) AS minutes,COUNT(*) AS entries FROM tmodule_times WHERE planner_absence_id IS NOT NULL AND status<>'rejected' AND medarbejder_id=ANY(%s) AND worked_date BETWEEN %s AND %s GROUP BY planner_absence_id) t ON t.planner_absence_id=a.id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND ((a.starts_at<%s AND a.ends_at>%s) OR t.entries>0) ORDER BY a.starts_at,a.user_id,a.id""",(ids,start_date,end_date,ids,end,start)) or [] schedules=execute_query('SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active',(ids,)) or [] summaries=[] for employee in employees: own=[r for r in rows if r['user_id']==employee['id']] norm=[r for r in schedules if r['user_id']==employee['id']] summaries.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm), actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own), **merged_absence_totals(own,norm,start,end))) # Mask before grouping/sorting: hidden reasons must never form separate groups. by_type={} for row in rows: kind=row['kind'] if can_see_absence_reason(user,row['user_id']) else 'unavailable' label=row['kind_label'] if kind!='unavailable' else 'Fravær' by_type.setdefault(kind,{'kind':kind,'kind_label':label,'rows':[]})['rows'].append(row) type_summary=[] for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind'])): members=[] for employee in employees: own=[r for r in group['rows'] if r['user_id']==employee['id']] if not own: continue norm=[r for r in schedules if r['user_id']==employee['id']] members.append(dict(user_id=employee['id'],name=employee['name'],norm_configured=bool(norm), actual_minutes=sum(r['actual_minutes'] for r in own),periods=len(own), **merged_absence_totals(own,norm,start,end))) type_summary.append(dict(kind=group['kind'],kind_label=group['kind_label'],employees=members, periods=len(group['rows']),norm_configured=all(r['norm_configured'] for r in members), planned_minutes=sum(r['planned_minutes'] for r in members), planned_days=round(sum(r['planned_days'] for r in members),2), actual_minutes=sum(r['actual_minutes'] for r in members))) if group_by=='type': rows=[row for group in sorted(by_type.values(),key=lambda g:(g['kind_label'].casefold(),g['kind'])) for row in sorted(group['rows'],key=lambda r:(names[r['user_id']].casefold(),r['user_id'],r['starts_at'],r['id']))] items=[] for row in rows[offset:offset+limit]: safe=visible_absence(row,user) norm=[r for r in schedules if r['user_id']==row['user_id']] values=merged_absence_totals([row],norm,start,end) # Private notes never belong in a report or its export. items.append(dict(id=row['id'],user_id=row['user_id'],name=names[row['user_id']], kind=safe['kind'],kind_label=row['kind_label'] if safe['kind']!='unavailable' else 'Fravær', starts_at=row['starts_at'],ends_at=row['ends_at'],actual_minutes=row['actual_minutes'], registered=bool(row['actual_entries']),norm_configured=bool(norm),**values)) return {'items':items,'total':len(rows),'limit':limit,'offset':offset,'summary':summaries,'type_summary':type_summary, 'employees':employees,'start_date':start_date,'end_date':end_date, 'totals':{'planned_minutes':sum(r['planned_minutes'] for r in summaries), 'planned_days':round(sum(r['planned_days'] for r in summaries),2), 'actual_minutes':sum(r['actual_minutes'] for r in summaries)}}