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"