624 lines
28 KiB
Python
624 lines
28 KiB
Python
import uuid
|
|
from datetime import datetime,timedelta,timezone
|
|
|
|
from sqlalchemy import and_,func,or_,select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from analytics.serializers import (
|
|
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
|
|
from job.cost.models import HiringCosts
|
|
from job.job_post.models import JobPosts
|
|
from offer.models import Offers
|
|
from org_settings.models import OrgSettings
|
|
from role.models import EnumRoles,Roles
|
|
from users.models import Users
|
|
|
|
|
|
def _as_uuid(value):
|
|
if value in (None,""):
|
|
return None
|
|
try:
|
|
return uuid.UUID(str(value))
|
|
except (TypeError,ValueError):
|
|
return None
|
|
|
|
|
|
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 _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)
|
|
|
|
|
|
def _days_expr(end_col,start_col):
|
|
return func.extract("epoch",end_col-start_col)/86400.0
|
|
|
|
|
|
class Analytics:
|
|
def __init__(self,session:AsyncSession):
|
|
self.session=session
|
|
|
|
async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False):
|
|
statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712
|
|
if status:
|
|
statement=statement.where(JobPosts.requisition_status==status)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
|
if closed_in_window:
|
|
if from_date is not None:
|
|
statement=statement.where(JobPosts.closed_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(JobPosts.closed_at<to_date)
|
|
result=await self.session.execute(statement)
|
|
return int(result.scalar_one() or 0)
|
|
|
|
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
|
|
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
|
statement=select(func.count()).select_from(JobPosts).where(
|
|
JobPosts.is_deleted==False, # noqa: E712
|
|
JobPosts.created_at<as_of,
|
|
or_(JobPosts.closed_at.is_(None),JobPosts.closed_at>=as_of),
|
|
JobPosts.requisition_status=="open",
|
|
)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
|
result=await self.session.execute(statement)
|
|
return int(result.scalar_one() or 0)
|
|
|
|
async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
|
statement=(
|
|
select(func.count())
|
|
.select_from(Inbox)
|
|
.join(Users,Inbox.user_id==Users.id)
|
|
.join(Roles,Users.role_id==Roles.id)
|
|
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
|
)
|
|
if from_date is not None:
|
|
statement=statement.where(Inbox.created_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(Inbox.created_at<to_date)
|
|
# Best-effort department/recruiter via linked message → job post
|
|
if department or recruiter_id:
|
|
statement=(
|
|
statement
|
|
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
result=await self.session.execute(statement)
|
|
return int(result.scalar_one() or 0)
|
|
|
|
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
|
|
statement=select(func.count()).select_from(Offers)
|
|
if exclude_draft:
|
|
statement=statement.where(Offers.status!="draft")
|
|
elif statuses:
|
|
statement=statement.where(Offers.status.in_(statuses))
|
|
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
|
|
if from_date is not None:
|
|
statement=statement.where(stamp>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(stamp<to_date)
|
|
if department or recruiter_id:
|
|
statement=statement.outerjoin(JobPosts,Offers.job_post_id==JobPosts.id)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
|
result=await self.session.execute(statement)
|
|
return int(result.scalar_one() or 0)
|
|
|
|
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
|
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
|
|
hired=ApplicationStageTransitions
|
|
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
|
|
if from_date is not None:
|
|
statement=statement.where(hired.valid_from>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(hired.valid_from<to_date)
|
|
if department or recruiter_id:
|
|
statement=(
|
|
statement
|
|
.outerjoin(Inbox,hired.inbox_id==Inbox.id)
|
|
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
result=await self.session.execute(statement)
|
|
count=int(result.scalar_one() or 0)
|
|
if count:
|
|
return count
|
|
# Fallback: messages currently HIRED, windowed via inbox.created_at
|
|
msg=(
|
|
select(func.count())
|
|
.select_from(Inbox_Messages)
|
|
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
|
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
|
|
)
|
|
if from_date is not None:
|
|
msg=msg.where(Inbox.created_at>=from_date)
|
|
if to_date is not None:
|
|
msg=msg.where(Inbox.created_at<to_date)
|
|
if department or recruiter_id:
|
|
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
if department:
|
|
msg=msg.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
|
|
result=await self.session.execute(msg)
|
|
return int(result.scalar_one() or 0)
|
|
|
|
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
|
entry=ApplicationStageTransitions.__table__.alias("entry")
|
|
hire=ApplicationStageTransitions.__table__.alias("hire")
|
|
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
|
|
statement=(
|
|
select(func.avg(days))
|
|
.select_from(
|
|
hire.join(
|
|
entry,
|
|
and_(
|
|
hire.c.inbox_id==entry.c.inbox_id,
|
|
entry.c.from_stage.is_(None),
|
|
),
|
|
)
|
|
)
|
|
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
|
|
)
|
|
if from_date is not None:
|
|
statement=statement.where(hire.c.valid_from>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(hire.c.valid_from<to_date)
|
|
if department or recruiter_id:
|
|
statement=(
|
|
statement
|
|
.outerjoin(Inbox,hire.c.inbox_id==Inbox.id)
|
|
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
result=await self.session.execute(statement)
|
|
value=result.scalar_one()
|
|
return float(value) if value is not None else None
|
|
|
|
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
|
|
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
|
|
statement=select(func.avg(days)).select_from(JobPosts).where(
|
|
JobPosts.is_deleted==False, # noqa: E712
|
|
JobPosts.requisition_status=="closed",
|
|
JobPosts.closed_at.is_not(None),
|
|
)
|
|
if from_date is not None:
|
|
statement=statement.where(JobPosts.closed_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(JobPosts.closed_at<to_date)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
|
result=await self.session.execute(statement)
|
|
value=result.scalar_one()
|
|
return float(value) if value is not None else None
|
|
|
|
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
|
|
if not hires:
|
|
return None
|
|
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
|
|
if from_date is not None:
|
|
statement=statement.where(HiringCosts.incurred_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(HiringCosts.incurred_at<to_date)
|
|
if department or recruiter_id:
|
|
statement=statement.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(JobPosts.current_recruiter_id==rid)
|
|
result=await self.session.execute(statement)
|
|
total=float(result.scalar_one() or 0.0)
|
|
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 self._count_jobs("open",department=department,recruiter_id=recruiter_id)
|
|
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
|
|
|
|
closed_jobs=await self._count_jobs(
|
|
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
|
|
)
|
|
closed_jobs_prior=await self._count_jobs(
|
|
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
|
|
)
|
|
|
|
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
|
|
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
|
|
|
|
interviews_today_q=select(func.count()).select_from(Interviews).where(
|
|
Interviews.interview_date>=today_start,
|
|
Interviews.interview_date<tomorrow,
|
|
)
|
|
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
|
|
|
|
upcoming_q=select(func.count()).select_from(Interviews).where(
|
|
Interviews.interview_status.ilike("scheduled"),
|
|
Interviews.interview_date>=now,
|
|
)
|
|
interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0)
|
|
|
|
next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where(
|
|
Interviews.interview_status.ilike("scheduled"),
|
|
Interviews.interview_date>=now,
|
|
)
|
|
next_at=(await self.session.execute(next_q)).scalar_one()
|
|
next_interview_at=next_at.isoformat() if next_at else None
|
|
|
|
offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id)
|
|
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id)
|
|
offers_sent=await self._count_offers(
|
|
["sent","negotiating","accepted","declined","expired"],
|
|
window_from,window_to,department,recruiter_id,exclude_draft=True,
|
|
)
|
|
offers_sent_prior=await self._count_offers(
|
|
["sent","negotiating","accepted","declined","expired"],
|
|
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 self._avg_time_to_hire(window_from,window_to,department,recruiter_id)
|
|
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id)
|
|
time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id)
|
|
time_to_fill_prior=await self._avg_time_to_fill(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):
|
|
statement=select(
|
|
Inbox_Messages.application_status,
|
|
func.count().label("count"),
|
|
).select_from(Inbox_Messages)
|
|
if department or recruiter_id:
|
|
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
if from_date is not None or to_date is not None:
|
|
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
|
if from_date is not None:
|
|
statement=statement.where(Inbox.created_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(Inbox.created_at<to_date)
|
|
statement=statement.group_by(Inbox_Messages.application_status)
|
|
result=await self.session.execute(statement)
|
|
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
|
|
return [
|
|
serialize_stage_count(stage.value,counts.get(stage.value,0))
|
|
for stage in Candidate_application_Status
|
|
]
|
|
|
|
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)
|
|
# Walk back (months-1) months
|
|
for _ in range(months-1):
|
|
start=_month_start(start-timedelta(days=1))
|
|
|
|
month_bucket=func.date_trunc("month",Inbox.created_at)
|
|
apps_q=(
|
|
select(month_bucket.label("month"),func.count().label("count"))
|
|
.select_from(Inbox)
|
|
.where(Inbox.created_at>=start)
|
|
.group_by(month_bucket)
|
|
.order_by(month_bucket)
|
|
)
|
|
if department or recruiter_id:
|
|
apps_q=(
|
|
apps_q
|
|
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
)
|
|
if department:
|
|
apps_q=apps_q.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
apps_q=apps_q.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
apps_rows=await self.session.execute(apps_q)
|
|
apps_map={}
|
|
for month,count in apps_rows.all():
|
|
apps_map[_month_key(month)]=int(count or 0)
|
|
|
|
hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from)
|
|
hires_q=(
|
|
select(hire_bucket.label("month"),func.count().label("count"))
|
|
.select_from(ApplicationStageTransitions)
|
|
.where(
|
|
ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value,
|
|
ApplicationStageTransitions.valid_from>=start,
|
|
)
|
|
.group_by(hire_bucket)
|
|
.order_by(hire_bucket)
|
|
)
|
|
if department or recruiter_id:
|
|
hires_q=(
|
|
hires_q
|
|
.outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id)
|
|
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
|
|
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
)
|
|
if department:
|
|
hires_q=hires_q.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
hires_q=hires_q.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
hire_rows=await self.session.execute(hires_q)
|
|
hire_map={}
|
|
for month,count in hire_rows.all():
|
|
hire_map[_month_key(month)]=int(count or 0)
|
|
|
|
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):
|
|
statement=(
|
|
select(
|
|
SourceChannels.id.label("source_id"),
|
|
func.coalesce(SourceChannels.label,"Unknown").label("source"),
|
|
func.count().label("count"),
|
|
)
|
|
.select_from(Inbox_Messages)
|
|
.outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id)
|
|
)
|
|
if department or recruiter_id:
|
|
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
|
|
if department:
|
|
statement=statement.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
statement=statement.where(
|
|
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
|
|
)
|
|
if from_date is not None or to_date is not None:
|
|
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
|
if from_date is not None:
|
|
statement=statement.where(Inbox.created_at>=from_date)
|
|
if to_date is not None:
|
|
statement=statement.where(Inbox.created_at<to_date)
|
|
statement=statement.group_by(SourceChannels.id,SourceChannels.label).order_by(func.count().desc())
|
|
result=await self.session.execute(statement)
|
|
rows=result.all()
|
|
|
|
# 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_q=select(
|
|
HiringCosts.source_channel_id,
|
|
func.coalesce(func.sum(HiringCosts.amount),0.0),
|
|
).where(HiringCosts.source_channel_id.is_not(None))
|
|
if from_date is not None:
|
|
spend_q=spend_q.where(HiringCosts.incurred_at>=from_date)
|
|
if to_date is not None:
|
|
spend_q=spend_q.where(HiringCosts.incurred_at<to_date)
|
|
if department or recruiter_id:
|
|
spend_q=spend_q.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
|
|
if department:
|
|
spend_q=spend_q.where(JobPosts.department==department)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
spend_q=spend_q.where(JobPosts.current_recruiter_id==rid)
|
|
spend_q=spend_q.group_by(HiringCosts.source_channel_id)
|
|
spend_map={
|
|
channel_id:float(total or 0.0)
|
|
for channel_id,total in (await self.session.execute(spend_q)).all()
|
|
}
|
|
|
|
# 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:
|
|
channels_q=select(SourceChannels.id,SourceChannels.label).where(SourceChannels.id.in_(missing))
|
|
rows=list(rows)+[
|
|
(cid,label,0) for cid,label in (await self.session.execute(channels_q)).all()
|
|
]
|
|
|
|
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_q=(
|
|
select(Users)
|
|
.join(Roles,Users.role_id==Roles.id)
|
|
.where(
|
|
Roles.role_name==EnumRoles.RECRUITER.value,
|
|
Users.is_deleted==False, # noqa: E712
|
|
)
|
|
)
|
|
rid=_as_uuid(recruiter_id)
|
|
if rid is not None:
|
|
recruiters_q=recruiters_q.where(Users.id==rid)
|
|
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
|
|
|
|
rows=[]
|
|
for user in recruiters:
|
|
hires_q=select(func.count()).select_from(Inbox_Messages).where(
|
|
Inbox_Messages.recruiter_id==user.id,
|
|
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
|
|
)
|
|
if department:
|
|
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
|
|
JobPosts.department==department
|
|
)
|
|
if from_date is not None or to_date is not None:
|
|
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
|
|
if from_date is not None:
|
|
hires_q=hires_q.where(Inbox.created_at>=from_date)
|
|
if to_date is not None:
|
|
hires_q=hires_q.where(Inbox.created_at<to_date)
|
|
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
|
|
|
|
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
|
|
open_posts_q=select(func.count()).select_from(JobPosts).where(
|
|
JobPosts.current_recruiter_id==user.id,
|
|
JobPosts.requisition_status=="open",
|
|
JobPosts.is_deleted==False, # noqa: E712
|
|
)
|
|
if department:
|
|
open_posts_q=open_posts_q.where(JobPosts.department==department)
|
|
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
|
|
open_reqs=max(open_assign,open_posts)
|
|
|
|
avg_tth=await self._avg_time_to_hire(
|
|
from_date,to_date,department,recruiter_id=str(user.id)
|
|
)
|
|
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
|
|
|
|
rows.sort(key=lambda r: r["hires"],reverse=True)
|
|
return rows[:top]
|