Files
arrr-erp/app/modules/employees/service.py
T
2026-08-07 13:47:34 +05:30

4161 lines
180 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timezone, timedelta
from math import asin, cos, radians, sin, sqrt
from typing import Any
import ipaddress
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.security.passwords import hash_password
from app.modules.core.iam.models import User
from app.modules.alerts.service import create_alert
from app.modules.alerts.workflow_escalations import create_workflow_escalation
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants, validate_branch_matches_tenant
from app.modules.core.rbac.deps import get_user_roles
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
from app.modules.clients.models import Client
from app.modules.documents.models import EngagementDocument
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment
from app.modules.services.execution import (
CLOSED_TASK_STATUSES,
TASK_PRIORITIES,
TASK_STATUSES,
apply_task_checklist_response,
recalculate_task_aqmm_status,
)
EMPLOYEE_STATUS = ["active", "inactive", "relieved"]
EMPLOYMENT_TYPES = ["full_time", "part_time", "article_assistant", "intern", "consultant", "contract"]
EMPLOYEE_ROLE_NAMES = ["Firm Admin", "Partner", "Branch Manager", "Staff"]
DOCUMENT_STATUS = ["uploaded", "verified", "rejected", "archived"]
DOCUMENT_VISIBILITY = ["employee_and_hr", "hr_only"]
ONBOARDING_TASK_STATUS = ["pending", "completed", "skipped"]
OFFBOARDING_REQUEST_STATUS = ["pending", "approved", "rejected", "completed", "cancelled"]
OFFBOARDING_TASK_STATUS = ["pending", "completed", "waived"]
PAYROLL_RUN_STATUS = ["draft", "generated", "approved", "paid", "cancelled"]
PAYSLIP_STATUS = ["generated", "approved", "paid", "cancelled"]
TASK_COMMUNICATION_TYPES = [
("internal_note", "Internal Note"),
("client_clarification", "Client Clarification"),
("consultant_communication", "Consultant Communication"),
("partner_review_note", "Partner Review Note"),
]
TASK_COMMUNICATION_VISIBILITIES = [
("internal", "Internal Team"),
("client_visible", "Client Visible Later"),
("consultant_visible", "Consultant Visible Later"),
("partner_review", "Partner / Review"),
]
TASK_COMMUNICATION_TYPE_CODES = {code for code, _ in TASK_COMMUNICATION_TYPES}
WORKFLOW_HOLD_REASONS = {
"documents_from_client": "Documents awaited from client",
"clarification_from_client": "Clarification awaited from client",
"client_requested_hold": "Client requested hold",
"manager_review": "Manager review pending",
"partner_review": "Partner review pending",
"review_partner_review": "Review Partner review pending",
"payment_pending": "Payment pending",
"portal_issue": "Portal or system issue",
"internal_dependency": "Internal dependency",
"other": "Other",
}
TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES}
# Phase 4 derived controls deliberately reuse existing task, engagement, review and
# alert records. No duplicate workflow or capacity tables are introduced.
WORKFLOW_ESCALATION_LEVELS = {
"manager": "Manager",
"partner": "Engagement Partner",
"review_partner": "Review Partner",
}
@dataclass
class EmployeeScope:
tenant_id: int
branch_id: int | None
is_system_admin: bool
is_firm_admin: bool
is_partner: bool
is_branch_manager: bool
is_staff: bool
allow_cross_tenant: bool
allow_cross_branch: bool
own_user_id: int
def _role_set(db: Session, user: User) -> set[str]:
return set(get_user_roles(db, user.id))
def build_employee_scope(db: Session, user: User, *, tenant_id: int | None = None, branch_id: int | None = None) -> EmployeeScope:
roles = _role_set(db, user)
is_system_admin = "System Admin" in roles
is_firm_admin = "Firm Admin" in roles
is_partner = "Partner" in roles
is_branch_manager = "Branch Manager" in roles
is_staff = "Staff" in roles
effective_tenant_id = int(tenant_id or user.tenant_id)
effective_branch_id = branch_id
if not is_system_admin:
effective_tenant_id = int(user.tenant_id)
# System Admin and Firm Admin may use all-branch context. Others stay locked to own branch.
if not (is_system_admin or is_firm_admin):
effective_branch_id = int(user.branch_id)
elif effective_branch_id in (0, "0", "", None):
effective_branch_id = None
else:
effective_branch_id = int(effective_branch_id)
if effective_branch_id is not None:
validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id)
return EmployeeScope(
tenant_id=effective_tenant_id,
branch_id=effective_branch_id,
is_system_admin=is_system_admin,
is_firm_admin=is_firm_admin,
is_partner=is_partner,
is_branch_manager=is_branch_manager,
is_staff=is_staff,
allow_cross_tenant=is_system_admin,
allow_cross_branch=is_system_admin or is_firm_admin,
own_user_id=user.id,
)
def list_employees(
db: Session,
scope: EmployeeScope,
*,
q: str = "",
include_inactive: bool = False,
link_status: str = "all",
) -> list[Employee]:
stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(Employee.branch_id == scope.branch_id)
if not include_inactive:
stmt = stmt.where(Employee.is_active.is_(True))
link_status = (link_status or "all").lower()
if link_status == "linked":
stmt = stmt.where(Employee.user_id.is_not(None))
elif link_status == "unlinked":
stmt = stmt.where(Employee.user_id.is_(None))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Employee.employee_code.ilike(like),
Employee.full_name.ilike(like),
Employee.email.ilike(like),
Employee.mobile.ilike(like),
Employee.department.ilike(like),
Employee.designation.ilike(like),
Employee.pan.ilike(like),
)
)
return db.execute(stmt.order_by(Employee.full_name, Employee.employee_code)).scalars().all()
def get_employee_or_404(db: Session, employee_id: int, scope: EmployeeScope) -> Employee:
stmt = select(Employee).where(Employee.id == employee_id, Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(Employee.branch_id == scope.branch_id)
emp = db.execute(stmt).scalar_one_or_none()
if not emp:
raise HTTPException(status_code=404, detail="Employee not found or not accessible.")
return emp
def _blank_to_none(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
if isinstance(value, str):
return value.strip()
return value
def parse_date(value: Any) -> date | None:
value = _blank_to_none(value)
if not value:
return None
if isinstance(value, date):
return value
return date.fromisoformat(str(value))
def _clean_payload(data: dict[str, Any]) -> dict[str, Any]:
cleaned = {k: _blank_to_none(v) for k, v in data.items()}
for key in ("date_of_joining", "date_of_leaving"):
cleaned[key] = parse_date(cleaned.get(key))
status = (cleaned.get("status") or "active").lower()
if status not in EMPLOYEE_STATUS:
raise HTTPException(status_code=400, detail="Invalid employee status.")
cleaned["status"] = status
emp_type = (cleaned.get("employment_type") or "full_time").lower()
if emp_type not in EMPLOYMENT_TYPES:
raise HTTPException(status_code=400, detail="Invalid employment type.")
cleaned["employment_type"] = emp_type
cleaned["is_active"] = bool(cleaned.get("is_active", True)) and status == "active"
return cleaned
def _ensure_unique(db: Session, *, tenant_id: int, employee_code: str, user_id: int | None, exclude_id: int | None = None) -> None:
stmt = select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == employee_code)
if exclude_id:
stmt = stmt.where(Employee.id != exclude_id)
if db.execute(stmt).scalar_one_or_none():
raise HTTPException(status_code=409, detail="Employee code already exists in this tenant.")
if user_id:
stmt = select(Employee).where(Employee.tenant_id == tenant_id, Employee.user_id == user_id)
if exclude_id:
stmt = stmt.where(Employee.id != exclude_id)
if db.execute(stmt).scalar_one_or_none():
raise HTTPException(status_code=409, detail="Selected user is already linked to another employee.")
def _get_role(db: Session, role_name: str) -> Role | None:
return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
def _assign_role_if_needed(db: Session, user_id: int, role_name: str) -> None:
role = _get_role(db, role_name)
if not role:
return
exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none()
if not exists:
db.add(UserRole(user_id=user_id, role_id=role.id))
def create_login_user_for_employee(
db: Session,
*,
tenant_id: int,
branch_id: int,
email: str,
full_name: str,
password: str,
role_name: str = "Staff",
) -> User:
if not email:
raise HTTPException(status_code=400, detail="Login email is required to create an employee user.")
if not password or len(password) < 8:
raise HTTPException(status_code=400, detail="Temporary password must be at least 8 characters.")
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="A user with this login email already exists. Select the existing user instead.")
if role_name not in EMPLOYEE_ROLE_NAMES:
role_name = "Staff"
user = User(
email=email.strip().lower(),
full_name=full_name.strip(),
password_hash=hash_password(password),
tenant_id=tenant_id,
branch_id=branch_id,
is_active=True,
allow_login=True,
is_locked=False,
deleted_at=None,
must_change_password=True,
password_changed_at_utc=None,
)
db.add(user)
db.flush()
_assign_role_if_needed(db, user.id, role_name)
return user
def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> Employee:
cleaned = _clean_payload(data)
partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin)
# Partners can create Staff or Branch Manager users only in their own
# tenant and branch. Never trust tenant or branch values posted by the
# browser for this workflow, and validate the requested role server-side.
if partner_staff_mode:
cleaned["tenant_id"] = scope.tenant_id
cleaned["branch_id"] = scope.branch_id or actor.branch_id
requested_role = (cleaned.get("employee_role") or "Staff").strip()
if requested_role not in {"Staff", "Branch Manager"}:
raise HTTPException(status_code=403, detail="Partners can create only Staff or Branch Manager users.")
cleaned["employee_role"] = requested_role
tenant_id = int(cleaned.get("tenant_id") or scope.tenant_id)
branch_id = int(cleaned.get("branch_id") or actor.branch_id)
if not scope.allow_cross_tenant and tenant_id != actor.tenant_id:
raise HTTPException(status_code=403, detail="You cannot create employee in another tenant.")
if not scope.allow_cross_branch and branch_id != actor.branch_id:
raise HTTPException(status_code=403, detail="You cannot create employee in another branch.")
validate_branch_matches_tenant(db, tenant_id, branch_id)
employee_code = (cleaned.get("employee_code") or "").strip()
full_name = (cleaned.get("full_name") or "").strip()
if not employee_code or not full_name:
raise HTTPException(status_code=400, detail="Employee code and full name are required.")
user_id = cleaned.get("user_id")
if user_id:
linked_user = db.get(User, int(user_id))
if not linked_user:
raise HTTPException(status_code=404, detail="Selected user was not found.")
if linked_user.tenant_id != tenant_id or linked_user.branch_id != branch_id:
raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.")
if partner_staff_mode:
linked_roles = _role_set(db, linked_user)
allowed_roles = {"Staff", "Branch Manager"}
prohibited_roles = {"System Admin", "Firm Admin", "Partner"}
if not linked_roles.intersection(allowed_roles) or linked_roles.intersection(prohibited_roles):
raise HTTPException(status_code=403, detail="Partners can link only a Staff or Branch Manager login user.")
user_id = linked_user.id
elif cleaned.get("create_login_user"):
login_user = create_login_user_for_employee(
db,
tenant_id=tenant_id,
branch_id=branch_id,
email=cleaned.get("login_email") or cleaned.get("email"),
full_name=full_name,
password=cleaned.get("temporary_password") or "",
role_name=cleaned.get("employee_role") or "Staff",
)
user_id = login_user.id
_ensure_unique(db, tenant_id=tenant_id, employee_code=employee_code, user_id=user_id)
emp = Employee(
tenant_id=tenant_id,
branch_id=branch_id,
user_id=user_id,
employee_code=employee_code,
full_name=full_name,
email=cleaned.get("email") or cleaned.get("login_email"),
mobile=cleaned.get("mobile"),
alternate_mobile=cleaned.get("alternate_mobile"),
date_of_joining=cleaned.get("date_of_joining"),
date_of_leaving=cleaned.get("date_of_leaving"),
employment_type=cleaned.get("employment_type") or "full_time",
status=cleaned.get("status") or "active",
is_active=cleaned.get("is_active", True),
department=cleaned.get("department"),
designation=cleaned.get("designation"),
reporting_manager_user_id=cleaned.get("reporting_manager_user_id"),
pan=cleaned.get("pan"),
uan=cleaned.get("uan"),
esi_no=cleaned.get("esi_no"),
pf_no=cleaned.get("pf_no"),
aadhaar_last4=cleaned.get("aadhaar_last4"),
bank_name=cleaned.get("bank_name"),
bank_account_no=cleaned.get("bank_account_no"),
bank_ifsc=cleaned.get("bank_ifsc"),
address=cleaned.get("address"),
emergency_contact_name=cleaned.get("emergency_contact_name"),
emergency_contact_mobile=cleaned.get("emergency_contact_mobile"),
notes=cleaned.get("notes"),
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(emp)
db.commit()
db.refresh(emp)
return emp
def update_employee(db: Session, actor: User, emp: Employee, data: dict[str, Any]) -> Employee:
cleaned = _clean_payload(data)
employee_code = (cleaned.get("employee_code") or emp.employee_code).strip()
full_name = (cleaned.get("full_name") or emp.full_name).strip()
if not employee_code or not full_name:
raise HTTPException(status_code=400, detail="Employee code and full name are required.")
user_id = cleaned.get("user_id")
if user_id:
linked_user = db.get(User, int(user_id))
if not linked_user:
raise HTTPException(status_code=404, detail="Selected user was not found.")
if linked_user.tenant_id != emp.tenant_id or linked_user.branch_id != emp.branch_id:
raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.")
user_id = linked_user.id
else:
user_id = None
_ensure_unique(db, tenant_id=emp.tenant_id, employee_code=employee_code, user_id=user_id, exclude_id=emp.id)
update_fields = [
"employee_code", "full_name", "email", "mobile", "alternate_mobile", "date_of_joining", "date_of_leaving",
"employment_type", "status", "is_active", "department", "designation", "reporting_manager_user_id",
"pan", "uan", "esi_no", "pf_no", "aadhaar_last4", "bank_name", "bank_account_no", "bank_ifsc",
"address", "emergency_contact_name", "emergency_contact_mobile", "notes",
]
cleaned["employee_code"] = employee_code
cleaned["full_name"] = full_name
cleaned["user_id"] = user_id
for field in update_fields + ["user_id"]:
setattr(emp, field, cleaned.get(field))
emp.updated_by_user_id = actor.id
emp.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(emp)
return emp
def change_employee_status(db: Session, actor: User, emp: Employee, status: str, date_of_leaving: date | None = None) -> Employee:
status = (status or "").lower()
if status not in EMPLOYEE_STATUS:
raise HTTPException(status_code=400, detail="Invalid employee status.")
emp.status = status
emp.is_active = status == "active"
if status == "relieved" and date_of_leaving:
emp.date_of_leaving = date_of_leaving
emp.updated_by_user_id = actor.id
emp.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(emp)
return emp
def visible_tenants(db: Session, user: User) -> list[Tenant]:
return list_visible_tenants(db, build_scope(db, user))
def visible_branches(db: Session, user: User, tenant_id: int | None = None) -> list[Branch]:
return list_visible_branches(db, build_scope(db, user), tenant_id or user.tenant_id)
def list_linkable_users(db: Session, scope: EmployeeScope, *, include_user_id: int | None = None) -> list[User]:
"""Return active login users that can be linked to an employee.
Already-linked users are excluded to avoid accidental duplicate linkage.
When editing an employee, include_user_id keeps that employee's current user
visible in the dropdown.
"""
linked_user_ids = set(
db.execute(
select(Employee.user_id).where(
Employee.tenant_id == scope.tenant_id,
Employee.user_id.is_not(None),
)
).scalars().all()
)
if include_user_id:
linked_user_ids.discard(int(include_user_id))
stmt = select(User).where(
User.tenant_id == scope.tenant_id,
User.deleted_at.is_(None),
User.is_active.is_(True),
)
if scope.branch_id is not None:
stmt = stmt.where(User.branch_id == scope.branch_id)
if linked_user_ids:
stmt = stmt.where(User.id.not_in(linked_user_ids))
return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all()
def get_employee_user_link_summary(db: Session, scope: EmployeeScope) -> dict[str, int]:
base = select(Employee).where(Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
base = base.where(Employee.branch_id == scope.branch_id)
rows = db.execute(base).scalars().all()
linked = sum(1 for emp in rows if emp.user_id)
unlinked = len(rows) - linked
return {"total": len(rows), "linked": linked, "unlinked": unlinked}
def link_employee_to_user(db: Session, actor: User, emp: Employee, user_id: int | None) -> Employee:
"""Link or unlink an employee master with an IAM login user.
This is intentionally a narrow helper for Phase 7A.1 UX cleanup. It does
not create users or change roles; it only updates Employee.user_id after
validating tenant/branch and duplicate linkage.
"""
resolved_user_id = None
if user_id:
linked_user = db.get(User, int(user_id))
if not linked_user or linked_user.deleted_at is not None:
raise HTTPException(status_code=404, detail="Selected user was not found or is inactive.")
if int(linked_user.tenant_id) != int(emp.tenant_id) or int(linked_user.branch_id) != int(emp.branch_id):
raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.")
resolved_user_id = linked_user.id
_ensure_unique(db, tenant_id=emp.tenant_id, employee_code=emp.employee_code, user_id=resolved_user_id, exclude_id=emp.id)
emp.user_id = resolved_user_id
emp.updated_by_user_id = actor.id
emp.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(emp)
return emp
def list_reporting_managers(db: Session, scope: EmployeeScope) -> list[User]:
"""Return only active Partner and Branch Manager users in scope.
Client and other non-employee login roles must never appear in the
Reporting Manager dropdown.
"""
stmt = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == scope.tenant_id,
User.deleted_at.is_(None),
User.is_active.is_(True),
Role.is_active.is_(True),
Role.name.in_(("Partner", "Branch Manager")),
)
.distinct()
)
if scope.branch_id is not None:
stmt = stmt.where(User.branch_id == scope.branch_id)
return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all()
def _count_for_scope(db: Session, model, scope: EmployeeScope, *conditions) -> int:
stmt = select(func.count(model.id)).where(model.tenant_id == scope.tenant_id)
if scope.branch_id is not None and hasattr(model, "branch_id"):
stmt = stmt.where(model.branch_id == scope.branch_id)
for condition in conditions:
stmt = stmt.where(condition)
return int(db.execute(stmt).scalar() or 0)
def get_employee_dashboard_stats(db: Session, scope: EmployeeScope) -> dict[str, Any]:
"""Return HR dashboard and report counters for the active tenant/branch context.
Phase 6H intentionally adds reporting only. It does not change any existing
employee, attendance, leave, document, onboarding, offboarding or payroll
workflows.
"""
today = date.today()
month_start = today.replace(day=1)
total_employees = _count_for_scope(db, Employee, scope)
active_employees = _count_for_scope(db, Employee, scope, Employee.status == "active", Employee.is_active.is_(True))
inactive_employees = _count_for_scope(db, Employee, scope, Employee.status == "inactive")
relieved_employees = _count_for_scope(db, Employee, scope, Employee.status == "relieved")
pending_registrations = _count_for_scope(db, EmployeeRegistrationRequest, scope, EmployeeRegistrationRequest.status == "pending")
attendance_today_total = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today)
attendance_today_present = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today, EmployeeAttendance.status == "present")
attendance_today_pending = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date == today, EmployeeAttendance.approval_status == "pending")
attendance_month_total = _count_for_scope(db, EmployeeAttendance, scope, EmployeeAttendance.attendance_date >= month_start)
pending_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "pending")
approved_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "approved")
rejected_leave_requests = _count_for_scope(db, EmployeeLeaveRequest, scope, EmployeeLeaveRequest.status == "rejected")
uploaded_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "uploaded")
verified_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "verified")
rejected_documents = _count_for_scope(db, EmployeeDocument, scope, EmployeeDocument.status == "rejected")
onboarding_pending = _count_for_scope(db, EmployeeOnboardingTask, scope, EmployeeOnboardingTask.status == "pending")
onboarding_completed = _count_for_scope(db, EmployeeOnboardingTask, scope, EmployeeOnboardingTask.status == "completed")
offboarding_pending = _count_for_scope(db, EmployeeOffboardingRequest, scope, EmployeeOffboardingRequest.status == "pending")
offboarding_approved = _count_for_scope(db, EmployeeOffboardingRequest, scope, EmployeeOffboardingRequest.status == "approved")
salary_structures_active = _count_for_scope(db, EmployeeSalaryStructure, scope, EmployeeSalaryStructure.is_active.is_(True))
payroll_runs_draft = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "draft")
payroll_runs_generated = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "generated")
payroll_runs_approved = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "approved")
payroll_runs_paid = _count_for_scope(db, EmployeePayrollRun, scope, EmployeePayrollRun.status == "paid")
payslips_generated = _count_for_scope(db, EmployeePayslip, scope, EmployeePayslip.status == "generated")
payslips_paid = _count_for_scope(db, EmployeePayslip, scope, EmployeePayslip.status == "paid")
recent_employees_stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
recent_employees_stmt = recent_employees_stmt.where(Employee.branch_id == scope.branch_id)
recent_employees = db.execute(
recent_employees_stmt.order_by(Employee.created_at_utc.desc()).limit(8)
).scalars().all()
recent_leave_stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
recent_leave_stmt = recent_leave_stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id)
recent_leave_requests = db.execute(
recent_leave_stmt.order_by(EmployeeLeaveRequest.created_at_utc.desc()).limit(8)
).scalars().all()
recent_offboarding_stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
recent_offboarding_stmt = recent_offboarding_stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id)
recent_offboarding_requests = db.execute(
recent_offboarding_stmt.order_by(EmployeeOffboardingRequest.created_at_utc.desc()).limit(8)
).scalars().all()
return {
"as_on": today,
"month_start": month_start,
"employees": {
"total": total_employees,
"active": active_employees,
"inactive": inactive_employees,
"relieved": relieved_employees,
"pending_registrations": pending_registrations,
},
"attendance": {
"today_total": attendance_today_total,
"today_present": attendance_today_present,
"today_pending": attendance_today_pending,
"month_total": attendance_month_total,
},
"leave": {
"pending": pending_leave_requests,
"approved": approved_leave_requests,
"rejected": rejected_leave_requests,
},
"documents": {
"uploaded": uploaded_documents,
"verified": verified_documents,
"rejected": rejected_documents,
},
"onboarding": {
"pending": onboarding_pending,
"completed": onboarding_completed,
},
"offboarding": {
"pending": offboarding_pending,
"approved": offboarding_approved,
},
"payroll": {
"salary_structures_active": salary_structures_active,
"runs_draft": payroll_runs_draft,
"runs_generated": payroll_runs_generated,
"runs_approved": payroll_runs_approved,
"runs_paid": payroll_runs_paid,
"payslips_generated": payslips_generated,
"payslips_paid": payslips_paid,
},
"recent_employees": recent_employees,
"recent_leave_requests": recent_leave_requests,
"recent_offboarding_requests": recent_offboarding_requests,
}
REGISTRATION_STATUS = ["pending", "approved", "rejected"]
def get_employee_for_user(db: Session, user: User) -> Employee | None:
return db.execute(
select(Employee).where(
Employee.tenant_id == user.tenant_id,
Employee.user_id == user.id,
)
).scalar_one_or_none()
def update_own_employee_profile(db: Session, actor: User, emp: Employee, data: dict[str, Any]) -> Employee:
"""Allow employees to update only safe self-service fields."""
if emp.user_id != actor.id:
raise HTTPException(status_code=403, detail="You can update only your own employee profile.")
allowed_fields = [
"mobile",
"alternate_mobile",
"address",
"emergency_contact_name",
"emergency_contact_mobile",
"bank_name",
"bank_account_no",
"bank_ifsc",
]
cleaned = {k: _blank_to_none(data.get(k)) for k in allowed_fields}
for field, value in cleaned.items():
setattr(emp, field, value)
emp.updated_by_user_id = actor.id
emp.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(emp)
return emp
def _next_employee_code(db: Session, tenant_id: int) -> str:
prefix = "EMP"
latest = db.execute(
select(Employee.employee_code)
.where(Employee.tenant_id == tenant_id, Employee.employee_code.ilike(f"{prefix}%"))
.order_by(Employee.id.desc())
.limit(1)
).scalar_one_or_none()
if not latest:
return "EMP0001"
digits = "".join(ch for ch in str(latest) if ch.isdigit())
next_no = (int(digits) + 1) if digits else 1
return f"EMP{next_no:04d}"
def get_pending_registration_for_user(db: Session, user: User):
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest
return db.execute(
select(EmployeeRegistrationRequest).where(
EmployeeRegistrationRequest.tenant_id == user.tenant_id,
EmployeeRegistrationRequest.user_id == user.id,
EmployeeRegistrationRequest.status == "pending",
)
).scalar_one_or_none()
def create_employee_registration_request(db: Session, actor: User, data: dict[str, Any]):
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest
if get_employee_for_user(db, actor):
raise HTTPException(status_code=400, detail="Your user is already linked to an employee profile.")
if get_pending_registration_for_user(db, actor):
raise HTTPException(status_code=409, detail="A pending employee registration request already exists for your user.")
full_name = (_blank_to_none(data.get("full_name")) or actor.full_name or actor.email).strip()
req = EmployeeRegistrationRequest(
tenant_id=actor.tenant_id,
branch_id=actor.branch_id,
user_id=actor.id,
requested_employee_code=_blank_to_none(data.get("requested_employee_code")),
full_name=full_name,
email=_blank_to_none(data.get("email")) or actor.email,
mobile=_blank_to_none(data.get("mobile")),
department=_blank_to_none(data.get("department")),
designation=_blank_to_none(data.get("designation")),
date_of_joining=parse_date(data.get("date_of_joining")),
remarks=_blank_to_none(data.get("remarks")),
status="pending",
)
db.add(req)
db.commit()
db.refresh(req)
return req
def list_employee_registration_requests(db: Session, scope: EmployeeScope, *, status: str | None = None):
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest
stmt = select(EmployeeRegistrationRequest).where(EmployeeRegistrationRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeRegistrationRequest.branch_id == scope.branch_id)
if status:
stmt = stmt.where(EmployeeRegistrationRequest.status == status)
return db.execute(stmt.order_by(EmployeeRegistrationRequest.created_at_utc.desc())).scalars().all()
def get_employee_registration_request_or_404(db: Session, request_id: int, scope: EmployeeScope):
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeRegistrationRequest
stmt = select(EmployeeRegistrationRequest).where(
EmployeeRegistrationRequest.id == request_id,
EmployeeRegistrationRequest.tenant_id == scope.tenant_id,
)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeRegistrationRequest.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Employee registration request not found or not accessible.")
return row
def approve_employee_registration_request(db: Session, actor: User, req, *, employee_code: str | None = None, notes: str | None = None) -> Employee:
if req.status != "pending":
raise HTTPException(status_code=400, detail="Only pending registration requests can be approved.")
existing_emp = db.execute(
select(Employee).where(Employee.tenant_id == req.tenant_id, Employee.user_id == req.user_id)
).scalar_one_or_none()
if existing_emp:
req.status = "approved"
req.review_notes = notes
req.reviewed_by_user_id = actor.id
req.reviewed_at_utc = datetime.now(timezone.utc)
req.created_employee_id = existing_emp.id
db.commit()
db.refresh(existing_emp)
return existing_emp
code = (employee_code or req.requested_employee_code or _next_employee_code(db, req.tenant_id)).strip()
_ensure_unique(db, tenant_id=req.tenant_id, employee_code=code, user_id=req.user_id)
emp = Employee(
tenant_id=req.tenant_id,
branch_id=req.branch_id,
user_id=req.user_id,
employee_code=code,
full_name=req.full_name,
email=req.email,
mobile=req.mobile,
date_of_joining=req.date_of_joining,
employment_type="full_time",
status="active",
is_active=True,
department=req.department,
designation=req.designation,
notes=req.remarks,
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(emp)
db.flush()
req.status = "approved"
req.review_notes = notes
req.reviewed_by_user_id = actor.id
req.reviewed_at_utc = datetime.now(timezone.utc)
req.created_employee_id = emp.id
req.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(emp)
return emp
def reject_employee_registration_request(db: Session, actor: User, req, *, notes: str | None = None):
if req.status != "pending":
raise HTTPException(status_code=400, detail="Only pending registration requests can be rejected.")
req.status = "rejected"
req.review_notes = _blank_to_none(notes)
req.reviewed_by_user_id = actor.id
req.reviewed_at_utc = datetime.now(timezone.utc)
req.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(req)
return req
ATTENDANCE_STATUS = ["present", "late", "absent", "half_day", "on_duty", "work_from_home"]
ATTENDANCE_APPROVAL_STATUS = ["pending", "approved", "rejected"]
ATTENDANCE_GEO_STATUS = ["not_configured", "inside_geofence", "outside_geofence", "location_missing", "invalid_location"]
ATTENDANCE_IP_STATUS = ["not_configured", "allowed_ip", "outside_allowed_ip", "ip_missing", "invalid_ip_rule"]
ATTENDANCE_RULE_STATUS = ["not_configured", "within_time", "within_grace", "late", "half_day", "weekly_off"]
WEEKDAY_CODES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
def _safe_timezone(tz_name: str | None) -> ZoneInfo:
name = (tz_name or "Asia/Kolkata").strip() or "Asia/Kolkata"
try:
return ZoneInfo(name)
except ZoneInfoNotFoundError:
return ZoneInfo("Asia/Kolkata")
def _utc_now_naive() -> datetime:
"""Return UTC now as a naive datetime for SQLite-safe storage.
The column names ending with `_utc` are still UTC values. Keeping them
naive avoids SQLite/SQLAlchemy timezone stripping inconsistencies. Branch
local display times are stored separately in *_local_at fields.
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _as_utc_aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _branch_local_naive_from_utc(value: datetime | None, tz_name: str | None) -> datetime | None:
utc_value = _as_utc_aware(value)
if utc_value is None:
return None
return utc_value.astimezone(_safe_timezone(tz_name)).replace(tzinfo=None)
def _refresh_attendance_local_evidence(row: "EmployeeAttendance") -> "EmployeeAttendance":
"""Keep displayed punch time tied to the branch timezone snapshot.
This also fixes old rows where local fields were missing or were saved
incorrectly after timezone-related changes. It updates the in-memory row;
callers may commit only when they intentionally edit attendance.
"""
tz_name = row.branch_timezone or "Asia/Kolkata"
if row.punch_in_utc:
row.punch_in_local_at = _branch_local_naive_from_utc(row.punch_in_utc, tz_name)
if row.punch_out_utc:
row.punch_out_local_at = _branch_local_naive_from_utc(row.punch_out_utc, tz_name)
return row
def _branch_settings(db: Session, branch_id: int) -> tuple[Branch | None, BranchSettings | None]:
branch = db.execute(select(Branch).where(Branch.id == int(branch_id))).scalar_one_or_none()
settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == int(branch_id))).scalar_one_or_none()
return branch, settings
def _branch_now(db: Session, branch_id: int) -> tuple[datetime, datetime, date, str, Branch | None, BranchSettings | None]:
branch, settings = _branch_settings(db, branch_id)
tz_name = getattr(branch, "timezone", None) or "Asia/Kolkata"
now_utc = _utc_now_naive()
local_dt = _branch_local_naive_from_utc(now_utc, tz_name) or now_utc
return now_utc, local_dt, local_dt.date(), tz_name, branch, settings
def _working_day_codes(settings: BranchSettings | None) -> set[str]:
raw = (getattr(settings, "working_days_csv", None) or "MON,TUE,WED,THU,FRI,SAT").strip()
return {x.strip().upper() for x in raw.split(',') if x.strip()}
def _evaluate_attendance_timing(branch: Branch | None, settings: BranchSettings | None, local_dt: datetime) -> dict[str, Any]:
rule_enabled = bool(getattr(settings, "attendance_rule_enabled", True)) if settings else True
start_time = getattr(branch, "office_start_time", None) if branch else None
end_time = getattr(branch, "office_end_time", None) if branch else None
grace_minutes = int(getattr(settings, "attendance_grace_minutes", 10) or 0) if settings else 10
half_day_after = getattr(settings, "attendance_half_day_after_time", None) if settings else None
weekday_code = WEEKDAY_CODES[local_dt.weekday()]
is_weekly_off = weekday_code not in _working_day_codes(settings)
result = {
"status": "present",
"rule_status": "within_time",
"late_by_minutes": None,
"scheduled_start_local": start_time,
"scheduled_end_local": end_time,
"is_weekly_off": is_weekly_off,
}
if not rule_enabled:
result["rule_status"] = "not_configured"
return result
if is_weekly_off:
result["rule_status"] = "weekly_off"
return result
if not start_time:
result["rule_status"] = "not_configured"
return result
local_time = local_dt.time().replace(tzinfo=None)
start_dt = datetime.combine(local_dt.date(), start_time)
local_naive = local_dt.replace(tzinfo=None)
late_by = max(int((local_naive - start_dt).total_seconds() // 60), 0)
result["late_by_minutes"] = late_by if late_by else None
if half_day_after and local_time >= half_day_after:
result["status"] = "half_day"
result["rule_status"] = "half_day"
elif late_by > grace_minutes:
result["status"] = "late"
result["rule_status"] = "late"
elif late_by > 0:
result["status"] = "present"
result["rule_status"] = "within_grace"
else:
result["status"] = "present"
result["rule_status"] = "within_time"
return result
def _attendance_duration_minutes(row: EmployeeAttendance) -> int | None:
if not row.punch_in_utc or not row.punch_out_utc:
return None
punch_in_utc = _as_utc_aware(row.punch_in_utc)
punch_out_utc = _as_utc_aware(row.punch_out_utc)
if not punch_in_utc or not punch_out_utc:
return None
delta = punch_out_utc - punch_in_utc
minutes = int(delta.total_seconds() // 60)
return max(minutes, 0)
def _to_float(value: float | str | None) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _haversine_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
radius_m = 6371000.0
lat1_r, lon1_r, lat2_r, lon2_r = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2_r - lat1_r
dlon = lon2_r - lon1_r
a = sin(dlat / 2) ** 2 + cos(lat1_r) * cos(lat2_r) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
return radius_m * c
def _ip_matches_allowed(ip_value: str | None, allowed_csv: str | None) -> tuple[bool, str]:
if not allowed_csv or not allowed_csv.strip():
return False, "not_configured"
if not ip_value or not ip_value.strip():
return False, "ip_missing"
try:
client_ip = ipaddress.ip_address(ip_value.strip())
except ValueError:
return False, "ip_missing"
invalid_rule_found = False
for raw_rule in allowed_csv.split(','):
rule = raw_rule.strip()
if not rule:
continue
try:
if '/' in rule:
if client_ip in ipaddress.ip_network(rule, strict=False):
return True, "allowed_ip"
elif client_ip == ipaddress.ip_address(rule):
return True, "allowed_ip"
except ValueError:
invalid_rule_found = True
return False, "invalid_ip_rule" if invalid_rule_found else "outside_allowed_ip"
def _attendance_geo_is_enforced(settings: BranchSettings | None) -> bool:
"""Return True when branch GPS geofence is configured and must be captured by browser."""
if not settings or not bool(getattr(settings, "attendance_geo_enabled", False)):
return False
return _to_float(getattr(settings, "latitude", None)) is not None and _to_float(getattr(settings, "longitude", None)) is not None
def _raise_if_required_geo_missing(settings: BranchSettings | None, evaluation: dict[str, Any]) -> None:
"""Block self-attendance when geofencing is enabled but browser location is missing/invalid."""
if not _attendance_geo_is_enforced(settings):
return
if evaluation.get("geo_status") in {"location_missing", "invalid_location"}:
raise HTTPException(
status_code=400,
detail=(
"Location is required for attendance because branch geo-fencing is enabled. "
"Please allow browser location/GPS and try again."
),
)
def _evaluate_attendance_controls(
db: Session,
*,
branch_id: int,
latitude: float | str | None = None,
longitude: float | str | None = None,
client_ip: str | None = None,
) -> dict[str, Any]:
settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch_id)).scalar_one_or_none()
lat = _to_float(latitude)
lon = _to_float(longitude)
geo_enabled = bool(getattr(settings, 'attendance_geo_enabled', False)) if settings else False
ip_enabled = bool(getattr(settings, 'attendance_ip_enabled', False)) if settings else False
branch_lat = _to_float(getattr(settings, 'latitude', None)) if settings else None
branch_lon = _to_float(getattr(settings, 'longitude', None)) if settings else None
radius_m = int(getattr(settings, 'attendance_geo_radius_meters', 100) or 100) if settings else 100
distance_m = None
geo_ok = False
if not geo_enabled:
geo_status = "not_configured"
elif branch_lat is None or branch_lon is None:
geo_status = "not_configured"
elif lat is None or lon is None:
geo_status = "location_missing"
else:
distance_m = round(_haversine_distance_meters(branch_lat, branch_lon, lat, lon), 2)
geo_ok = distance_m <= radius_m
geo_status = "inside_geofence" if geo_ok else "outside_geofence"
if ip_enabled:
ip_ok, ip_status = _ip_matches_allowed(client_ip, getattr(settings, 'attendance_allowed_ip_csv', None) if settings else None)
else:
ip_ok, ip_status = False, "not_configured"
if not geo_enabled and not ip_enabled:
approval_status = "approved"
status = "present"
source = "self_punch"
elif geo_ok or ip_ok:
approval_status = "approved"
status = "present"
source = "geo_ip_punch" if geo_ok and ip_ok else ("geo_punch" if geo_ok else "ip_punch")
else:
approval_status = "pending"
status = "on_duty"
source = "od_request"
return {
"approval_status": approval_status,
"status": status,
"source": source,
"geo_status": geo_status,
"ip_status": ip_status,
"distance_meters": distance_m,
}
def get_today_attendance_for_user(db: Session, actor: User) -> EmployeeAttendance | None:
emp = get_employee_for_user(db, actor)
if not emp:
return None
_now_utc, _local_dt, local_date, _tz_name, _branch, _settings = _branch_now(db, emp.branch_id)
row = db.execute(
select(EmployeeAttendance).where(
EmployeeAttendance.tenant_id == emp.tenant_id,
EmployeeAttendance.employee_id == emp.id,
EmployeeAttendance.attendance_date == local_date,
)
).scalar_one_or_none()
return _refresh_attendance_local_evidence(row) if row else None
def list_own_attendance(db: Session, actor: User, *, limit: int = 60) -> list[EmployeeAttendance]:
emp = get_employee_for_user(db, actor)
if not emp:
return []
rows = db.execute(
select(EmployeeAttendance)
.where(EmployeeAttendance.tenant_id == emp.tenant_id, EmployeeAttendance.employee_id == emp.id)
.order_by(EmployeeAttendance.attendance_date.desc(), EmployeeAttendance.id.desc())
.limit(limit)
).scalars().all()
return [_refresh_attendance_local_evidence(row) for row in rows]
def punch_in_attendance(
db: Session,
actor: User,
*,
remarks: str | None = None,
latitude: float | str | None = None,
longitude: float | str | None = None,
accuracy_meters: float | str | None = None,
client_ip: str | None = None,
) -> EmployeeAttendance:
emp = get_employee_for_user(db, actor)
if not emp:
raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile. Please request employee registration first.")
if not emp.is_active or emp.status != "active":
raise HTTPException(status_code=400, detail="Attendance punch is allowed only for active employees.")
now, local_dt, today, branch_tz, branch, branch_settings = _branch_now(db, emp.branch_id)
existing = db.execute(
select(EmployeeAttendance).where(
EmployeeAttendance.tenant_id == emp.tenant_id,
EmployeeAttendance.employee_id == emp.id,
EmployeeAttendance.attendance_date == today,
)
).scalar_one_or_none()
if existing and existing.punch_in_utc:
raise HTTPException(status_code=409, detail="You have already punched in today.")
evaluation = _evaluate_attendance_controls(
db,
branch_id=emp.branch_id,
latitude=latitude,
longitude=longitude,
client_ip=client_ip,
)
_raise_if_required_geo_missing(branch_settings, evaluation)
timing = _evaluate_attendance_timing(branch, branch_settings, local_dt)
final_status = evaluation["status"] if evaluation["approval_status"] == "pending" else timing["status"]
punch_remarks = _blank_to_none(remarks)
if evaluation["approval_status"] == "pending" and not punch_remarks:
punch_remarks = "Outside branch geofence / office IP. Approval required for OD, client visit or remote duty."
if not existing:
existing = EmployeeAttendance(
tenant_id=emp.tenant_id,
branch_id=emp.branch_id,
employee_id=emp.id,
user_id=actor.id,
attendance_date=today,
punch_in_utc=now,
punch_in_local_at=local_dt,
branch_timezone=branch_tz,
scheduled_start_local=timing["scheduled_start_local"],
scheduled_end_local=timing["scheduled_end_local"],
late_by_minutes=timing["late_by_minutes"],
attendance_rule_status=timing["rule_status"],
is_weekly_off=timing["is_weekly_off"],
status=final_status,
approval_status=evaluation["approval_status"],
source=evaluation["source"],
remarks=punch_remarks,
punch_in_latitude=_to_float(latitude),
punch_in_longitude=_to_float(longitude),
punch_in_accuracy_meters=_to_float(accuracy_meters),
punch_in_distance_meters=evaluation["distance_meters"],
punch_in_ip=client_ip,
punch_in_geo_status=evaluation["geo_status"],
punch_in_ip_status=evaluation["ip_status"],
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(existing)
else:
existing.punch_in_utc = now
existing.punch_in_local_at = local_dt
existing.branch_timezone = branch_tz
existing.scheduled_start_local = timing["scheduled_start_local"]
existing.scheduled_end_local = timing["scheduled_end_local"]
existing.late_by_minutes = timing["late_by_minutes"]
existing.attendance_rule_status = timing["rule_status"]
existing.is_weekly_off = timing["is_weekly_off"]
existing.status = final_status
existing.approval_status = evaluation["approval_status"]
existing.source = evaluation["source"]
existing.remarks = punch_remarks
existing.punch_in_latitude = _to_float(latitude)
existing.punch_in_longitude = _to_float(longitude)
existing.punch_in_accuracy_meters = _to_float(accuracy_meters)
existing.punch_in_distance_meters = evaluation["distance_meters"]
existing.punch_in_ip = client_ip
existing.punch_in_geo_status = evaluation["geo_status"]
existing.punch_in_ip_status = evaluation["ip_status"]
existing.updated_by_user_id = actor.id
existing.updated_at_utc = now
db.commit()
db.refresh(existing)
return existing
def punch_out_attendance(
db: Session,
actor: User,
*,
remarks: str | None = None,
latitude: float | str | None = None,
longitude: float | str | None = None,
accuracy_meters: float | str | None = None,
client_ip: str | None = None,
) -> EmployeeAttendance:
emp = get_employee_for_user(db, actor)
if not emp:
raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile. Please request employee registration first.")
now, local_dt, today, branch_tz, branch, branch_settings = _branch_now(db, emp.branch_id)
row = db.execute(
select(EmployeeAttendance).where(
EmployeeAttendance.tenant_id == emp.tenant_id,
EmployeeAttendance.employee_id == emp.id,
EmployeeAttendance.attendance_date == today,
)
).scalar_one_or_none()
if not row or not row.punch_in_utc:
raise HTTPException(status_code=400, detail="No punch-in found for today.")
if row.punch_out_utc:
raise HTTPException(status_code=409, detail="You have already punched out today.")
evaluation = _evaluate_attendance_controls(
db,
branch_id=emp.branch_id,
latitude=latitude,
longitude=longitude,
client_ip=client_ip,
)
_raise_if_required_geo_missing(branch_settings, evaluation)
row.punch_out_utc = now
row.punch_out_local_at = local_dt
row.branch_timezone = row.branch_timezone or branch_tz
row.work_duration_minutes = _attendance_duration_minutes(row)
row.punch_out_latitude = _to_float(latitude)
row.punch_out_longitude = _to_float(longitude)
row.punch_out_accuracy_meters = _to_float(accuracy_meters)
row.punch_out_distance_meters = evaluation["distance_meters"]
row.punch_out_ip = client_ip
row.punch_out_geo_status = evaluation["geo_status"]
row.punch_out_ip_status = evaluation["ip_status"]
if row.approval_status == "approved" and evaluation["approval_status"] == "pending":
row.approval_status = "pending"
row.status = "on_duty"
row.source = "od_request"
if _blank_to_none(remarks):
row.remarks = _blank_to_none(remarks)
elif row.approval_status == "pending" and not _blank_to_none(row.remarks):
row.remarks = "Punch-out outside branch geofence / office IP. Approval required."
row.updated_by_user_id = actor.id
row.updated_at_utc = now
db.commit()
db.refresh(row)
return row
def list_attendance_records(
db: Session,
scope: EmployeeScope,
*,
employee_id: int | None = None,
from_date: date | None = None,
to_date: date | None = None,
status: str | None = None,
approval_status: str | None = None,
) -> list[EmployeeAttendance]:
stmt = select(EmployeeAttendance).options(selectinload(EmployeeAttendance.employee)).where(EmployeeAttendance.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeAttendance.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeAttendance.employee_id == int(employee_id))
if from_date:
stmt = stmt.where(EmployeeAttendance.attendance_date >= from_date)
if to_date:
stmt = stmt.where(EmployeeAttendance.attendance_date <= to_date)
if status:
stmt = stmt.where(EmployeeAttendance.status == status)
if approval_status:
stmt = stmt.where(EmployeeAttendance.approval_status == approval_status)
rows = db.execute(stmt.order_by(EmployeeAttendance.attendance_date.desc(), EmployeeAttendance.id.desc())).scalars().all()
return [_refresh_attendance_local_evidence(row) for row in rows]
def get_attendance_or_404(db: Session, attendance_id: int, scope: EmployeeScope) -> EmployeeAttendance:
stmt = select(EmployeeAttendance).where(EmployeeAttendance.id == attendance_id, EmployeeAttendance.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeAttendance.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Attendance record not found or not accessible.")
return row
def review_attendance_record(
db: Session,
actor: User,
row: EmployeeAttendance,
*,
approval_status: str,
review_notes: str | None = None,
) -> EmployeeAttendance:
approval_status = (approval_status or "").lower()
if approval_status not in ATTENDANCE_APPROVAL_STATUS:
raise HTTPException(status_code=400, detail="Invalid attendance approval status.")
now = _utc_now_naive()
row.approval_status = approval_status
row.review_notes = _blank_to_none(review_notes)
row.reviewed_by_user_id = actor.id
row.reviewed_at_utc = now
row.updated_by_user_id = actor.id
row.updated_at_utc = now
db.commit()
db.refresh(row)
return row
def create_or_update_manual_attendance(
db: Session,
actor: User,
scope: EmployeeScope,
*,
employee_id: int,
attendance_date: date,
status: str,
remarks: str | None = None,
) -> EmployeeAttendance:
emp = get_employee_or_404(db, int(employee_id), scope)
status = (status or "present").lower()
if status not in ATTENDANCE_STATUS:
raise HTTPException(status_code=400, detail="Invalid attendance status.")
today_now = _utc_now_naive()
branch, branch_settings = _branch_settings(db, emp.branch_id)
branch_tz = getattr(branch, "timezone", None) or "Asia/Kolkata"
row = db.execute(
select(EmployeeAttendance).where(
EmployeeAttendance.tenant_id == emp.tenant_id,
EmployeeAttendance.employee_id == emp.id,
EmployeeAttendance.attendance_date == attendance_date,
)
).scalar_one_or_none()
if not row:
row = EmployeeAttendance(
tenant_id=emp.tenant_id,
branch_id=emp.branch_id,
employee_id=emp.id,
user_id=emp.user_id,
attendance_date=attendance_date,
branch_timezone=branch_tz,
scheduled_start_local=getattr(branch, "office_start_time", None) if branch else None,
scheduled_end_local=getattr(branch, "office_end_time", None) if branch else None,
attendance_rule_status="manual",
status=status,
approval_status="approved",
source="manual",
remarks=_blank_to_none(remarks),
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(row)
else:
row.status = status
row.approval_status = "approved"
row.source = "manual"
row.remarks = _blank_to_none(remarks)
row.updated_by_user_id = actor.id
row.updated_at_utc = today_now
db.commit()
db.refresh(row)
return row
LEAVE_REQUEST_STATUS = ["pending", "approved", "rejected", "cancelled"]
LEAVE_TYPE_DEFAULTS = [
("CL", "Casual Leave", 12, True),
("SL", "Sick Leave", 12, True),
("EL", "Earned Leave", 0, True),
("LOP", "Loss of Pay", 0, False),
]
def _days_between(from_date: date, to_date: date) -> int:
days = (to_date - from_date).days + 1
if days <= 0:
raise HTTPException(status_code=400, detail="Leave to-date must be on or after from-date.")
return days
def list_leave_types(db: Session, scope: EmployeeScope, *, include_inactive: bool = False) -> list[EmployeeLeaveType]:
stmt = select(EmployeeLeaveType).where(EmployeeLeaveType.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeLeaveType.branch_id == scope.branch_id)
if not include_inactive:
stmt = stmt.where(EmployeeLeaveType.is_active.is_(True))
return db.execute(stmt.order_by(EmployeeLeaveType.code)).scalars().all()
def get_leave_type_or_404(db: Session, leave_type_id: int, scope: EmployeeScope) -> EmployeeLeaveType:
stmt = select(EmployeeLeaveType).where(EmployeeLeaveType.id == leave_type_id, EmployeeLeaveType.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeLeaveType.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Leave type not found or not accessible.")
return row
def ensure_default_leave_types(db: Session, actor: User, scope: EmployeeScope) -> int:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Select a branch before creating default leave types.")
created = 0
for code, name, quota, paid in LEAVE_TYPE_DEFAULTS:
exists = db.execute(
select(EmployeeLeaveType).where(
EmployeeLeaveType.tenant_id == scope.tenant_id,
EmployeeLeaveType.branch_id == scope.branch_id,
EmployeeLeaveType.code == code,
)
).scalar_one_or_none()
if exists:
continue
db.add(EmployeeLeaveType(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
name=name,
annual_quota_days=quota,
is_paid=paid,
allow_negative_balance=(code == "LOP"),
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
))
created += 1
db.commit()
return created
def create_leave_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeLeaveType:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Select a branch before creating a leave type.")
code = (_blank_to_none(data.get("code")) or "").upper()
name = _blank_to_none(data.get("name")) or ""
if not code or not name:
raise HTTPException(status_code=400, detail="Leave code and name are required.")
exists = db.execute(select(EmployeeLeaveType).where(
EmployeeLeaveType.tenant_id == scope.tenant_id,
EmployeeLeaveType.branch_id == scope.branch_id,
EmployeeLeaveType.code == code,
)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Leave type code already exists for this branch.")
row = EmployeeLeaveType(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
name=name,
description=_blank_to_none(data.get("description")),
annual_quota_days=int(data.get("annual_quota_days") or 0),
carry_forward_allowed=bool(data.get("carry_forward_allowed")),
allow_negative_balance=bool(data.get("allow_negative_balance")),
requires_approval=bool(data.get("requires_approval", True)),
is_paid=bool(data.get("is_paid", True)),
is_active=bool(data.get("is_active", True)),
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def update_leave_type(db: Session, actor: User, row: EmployeeLeaveType, data: dict[str, Any]) -> EmployeeLeaveType:
row.name = _blank_to_none(data.get("name")) or row.name
row.description = _blank_to_none(data.get("description"))
row.annual_quota_days = int(data.get("annual_quota_days") or 0)
row.carry_forward_allowed = bool(data.get("carry_forward_allowed"))
row.allow_negative_balance = bool(data.get("allow_negative_balance"))
row.requires_approval = bool(data.get("requires_approval", True))
row.is_paid = bool(data.get("is_paid", True))
row.is_active = bool(data.get("is_active"))
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
def _get_or_create_leave_balance(db: Session, emp: Employee, leave_type: EmployeeLeaveType, actor_id: int | None = None) -> EmployeeLeaveBalance:
bal = db.execute(select(EmployeeLeaveBalance).where(
EmployeeLeaveBalance.tenant_id == emp.tenant_id,
EmployeeLeaveBalance.employee_id == emp.id,
EmployeeLeaveBalance.leave_type_id == leave_type.id,
)).scalar_one_or_none()
if bal:
return bal
initial = int(leave_type.annual_quota_days or 0)
bal = EmployeeLeaveBalance(
tenant_id=emp.tenant_id,
branch_id=emp.branch_id,
employee_id=emp.id,
leave_type_id=leave_type.id,
opening_days=0,
credited_days=initial,
availed_days=0,
adjusted_days=0,
balance_days=initial,
updated_by_user_id=actor_id,
)
db.add(bal)
db.flush()
return bal
def list_leave_balances(db: Session, scope: EmployeeScope, *, employee_id: int | None = None) -> list[EmployeeLeaveBalance]:
stmt = select(EmployeeLeaveBalance).options(selectinload(EmployeeLeaveBalance.employee), selectinload(EmployeeLeaveBalance.leave_type)).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeLeaveBalance.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeLeaveBalance.employee_id == employee_id)
return db.execute(stmt.order_by(EmployeeLeaveBalance.employee_id, EmployeeLeaveBalance.leave_type_id)).scalars().all()
def list_own_leave_balances(db: Session, actor: User) -> list[EmployeeLeaveBalance]:
emp = get_employee_for_user(db, actor)
if not emp:
return []
return db.execute(select(EmployeeLeaveBalance).options(selectinload(EmployeeLeaveBalance.leave_type)).where(EmployeeLeaveBalance.employee_id == emp.id).order_by(EmployeeLeaveBalance.leave_type_id)).scalars().all()
def adjust_leave_balance(db: Session, actor: User, scope: EmployeeScope, *, employee_id: int, leave_type_id: int, adjusted_days: int, notes: str | None = None) -> EmployeeLeaveBalance:
emp = get_employee_or_404(db, employee_id, scope)
leave_type = get_leave_type_or_404(db, leave_type_id, scope)
if leave_type.branch_id != emp.branch_id:
raise HTTPException(status_code=400, detail="Leave type and employee branch do not match.")
bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id)
bal.adjusted_days = int(adjusted_days or 0)
bal.balance_days = int(bal.opening_days or 0) + int(bal.credited_days or 0) + int(bal.adjusted_days or 0) - int(bal.availed_days or 0)
bal.updated_by_user_id = actor.id
bal.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(bal)
return bal
def list_leave_requests(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, status: str | None = None) -> list[EmployeeLeaveRequest]:
stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeLeaveRequest.employee_id == employee_id)
if status:
stmt = stmt.where(EmployeeLeaveRequest.status == status)
return db.execute(stmt.order_by(EmployeeLeaveRequest.created_at_utc.desc())).scalars().all()
def list_own_leave_requests(db: Session, actor: User) -> list[EmployeeLeaveRequest]:
emp = get_employee_for_user(db, actor)
if not emp:
return []
return db.execute(select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.employee_id == emp.id).order_by(EmployeeLeaveRequest.created_at_utc.desc())).scalars().all()
def get_leave_request_or_404(db: Session, request_id: int, scope: EmployeeScope) -> EmployeeLeaveRequest:
stmt = select(EmployeeLeaveRequest).options(selectinload(EmployeeLeaveRequest.employee), selectinload(EmployeeLeaveRequest.leave_type)).where(EmployeeLeaveRequest.id == request_id, EmployeeLeaveRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeLeaveRequest.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Leave request not found or not accessible.")
return row
def apply_employee_leave(db: Session, actor: User, *, leave_type_id: int, from_date: date, to_date: date, reason: str | None = None) -> EmployeeLeaveRequest:
emp = get_employee_for_user(db, actor)
if not emp:
raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.")
days = _days_between(from_date, to_date)
leave_type = db.get(EmployeeLeaveType, int(leave_type_id))
if not leave_type or leave_type.tenant_id != emp.tenant_id or leave_type.branch_id != emp.branch_id or not leave_type.is_active:
raise HTTPException(status_code=404, detail="Leave type not available for your branch.")
row = EmployeeLeaveRequest(
tenant_id=emp.tenant_id,
branch_id=emp.branch_id,
employee_id=emp.id,
user_id=actor.id,
leave_type_id=leave_type.id,
from_date=from_date,
to_date=to_date,
days=days,
reason=_blank_to_none(reason),
status="pending" if leave_type.requires_approval else "approved",
request_source="employee_portal",
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(row)
if not leave_type.requires_approval:
bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id)
if not leave_type.allow_negative_balance and bal.balance_days < days:
raise HTTPException(status_code=400, detail="Insufficient leave balance.")
bal.availed_days += days
bal.balance_days -= days
db.commit()
db.refresh(row)
return row
def review_leave_request(db: Session, actor: User, row: EmployeeLeaveRequest, *, status: str, review_notes: str | None = None) -> EmployeeLeaveRequest:
status = (status or "").lower()
if status not in ("approved", "rejected"):
raise HTTPException(status_code=400, detail="Invalid leave review status.")
if row.status not in ("pending",):
raise HTTPException(status_code=400, detail="Only pending leave requests can be reviewed.")
leave_type = row.leave_type or db.get(EmployeeLeaveType, row.leave_type_id)
emp = row.employee or db.get(Employee, row.employee_id)
if status == "approved":
bal = _get_or_create_leave_balance(db, emp, leave_type, actor.id)
if not leave_type.allow_negative_balance and bal.balance_days < row.days:
raise HTTPException(status_code=400, detail="Insufficient leave balance for approval.")
bal.availed_days += row.days
bal.balance_days -= row.days
bal.updated_by_user_id = actor.id
bal.updated_at_utc = datetime.now(timezone.utc)
row.status = status
row.review_notes = _blank_to_none(review_notes)
row.reviewed_by_user_id = actor.id
row.reviewed_at_utc = datetime.now(timezone.utc)
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
def cancel_own_leave_request(db: Session, actor: User, request_id: int) -> EmployeeLeaveRequest:
emp = get_employee_for_user(db, actor)
if not emp:
raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.")
row = db.execute(select(EmployeeLeaveRequest).where(EmployeeLeaveRequest.id == request_id, EmployeeLeaveRequest.employee_id == emp.id)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Leave request not found.")
if row.status != "pending":
raise HTTPException(status_code=400, detail="Only pending leave requests can be cancelled.")
row.status = "cancelled"
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
DOCUMENT_TYPE_DEFAULTS = [
("AADHAAR", "Aadhaar / ID Proof", True),
("PAN", "PAN Card", True),
("PHOTO", "Photo", False),
("ADDRESS", "Address Proof", False),
("EDU", "Education Certificate", False),
("EXP", "Experience Certificate", False),
("BANK", "Bank Proof / Cancelled Cheque", False),
("OTHER", "Other Document", False),
]
def list_document_types(db: Session, scope: EmployeeScope, *, include_inactive: bool = False) -> list[EmployeeDocumentType]:
stmt = select(EmployeeDocumentType).where(EmployeeDocumentType.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeDocumentType.branch_id == scope.branch_id)
if not include_inactive:
stmt = stmt.where(EmployeeDocumentType.is_active.is_(True))
return db.execute(stmt.order_by(EmployeeDocumentType.code)).scalars().all()
def get_document_type_or_404(db: Session, document_type_id: int, scope: EmployeeScope) -> EmployeeDocumentType:
stmt = select(EmployeeDocumentType).where(EmployeeDocumentType.id == document_type_id, EmployeeDocumentType.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeDocumentType.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Document type not found or not accessible.")
return row
def ensure_default_document_types(db: Session, actor: User, scope: EmployeeScope) -> int:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Select a branch before creating default document types.")
created = 0
for code, name, mandatory in DOCUMENT_TYPE_DEFAULTS:
exists = db.execute(
select(EmployeeDocumentType).where(
EmployeeDocumentType.tenant_id == scope.tenant_id,
EmployeeDocumentType.branch_id == scope.branch_id,
EmployeeDocumentType.code == code,
)
).scalar_one_or_none()
if exists:
continue
db.add(EmployeeDocumentType(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
name=name,
is_mandatory=mandatory,
allow_employee_upload=True,
requires_verification=True,
is_active=True,
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
))
created += 1
db.commit()
return created
def create_document_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeDocumentType:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Select a branch before creating a document type.")
code = (_blank_to_none(data.get("code")) or "").upper()
name = _blank_to_none(data.get("name")) or ""
if not code or not name:
raise HTTPException(status_code=400, detail="Document type code and name are required.")
exists = db.execute(select(EmployeeDocumentType).where(
EmployeeDocumentType.tenant_id == scope.tenant_id,
EmployeeDocumentType.branch_id == scope.branch_id,
EmployeeDocumentType.code == code,
)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Document type code already exists for this branch.")
row = EmployeeDocumentType(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
name=name,
description=_blank_to_none(data.get("description")),
is_mandatory=bool(data.get("is_mandatory")),
allow_employee_upload=bool(data.get("allow_employee_upload", True)),
requires_verification=bool(data.get("requires_verification", True)),
is_active=bool(data.get("is_active", True)),
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def update_document_type(db: Session, actor: User, row: EmployeeDocumentType, data: dict[str, Any]) -> EmployeeDocumentType:
row.name = _blank_to_none(data.get("name")) or row.name
row.description = _blank_to_none(data.get("description"))
row.is_mandatory = bool(data.get("is_mandatory"))
row.allow_employee_upload = bool(data.get("allow_employee_upload", True))
row.requires_verification = bool(data.get("requires_verification", True))
row.is_active = bool(data.get("is_active"))
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
def list_employee_documents(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, status: str | None = None) -> list[EmployeeDocument]:
stmt = select(EmployeeDocument).options(selectinload(EmployeeDocument.employee), selectinload(EmployeeDocument.document_type)).where(EmployeeDocument.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeDocument.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeDocument.employee_id == employee_id)
if status:
stmt = stmt.where(EmployeeDocument.status == status)
return db.execute(stmt.order_by(EmployeeDocument.created_at_utc.desc())).scalars().all()
def list_own_employee_documents(db: Session, actor: User) -> list[EmployeeDocument]:
emp = get_employee_for_user(db, actor)
if not emp:
return []
return db.execute(
select(EmployeeDocument)
.options(selectinload(EmployeeDocument.document_type))
.where(
EmployeeDocument.employee_id == emp.id,
EmployeeDocument.visibility == "employee_and_hr",
EmployeeDocument.status != "archived",
)
.order_by(EmployeeDocument.created_at_utc.desc())
).scalars().all()
def get_employee_document_or_404(db: Session, document_id: int, scope: EmployeeScope) -> EmployeeDocument:
stmt = select(EmployeeDocument).options(selectinload(EmployeeDocument.employee), selectinload(EmployeeDocument.document_type)).where(EmployeeDocument.id == document_id, EmployeeDocument.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeDocument.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Employee document not found or not accessible.")
return row
def get_own_employee_document_or_404(db: Session, actor: User, document_id: int) -> EmployeeDocument:
emp = get_employee_for_user(db, actor)
if not emp:
raise HTTPException(status_code=400, detail="Your user is not linked to an employee profile.")
row = db.execute(select(EmployeeDocument).where(EmployeeDocument.id == document_id, EmployeeDocument.employee_id == emp.id)).scalar_one_or_none()
if not row or row.visibility != "employee_and_hr" or row.status == "archived":
raise HTTPException(status_code=404, detail="Employee document not found.")
return row
def create_employee_document_record(
db: Session,
actor: User,
employee: Employee,
*,
document_type_id: int | None,
title: str,
document_no: str | None,
issue_date: date | None,
expiry_date: date | None,
original_filename: str,
stored_filename: str,
storage_path: str,
content_type: str | None,
file_size_bytes: int | None,
remarks: str | None = None,
visibility: str = "employee_and_hr",
uploaded_status: str = "uploaded",
) -> EmployeeDocument:
title = (_blank_to_none(title) or original_filename or "Employee Document").strip()
if visibility not in DOCUMENT_VISIBILITY:
visibility = "employee_and_hr"
document_type = None
if document_type_id:
document_type = db.get(EmployeeDocumentType, int(document_type_id))
if not document_type or document_type.tenant_id != employee.tenant_id or document_type.branch_id != employee.branch_id:
raise HTTPException(status_code=404, detail="Document type is not available for this employee branch.")
row = EmployeeDocument(
tenant_id=employee.tenant_id,
branch_id=employee.branch_id,
employee_id=employee.id,
document_type_id=document_type.id if document_type else None,
title=title,
document_no=_blank_to_none(document_no),
issue_date=issue_date,
expiry_date=expiry_date,
original_filename=original_filename,
stored_filename=stored_filename,
storage_path=storage_path,
content_type=content_type,
file_size_bytes=file_size_bytes,
status=uploaded_status if uploaded_status in DOCUMENT_STATUS else "uploaded",
visibility=visibility,
remarks=_blank_to_none(remarks),
uploaded_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def review_employee_document(db: Session, actor: User, row: EmployeeDocument, *, status: str, verification_notes: str | None = None) -> EmployeeDocument:
status = (status or "").lower()
if status not in ("verified", "rejected"):
raise HTTPException(status_code=400, detail="Invalid document review status.")
if row.status == "archived":
raise HTTPException(status_code=400, detail="Archived documents cannot be reviewed.")
row.status = status
row.verification_notes = _blank_to_none(verification_notes)
row.verified_by_user_id = actor.id
row.verified_at_utc = datetime.now(timezone.utc)
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
def archive_employee_document(db: Session, actor: User, row: EmployeeDocument, *, notes: str | None = None) -> EmployeeDocument:
row.status = "archived"
row.verification_notes = _blank_to_none(notes) or row.verification_notes
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
ONBOARDING_DEFAULTS = [
("WELCOME", "Welcome and joining confirmation", "joining", 0, 10),
("ID_DOCS", "Collect and verify identity documents", "joining", 1, 20),
("BANK", "Collect bank account details", "joining", 1, 30),
("SYSTEM_ACCESS", "Create system access and assign role", "first_week", 1, 40),
("POLICIES", "Share office policies and confidentiality instructions", "first_week", 2, 50),
]
OFFBOARDING_DEFAULT_TASKS = [
("Collect handover notes", "Collect pending work, client list and handover notes."),
("Recover office assets", "Recover laptop, tokens, books, keys and other assets."),
("Disable system access", "Disable/limit application, email and storage access after relieving."),
("Final settlement checklist", "Verify attendance, leave and final settlement points."),
("Relieving documentation", "Prepare relieving/experience documentation where applicable."),
]
def _task_due(base_date: date | None, days: int) -> date | None:
if not base_date:
return None
return base_date + timedelta(days=int(days or 0))
def list_onboarding_checklist_items(db: Session, scope: EmployeeScope, *, include_inactive: bool = True) -> list[EmployeeOnboardingChecklistItem]:
stmt = select(EmployeeOnboardingChecklistItem).where(EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOnboardingChecklistItem.branch_id == scope.branch_id)
if not include_inactive:
stmt = stmt.where(EmployeeOnboardingChecklistItem.is_active.is_(True))
return db.execute(stmt.order_by(EmployeeOnboardingChecklistItem.sort_order, EmployeeOnboardingChecklistItem.title)).scalars().all()
def ensure_default_onboarding_checklist(db: Session, user: User, scope: EmployeeScope) -> int:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Please select a specific branch before creating onboarding defaults.")
created = 0
for code, title, stage, due_days, sort_order in ONBOARDING_DEFAULTS:
exists = db.execute(select(EmployeeOnboardingChecklistItem).where(
EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id,
EmployeeOnboardingChecklistItem.branch_id == scope.branch_id,
EmployeeOnboardingChecklistItem.code == code,
)).scalar_one_or_none()
if exists:
continue
db.add(EmployeeOnboardingChecklistItem(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
title=title,
stage=stage,
default_due_days=due_days,
sort_order=sort_order,
is_mandatory=True,
is_active=True,
created_by_user_id=user.id,
updated_by_user_id=user.id,
))
created += 1
db.commit()
return created
def create_onboarding_checklist_item(db: Session, user: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeOnboardingChecklistItem:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Please select a specific branch before creating checklist item.")
code = str(_blank_to_none(data.get("code")) or "").upper()
title = str(_blank_to_none(data.get("title")) or "")
if not code or not title:
raise HTTPException(status_code=400, detail="Code and title are required.")
exists = db.execute(select(EmployeeOnboardingChecklistItem).where(
EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id,
EmployeeOnboardingChecklistItem.branch_id == scope.branch_id,
EmployeeOnboardingChecklistItem.code == code,
)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Checklist code already exists for this branch.")
item = EmployeeOnboardingChecklistItem(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
code=code,
title=title,
description=_blank_to_none(data.get("description")),
stage=_blank_to_none(data.get("stage")) or "joining",
default_due_days=int(data.get("default_due_days") or 0),
sort_order=int(data.get("sort_order") or 0),
is_mandatory=bool(data.get("is_mandatory", True)),
is_active=bool(data.get("is_active", True)),
created_by_user_id=user.id,
updated_by_user_id=user.id,
)
db.add(item)
db.commit()
db.refresh(item)
return item
def get_onboarding_checklist_item_or_404(db: Session, item_id: int, scope: EmployeeScope) -> EmployeeOnboardingChecklistItem:
stmt = select(EmployeeOnboardingChecklistItem).where(EmployeeOnboardingChecklistItem.id == item_id, EmployeeOnboardingChecklistItem.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOnboardingChecklistItem.branch_id == scope.branch_id)
item = db.execute(stmt).scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Onboarding checklist item not found or not accessible.")
return item
def update_onboarding_checklist_item(db: Session, user: User, item: EmployeeOnboardingChecklistItem, data: dict[str, Any]) -> EmployeeOnboardingChecklistItem:
item.title = str(_blank_to_none(data.get("title")) or item.title)
item.description = _blank_to_none(data.get("description"))
item.stage = _blank_to_none(data.get("stage")) or "joining"
item.default_due_days = int(data.get("default_due_days") or 0)
item.sort_order = int(data.get("sort_order") or 0)
item.is_mandatory = bool(data.get("is_mandatory", True))
item.is_active = bool(data.get("is_active", True))
item.updated_by_user_id = user.id
db.commit()
db.refresh(item)
return item
def generate_onboarding_tasks_for_employee(db: Session, user: User, employee: Employee, scope: EmployeeScope) -> int:
items = db.execute(select(EmployeeOnboardingChecklistItem).where(
EmployeeOnboardingChecklistItem.tenant_id == employee.tenant_id,
EmployeeOnboardingChecklistItem.branch_id == employee.branch_id,
EmployeeOnboardingChecklistItem.is_active.is_(True),
).order_by(EmployeeOnboardingChecklistItem.sort_order)).scalars().all()
if not items:
branch_scope = EmployeeScope(employee.tenant_id, employee.branch_id, scope.is_system_admin, scope.is_firm_admin, scope.is_partner, scope.is_branch_manager, scope.is_staff, scope.allow_cross_tenant, scope.allow_cross_branch, scope.own_user_id)
ensure_default_onboarding_checklist(db, user, branch_scope)
items = db.execute(select(EmployeeOnboardingChecklistItem).where(
EmployeeOnboardingChecklistItem.tenant_id == employee.tenant_id,
EmployeeOnboardingChecklistItem.branch_id == employee.branch_id,
EmployeeOnboardingChecklistItem.is_active.is_(True),
).order_by(EmployeeOnboardingChecklistItem.sort_order)).scalars().all()
created = 0
base_date = employee.date_of_joining or date.today()
for item in items:
exists = db.execute(select(EmployeeOnboardingTask).where(
EmployeeOnboardingTask.tenant_id == employee.tenant_id,
EmployeeOnboardingTask.employee_id == employee.id,
EmployeeOnboardingTask.checklist_item_id == item.id,
)).scalar_one_or_none()
if exists:
continue
db.add(EmployeeOnboardingTask(
tenant_id=employee.tenant_id,
branch_id=employee.branch_id,
employee_id=employee.id,
checklist_item_id=item.id,
title=item.title,
description=item.description,
stage=item.stage,
due_date=_task_due(base_date, item.default_due_days),
status="pending",
assigned_to_user_id=employee.reporting_manager_user_id,
created_by_user_id=user.id,
updated_by_user_id=user.id,
))
created += 1
db.commit()
return created
def list_onboarding_tasks(db: Session, scope: EmployeeScope, *, employee_id: int | None = None) -> list[EmployeeOnboardingTask]:
stmt = select(EmployeeOnboardingTask).options(selectinload(EmployeeOnboardingTask.employee), selectinload(EmployeeOnboardingTask.assigned_to)).where(EmployeeOnboardingTask.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOnboardingTask.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeOnboardingTask.employee_id == employee_id)
return db.execute(stmt.order_by(EmployeeOnboardingTask.status, EmployeeOnboardingTask.due_date)).scalars().all()
def get_onboarding_task_or_404(db: Session, task_id: int, scope: EmployeeScope) -> EmployeeOnboardingTask:
stmt = select(EmployeeOnboardingTask).where(EmployeeOnboardingTask.id == task_id, EmployeeOnboardingTask.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOnboardingTask.branch_id == scope.branch_id)
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Onboarding task not found or not accessible.")
return task
def update_onboarding_task_status(db: Session, user: User, task: EmployeeOnboardingTask, status: str, notes: str | None = None) -> EmployeeOnboardingTask:
status = (status or "pending").lower()
if status not in ONBOARDING_TASK_STATUS:
raise HTTPException(status_code=400, detail="Invalid onboarding task status.")
task.status = status
task.review_notes = _blank_to_none(notes)
task.updated_by_user_id = user.id
if status in ("completed", "skipped"):
task.completed_by_user_id = user.id
task.completed_at_utc = datetime.now(timezone.utc)
else:
task.completed_by_user_id = None
task.completed_at_utc = None
db.commit()
db.refresh(task)
return task
def list_offboarding_requests(db: Session, scope: EmployeeScope, *, status: str | None = None) -> list[EmployeeOffboardingRequest]:
stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id)
if status:
stmt = stmt.where(EmployeeOffboardingRequest.status == status)
return db.execute(stmt.order_by(EmployeeOffboardingRequest.created_at_utc.desc())).scalars().all()
def list_own_offboarding_requests(db: Session, user: User) -> list[EmployeeOffboardingRequest]:
employee = get_employee_for_user(db, user)
if not employee:
return []
stmt = select(EmployeeOffboardingRequest).where(
EmployeeOffboardingRequest.tenant_id == employee.tenant_id,
EmployeeOffboardingRequest.employee_id == employee.id,
).order_by(EmployeeOffboardingRequest.created_at_utc.desc())
return db.execute(stmt).scalars().all()
def create_offboarding_request(db: Session, user: User, employee: Employee, data: dict[str, Any], *, source: str = "admin") -> EmployeeOffboardingRequest:
pending = db.execute(select(EmployeeOffboardingRequest).where(
EmployeeOffboardingRequest.employee_id == employee.id,
EmployeeOffboardingRequest.status.in_(["pending", "approved"]),
)).scalar_one_or_none()
if pending:
raise HTTPException(status_code=409, detail="This employee already has an active offboarding request.")
req = EmployeeOffboardingRequest(
tenant_id=employee.tenant_id,
branch_id=employee.branch_id,
employee_id=employee.id,
user_id=employee.user_id,
request_type=_blank_to_none(data.get("request_type")) or "resignation",
requested_relieving_date=parse_date(data.get("requested_relieving_date")),
reason=_blank_to_none(data.get("reason")),
handover_notes=_blank_to_none(data.get("handover_notes")),
status="pending",
requested_by_user_id=user.id,
)
db.add(req)
db.commit()
db.refresh(req)
return req
def get_offboarding_request_or_404(db: Session, request_id: int, scope: EmployeeScope) -> EmployeeOffboardingRequest:
stmt = select(EmployeeOffboardingRequest).options(selectinload(EmployeeOffboardingRequest.employee)).where(EmployeeOffboardingRequest.id == request_id, EmployeeOffboardingRequest.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOffboardingRequest.branch_id == scope.branch_id)
req = db.execute(stmt).scalar_one_or_none()
if not req:
raise HTTPException(status_code=404, detail="Offboarding request not found or not accessible.")
return req
def _ensure_offboarding_tasks(db: Session, user: User, req: EmployeeOffboardingRequest) -> int:
existing = db.execute(select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.request_id == req.id)).scalars().all()
if existing:
return 0
base = req.approved_relieving_date or req.requested_relieving_date or date.today()
created = 0
for title, desc in OFFBOARDING_DEFAULT_TASKS:
db.add(EmployeeOffboardingTask(
tenant_id=req.tenant_id,
branch_id=req.branch_id,
request_id=req.id,
employee_id=req.employee_id,
title=title,
description=desc,
due_date=base,
status="pending",
created_by_user_id=user.id,
updated_by_user_id=user.id,
))
created += 1
db.commit()
return created
def review_offboarding_request(db: Session, user: User, req: EmployeeOffboardingRequest, *, status: str, approved_relieving_date: date | None = None, review_notes: str | None = None) -> EmployeeOffboardingRequest:
status = (status or "").lower()
if status not in ("approved", "rejected"):
raise HTTPException(status_code=400, detail="Offboarding review status must be approved or rejected.")
if req.status not in ("pending", "approved"):
raise HTTPException(status_code=400, detail="Only pending/approved requests can be reviewed.")
req.status = status
req.approved_relieving_date = approved_relieving_date or req.requested_relieving_date
req.review_notes = _blank_to_none(review_notes)
req.reviewed_by_user_id = user.id
req.reviewed_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(req)
if status == "approved":
_ensure_offboarding_tasks(db, user, req)
db.refresh(req)
return req
def list_offboarding_tasks(db: Session, req: EmployeeOffboardingRequest) -> list[EmployeeOffboardingTask]:
return db.execute(select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.request_id == req.id).order_by(EmployeeOffboardingTask.status, EmployeeOffboardingTask.due_date, EmployeeOffboardingTask.id)).scalars().all()
def get_offboarding_task_or_404(db: Session, task_id: int, scope: EmployeeScope) -> EmployeeOffboardingTask:
stmt = select(EmployeeOffboardingTask).where(EmployeeOffboardingTask.id == task_id, EmployeeOffboardingTask.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeOffboardingTask.branch_id == scope.branch_id)
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Offboarding task not found or not accessible.")
return task
def update_offboarding_task_status(db: Session, user: User, task: EmployeeOffboardingTask, status: str, notes: str | None = None) -> EmployeeOffboardingTask:
status = (status or "pending").lower()
if status not in OFFBOARDING_TASK_STATUS:
raise HTTPException(status_code=400, detail="Invalid offboarding task status.")
task.status = status
task.review_notes = _blank_to_none(notes)
task.updated_by_user_id = user.id
if status in ("completed", "waived"):
task.completed_by_user_id = user.id
task.completed_at_utc = datetime.now(timezone.utc)
else:
task.completed_by_user_id = None
task.completed_at_utc = None
db.commit()
db.refresh(task)
return task
def complete_offboarding_request(db: Session, user: User, req: EmployeeOffboardingRequest) -> EmployeeOffboardingRequest:
if req.status != "approved":
raise HTTPException(status_code=400, detail="Only approved offboarding requests can be completed.")
tasks = list_offboarding_tasks(db, req)
pending = [t for t in tasks if t.status == "pending"]
if pending:
raise HTTPException(status_code=400, detail="Complete or waive all offboarding tasks before final completion.")
employee = db.get(Employee, req.employee_id)
if employee:
employee.status = "relieved"
employee.is_active = False
employee.date_of_leaving = req.approved_relieving_date or req.requested_relieving_date or date.today()
employee.updated_by_user_id = user.id
req.status = "completed"
req.completed_by_user_id = user.id
req.completed_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(req)
return req
# -------------------------
# Phase 6G payroll foundation
# -------------------------
def _amount_int(value: Any) -> int:
value = _blank_to_none(value)
if value is None:
return 0
try:
return int(round(float(value)))
except Exception:
raise HTTPException(status_code=400, detail=f"Invalid amount: {value}")
def list_salary_structures(db: Session, scope: EmployeeScope, *, employee_id: int | None = None, include_inactive: bool = True) -> list[EmployeeSalaryStructure]:
stmt = select(EmployeeSalaryStructure).options(selectinload(EmployeeSalaryStructure.employee)).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeSalaryStructure.branch_id == scope.branch_id)
if employee_id:
stmt = stmt.where(EmployeeSalaryStructure.employee_id == employee_id)
if not include_inactive:
stmt = stmt.where(EmployeeSalaryStructure.is_active.is_(True))
return db.execute(stmt.order_by(EmployeeSalaryStructure.effective_from.desc(), EmployeeSalaryStructure.id.desc())).scalars().all()
def get_salary_structure_or_404(db: Session, structure_id: int, scope: EmployeeScope) -> EmployeeSalaryStructure:
stmt = select(EmployeeSalaryStructure).options(selectinload(EmployeeSalaryStructure.employee)).where(EmployeeSalaryStructure.id == structure_id, EmployeeSalaryStructure.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeeSalaryStructure.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Salary structure not found or not accessible.")
return row
def _salary_payload(data: dict[str, Any]) -> dict[str, Any]:
return {
"effective_from": parse_date(data.get("effective_from")) or date.today(),
"effective_to": parse_date(data.get("effective_to")),
"pay_cycle": _blank_to_none(data.get("pay_cycle")) or "monthly",
"monthly_ctc_amount": _amount_int(data.get("monthly_ctc_amount")),
"basic_amount": _amount_int(data.get("basic_amount")),
"hra_amount": _amount_int(data.get("hra_amount")),
"allowance_amount": _amount_int(data.get("allowance_amount")),
"employee_pf_amount": _amount_int(data.get("employee_pf_amount")),
"employee_esi_amount": _amount_int(data.get("employee_esi_amount")),
"professional_tax_amount": _amount_int(data.get("professional_tax_amount")),
"tds_amount": _amount_int(data.get("tds_amount")),
"other_deduction_amount": _amount_int(data.get("other_deduction_amount")),
"is_active": bool(data.get("is_active", True)),
"remarks": _blank_to_none(data.get("remarks")),
}
def create_salary_structure(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> EmployeeSalaryStructure:
employee_id = int(data.get("employee_id") or 0)
emp = get_employee_or_404(db, employee_id, scope)
payload = _salary_payload(data)
if payload["effective_to"] and payload["effective_to"] < payload["effective_from"]:
raise HTTPException(status_code=400, detail="Effective to date cannot be before effective from date.")
exists = db.execute(select(EmployeeSalaryStructure).where(
EmployeeSalaryStructure.tenant_id == emp.tenant_id,
EmployeeSalaryStructure.employee_id == emp.id,
EmployeeSalaryStructure.effective_from == payload["effective_from"],
)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Salary structure already exists for this employee and effective date.")
row = EmployeeSalaryStructure(tenant_id=emp.tenant_id, branch_id=emp.branch_id, employee_id=emp.id, created_by_user_id=actor.id, updated_by_user_id=actor.id, **payload)
db.add(row)
db.commit()
db.refresh(row)
return row
def update_salary_structure(db: Session, actor: User, row: EmployeeSalaryStructure, data: dict[str, Any]) -> EmployeeSalaryStructure:
payload = _salary_payload(data)
if payload["effective_to"] and payload["effective_to"] < payload["effective_from"]:
raise HTTPException(status_code=400, detail="Effective to date cannot be before effective from date.")
for key, value in payload.items():
setattr(row, key, value)
row.updated_by_user_id = actor.id
row.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return row
def _active_salary_structure_for_employee(db: Session, emp: Employee, period_end: date) -> EmployeeSalaryStructure | None:
stmt = select(EmployeeSalaryStructure).where(
EmployeeSalaryStructure.employee_id == emp.id,
EmployeeSalaryStructure.is_active.is_(True),
EmployeeSalaryStructure.effective_from <= period_end,
or_(EmployeeSalaryStructure.effective_to.is_(None), EmployeeSalaryStructure.effective_to >= period_end),
).order_by(EmployeeSalaryStructure.effective_from.desc(), EmployeeSalaryStructure.id.desc())
return db.execute(stmt).scalar_one_or_none()
def list_payroll_runs(db: Session, scope: EmployeeScope) -> list[EmployeePayrollRun]:
stmt = select(EmployeePayrollRun).where(EmployeePayrollRun.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeePayrollRun.branch_id == scope.branch_id)
return db.execute(stmt.order_by(EmployeePayrollRun.pay_year.desc(), EmployeePayrollRun.pay_month.desc(), EmployeePayrollRun.id.desc())).scalars().all()
def get_payroll_run_or_404(db: Session, run_id: int, scope: EmployeeScope) -> EmployeePayrollRun:
stmt = select(EmployeePayrollRun).where(EmployeePayrollRun.id == run_id, EmployeePayrollRun.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeePayrollRun.branch_id == scope.branch_id)
row = db.execute(stmt).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Payroll run not found or not accessible.")
return row
def create_payroll_run(db: Session, actor: User, scope: EmployeeScope, *, pay_year: int, pay_month: int, notes: str | None = None) -> EmployeePayrollRun:
if scope.branch_id is None:
raise HTTPException(status_code=400, detail="Select a branch before creating payroll run.")
pay_year = int(pay_year)
pay_month = int(pay_month)
if pay_month < 1 or pay_month > 12:
raise HTTPException(status_code=400, detail="Pay month must be between 1 and 12.")
exists = db.execute(select(EmployeePayrollRun).where(EmployeePayrollRun.tenant_id == scope.tenant_id, EmployeePayrollRun.branch_id == scope.branch_id, EmployeePayrollRun.pay_year == pay_year, EmployeePayrollRun.pay_month == pay_month)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Payroll run already exists for this branch and period.")
row = EmployeePayrollRun(
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
pay_year=pay_year,
pay_month=pay_month,
run_name=f"Payroll {pay_month:02d}/{pay_year}",
status="draft",
notes=_blank_to_none(notes),
processed_by_user_id=actor.id,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def list_payslips(db: Session, scope: EmployeeScope, *, payroll_run_id: int | None = None, employee_id: int | None = None) -> list[EmployeePayslip]:
stmt = select(EmployeePayslip).options(selectinload(EmployeePayslip.employee), selectinload(EmployeePayslip.payroll_run)).where(EmployeePayslip.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(EmployeePayslip.branch_id == scope.branch_id)
if payroll_run_id:
stmt = stmt.where(EmployeePayslip.payroll_run_id == payroll_run_id)
if employee_id:
stmt = stmt.where(EmployeePayslip.employee_id == employee_id)
return db.execute(stmt.order_by(EmployeePayslip.pay_year.desc(), EmployeePayslip.pay_month.desc(), EmployeePayslip.employee_id)).scalars().all()
def list_own_payslips(db: Session, actor: User) -> list[EmployeePayslip]:
emp = get_employee_for_user(db, actor)
if not emp:
return []
return db.execute(select(EmployeePayslip).options(selectinload(EmployeePayslip.payroll_run)).where(EmployeePayslip.employee_id == emp.id).order_by(EmployeePayslip.pay_year.desc(), EmployeePayslip.pay_month.desc())).scalars().all()
def generate_payslips_for_run(db: Session, actor: User, scope: EmployeeScope, run: EmployeePayrollRun) -> int:
if run.status not in ("draft", "generated"):
raise HTTPException(status_code=400, detail="Only draft/generated payroll runs can be regenerated.")
period_end = date(run.pay_year, run.pay_month, 28)
employees = db.execute(select(Employee).where(Employee.tenant_id == run.tenant_id, Employee.branch_id == run.branch_id, Employee.is_active.is_(True)).order_by(Employee.full_name)).scalars().all()
created = 0
gross_total = deduction_total = net_total = 0
for emp in employees:
structure = _active_salary_structure_for_employee(db, emp, period_end)
if not structure:
continue
gross = int(structure.basic_amount or 0) + int(structure.hra_amount or 0) + int(structure.allowance_amount or 0)
deductions = int(structure.employee_pf_amount or 0) + int(structure.employee_esi_amount or 0) + int(structure.professional_tax_amount or 0) + int(structure.tds_amount or 0) + int(structure.other_deduction_amount or 0)
net = gross - deductions
existing = db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id, EmployeePayslip.employee_id == emp.id)).scalar_one_or_none()
if not existing:
existing = EmployeePayslip(tenant_id=run.tenant_id, branch_id=run.branch_id, payroll_run_id=run.id, employee_id=emp.id, generated_by_user_id=actor.id)
db.add(existing)
created += 1
existing.salary_structure_id = structure.id
existing.pay_year = run.pay_year
existing.pay_month = run.pay_month
existing.basic_amount = int(structure.basic_amount or 0)
existing.hra_amount = int(structure.hra_amount or 0)
existing.allowance_amount = int(structure.allowance_amount or 0)
existing.gross_amount = gross
existing.employee_pf_amount = int(structure.employee_pf_amount or 0)
existing.employee_esi_amount = int(structure.employee_esi_amount or 0)
existing.professional_tax_amount = int(structure.professional_tax_amount or 0)
existing.tds_amount = int(structure.tds_amount or 0)
existing.other_deduction_amount = int(structure.other_deduction_amount or 0)
existing.deduction_amount = deductions
existing.net_amount = net
existing.status = "generated"
gross_total += gross
deduction_total += deductions
net_total += net
run.total_employees = len(list_payslips(db, scope, payroll_run_id=run.id))
run.gross_amount = gross_total
run.deduction_amount = deduction_total
run.net_amount = net_total
run.status = "generated"
run.processed_by_user_id = actor.id
run.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(run)
return created
def approve_payroll_run(db: Session, actor: User, run: EmployeePayrollRun) -> EmployeePayrollRun:
if run.status != "generated":
raise HTTPException(status_code=400, detail="Only generated payroll runs can be approved.")
run.status = "approved"
run.approved_by_user_id = actor.id
run.approved_at_utc = datetime.now(timezone.utc)
for slip in db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id)).scalars().all():
slip.status = "approved"
db.commit()
db.refresh(run)
return run
def mark_payroll_run_paid(db: Session, actor: User, run: EmployeePayrollRun) -> EmployeePayrollRun:
if run.status != "approved":
raise HTTPException(status_code=400, detail="Only approved payroll runs can be marked paid.")
run.status = "paid"
run.paid_by_user_id = actor.id
run.paid_at_utc = datetime.now(timezone.utc)
for slip in db.execute(select(EmployeePayslip).where(EmployeePayslip.payroll_run_id == run.id)).scalars().all():
slip.status = "paid"
db.commit()
db.refresh(run)
return run
# -------------------------
# Phase 7A Employee Work Dashboard
# -------------------------
PRIORITY_BUCKETS = [
("urgent", "Urgent"),
("high", "High"),
("normal", "Normal"),
("low", "Low"),
("none", "No Priority"),
]
def _task_priority_key(task: ClientServiceTaskInstance) -> str:
value = (getattr(task, "priority", None) or "normal").strip().lower()
return value if value in {code for code, _ in PRIORITY_BUCKETS} else "normal"
def _task_date_bucket(task: ClientServiceTaskInstance, *, today: date) -> str:
target = getattr(task, "internal_target_date", None)
status = (getattr(task, "status", "") or "").lower()
if status in CLOSED_TASK_STATUSES:
return "Completed / Closed"
if target and target < today:
return "Overdue"
if target and target == today:
return "Due Today"
if target:
return "Upcoming"
return "No Target Date"
def _subscription_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance) -> str:
catalogue = getattr(task, "catalogue", None)
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "service_code", None)
if not service_name and subscription:
cat = getattr(subscription, "catalogue", None)
service_name = getattr(cat, "service_name", None) or getattr(cat, "service_code", None)
fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-"
due = getattr(subscription, "current_due_date", None) if subscription else None
label = f"{service_name or 'Service Engagement'} · FY {fy}"
if due:
label = f"{label} · Due {due}"
return label
def _task_status_label(task: ClientServiceTaskInstance) -> str:
return dict(TASK_STATUSES).get(getattr(task, "status", ""), getattr(task, "status", ""))
def _task_priority_label(task: ClientServiceTaskInstance) -> str:
return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), getattr(task, "priority", ""))
def _employee_owned_task_condition(scope: EmployeeScope):
"""Return the employee self-work ownership condition.
Existing explicit task assignments remain authoritative. For legacy/open
engagements created before a staff assignee was propagated to task rows, an
unassigned Staff/Employee task is also treated as belonging to the
engagement's assigned staff member. Tasks explicitly assigned to another
user are never taken over by this fallback.
"""
role_name = func.lower(func.coalesce(ClientServiceTaskInstance.default_role_name, ""))
staff_role = or_(
role_name.contains("staff"),
role_name.contains("employee"),
)
assigned_via_engagement = (
ClientServiceTaskInstance.assigned_to_user_id.is_(None)
& staff_role
& ClientServiceTaskInstance.subscription.has(
ClientServiceSubscription.assigned_staff_user_id == scope.own_user_id
)
)
return or_(
ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id,
assigned_via_engagement,
)
def list_employee_work_dashboard(
db: Session,
scope: EmployeeScope,
*,
q: str = "",
status: str = "open",
financial_year: str | None = None,
) -> dict[str, Any]:
"""Return assigned service/engagement tasks grouped for the employee workspace.
Phase 7A is intentionally read-only. It does not modify existing engagement/task
creation flows. It groups existing ClientServiceTaskInstance records assigned to
the logged-in user by priority -> client -> engagement/service subscription.
"""
today = date.today()
status_filter = (status or "open").strip().lower()
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
_employee_owned_task_condition(scope),
ClientServiceTaskInstance.is_active.is_(True),
)
)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if status_filter == "open":
stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)))
elif status_filter == "closed":
stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES)))
elif status_filter in {code for code, _ in TASK_STATUSES}:
stmt = stmt.where(ClientServiceTaskInstance.status == status_filter)
if q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
ClientServiceTaskInstance.task_name.ilike(like),
ClientServiceTaskInstance.description.ilike(like),
ClientServiceTaskInstance.client.has(or_(
Client.client_name.ilike(like),
Client.client_code.ilike(like),
)),
ClientServiceTaskInstance.catalogue.has(or_(
ServiceCatalogue.service_name.ilike(like),
ServiceCatalogue.service_code.ilike(like),
)),
)
)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.priority.desc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.id.desc(),
)
).scalars().all()
summary = {
"total": len(tasks),
"open": 0,
"overdue": 0,
"due_today": 0,
"upcoming": 0,
"completed": 0,
}
priority_map: dict[str, dict[str, Any]] = {
code: {"code": code, "label": label, "task_count": 0, "clients": []}
for code, label in PRIORITY_BUCKETS
}
client_lookup: dict[tuple[str, int], dict[str, Any]] = {}
engagement_lookup: dict[tuple[str, int, int], dict[str, Any]] = {}
for task in tasks:
is_closed = (task.status or "") in CLOSED_TASK_STATUSES
if is_closed:
summary["completed"] += 1
else:
summary["open"] += 1
target = task.internal_target_date
if target and target < today and not is_closed:
summary["overdue"] += 1
elif target and target == today and not is_closed:
summary["due_today"] += 1
elif target and target > today and not is_closed:
summary["upcoming"] += 1
priority_key = _task_priority_key(task)
priority_bucket = priority_map.setdefault(priority_key, {"code": priority_key, "label": priority_key.title(), "task_count": 0, "clients": []})
priority_bucket["task_count"] += 1
client = getattr(task, "client", None)
client_id = getattr(client, "id", 0) or 0
client_key = (priority_key, client_id)
if client_key not in client_lookup:
client_group = {
"client": client,
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"task_count": 0,
"engagements": [],
}
client_lookup[client_key] = client_group
priority_bucket["clients"].append(client_group)
client_group = client_lookup[client_key]
client_group["task_count"] += 1
subscription = getattr(task, "subscription", None)
engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0
engagement_key = (priority_key, client_id, engagement_id)
if engagement_key not in engagement_lookup:
engagement_group = {
"subscription": subscription,
"label": _subscription_label(subscription, task),
"status": getattr(subscription, "status", None) or "-",
"due_date": getattr(subscription, "current_due_date", None) if subscription else None,
"task_count": 0,
"open_count": 0,
"completed_count": 0,
"tasks": [],
}
engagement_lookup[engagement_key] = engagement_group
client_group["engagements"].append(engagement_group)
engagement_group = engagement_lookup[engagement_key]
engagement_group["task_count"] += 1
if is_closed:
engagement_group["completed_count"] += 1
else:
engagement_group["open_count"] += 1
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None)
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.date_bucket = _task_date_bucket(task, today=today)
task.is_overdue = bool(target and target < today and not is_closed)
task.is_due_today = bool(target and target == today and not is_closed)
engagement_group["tasks"].append(task)
groups = [priority_map[code] for code, _label in PRIORITY_BUCKETS if priority_map.get(code, {}).get("task_count")]
return {"summary": summary, "groups": groups, "q": q, "status": status_filter, "today": today}
def _employee_work_task_query(db: Session, scope: EmployeeScope, *, assigned_only: bool = True, financial_year: str | None = None):
"""Base query for employee self-work views.
Kept separate for Phase 7I so the staff kanban and engagement board reuse the
same tenant/branch/assignee scoping and do not bypass existing security rules.
"""
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
)
if assigned_only:
stmt = stmt.where(_employee_owned_task_condition(scope))
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
return stmt
def _phase7i_task_card_enrich(task: ClientServiceTaskInstance, *, today: date) -> None:
is_closed = (task.status or "") in CLOSED_TASK_STATUSES
target = getattr(task, "internal_target_date", None)
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None)
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.date_bucket = _task_date_bucket(task, today=today)
task.is_overdue = bool(target and target < today and not is_closed)
task.is_due_today = bool(target and target == today and not is_closed)
task.engagement_label = _subscription_label(getattr(task, "subscription", None), task)
def _workflow_manual_blocker(subscription: ClientServiceSubscription | None) -> dict[str, Any] | None:
if not subscription or not getattr(subscription, "workflow_pause_reason", None):
return None
code = str(subscription.workflow_pause_reason).strip()
return {
"type": "manual",
"code": code,
"label": WORKFLOW_HOLD_REASONS.get(code, code.replace("_", " ").title()),
"notes": (getattr(subscription, "workflow_pause_notes", None) or "").strip() or None,
"follow_up_date": getattr(subscription, "workflow_follow_up_date", None),
}
def _workflow_automatic_blocker(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any] | None:
if subscription and getattr(subscription, "quality_workflow_required", False):
qstatus = (getattr(subscription, "quality_acceptance_status", None) or "").strip().lower()
if qstatus not in {"approved", "not_required"}:
return {"type": "automatic", "code": "quality_acceptance", "label": "AQMM acceptance pending", "notes": getattr(subscription, "quality_block_reason", None), "follow_up_date": None}
checks = [
("rework", "rework_status", {"open"}, "Rework response pending"),
("manager_review", "manager_review_status", {"pending", "rework_required"}, "Manager review pending"),
("partner_review", "partner_review_status", {"pending", "rework_required"}, "Partner review pending"),
("review_partner_review", "review_partner_review_status", {"pending", "rework_required"}, "Review Partner review pending"),
]
for code, field, values, label in checks:
for task in tasks:
if (getattr(task, field, None) or "").strip().lower() in values:
return {"type": "automatic", "code": code, "label": label, "notes": task.task_name, "follow_up_date": None}
return None
def pause_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, reason: str, notes: str, follow_up_date: str, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription:
code = (reason or "").strip().lower()
if code not in WORKFLOW_HOLD_REASONS:
raise ValueError("Select a valid hold reason.")
clean_notes = (notes or "").strip()
if code == "other" and not clean_notes:
raise ValueError("Remarks are required for Other hold reason.")
tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all()
if not tasks:
raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you")
subscription = tasks[0].subscription
if subscription.is_locked:
raise ValueError("Locked engagement cannot be paused.")
parsed_follow_up = parse_date(follow_up_date) if (follow_up_date or "").strip() else None
subscription.workflow_pause_reason = code
subscription.workflow_pause_notes = clean_notes or None
subscription.workflow_follow_up_date = parsed_follow_up
subscription.workflow_paused_at_utc = datetime.now(timezone.utc)
subscription.workflow_paused_by_user_id = actor_user_id
subscription.workflow_resumed_at_utc = None
subscription.workflow_resumed_by_user_id = None
subscription.updated_by_user_id = actor_user_id
manager_id = getattr(subscription, "assigned_manager_user_id", None)
if manager_id and manager_id != actor_user_id:
create_alert(db, user_id=manager_id, title=f"Engagement paused: {_subscription_label(subscription, tasks[0])}", message=f"{WORKFLOW_HOLD_REASONS[code]}" + (f". {clean_notes}" if clean_notes else "") + (f" Follow-up: {parsed_follow_up}." if parsed_follow_up else ""), tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="clarification", priority="high", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False)
db.commit(); db.refresh(subscription); return subscription
def resume_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription:
tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all()
if not tasks:
raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you")
subscription = tasks[0].subscription
if subscription.is_locked:
raise ValueError("Locked engagement cannot be resumed.")
old_reason = WORKFLOW_HOLD_REASONS.get(getattr(subscription, "workflow_pause_reason", None), "Paused")
subscription.workflow_pause_reason = None
subscription.workflow_pause_notes = None
subscription.workflow_follow_up_date = None
subscription.workflow_resumed_at_utc = datetime.now(timezone.utc)
subscription.workflow_resumed_by_user_id = actor_user_id
subscription.updated_by_user_id = actor_user_id
manager_id = getattr(subscription, "assigned_manager_user_id", None)
if manager_id and manager_id != actor_user_id:
create_alert(db, user_id=manager_id, title=f"Engagement resumed: {_subscription_label(subscription, tasks[0])}", message=f"The employee resumed work previously held for: {old_reason}.", tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="general", priority="normal", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False)
db.commit(); db.refresh(subscription); return subscription
def list_employee_work_kanban(
db: Session,
scope: EmployeeScope,
*,
q: str = "",
status: str = "open",
financial_year: str | None = None,
) -> dict[str, Any]:
"""Return exactly one employee-board card per assigned engagement.
The task engine remains unchanged. The board derives an engagement state from
the staff member's assigned tasks using this precedence:
completed -> all assigned tasks are closed
blocked -> at least one assigned task is blocked
in_progress -> at least one assigned task is started/completed
pending -> no assigned task has started
"""
today = date.today()
status_filter = (status or "open").strip().lower()
valid_filters = {"open", "closed", "pending", "in_progress", "blocked", "completed"}
if status_filter not in valid_filters:
status_filter = "open"
stmt = _employee_work_task_query(
db,
scope,
assigned_only=True,
financial_year=financial_year,
)
if q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
ClientServiceTaskInstance.task_name.ilike(like),
ClientServiceTaskInstance.description.ilike(like),
ClientServiceTaskInstance.client.has(
or_(Client.client_name.ilike(like), Client.client_code.ilike(like))
),
ClientServiceTaskInstance.catalogue.has(
or_(
ServiceCatalogue.service_name.ilike(like),
ServiceCatalogue.service_code.ilike(like),
)
),
)
)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.subscription_id.asc(),
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.id.asc(),
)
).scalars().all()
columns = [
{"code": "pending", "label": "Pending", "cards": []},
{"code": "in_progress", "label": "In Progress", "cards": []},
{"code": "blocked", "label": "Blocked", "cards": []},
{"code": "completed", "label": "Completed", "cards": []},
]
column_lookup = {column["code"]: column for column in columns}
engagement_lookup: dict[int, dict[str, Any]] = {}
for task in tasks:
_phase7i_task_card_enrich(task, today=today)
subscription = getattr(task, "subscription", None)
engagement_id = (
getattr(subscription, "id", None)
or getattr(task, "subscription_id", 0)
or 0
)
if engagement_id not in engagement_lookup:
client = getattr(task, "client", None)
engagement_lookup[engagement_id] = {
"engagement_id": engagement_id,
"subscription": subscription,
"label": _subscription_label(subscription, task),
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"service_name": (
getattr(getattr(subscription, "catalogue", None), "service_name", None)
or getattr(getattr(task, "catalogue", None), "service_name", None)
or "Service Engagement"
),
"financial_year": (
getattr(subscription, "financial_year", None)
or getattr(task, "financial_year", None)
or "-"
),
"due_date": (
getattr(subscription, "current_due_date", None)
if subscription
else getattr(task, "internal_target_date", None)
),
"task_count": 0,
"pending_count": 0,
"in_progress_count": 0,
"blocked_count": 0,
"completed_count": 0,
"overdue_count": 0,
"due_today_count": 0,
"latest_comment": None,
"blocked_reason": None,
"blocked_task_name": None,
"next_task_name": None,
"tasks": [],
}
card = engagement_lookup[engagement_id]
task_status = (task.status or "pending").strip().lower()
is_closed = task_status in CLOSED_TASK_STATUSES
card["task_count"] += 1
card["tasks"].append(task)
if is_closed:
card["completed_count"] += 1
elif task_status == "blocked":
card["blocked_count"] += 1
if not card["blocked_reason"]:
card["blocked_reason"] = (
(getattr(task, "remarks", None) or "").strip()
or (
getattr(task.latest_comment, "message", None)
if task.latest_comment
else None
)
or "Work is awaiting a dependency or clarification."
)
card["blocked_task_name"] = task.task_name
elif task_status == "in_progress":
card["in_progress_count"] += 1
else:
card["pending_count"] += 1
if task.is_overdue:
card["overdue_count"] += 1
if task.is_due_today:
card["due_today_count"] += 1
if task.latest_comment and not card["latest_comment"]:
card["latest_comment"] = task.latest_comment
if not is_closed and not card["next_task_name"]:
card["next_task_name"] = task.task_name
summary = {
"total": 0,
"open": 0,
"pending": 0,
"in_progress": 0,
"blocked": 0,
"completed": 0,
"overdue": 0,
"due_today": 0,
}
for card in engagement_lookup.values():
total = card["task_count"]
completed = card["completed_count"]
weighted = _weighted_progress(card.get("tasks", []))
card.update(weighted)
card["simple_progress_percent"] = int(round((completed * 100) / total)) if total else 0
card["open_count"] = max(total - completed, 0)
card["engagement_team"] = _engagement_team(card.get("subscription"))
card["sla"] = _engagement_sla(card.get("subscription"), card.get("tasks", []))
manual_blocker = _workflow_manual_blocker(card.get("subscription"))
automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", []))
card["manual_blocker"] = manual_blocker
card["automatic_blocker"] = automatic_blocker
card["workflow_blocker"] = manual_blocker or automatic_blocker
if card["workflow_blocker"]:
card["blocked_reason"] = card["workflow_blocker"]["label"]
card["blocked_notes"] = card["workflow_blocker"].get("notes")
card["follow_up_date"] = card["workflow_blocker"].get("follow_up_date")
if total and completed == total:
card_status = "completed"
card["action_label"] = "View"
elif card["workflow_blocker"] or card["blocked_count"]:
card_status = "blocked"
card["action_label"] = "Open / Follow Up"
elif card["in_progress_count"] or completed:
card_status = "in_progress"
card["action_label"] = "Continue"
else:
card_status = "pending"
card["action_label"] = "Start"
card["workflow_status"] = card_status
card["is_overdue"] = bool(card["overdue_count"])
card["is_due_today"] = bool(card["due_today_count"])
include = (
status_filter == card_status
or (status_filter == "open" and card_status != "completed")
or (status_filter == "closed" and card_status == "completed")
)
if not include:
continue
column_lookup[card_status]["cards"].append(card)
summary["total"] += 1
summary[card_status] += 1
summary["completed" if card_status == "completed" else "open"] += 1
if card["is_overdue"]:
summary["overdue"] += 1
if card["is_due_today"]:
summary["due_today"] += 1
for column in columns:
column["cards"].sort(
key=lambda card: (
card["due_date"] is None,
card["due_date"] or date.max,
card["client_name"].lower(),
card["service_name"].lower(),
)
)
return {
"summary": summary,
"columns": columns,
"q": q,
"status": status_filter,
"today": today,
}
def start_employee_engagement_workflow(
db: Session,
scope: EmployeeScope,
engagement_id: int,
*,
actor_user_id: int,
financial_year: str | None = None,
) -> ClientServiceTaskInstance:
"""Start the first pending task assigned to the employee in an engagement.
Existing task status, lock, tenant, branch and financial-year controls are
reused. No engagement or task records are duplicated.
"""
stmt = _employee_work_task_query(
db,
scope,
assigned_only=True,
financial_year=financial_year,
).where(ClientServiceTaskInstance.subscription_id == engagement_id)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.id.asc(),
)
).scalars().all()
if not tasks:
raise HTTPException(
status_code=404,
detail="Engagement work not found or not assigned to you",
)
already_started = next(
(
task
for task in tasks
if (task.status or "").strip().lower() == "in_progress"
),
None,
)
if already_started:
return already_started
first_pending = next(
(
task
for task in tasks
if (task.status or "pending").strip().lower() == "pending"
),
None,
)
if not first_pending:
return tasks[0]
return update_own_service_task_status(
db,
scope,
first_pending.id,
status="in_progress",
remarks=None,
actor_user_id=actor_user_id,
financial_year=financial_year,
)
def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]:
stmt = (
select(EngagementDocument)
.options(selectinload(EngagementDocument.versions))
.where(
EngagementDocument.tenant_id == scope.tenant_id,
EngagementDocument.engagement_id == engagement_id,
EngagementDocument.is_deleted.is_(False),
)
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
)
if scope.branch_id is not None:
stmt = stmt.where(or_(EngagementDocument.branch_id == scope.branch_id, EngagementDocument.branch_id.is_(None)))
if financial_year:
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
return db.execute(stmt).scalars().unique().all()
def _employee_task_category(task: ClientServiceTaskInstance) -> str:
value = (getattr(task, "task_category", None) or "").strip()
return value or "General Workflow"
def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current_task_id: int) -> ClientServiceTaskInstance | None:
current_index = next((index for index, row in enumerate(tasks) if row.id == current_task_id), -1)
ordered = tasks[current_index + 1 :] + tasks[: max(current_index, 0)]
return next(
(row for row in ordered if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES),
None,
)
def _workflow_task_weight(task: ClientServiceTaskInstance) -> int:
"""Stable derived progress weight without changing the task schema.
Ordinary execution tasks count as 1. Review and AQMM/final-release tasks carry
higher weight so completion cannot appear artificially high while significant
review gates remain open.
"""
weight = 1
if bool(getattr(task, "is_aqmm_task", False)):
weight += 2
if bool(getattr(task, "aqmm_blocks_final_release", False)):
weight += 2
if any(bool(getattr(task, name, False)) for name in (
"aqmm_manager_review_required",
"aqmm_partner_review_required",
"aqmm_review_partner_required",
)):
weight += 1
if (getattr(task, "default_role_name", "") or "").strip().lower() in {"manager", "partner", "review partner"}:
weight += 1
return min(weight, 7)
def _weighted_progress(tasks: list[ClientServiceTaskInstance]) -> dict[str, int]:
total_weight = sum(_workflow_task_weight(task) for task in tasks)
completed_weight = sum(
_workflow_task_weight(task)
for task in tasks
if (getattr(task, "status", None) or "pending").strip().lower() in CLOSED_TASK_STATUSES
)
percent = int(round((completed_weight * 100) / total_weight)) if total_weight else 0
return {
"total_weight": total_weight,
"completed_weight": completed_weight,
"progress_percent": percent,
}
def _engagement_team(subscription: ClientServiceSubscription | None) -> list[dict[str, Any]]:
if subscription is None:
return []
team = []
for role, rel_name, id_name in (
("Engagement Partner", "assigned_partner", "assigned_partner_user_id"),
("Manager", "assigned_manager", "assigned_manager_user_id"),
("Primary Staff", "assigned_staff", "assigned_staff_user_id"),
("Review Partner", "review_partner", "review_partner_user_id"),
):
user = getattr(subscription, rel_name, None)
user_id = getattr(subscription, id_name, None)
if user_id:
team.append({
"role": role,
"user_id": user_id,
"name": getattr(user, "full_name", None) or getattr(user, "email", None) or f"User #{user_id}",
"email": getattr(user, "email", None) or "",
})
return team
def _engagement_sla(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any]:
today = date.today()
due_date = None
if subscription is not None:
due_date = getattr(subscription, "current_due_date", None) or getattr(subscription, "original_due_date", None) or getattr(subscription, "end_date", None)
if due_date is None:
dates = [getattr(task, "internal_target_date", None) for task in tasks if getattr(task, "internal_target_date", None)]
due_date = max(dates) if dates else None
started_dates = [getattr(task, "started_at_utc", None) for task in tasks if getattr(task, "started_at_utc", None)]
created_at = getattr(subscription, "created_at_utc", None) if subscription is not None else None
anchor = min(started_dates) if started_dates else created_at
age_days = max((datetime.now(timezone.utc) - anchor).days, 0) if anchor else 0
days_remaining = (due_date - today).days if due_date else None
if due_date and days_remaining < 0:
status = "breached"
label = f"Overdue by {abs(days_remaining)} day(s)"
elif due_date and days_remaining <= 3:
status = "critical"
label = f"Due in {days_remaining} day(s)"
elif due_date and days_remaining <= 7:
status = "warning"
label = f"Due in {days_remaining} day(s)"
elif due_date:
status = "on_track"
label = f"{days_remaining} day(s) remaining"
else:
status = "not_set"
label = "No SLA due date"
return {
"due_date": due_date,
"days_remaining": days_remaining,
"age_days": age_days,
"status": status,
"label": label,
}
def escalate_employee_engagement_workflow(
db: Session,
scope: EmployeeScope,
engagement_id: int,
*,
level: str,
message: str,
actor_user_id: int,
financial_year: str | None = None,
) -> int:
tasks = _employee_engagement_tasks(db, scope, engagement_id, financial_year=financial_year)
if not tasks:
raise ValueError("Engagement was not found or is not assigned to you.")
subscription = getattr(tasks[0], "subscription", None)
if subscription is None:
raise ValueError("Engagement subscription was not found.")
code = (level or "manager").strip().lower()
target_id = {
"manager": getattr(subscription, "assigned_manager_user_id", None),
"partner": getattr(subscription, "assigned_partner_user_id", None),
"review_partner": getattr(subscription, "review_partner_user_id", None),
}.get(code)
if not target_id:
raise ValueError(f"{WORKFLOW_ESCALATION_LEVELS.get(code, 'Selected reviewer')} is not assigned to this engagement.")
clean_message = (message or "").strip()[:1000]
if not clean_message:
raise ValueError("Escalation details are required.")
create_workflow_escalation(
db, subscription=subscription, raised_by_user_id=actor_user_id,
assigned_to_user_id=int(target_id), escalation_level=code,
message=clean_message, category="workflow_dependency", priority="high",
)
create_alert(
db,
user_id=int(target_id),
title=f"Workflow escalation: {_subscription_label(subscription, tasks[0])}",
message=clean_message,
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
role_context=code,
alert_type="escalation",
priority="high",
target_url=f"/employee/work/engagements/{engagement_id}",
created_by_user_id=actor_user_id,
commit=False,
)
db.commit()
return int(target_id)
def get_employee_engagement_work_board(
db: Session,
scope: EmployeeScope,
engagement_id: int,
*,
financial_year: str | None = None,
active_task_id: int | None = None,
) -> dict[str, Any]:
"""Build the employee engagement workspace grouped by task_category.
Only tasks assigned to the logged-in employee are returned. Existing task,
document, checklist, AQMM and lock controls remain the source of truth.
"""
today = date.today()
stmt = _employee_work_task_query(
db, scope, assigned_only=True, financial_year=financial_year
).where(ClientServiceTaskInstance.subscription_id == engagement_id)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.id.asc(),
)
).scalars().all()
if not tasks:
raise HTTPException(
status_code=404,
detail="Engagement work not found or not assigned to you",
)
summary = {
"total": len(tasks),
"open": 0,
"pending": 0,
"in_progress": 0,
"blocked": 0,
"completed": 0,
"overdue": 0,
"due_today": 0,
}
category_lookup: dict[str, dict[str, Any]] = {}
categories: list[dict[str, Any]] = []
for task in tasks:
_phase7i_task_card_enrich(task, today=today)
status_code = (task.status or "pending").strip().lower()
is_closed = status_code in CLOSED_TASK_STATUSES
summary["completed" if is_closed else "open"] += 1
if status_code in summary:
summary[status_code] += 1
if task.is_overdue:
summary["overdue"] += 1
if task.is_due_today:
summary["due_today"] += 1
category_name = _employee_task_category(task)
category = category_lookup.get(category_name)
if category is None:
category = {
"name": category_name,
"tasks": [],
"total": 0,
"completed": 0,
"blocked": 0,
"in_progress": 0,
"progress_percent": 0,
"status": "pending",
}
category_lookup[category_name] = category
categories.append(category)
category["tasks"].append(task)
category["total"] += 1
if is_closed:
category["completed"] += 1
elif status_code == "blocked":
category["blocked"] += 1
elif status_code == "in_progress":
category["in_progress"] += 1
for category in categories:
weighted_category = _weighted_progress(category["tasks"])
category.update(weighted_category)
category["simple_progress_percent"] = round(
(category["completed"] / category["total"]) * 100
) if category["total"] else 0
if category["completed"] == category["total"] and category["total"]:
category["status"] = "completed"
elif category["blocked"]:
category["status"] = "blocked"
elif category["in_progress"] or category["completed"]:
category["status"] = "in_progress"
selected_task = None
if active_task_id is not None:
selected_task = next((row for row in tasks if row.id == active_task_id), None)
if selected_task is None:
selected_task = next(
(row for row in tasks if (row.status or "pending").strip().lower() == "in_progress"),
None,
)
if selected_task is None:
selected_task = next(
(row for row in tasks if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES),
tasks[0],
)
selected_category = _employee_task_category(selected_task)
next_task = _employee_workflow_next_task(tasks, selected_task.id)
weighted_overall = _weighted_progress(tasks)
overall_progress = weighted_overall["progress_percent"]
subscription = getattr(tasks[0], "subscription", None)
manual_blocker = _workflow_manual_blocker(subscription)
automatic_blocker = _workflow_automatic_blocker(subscription, tasks)
return {
"engagement_id": engagement_id,
"subscription": subscription,
"manual_blocker": manual_blocker,
"automatic_blocker": automatic_blocker,
"workflow_blocker": manual_blocker or automatic_blocker,
"hold_reasons": WORKFLOW_HOLD_REASONS,
"client": getattr(tasks[0], "client", None),
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
"summary": summary,
"progress_percent": overall_progress,
"weighted_progress": weighted_overall,
"engagement_team": _engagement_team(subscription),
"sla": _engagement_sla(subscription, tasks),
"escalation_levels": WORKFLOW_ESCALATION_LEVELS,
"categories": categories,
"active_task": selected_task,
"active_category": selected_category,
"next_task": next_task,
"documents": list_employee_engagement_documents(
db, scope, engagement_id, financial_year=financial_year
),
"today": today,
}
def save_employee_workflow_task(
db: Session,
scope: EmployeeScope,
task_id: int,
*,
status: str,
remarks: str,
checklist_response: str,
checklist_text_response: str,
checklist_number_response: str,
checklist_date_response: str,
checklist_remarks: str,
actor_user_id: int,
financial_year: str | None = None,
) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]:
"""Save one assigned workflow task and return its next open task.
The operation is atomic: checklist validation, task status, AQMM status and
timestamps are committed together. Locked tasks and locked engagements are
never modified.
"""
stmt = _employee_work_task_query(
db, scope, assigned_only=True, financial_year=financial_year
).where(ClientServiceTaskInstance.id == task_id)
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found or not assigned to you")
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
raise ValueError("Locked task cannot be changed.")
normalised_status = (status or "pending").strip().lower()
if normalised_status not in {"pending", "in_progress", "blocked", "completed"}:
raise ValueError("Invalid task status.")
apply_task_checklist_response(
db,
task,
checklist_response=checklist_response,
checklist_text_response=checklist_text_response,
checklist_number_response=checklist_number_response,
checklist_date_response=checklist_date_response,
checklist_remarks=checklist_remarks,
requested_status=normalised_status,
)
task.status = normalised_status
if normalised_status == "completed":
task.completed_at_utc = datetime.now(timezone.utc)
else:
task.completed_at_utc = None
if normalised_status == "in_progress" and not getattr(task, "started_at_utc", None):
task.started_at_utc = datetime.now(timezone.utc)
if remarks.strip():
task.remarks = remarks.strip()
task.updated_by_user_id = actor_user_id
recalculate_task_aqmm_status(db, task)
db.add(task)
db.commit()
db.refresh(task)
engagement_tasks = db.execute(
_employee_work_task_query(
db, scope, assigned_only=True, financial_year=financial_year
)
.where(ClientServiceTaskInstance.subscription_id == task.subscription_id)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
).scalars().all()
return task, _employee_workflow_next_task(engagement_tasks, task.id)
def list_employee_work_assignable_users(db: Session, scope: EmployeeScope) -> list[User]:
"""Users that can be assigned engagement/service tasks in the active employee scope."""
stmt = (
select(User)
.where(User.tenant_id == scope.tenant_id)
.order_by(User.full_name.asc(), User.email.asc(), User.id.asc())
)
if scope.branch_id is not None:
stmt = stmt.where(or_(User.branch_id == scope.branch_id, User.branch_id.is_(None)))
return db.execute(stmt).scalars().all()
def list_visible_work_assignment_dashboard(
db: Session,
scope: EmployeeScope,
*,
q: str = "",
status: str = "open",
assigned_to_user_id: int | None = None,
client_id: int | None = None,
financial_year: str | None = None,
) -> dict[str, Any]:
"""Manager/admin work allocation view grouped by priority -> client -> engagement.
This uses the existing client_service_task_instances table and therefore does
not disturb the engagement/task generation flow. It is intentionally an
assignment/review layer over existing service execution tasks.
"""
today = date.today()
status_filter = (status or "open").strip().lower()
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_to_user_id:
stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assigned_to_user_id))
if client_id:
stmt = stmt.where(ClientServiceTaskInstance.client_id == int(client_id))
if status_filter == "open":
stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)))
elif status_filter == "unassigned":
stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None))
elif status_filter == "closed":
stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES)))
elif status_filter in {code for code, _ in TASK_STATUSES}:
stmt = stmt.where(ClientServiceTaskInstance.status == status_filter)
if q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
ClientServiceTaskInstance.task_name.ilike(like),
ClientServiceTaskInstance.description.ilike(like),
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
)
)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.priority.desc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.id.desc(),
)
).scalars().all()
summary = {
"total": len(tasks),
"open": 0,
"unassigned": 0,
"overdue": 0,
"due_today": 0,
"completed": 0,
}
priority_map: dict[str, dict[str, Any]] = {code: {"code": code, "label": label, "task_count": 0, "clients": []} for code, label in PRIORITY_BUCKETS}
client_lookup: dict[tuple[str, int], dict[str, Any]] = {}
engagement_lookup: dict[tuple[str, int, int], dict[str, Any]] = {}
for task in tasks:
is_closed = (task.status or "") in CLOSED_TASK_STATUSES
if is_closed:
summary["completed"] += 1
else:
summary["open"] += 1
if not getattr(task, "assigned_to_user_id", None):
summary["unassigned"] += 1
target = task.internal_target_date
if target and target < today and not is_closed:
summary["overdue"] += 1
elif target and target == today and not is_closed:
summary["due_today"] += 1
priority_key = _task_priority_key(task)
priority_bucket = priority_map.setdefault(priority_key, {"code": priority_key, "label": priority_key.title(), "task_count": 0, "clients": []})
priority_bucket["task_count"] += 1
client = getattr(task, "client", None)
client_id_value = getattr(client, "id", 0) or getattr(task, "client_id", 0) or 0
client_key = (priority_key, client_id_value)
if client_key not in client_lookup:
client_group = {
"client": client,
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"task_count": 0,
"engagements": [],
}
client_lookup[client_key] = client_group
priority_bucket["clients"].append(client_group)
client_group = client_lookup[client_key]
client_group["task_count"] += 1
subscription = getattr(task, "subscription", None)
engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0
engagement_key = (priority_key, client_id_value, engagement_id)
if engagement_key not in engagement_lookup:
engagement_group = {
"subscription": subscription,
"label": _subscription_label(subscription, task),
"status": getattr(subscription, "status", None) or "-",
"due_date": getattr(subscription, "current_due_date", None) if subscription else None,
"task_count": 0,
"open_count": 0,
"completed_count": 0,
"tasks": [],
}
engagement_lookup[engagement_key] = engagement_group
client_group["engagements"].append(engagement_group)
engagement_group = engagement_lookup[engagement_key]
engagement_group["task_count"] += 1
if is_closed:
engagement_group["completed_count"] += 1
else:
engagement_group["open_count"] += 1
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None)
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.date_bucket = _task_date_bucket(task, today=today)
task.is_overdue = bool(target and target < today and not is_closed)
task.is_due_today = bool(target and target == today and not is_closed)
engagement_group["tasks"].append(task)
groups = [priority_map[code] for code, _label in PRIORITY_BUCKETS if priority_map.get(code, {}).get("task_count")]
return {"summary": summary, "groups": groups, "q": q, "status": status_filter, "today": today}
def update_service_task_assignment(
db: Session,
scope: EmployeeScope,
task_id: int,
*,
assigned_to_user_id: int | None,
status: str | None,
priority: str | None,
internal_target_date: date | None,
remarks: str | None,
actor_user_id: int,
financial_year: str | None = None,
) -> ClientServiceTaskInstance:
stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if getattr(task, "is_locked", False):
raise HTTPException(status_code=400, detail="Locked task cannot be changed")
if task.assigned_to_user_id is None:
# Legacy engagement fallback: once the assigned staff member actually
# works the task, persist the normal explicit assignment. This never
# overwrites an existing/manual assignee.
task.assigned_to_user_id = scope.own_user_id
if assigned_to_user_id:
assignee = db.get(User, int(assigned_to_user_id))
if not assignee or assignee.tenant_id != scope.tenant_id:
raise HTTPException(status_code=400, detail="Invalid assignee")
if scope.branch_id is not None and getattr(assignee, "branch_id", None) not in (None, scope.branch_id):
raise HTTPException(status_code=400, detail="Assignee is outside active branch")
task.assigned_to_user_id = int(assigned_to_user_id)
else:
task.assigned_to_user_id = None
allowed_statuses = {code for code, _ in TASK_STATUSES}
if status and status in allowed_statuses:
task.status = status
if status == "completed" and not getattr(task, "completed_at_utc", None):
task.completed_at_utc = datetime.now(timezone.utc)
elif status != "completed":
task.completed_at_utc = None
allowed_priorities = {code for code, _ in TASK_PRIORITIES}
if priority and priority in allowed_priorities:
task.priority = priority
task.internal_target_date = internal_target_date
if remarks is not None:
task.remarks = remarks.strip() or None
task.updated_by_user_id = actor_user_id
db.add(task)
db.commit()
db.refresh(task)
return task
def update_own_service_task_status(
db: Session,
scope: EmployeeScope,
task_id: int,
*,
status: str,
remarks: str | None,
actor_user_id: int,
financial_year: str | None = None,
) -> ClientServiceTaskInstance:
stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
_employee_owned_task_condition(scope),
)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found or not assigned to you")
if getattr(task, "is_locked", False):
raise HTTPException(status_code=400, detail="Locked task cannot be changed")
allowed = {"pending", "in_progress", "blocked", "completed"}
if status not in allowed:
raise HTTPException(status_code=400, detail="Invalid status")
task.status = status
if status == "completed":
task.completed_at_utc = datetime.now(timezone.utc)
else:
task.completed_at_utc = None
if status == "in_progress" and not getattr(task, "started_at_utc", None):
task.started_at_utc = datetime.now(timezone.utc)
if remarks is not None and remarks.strip():
task.remarks = remarks.strip()
task.updated_by_user_id = actor_user_id
db.add(task)
db.commit()
db.refresh(task)
return task
# -------------------------
# Phase 7D Task Communication Timeline
# -------------------------
def _apply_task_scope(stmt, scope: EmployeeScope):
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
return stmt
def get_work_task_with_communications(
db: Session,
scope: EmployeeScope,
task_id: int,
*,
assigned_only: bool = False,
financial_year: str | None = None,
) -> ClientServiceTaskInstance:
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.is_active.is_(True),
)
)
stmt = _apply_task_scope(stmt, scope)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_only:
stmt = stmt.where(_employee_owned_task_condition(scope))
task = db.execute(stmt).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.date_bucket = _task_date_bucket(task, today=date.today())
task.engagement_label = _subscription_label(getattr(task, "subscription", None), task)
task.communication_items = [c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]
return task
def add_work_task_communication(
db: Session,
scope: EmployeeScope,
task_id: int,
*,
comment_type: str,
visibility: str,
message: str,
actor_user_id: int,
assigned_only: bool = False,
financial_year: str | None = None,
) -> ServiceTaskComment:
task = get_work_task_with_communications(db, scope, task_id, assigned_only=assigned_only, financial_year=financial_year)
comment_type = (comment_type or "internal_note").strip()
visibility = (visibility or "internal").strip()
message = (message or "").strip()
if comment_type not in TASK_COMMUNICATION_TYPE_CODES:
raise HTTPException(status_code=400, detail="Invalid communication type")
if visibility not in TASK_COMMUNICATION_VISIBILITY_CODES:
raise HTTPException(status_code=400, detail="Invalid communication visibility")
if not message:
raise HTTPException(status_code=400, detail="Message is required")
comment = ServiceTaskComment(
tenant_id=task.tenant_id,
branch_id=task.branch_id,
subscription_id=task.subscription_id,
task_instance_id=task.id,
comment_type=comment_type,
visibility=visibility,
message=message,
created_by_user_id=actor_user_id,
is_deleted=False,
)
db.add(comment)
task.updated_by_user_id = actor_user_id
db.add(task)
db.commit()
db.refresh(comment)
return comment
# -------------------------
# Phase 7C Engagement Progress Dashboard
# -------------------------
def _safe_progress(completed: int, total: int) -> int:
if total <= 0:
return 0
return int(round((completed / total) * 100))
def _progress_status_bucket(open_count: int, overdue_count: int, completed_count: int, total_count: int) -> str:
if total_count <= 0:
return "no_tasks"
if completed_count >= total_count:
return "completed"
if overdue_count > 0:
return "overdue"
if open_count > 0:
return "in_progress"
return "pending"
def list_engagement_progress_dashboard(
db: Session,
scope: EmployeeScope,
*,
q: str = "",
status: str = "open",
assigned_to_user_id: int | None = None,
client_id: int | None = None,
financial_year: str | None = None,
) -> dict[str, Any]:
"""Return engagement progress grouped by client -> engagement/service subscription.
This is a read-only reporting layer over existing client_service_task_instances.
It does not change task generation, assignment, or service execution logic.
"""
today = date.today()
status_filter = (status or "open").strip().lower()
stmt = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
)
.where(
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
)
if scope.branch_id is not None:
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
if financial_year:
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_to_user_id:
stmt = stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assigned_to_user_id))
if client_id:
stmt = stmt.where(ClientServiceTaskInstance.client_id == int(client_id))
if status_filter == "open":
stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)))
elif status_filter == "closed":
stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES)))
elif status_filter == "overdue":
stmt = stmt.where(
ClientServiceTaskInstance.internal_target_date.is_not(None),
ClientServiceTaskInstance.internal_target_date < today,
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
)
elif status_filter == "due_today":
stmt = stmt.where(
ClientServiceTaskInstance.internal_target_date == today,
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
)
elif status_filter == "all":
pass
elif status_filter in {code for code, _ in TASK_STATUSES}:
stmt = stmt.where(ClientServiceTaskInstance.status == status_filter)
if q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
ClientServiceTaskInstance.task_name.ilike(like),
ClientServiceTaskInstance.description.ilike(like),
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
)
)
tasks = db.execute(
stmt.order_by(
ClientServiceTaskInstance.client_id.asc(),
ClientServiceTaskInstance.subscription_id.asc(),
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.id.asc(),
)
).scalars().all()
summary = {
"clients": 0,
"engagements": 0,
"tasks": len(tasks),
"open": 0,
"completed": 0,
"overdue": 0,
"due_today": 0,
"unassigned": 0,
"average_progress": 0,
}
clients: dict[int, dict[str, Any]] = {}
engagement_lookup: dict[tuple[int, int], dict[str, Any]] = {}
for task in tasks:
is_closed = (task.status or "") in CLOSED_TASK_STATUSES
target = task.internal_target_date
if is_closed:
summary["completed"] += 1
else:
summary["open"] += 1
if target and target < today and not is_closed:
summary["overdue"] += 1
if target and target == today and not is_closed:
summary["due_today"] += 1
if not getattr(task, "assigned_to_user_id", None):
summary["unassigned"] += 1
client = getattr(task, "client", None)
client_id_value = getattr(client, "id", None) or getattr(task, "client_id", None) or 0
if client_id_value not in clients:
clients[client_id_value] = {
"client": client,
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
"client_code": getattr(client, "client_code", None) or "",
"task_count": 0,
"open_count": 0,
"completed_count": 0,
"overdue_count": 0,
"due_today_count": 0,
"progress_percent": 0,
"engagements": [],
}
client_group = clients[client_id_value]
client_group["task_count"] += 1
if is_closed:
client_group["completed_count"] += 1
else:
client_group["open_count"] += 1
if target and target < today and not is_closed:
client_group["overdue_count"] += 1
if target and target == today and not is_closed:
client_group["due_today_count"] += 1
subscription = getattr(task, "subscription", None)
engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", None) or 0
key = (client_id_value, engagement_id)
if key not in engagement_lookup:
engagement_group = {
"subscription": subscription,
"label": _subscription_label(subscription, task),
"status": getattr(subscription, "status", None) or "-",
"due_date": getattr(subscription, "current_due_date", None) if subscription else None,
"task_count": 0,
"open_count": 0,
"completed_count": 0,
"overdue_count": 0,
"due_today_count": 0,
"unassigned_count": 0,
"progress_percent": 0,
"progress_status": "pending",
"tasks": [],
}
engagement_lookup[key] = engagement_group
client_group["engagements"].append(engagement_group)
engagement_group = engagement_lookup[key]
engagement_group["task_count"] += 1
if is_closed:
engagement_group["completed_count"] += 1
else:
engagement_group["open_count"] += 1
if target and target < today and not is_closed:
engagement_group["overdue_count"] += 1
if target and target == today and not is_closed:
engagement_group["due_today_count"] += 1
if not getattr(task, "assigned_to_user_id", None):
engagement_group["unassigned_count"] += 1
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
task.latest_comment = next((c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)), None)
task.status_label = _task_status_label(task)
task.priority_label = _task_priority_label(task)
task.date_bucket = _task_date_bucket(task, today=today)
task.is_overdue = bool(target and target < today and not is_closed)
task.is_due_today = bool(target and target == today and not is_closed)
engagement_group["tasks"].append(task)
total_progress = 0
for client_group in clients.values():
client_group["progress_percent"] = _safe_progress(client_group["completed_count"], client_group["task_count"])
for engagement_group in client_group["engagements"]:
engagement_group["progress_percent"] = _safe_progress(engagement_group["completed_count"], engagement_group["task_count"])
engagement_group["progress_status"] = _progress_status_bucket(
engagement_group["open_count"],
engagement_group["overdue_count"],
engagement_group["completed_count"],
engagement_group["task_count"],
)
total_progress += engagement_group["progress_percent"]
client_list = sorted(
clients.values(),
key=lambda c: (-c["overdue_count"], -c["due_today_count"], c["client_name"].lower()),
)
engagement_count = len(engagement_lookup)
summary["clients"] = len(client_list)
summary["engagements"] = engagement_count
summary["average_progress"] = _safe_progress(total_progress, engagement_count * 100) if engagement_count else 0
return {"summary": summary, "clients": client_list, "q": q, "status": status_filter, "today": today}