from datetime import datetime,timedelta,timezone from sqlalchemy.ext.asyncio import AsyncSession from analytics.serializers import ( serialize_job_application_count, serialize_recruiter_row, serialize_source_count, serialize_stage_count, ) from inbox.enums import Candidate_application_Status from inbox.models import Inbox,Inbox_Messages,SourceChannels from job.assignment.models import JobAssignments from job.candidate.models import ApplicationStageTransitions,Interviews,Manual_UPLOAD_CANDIDATE from job.cost.models import HiringCosts from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts from offer.models import Offers from org_settings.models import OrgSettings from role.models import EnumRoles from users.models import Users def _month_start(dt: datetime) -> datetime: return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) def _next_month_start(dt: datetime) -> datetime: if dt.month==12: return datetime(dt.year+1,1,1,tzinfo=timezone.utc) return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc) def _resolve_windows(from_date,to_date): """Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month.""" now=datetime.now(timezone.utc) if from_date is None and to_date is None: from_date=_month_start(now) to_date=_next_month_start(now) elif from_date is None: # open-ended lower bound: treat as same length as a calendar month ending at to_date to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc) from_date=_month_start(to_date) elif to_date is None: from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc) to_date=_next_month_start(from_date) else: if from_date.tzinfo is None: from_date=from_date.replace(tzinfo=timezone.utc) if to_date.tzinfo is None: to_date=to_date.replace(tzinfo=timezone.utc) duration=to_date-from_date prior_to=from_date prior_from=from_date-duration return from_date,to_date,prior_from,prior_to def _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top): """Merge per-job counts from both sources, zero-fill open reqs, sort, cap. job_rows are the posts that actually received applications — closed or even deleted ones stay visible, because their applications happened. open_rows zero-fill only open, non-deleted reqs, so the fill never resurrects a dead posting. Sorted by count desc then title, capped at `top`. """ counts={} for src in (inbox_map,manual_map): for job_id,n in src.items(): counts[job_id]=counts.get(job_id,0)+int(n or 0) by_id={str(job.id):job for job in job_rows} for job in open_rows: key=str(job.id) counts.setdefault(key,0) by_id.setdefault(key,job) rows=[ serialize_job_application_count(by_id[job_id],count) for job_id,count in counts.items() if job_id in by_id ] rows.sort(key=lambda r: (-r["count"],r["title"].lower())) return rows[:max(1,int(top or 10))] def _month_key(dt): """Normalize date_trunc / python month buckets for dict lookup.""" if dt is None: return None if getattr(dt,"tzinfo",None) is None: dt=dt.replace(tzinfo=timezone.utc) else: dt=dt.astimezone(timezone.utc) return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) class Analytics: def __init__(self,session:AsyncSession): self.session=session async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None): count=await ApplicationStageTransitions.count_hires( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) if count: return count return await Inbox_Messages.count_hired( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None): if not hires: return None total=await HiringCosts.sum_amount( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) return total/hires async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None): window_from,window_to,prior_from,prior_to=_resolve_windows(from_date,to_date) now=datetime.now(timezone.utc) today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc) tomorrow=today_start+timedelta(days=1) open_jobs=await JobPosts.count_requisitions( self.session,status="open",department=department,recruiter_id=recruiter_id, ) open_jobs_prior=await JobPosts.count_open_snapshot( self.session,window_from,department=department,recruiter_id=recruiter_id, ) closed_jobs=await JobPosts.count_requisitions( self.session,status="closed",department=department,recruiter_id=recruiter_id, from_date=window_from,to_date=window_to,closed_in_window=True, ) closed_jobs_prior=await JobPosts.count_requisitions( self.session,status="closed",department=department,recruiter_id=recruiter_id, from_date=prior_from,to_date=prior_to,closed_in_window=True, ) total_candidates=await Inbox.count_in_window( self.session,window_from,window_to,department,recruiter_id, ) total_candidates_prior=await Inbox.count_in_window( self.session,prior_from,prior_to,department,recruiter_id, ) interviews_today=await Interviews.count_between( self.session,today_start,tomorrow,recruiter_id=recruiter_id, ) interviews_upcoming=await Interviews.count_upcoming( self.session,now,recruiter_id=recruiter_id, ) next_at=await Interviews.next_scheduled_at( self.session,now,recruiter_id=recruiter_id, ) next_interview_at=next_at.isoformat() if next_at else None offers_accepted=await Offers.count_in_window( self.session,["accepted"],window_from,window_to,department,recruiter_id, ) offers_accepted_prior=await Offers.count_in_window( self.session,["accepted"],prior_from,prior_to,department,recruiter_id, ) offers_sent=await Offers.count_in_window( self.session,None,window_from,window_to,department,recruiter_id,exclude_draft=True, ) offers_sent_prior=await Offers.count_in_window( self.session,None,prior_from,prior_to,department,recruiter_id,exclude_draft=True, ) hires=await self._count_hires(window_from,window_to,department,recruiter_id) hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) time_to_hire=await ApplicationStageTransitions.avg_time_to_hire( self.session,window_from,window_to,department,recruiter_id, ) time_to_hire_prior=await ApplicationStageTransitions.avg_time_to_hire( self.session,prior_from,prior_to,department,recruiter_id, ) time_to_fill=await JobPosts.avg_time_to_fill( self.session,window_from,window_to,department,recruiter_id, ) time_to_fill_prior=await JobPosts.avg_time_to_fill( self.session,prior_from,prior_to,department,recruiter_id, ) cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) cost_per_hire_prior=await self._cost_per_hire( hires_prior,prior_from,prior_to,department,recruiter_id, ) # REQ-ANL-08: the time-to-hire baseline is an org setting with provenance # ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's # 27-day figure as unconfirmed, so an unset baseline stays absent here. baseline_days=None baseline_source=None baseline_set_at=None baseline_row=await OrgSettings.get_by_key(self.session,"analytics.tth_baseline") if baseline_row is not None: value=baseline_row.setting_value raw_days=value.get("days") if isinstance(value,dict) else value if isinstance(value,dict): baseline_source=(str(value.get("source") or "").strip() or None) try: baseline_days=int(raw_days) if raw_days is not None else None except (TypeError,ValueError): baseline_days=None if baseline_days is not None and baseline_row.updated_at: baseline_set_at=baseline_row.updated_at.isoformat() return { "open_jobs": open_jobs, "open_jobs_prior": open_jobs_prior, "total_candidates": total_candidates, "total_candidates_prior": total_candidates_prior, "interviews_today": interviews_today, "interviews_upcoming": interviews_upcoming, "next_interview_at": next_interview_at, "offers_accepted": offers_accepted, "offers_accepted_prior": offers_accepted_prior, "offers_sent": offers_sent, "offers_sent_prior": offers_sent_prior, "time_to_hire": time_to_hire, "time_to_hire_prior": time_to_hire_prior, "time_to_fill": time_to_fill, "time_to_fill_prior": time_to_fill_prior, "cost_per_hire": cost_per_hire, "cost_per_hire_prior": cost_per_hire_prior, "closed_jobs": closed_jobs, "closed_jobs_prior": closed_jobs_prior, "hires": hires, "hires_prior": hires_prior, "tth_baseline_days": baseline_days, "tth_baseline_source": baseline_source, "tth_baseline_set_at": baseline_set_at, } async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): # Same two sources the pipeline board counts: inbox applications on an # assigned job, plus manual-upload candidates. Counting only # inbox_messages left Add Candidate rows (and anyone dragged to # Interview there) invisible on the dashboard doughnut. inbox_counts=await Inbox_Messages.counts_by_application_status( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) manual_counts=await Manual_UPLOAD_CANDIDATE.counts_by_application_status( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) counts={stage.value:0 for stage in Candidate_application_Status} pending=Candidate_application_Status.PENDING.value for src in (inbox_counts,manual_counts): for key,n in src.items(): n=int(n or 0) if key in counts: counts[key]+=n else: counts[pending]+=n return [ serialize_stage_count(stage.value,counts.get(stage.value,0)) for stage in Candidate_application_Status ] async def get_applications_per_job(self,top=10,from_date=None,to_date=None,department=None,recruiter_id=None): """Applications received per job post — inbox + manual upload, the same two sources get_funnel counts, so these rows sum to the funnel total under identical filters. form_data stays excluded because the funnel excludes it; the two cards share a screen and must agree. Dates pass through raw (no _resolve_windows), matching funnel/sources: the caller sends both bounds, and none means all-time. Open reqs with zero applications are included on purpose — a req nobody applied to is the strongest signal this endpoint exists to surface. """ inbox_map=await Inbox_Messages.counts_by_job_post( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) manual_map=await Manual_UPLOAD_CANDIDATE.counts_by_job_post( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) ids=set(inbox_map)|set(manual_map) job_rows=await JobPosts.get_by_ids(self.session,list(ids),active_only=False) if ids else [] open_rows=await JobPosts.list_open_reqs( self.session,department=department,recruiter_id=recruiter_id, ) return _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top) async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None): months=max(1,int(months or 7)) now=datetime.now(timezone.utc) start=_month_start(now) for _ in range(months-1): start=_month_start(start-timedelta(days=1)) apps_map={} for month,count in await Inbox.counts_by_month( self.session,start,department=department,recruiter_id=recruiter_id, ): apps_map[_month_key(month)]=count hire_map={} for month,count in await ApplicationStageTransitions.counts_hires_by_month( self.session,start,department=department,recruiter_id=recruiter_id, ): hire_map[_month_key(month)]=count labels=[] applications=[] hires=[] cursor=start for _ in range(months): labels.append(cursor.strftime("%b %Y")) applications.append(apps_map.get(cursor,0)) hires.append(hire_map.get(cursor,0)) cursor=_next_month_start(cursor) return {"labels": labels,"applications": applications,"hires": hires} async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): rows=await Inbox_Messages.counts_by_source( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) # REQ-ANL-09 cost side: spend explicitly tagged to a source channel in the # cost ledger. Untagged spend is deliberately excluded — it belongs to # cost-per-hire, and folding it into "Unknown" would fabricate a ROI figure. spend_map=await HiringCosts.sum_by_source_channel( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) # A channel with tagged spend but zero applications must still get a row: # spend that produced nothing is the strongest ROI signal this table has, # and dropping it would hide exactly the waste it exists to surface. present={source_id for source_id,_,_ in rows} missing=[cid for cid in spend_map if cid not in present] if missing: rows=list(rows)+[ (cid,label,0) for cid,label in await SourceChannels.labels_by_ids(self.session,missing) ] return [ serialize_source_count( source,count,source_id=source_id,spend=spend_map.get(source_id,0.0) ) for source_id,source,count in rows ] async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None): top=max(1,int(top or 5)) recruiters=await Users.list_by_role_name( self.session,EnumRoles.RECRUITER.value,user_id=recruiter_id, ) rows=[] for user in recruiters: hires=await Inbox_Messages.count_hires_by_recruiter( self.session,user.id,from_date=from_date,to_date=to_date,department=department, ) open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id) open_posts=await JobPosts.count_by_current_recruiter( self.session,user.id,status=RequisitionStatus.OPEN.value,department=department, ) open_reqs=max(open_assign,open_posts) completed=await JobPosts.count_by_current_recruiter( self.session,user.id,status=RequisitionStatus.COMPLETED.value, department=department,from_date=from_date,to_date=to_date, ) avg_tth=await ApplicationStageTransitions.avg_time_to_hire( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=str(user.id), ) rows.append(serialize_recruiter_row( user.id,user.name,hires,open_reqs,avg_tth,completed=completed, )) rows.sort(key=lambda r: (r["completed"], r["hires"]),reverse=True) return rows[:top]