544 lines
25 KiB
Python
544 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime
|
|
from io import BytesIO
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from openpyxl import Workbook, load_workbook
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.security.passwords import hash_password
|
|
from app.modules.core.iam.models import User
|
|
from app.modules.core.iam.scope import validate_branch_matches_tenant
|
|
from app.modules.core.rbac.models import Role, UserRole
|
|
from app.modules.employees.models import (
|
|
Employee,
|
|
EmployeeLeaveBalance,
|
|
EmployeeLeaveType,
|
|
EmployeeSalaryStructure,
|
|
)
|
|
from app.modules.employees.service import EMPLOYEE_ROLE_NAMES, EMPLOYEE_STATUS, EMPLOYMENT_TYPES, EmployeeScope
|
|
|
|
|
|
IMPORT_TYPES = {
|
|
"employees": "Employees / Staff",
|
|
"leave_types": "Leave Types",
|
|
"leave_balances": "Leave Balances",
|
|
"salary_structures": "Salary Structures",
|
|
}
|
|
|
|
TEMPLATE_HEADERS: dict[str, list[str]] = {
|
|
"employees": [
|
|
"employee_code", "full_name", "email", "mobile", "alternate_mobile", "department", "designation",
|
|
"employment_type", "date_of_joining", "status", "pan", "uan", "esi_no", "pf_no", "aadhaar_last4",
|
|
"bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name",
|
|
"emergency_contact_mobile", "branch_id", "reporting_manager_email", "create_user", "login_email",
|
|
"temporary_password", "employee_role", "notes",
|
|
],
|
|
"leave_types": [
|
|
"code", "name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance",
|
|
"requires_approval", "is_paid", "is_active", "branch_id",
|
|
],
|
|
"leave_balances": [
|
|
"employee_code", "leave_code", "opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days", "branch_id",
|
|
],
|
|
"salary_structures": [
|
|
"employee_code", "effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount",
|
|
"hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount",
|
|
"tds_amount", "other_deduction_amount", "is_active", "remarks", "branch_id",
|
|
],
|
|
}
|
|
|
|
SAMPLE_ROWS: dict[str, list[Any]] = {
|
|
"employees": [
|
|
"EMP001", "Sample Staff", "staff@example.com", "9999999999", "", "Audit", "Associate",
|
|
"full_time", "2026-04-01", "active", "ABCDE1234F", "", "", "", "1234", "Bank", "1234567890", "IFSC0000001",
|
|
"Office address", "Emergency Contact", "9999999998", "", "", "no", "", "", "Staff", "Sample only - delete before import",
|
|
],
|
|
"leave_types": ["CL", "Casual Leave", "Casual leave", 12, "yes", "no", "yes", "yes", "yes", ""],
|
|
"leave_balances": ["EMP001", "CL", 0, 12, 0, 0, 12, ""],
|
|
"salary_structures": ["EMP001", "2026-04-01", "", "monthly", 30000, 15000, 6000, 9000, 0, 0, 0, 0, 0, "yes", "Initial structure", ""],
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ImportRowResult:
|
|
row_no: int
|
|
status: str
|
|
action: str
|
|
data: dict[str, Any]
|
|
messages: list[str]
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {"row_no": self.row_no, "status": self.status, "action": self.action, "data": self.data, "messages": self.messages}
|
|
|
|
|
|
def supported_import_types() -> dict[str, str]:
|
|
return IMPORT_TYPES.copy()
|
|
|
|
|
|
def normalize_import_type(import_type: str) -> str:
|
|
import_type = (import_type or "").strip().lower()
|
|
if import_type not in IMPORT_TYPES:
|
|
raise HTTPException(status_code=400, detail="Unsupported HR import type.")
|
|
return import_type
|
|
|
|
|
|
def _safe_excel_sheet_title(title: str) -> str:
|
|
"""Return a valid Excel worksheet title for openpyxl."""
|
|
invalid_chars = {"\\", "/", "?", "*", "[", "]", ":"}
|
|
safe_title = "".join("-" if ch in invalid_chars else ch for ch in (title or "Sheet"))
|
|
safe_title = safe_title.strip() or "Sheet"
|
|
return safe_title[:31]
|
|
|
|
|
|
def build_template_workbook(import_type: str) -> bytes:
|
|
import_type = normalize_import_type(import_type)
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = _safe_excel_sheet_title(IMPORT_TYPES[import_type])
|
|
headers = TEMPLATE_HEADERS[import_type]
|
|
ws.append(headers)
|
|
ws.append(SAMPLE_ROWS[import_type])
|
|
for col_no, header in enumerate(headers, start=1):
|
|
ws.cell(row=1, column=col_no).font = ws.cell(row=1, column=col_no).font.copy(bold=True)
|
|
ws.column_dimensions[ws.cell(row=1, column=col_no).column_letter].width = max(14, min(28, len(header) + 4))
|
|
bio = BytesIO()
|
|
wb.save(bio)
|
|
return bio.getvalue()
|
|
|
|
|
|
def parse_workbook_rows(content: bytes) -> list[dict[str, Any]]:
|
|
try:
|
|
wb = load_workbook(BytesIO(content), data_only=True)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail=f"Unable to read Excel file: {exc}")
|
|
ws = wb.active
|
|
raw_headers = [str(cell.value or "").strip().lower() for cell in ws[1]]
|
|
headers = [h for h in raw_headers]
|
|
if not any(headers):
|
|
raise HTTPException(status_code=400, detail="Excel file has no header row.")
|
|
rows: list[dict[str, Any]] = []
|
|
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
|
if not any(cell not in (None, "") for cell in row):
|
|
continue
|
|
item = {headers[idx]: _cell_value(row[idx] if idx < len(row) else None) for idx in range(len(headers)) if headers[idx]}
|
|
item["_row_no"] = row_no
|
|
rows.append(item)
|
|
return rows
|
|
|
|
|
|
def preview_import(db: Session, scope: EmployeeScope, import_type: str, content: bytes) -> dict[str, Any]:
|
|
import_type = normalize_import_type(import_type)
|
|
rows = parse_workbook_rows(content)
|
|
results: list[ImportRowResult] = []
|
|
for row in rows:
|
|
if import_type == "employees":
|
|
results.append(_preview_employee(db, scope, row))
|
|
elif import_type == "leave_types":
|
|
results.append(_preview_leave_type(db, scope, row))
|
|
elif import_type == "leave_balances":
|
|
results.append(_preview_leave_balance(db, scope, row))
|
|
elif import_type == "salary_structures":
|
|
results.append(_preview_salary_structure(db, scope, row))
|
|
valid = sum(1 for r in results if r.status == "valid")
|
|
warning = sum(1 for r in results if r.status == "warning")
|
|
error = sum(1 for r in results if r.status == "error")
|
|
return {
|
|
"import_type": import_type,
|
|
"import_label": IMPORT_TYPES[import_type],
|
|
"rows": [r.as_dict() for r in results],
|
|
"summary": {"total": len(results), "valid": valid, "warning": warning, "error": error},
|
|
}
|
|
|
|
|
|
def commit_import(db: Session, actor: User, scope: EmployeeScope, preview: dict[str, Any]) -> dict[str, Any]:
|
|
import_type = normalize_import_type(preview.get("import_type"))
|
|
created = updated = skipped = failed = 0
|
|
errors: list[str] = []
|
|
for row in preview.get("rows", []):
|
|
if row.get("status") == "error":
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
action = row.get("action") or "create"
|
|
data = row.get("data") or {}
|
|
if import_type == "employees":
|
|
action = _commit_employee(db, actor, scope, data)
|
|
elif import_type == "leave_types":
|
|
action = _commit_leave_type(db, actor, scope, data)
|
|
elif import_type == "leave_balances":
|
|
action = _commit_leave_balance(db, actor, scope, data)
|
|
elif import_type == "salary_structures":
|
|
action = _commit_salary_structure(db, actor, scope, data)
|
|
if action == "updated":
|
|
updated += 1
|
|
else:
|
|
created += 1
|
|
except Exception as exc:
|
|
db.rollback()
|
|
failed += 1
|
|
errors.append(f"Row {row.get('row_no')}: {getattr(exc, 'detail', str(exc))}")
|
|
return {"created": created, "updated": updated, "skipped": skipped, "failed": failed, "errors": errors}
|
|
|
|
|
|
def _cell_value(value: Any) -> Any:
|
|
if isinstance(value, datetime):
|
|
return value.date().isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
if isinstance(value, str):
|
|
return value.strip()
|
|
return value
|
|
|
|
|
|
def _blank(value: Any) -> Any:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str) and not value.strip():
|
|
return None
|
|
return value.strip() if isinstance(value, str) else value
|
|
|
|
|
|
def _str(value: Any, default: str = "") -> str:
|
|
value = _blank(value)
|
|
return str(value).strip() if value is not None else default
|
|
|
|
|
|
def _lower(value: Any, default: str = "") -> str:
|
|
return _str(value, default).lower()
|
|
|
|
|
|
def _upper(value: Any, default: str = "") -> str:
|
|
return _str(value, default).upper()
|
|
|
|
|
|
def _bool(value: Any, default: bool = False) -> bool:
|
|
value = _blank(value)
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "active"}
|
|
|
|
|
|
def _int(value: Any, default: int = 0) -> int:
|
|
value = _blank(value)
|
|
if value is None:
|
|
return default
|
|
try:
|
|
return int(round(float(value)))
|
|
except Exception:
|
|
raise ValueError(f"Invalid integer/amount value: {value}")
|
|
|
|
|
|
def _date(value: Any) -> date | None:
|
|
value = _blank(value)
|
|
if not value:
|
|
return None
|
|
if isinstance(value, date):
|
|
return value
|
|
text = str(value).strip()
|
|
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%m/%d/%Y"):
|
|
try:
|
|
return datetime.strptime(text, fmt).date()
|
|
except ValueError:
|
|
pass
|
|
return date.fromisoformat(text)
|
|
|
|
|
|
def _branch_id(scope: EmployeeScope, row: dict[str, Any]) -> int:
|
|
raw = _blank(row.get("branch_id"))
|
|
if raw is not None:
|
|
branch_id = int(raw)
|
|
elif scope.branch_id is not None:
|
|
branch_id = int(scope.branch_id)
|
|
else:
|
|
raise ValueError("Branch is required. Select active branch or provide branch_id in Excel.")
|
|
validate_branch_matches_tenant(_CURRENT_DB.get(), scope.tenant_id, branch_id)
|
|
return branch_id
|
|
|
|
|
|
class _DbHolder:
|
|
def __init__(self):
|
|
self.db = None
|
|
def set(self, db):
|
|
self.db = db
|
|
def get(self):
|
|
return self.db
|
|
|
|
_CURRENT_DB = _DbHolder()
|
|
|
|
|
|
def _employee_by_code(db: Session, tenant_id: int, code: str) -> Employee | None:
|
|
return db.execute(select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == code)).scalar_one_or_none()
|
|
|
|
|
|
def _leave_type_by_code(db: Session, tenant_id: int, branch_id: int, code: str) -> EmployeeLeaveType | None:
|
|
return db.execute(select(EmployeeLeaveType).where(EmployeeLeaveType.tenant_id == tenant_id, EmployeeLeaveType.branch_id == branch_id, EmployeeLeaveType.code == code)).scalar_one_or_none()
|
|
|
|
|
|
def _preview_employee(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
|
_CURRENT_DB.set(db)
|
|
messages: list[str] = []
|
|
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
|
try:
|
|
data["branch_id"] = _branch_id(scope, row)
|
|
data["employee_code"] = _upper(row.get("employee_code"))
|
|
data["full_name"] = _str(row.get("full_name"))
|
|
if not data["employee_code"] or not data["full_name"]:
|
|
raise ValueError("employee_code and full_name are required.")
|
|
data["email"] = _str(row.get("email")) or None
|
|
data["mobile"] = _str(row.get("mobile")) or None
|
|
data["alternate_mobile"] = _str(row.get("alternate_mobile")) or None
|
|
data["department"] = _str(row.get("department")) or None
|
|
data["designation"] = _str(row.get("designation")) or None
|
|
data["employment_type"] = _lower(row.get("employment_type"), "full_time")
|
|
if data["employment_type"] not in EMPLOYMENT_TYPES:
|
|
raise ValueError(f"Invalid employment_type: {data['employment_type']}.")
|
|
data["date_of_joining"] = _date(row.get("date_of_joining")).isoformat() if _date(row.get("date_of_joining")) else None
|
|
data["status"] = _lower(row.get("status"), "active")
|
|
if data["status"] not in EMPLOYEE_STATUS:
|
|
raise ValueError(f"Invalid status: {data['status']}.")
|
|
for key in ("pan", "uan", "esi_no", "pf_no", "aadhaar_last4", "bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name", "emergency_contact_mobile", "notes"):
|
|
data[key] = _str(row.get(key)) or None
|
|
data["create_user"] = _bool(row.get("create_user"), False)
|
|
data["login_email"] = (_str(row.get("login_email")) or data["email"] or "").lower() or None
|
|
data["temporary_password"] = _str(row.get("temporary_password")) or None
|
|
data["employee_role"] = _str(row.get("employee_role"), "Staff") or "Staff"
|
|
mgr_email = _str(row.get("reporting_manager_email"))
|
|
if mgr_email:
|
|
mgr = db.execute(select(User).where(User.email == mgr_email.lower(), User.tenant_id == scope.tenant_id)).scalar_one_or_none()
|
|
if not mgr:
|
|
messages.append("Reporting manager email was not found; manager will be blank.")
|
|
else:
|
|
data["reporting_manager_user_id"] = mgr.id
|
|
existing = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
|
if existing and existing.branch_id != data["branch_id"]:
|
|
raise ValueError("Employee code exists in another branch of this tenant.")
|
|
if data["create_user"]:
|
|
if not data["login_email"]:
|
|
raise ValueError("login_email/email is required when create_user is yes.")
|
|
if not existing and (not data["temporary_password"] or len(data["temporary_password"]) < 8):
|
|
raise ValueError("temporary_password must be at least 8 characters when creating user.")
|
|
if data["employee_role"] not in EMPLOYEE_ROLE_NAMES:
|
|
messages.append("Invalid employee_role; Staff will be used.")
|
|
data["employee_role"] = "Staff"
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "warning" if messages else "valid", "update" if existing else "create", data, messages)
|
|
except Exception as exc:
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
|
|
|
|
|
def _preview_leave_type(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
|
_CURRENT_DB.set(db)
|
|
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
|
try:
|
|
data["branch_id"] = _branch_id(scope, row)
|
|
data["code"] = _upper(row.get("code"))
|
|
data["name"] = _str(row.get("name"))
|
|
if not data["code"] or not data["name"]:
|
|
raise ValueError("code and name are required.")
|
|
data["description"] = _str(row.get("description")) or None
|
|
data["annual_quota_days"] = _int(row.get("annual_quota_days"), 0)
|
|
data["carry_forward_allowed"] = _bool(row.get("carry_forward_allowed"), False)
|
|
data["allow_negative_balance"] = _bool(row.get("allow_negative_balance"), False)
|
|
data["requires_approval"] = _bool(row.get("requires_approval"), True)
|
|
data["is_paid"] = _bool(row.get("is_paid"), True)
|
|
data["is_active"] = _bool(row.get("is_active"), True)
|
|
existing = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["code"])
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
|
except Exception as exc:
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
|
|
|
|
|
def _preview_leave_balance(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
|
_CURRENT_DB.set(db)
|
|
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
|
try:
|
|
data["branch_id"] = _branch_id(scope, row)
|
|
data["employee_code"] = _upper(row.get("employee_code"))
|
|
data["leave_code"] = _upper(row.get("leave_code"))
|
|
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
|
if not emp or emp.branch_id != data["branch_id"]:
|
|
raise ValueError("Employee not found in selected/provided branch.")
|
|
lt = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["leave_code"])
|
|
if not lt:
|
|
raise ValueError("Leave type not found for selected/provided branch.")
|
|
data["employee_id"] = emp.id
|
|
data["leave_type_id"] = lt.id
|
|
for key in ("opening_days", "credited_days", "availed_days", "adjusted_days"):
|
|
data[key] = _int(row.get(key), 0)
|
|
data["balance_days"] = _int(row.get("balance_days"), data["opening_days"] + data["credited_days"] + data["adjusted_days"] - data["availed_days"])
|
|
existing = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == emp.id, EmployeeLeaveBalance.leave_type_id == lt.id)).scalar_one_or_none()
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
|
except Exception as exc:
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
|
|
|
|
|
def _preview_salary_structure(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
|
_CURRENT_DB.set(db)
|
|
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
|
try:
|
|
data["branch_id"] = _branch_id(scope, row)
|
|
data["employee_code"] = _upper(row.get("employee_code"))
|
|
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
|
if not emp or emp.branch_id != data["branch_id"]:
|
|
raise ValueError("Employee not found in selected/provided branch.")
|
|
data["employee_id"] = emp.id
|
|
eff = _date(row.get("effective_from"))
|
|
if not eff:
|
|
raise ValueError("effective_from is required.")
|
|
data["effective_from"] = eff.isoformat()
|
|
eff_to = _date(row.get("effective_to"))
|
|
data["effective_to"] = eff_to.isoformat() if eff_to else None
|
|
if eff_to and eff_to < eff:
|
|
raise ValueError("effective_to cannot be before effective_from.")
|
|
data["pay_cycle"] = _lower(row.get("pay_cycle"), "monthly")
|
|
for key in ("monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount"):
|
|
data[key] = _int(row.get(key), 0)
|
|
data["is_active"] = _bool(row.get("is_active"), True)
|
|
data["remarks"] = _str(row.get("remarks")) or None
|
|
existing = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == emp.id, EmployeeSalaryStructure.effective_from == eff)).scalar_one_or_none()
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
|
except Exception as exc:
|
|
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
|
|
|
|
|
def _assign_role_if_needed(db: Session, user_id: int, role_name: str) -> None:
|
|
role = db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
|
|
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 _get_or_create_user(db: Session, data: dict[str, Any], actor: User) -> int | None:
|
|
if not data.get("create_user"):
|
|
return None
|
|
email = (data.get("login_email") or data.get("email") or "").lower().strip()
|
|
if not email:
|
|
return None
|
|
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
|
role_name = data.get("employee_role") or "Staff"
|
|
if role_name not in EMPLOYEE_ROLE_NAMES:
|
|
role_name = "Staff"
|
|
if existing:
|
|
_assign_role_if_needed(db, existing.id, role_name)
|
|
return existing.id
|
|
password = data.get("temporary_password") or ""
|
|
if len(password) < 8:
|
|
raise ValueError("temporary_password must be at least 8 characters.")
|
|
user = User(
|
|
email=email,
|
|
full_name=data.get("full_name") or email,
|
|
password_hash=hash_password(password),
|
|
tenant_id=int(data["tenant_id"]),
|
|
branch_id=int(data["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.id
|
|
|
|
|
|
def _commit_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
|
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
|
user_id = _get_or_create_user(db, data, actor)
|
|
payload = dict(data)
|
|
payload["date_of_joining"] = _date(payload.get("date_of_joining"))
|
|
payload["is_active"] = payload.get("status") == "active"
|
|
payload.pop("create_user", None)
|
|
payload.pop("login_email", None)
|
|
payload.pop("temporary_password", None)
|
|
payload.pop("employee_role", None)
|
|
payload.pop("employee_code", None)
|
|
payload.pop("full_name", None)
|
|
payload.pop("tenant_id", None)
|
|
payload.pop("branch_id", None)
|
|
if emp:
|
|
emp.full_name = data["full_name"]
|
|
emp.email = data.get("email")
|
|
if user_id and not emp.user_id:
|
|
emp.user_id = user_id
|
|
for key, value in payload.items():
|
|
if hasattr(emp, key):
|
|
setattr(emp, key, value)
|
|
emp.updated_by_user_id = actor.id
|
|
emp.updated_at_utc = datetime.utcnow()
|
|
db.commit()
|
|
return "updated"
|
|
emp = Employee(
|
|
tenant_id=int(data["tenant_id"]), branch_id=int(data["branch_id"]), user_id=user_id,
|
|
employee_code=data["employee_code"], full_name=data["full_name"], created_by_user_id=actor.id, updated_by_user_id=actor.id,
|
|
**{k: v for k, v in payload.items() if hasattr(Employee, k)}
|
|
)
|
|
db.add(emp)
|
|
db.commit()
|
|
return "created"
|
|
|
|
|
|
def _commit_leave_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
|
row = _leave_type_by_code(db, scope.tenant_id, int(data["branch_id"]), data["code"])
|
|
fields = ["name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance", "requires_approval", "is_paid", "is_active"]
|
|
if row:
|
|
for field in fields:
|
|
setattr(row, field, data.get(field))
|
|
row.updated_by_user_id = actor.id
|
|
row.updated_at_utc = datetime.utcnow()
|
|
db.commit()
|
|
return "updated"
|
|
row = EmployeeLeaveType(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), code=data["code"], created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: data.get(k) for k in fields})
|
|
db.add(row)
|
|
db.commit()
|
|
return "created"
|
|
|
|
|
|
def _commit_leave_balance(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
|
row = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == data["employee_id"], EmployeeLeaveBalance.leave_type_id == data["leave_type_id"])).scalar_one_or_none()
|
|
fields = ["opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days"]
|
|
if row:
|
|
for field in fields:
|
|
setattr(row, field, int(data.get(field) or 0))
|
|
row.updated_by_user_id = actor.id
|
|
row.updated_at_utc = datetime.utcnow()
|
|
db.commit()
|
|
return "updated"
|
|
row = EmployeeLeaveBalance(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), leave_type_id=int(data["leave_type_id"]), updated_by_user_id=actor.id, **{k: int(data.get(k) or 0) for k in fields})
|
|
db.add(row)
|
|
db.commit()
|
|
return "created"
|
|
|
|
|
|
def _commit_salary_structure(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
|
effective_from = _date(data.get("effective_from"))
|
|
row = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == data["employee_id"], EmployeeSalaryStructure.effective_from == effective_from)).scalar_one_or_none()
|
|
payload = dict(data)
|
|
payload["effective_from"] = effective_from
|
|
payload["effective_to"] = _date(payload.get("effective_to"))
|
|
for remove in ("employee_code", "tenant_id"):
|
|
payload.pop(remove, None)
|
|
fields = ["effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount", "is_active", "remarks"]
|
|
if row:
|
|
for field in fields:
|
|
setattr(row, field, payload.get(field))
|
|
row.updated_by_user_id = actor.id
|
|
row.updated_at_utc = datetime.utcnow()
|
|
db.commit()
|
|
return "updated"
|
|
row = EmployeeSalaryStructure(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: payload.get(k) for k in fields})
|
|
db.add(row)
|
|
db.commit()
|
|
return "created"
|