feat(hr): link headcount/onboarding to employees + idempotent actions

This commit is contained in:
Abhimanyu Saharan
2026-02-02 14:35:20 +05:30
parent fbc9af035b
commit 9617f1fc3b
2 changed files with 90 additions and 5 deletions

View File

@@ -0,0 +1,48 @@
"""hr data model links (onboarding.employee_id, headcount fulfillment, employment action idempotency)
Revision ID: 2b8d1e2c0d01
Revises: 9d3d9b9c1a23
Create Date: 2026-02-02 09:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "2b8d1e2c0d01"
down_revision = "9d3d9b9c1a23"
branch_labels = None
depends_on = None
def upgrade() -> None:
# headcount_requests fulfillment fields
op.add_column("headcount_requests", sa.Column("fulfilled_employee_id", sa.Integer(), nullable=True))
op.add_column("headcount_requests", sa.Column("fulfilled_onboarding_id", sa.Integer(), nullable=True))
op.add_column("headcount_requests", sa.Column("fulfilled_at", sa.DateTime(), nullable=True))
op.create_foreign_key("fk_headcount_fulfilled_employee", "headcount_requests", "employees", ["fulfilled_employee_id"], ["id"])
op.create_foreign_key("fk_headcount_fulfilled_onboarding", "headcount_requests", "agent_onboardings", ["fulfilled_onboarding_id"], ["id"])
# employment_actions idempotency key
op.add_column("employment_actions", sa.Column("idempotency_key", sa.String(), nullable=True))
op.create_unique_constraint("uq_employment_actions_idempotency_key", "employment_actions", ["idempotency_key"])
op.create_index("ix_employment_actions_idempotency_key", "employment_actions", ["idempotency_key"])
# agent_onboardings employee link
op.add_column("agent_onboardings", sa.Column("employee_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_agent_onboardings_employee", "agent_onboardings", "employees", ["employee_id"], ["id"])
def downgrade() -> None:
op.drop_constraint("fk_agent_onboardings_employee", "agent_onboardings", type_="foreignkey")
op.drop_column("agent_onboardings", "employee_id")
op.drop_index("ix_employment_actions_idempotency_key", table_name="employment_actions")
op.drop_constraint("uq_employment_actions_idempotency_key", "employment_actions", type_="unique")
op.drop_column("employment_actions", "idempotency_key")
op.drop_constraint("fk_headcount_fulfilled_onboarding", "headcount_requests", type_="foreignkey")
op.drop_constraint("fk_headcount_fulfilled_employee", "headcount_requests", type_="foreignkey")
op.drop_column("headcount_requests", "fulfilled_at")
op.drop_column("headcount_requests", "fulfilled_onboarding_id")
op.drop_column("headcount_requests", "fulfilled_employee_id")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.exc import IntegrityError
from app.api.utils import log_activity, get_actor_employee_id from app.api.utils import log_activity, get_actor_employee_id
from app.db.session import get_session from app.db.session import get_session
@@ -34,6 +35,8 @@ def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, s
raise HTTPException(status_code=404, detail="Request not found") raise HTTPException(status_code=404, detail="Request not found")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None:
req.fulfilled_at = datetime.utcnow()
for k, v in data.items(): for k, v in data.items():
setattr(req, k, v) setattr(req, k, v)
@@ -51,14 +54,46 @@ def list_employment_actions(session: Session = Depends(get_session)):
@router.post("/actions", response_model=EmploymentAction) @router.post("/actions", response_model=EmploymentAction)
def create_employment_action(payload: EmploymentActionCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): def create_employment_action(
payload: EmploymentActionCreate,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
# Prefer explicit payload key; header can supply one for retry-safety.
if payload.idempotency_key is None and idempotency_key is not None:
payload = EmploymentActionCreate(**{**payload.model_dump(), "idempotency_key": idempotency_key})
if payload.idempotency_key:
existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first()
if existing:
return existing
action = EmploymentAction(**payload.model_dump()) action = EmploymentAction(**payload.model_dump())
session.add(action) session.add(action)
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employment_action",
entity_id=action.id,
verb=action.action_type,
payload={"employee_id": action.employee_id},
)
session.commit() session.commit()
except IntegrityError:
session.rollback()
# if unique constraint on idempotency_key raced
if payload.idempotency_key:
existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first()
if existing:
return existing
raise HTTPException(status_code=409, detail="Employment action violates constraints")
session.refresh(action) session.refresh(action)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employment_action", entity_id=action.id, verb=action.action_type, payload={"employee_id": action.employee_id}) return EmploymentAction.model_validate(action)
session.commit()
return action
@router.get("/onboarding", response_model=list[AgentOnboarding]) @router.get("/onboarding", response_model=list[AgentOnboarding])
def list_agent_onboarding(session: Session = Depends(get_session)): def list_agent_onboarding(session: Session = Depends(get_session)):
@@ -83,6 +118,8 @@ def update_agent_onboarding(onboarding_id: int, payload: AgentOnboardingUpdate,
raise HTTPException(status_code=404, detail="Onboarding record not found") raise HTTPException(status_code=404, detail="Onboarding record not found")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None:
req.fulfilled_at = datetime.utcnow()
for k, v in data.items(): for k, v in data.items():
setattr(item, k, v) setattr(item, k, v)
from datetime import datetime from datetime import datetime