TorbatYar/backend/services/accounting/app/api/v1/payroll.py
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

301 lines
9.3 KiB
Python

"""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
]