"""Phase 5.9 — Payroll API.""" from __future__ import annotations from datetime import date from decimal import Decimal from uuid import UUID from fastapi import APIRouter, Depends, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, require_tenant from app.core.security import get_current_user from app.models.payroll import Department, Employee, EmploymentContract, PayrollPeriod, SalaryComponent from app.models.types import EmploymentStatus, PayrollPeriodStatus from app.repositories.base import TenantBaseRepository from app.services.payroll_service import PayrollAccountingService from shared.security import CurrentUser router = APIRouter() class DepartmentCreate(BaseModel): code: str name: str class EmployeeCreate(BaseModel): employee_code: str first_name: str last_name: str department_id: UUID | None = None base_salary: Decimal = Decimal("0") hire_date: date | None = None class PayrollPeriodCreate(BaseModel): name: str start_date: date end_date: date class CalculatePayrollRequest(BaseModel): payroll_period_id: UUID employee_id: UUID class PostPayrollRequest(BaseModel): profile_id: UUID | None = None class DepartmentRepo(TenantBaseRepository[Department]): model = Department class EmployeeRepo(TenantBaseRepository[Employee]): model = Employee class PayrollPeriodRepo(TenantBaseRepository[PayrollPeriod]): model = PayrollPeriod @router.post("/departments", status_code=status.HTTP_201_CREATED) async def create_department( body: DepartmentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): repo = DepartmentRepo(db) entity = Department(tenant_id=tenant_id, **body.model_dump()) await repo.add(entity) await db.commit() return {"id": str(entity.id)} @router.get("/departments") async def list_departments( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): deps = await DepartmentRepo(db).list_by_tenant(tenant_id, limit=100) return [{"id": str(d.id), "code": d.code, "name": d.name} for d in deps] @router.post("/employees", status_code=status.HTTP_201_CREATED) async def create_employee( body: EmployeeCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): repo = EmployeeRepo(db) entity = Employee(tenant_id=tenant_id, employment_status=EmploymentStatus.ACTIVE, **body.model_dump()) await repo.add(entity) await db.commit() return {"id": str(entity.id), "employee_code": entity.employee_code} @router.get("/employees") async def list_employees( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): repo = EmployeeRepo(db) employees = await repo.list_by_tenant(tenant_id, limit=100) return [{"id": str(e.id), "code": e.employee_code, "name": f"{e.first_name} {e.last_name}"} for e in employees] @router.post("/periods", status_code=status.HTTP_201_CREATED) async def create_payroll_period( body: PayrollPeriodCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): repo = PayrollPeriodRepo(db) entity = PayrollPeriod(tenant_id=tenant_id, status=PayrollPeriodStatus.OPEN, **body.model_dump()) await repo.add(entity) await db.commit() return {"id": str(entity.id)} @router.get("/periods") async def list_payroll_periods( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): periods = await PayrollPeriodRepo(db).list_by_tenant(tenant_id, limit=100) return [ { "id": str(p.id), "name": p.name, "start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat(), "status": p.status.value, } for p in periods ] @router.post("/calculate") async def calculate_payroll( body: CalculatePayrollRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): svc = PayrollAccountingService(db) payroll = await svc.calculate_payroll(tenant_id, body.payroll_period_id, body.employee_id) await db.commit() return { "id": str(payroll.id), "gross_salary": str(payroll.gross_salary), "net_salary": str(payroll.net_salary), "status": payroll.status.value, } @router.post("/payrolls/{payroll_id}/post") async def post_payroll( payroll_id: UUID, body: PostPayrollRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user), ): from app.models.types import PayrollStatus from app.services.payroll_service import PayrollRepo payroll_repo = PayrollRepo(db) payroll = await payroll_repo.get(tenant_id, payroll_id) if payroll is None: from shared.exceptions import NotFoundError raise NotFoundError("حقوق یافت نشد", error_code="payroll_not_found") payroll.status = PayrollStatus.APPROVED svc = PayrollAccountingService(db) result = await svc.post_payroll(tenant_id, payroll_id, actor_user_id=user.user_id, profile_id=body.profile_id) await db.commit() return {"id": str(result.id), "status": result.status.value, "voucher_id": str(result.voucher_id)} class ContractCreate(BaseModel): employee_id: UUID contract_type: str = "permanent" start_date: date end_date: date | None = None base_salary: Decimal = Decimal("0") class SalaryComponentCreate(BaseModel): code: str name: str component_type: str = "earning" is_taxable: bool = True class ContractRepo(TenantBaseRepository[EmploymentContract]): model = EmploymentContract class SalaryComponentRepo(TenantBaseRepository[SalaryComponent]): model = SalaryComponent @router.get("/contracts") async def list_contracts( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): rows = await ContractRepo(db).list_by_tenant(tenant_id, limit=200) return [ { "id": str(r.id), "employee_id": str(r.employee_id), "contract_type": r.contract_type, "start_date": r.start_date.isoformat(), "end_date": r.end_date.isoformat() if r.end_date else None, "base_salary": str(r.base_salary), "is_active": r.is_active, } for r in rows ] @router.post("/contracts", status_code=status.HTTP_201_CREATED) async def create_contract( body: ContractCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): entity = EmploymentContract(tenant_id=tenant_id, **body.model_dump()) await ContractRepo(db).add(entity) await db.commit() return {"id": str(entity.id)} @router.get("/salary-components") async def list_salary_components( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): rows = await SalaryComponentRepo(db).list_by_tenant(tenant_id, limit=200) return [ { "id": str(r.id), "code": r.code, "name": r.name, "component_type": r.component_type, "is_taxable": r.is_taxable, "is_active": r.is_active, } for r in rows ] @router.post("/salary-components", status_code=status.HTTP_201_CREATED) async def create_salary_component( body: SalaryComponentCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): entity = SalaryComponent(tenant_id=tenant_id, **body.model_dump()) await SalaryComponentRepo(db).add(entity) await db.commit() return {"id": str(entity.id)} @router.get("/payrolls") async def list_payrolls( tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ): from app.models.payroll import Payroll from sqlalchemy import select stmt = select(Payroll).where(Payroll.tenant_id == tenant_id).order_by(Payroll.created_at.desc()).limit(200) rows = list((await db.execute(stmt)).scalars().all()) return [ { "id": str(r.id), "payroll_period_id": str(r.payroll_period_id), "employee_id": str(r.employee_id), "gross_salary": str(r.gross_salary), "net_salary": str(r.net_salary), "status": r.status.value, "voucher_id": str(r.voucher_id) if r.voucher_id else None, } for r in rows ]