Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+543
View File
@@ -0,0 +1,543 @@
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"