385 lines
17 KiB
Python
385 lines
17 KiB
Python
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]
|