TorbatYar/backend/services/accounting/app/api/v1/accounts.py
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

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

483 lines
17 KiB
Python

"""Foundation CRUD API — Phase 5.1 + PATCH/archive + COA templates."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.core.security import get_current_user
from app.models.foundation import Account, ChartOfAccounts, CostCenter, Currency, Project
from app.models.types import AccountStatus
from app.repositories.foundation import (
AccountRepository,
ChartOfAccountsRepository,
CostCenterRepository,
CurrencyRepository,
ProjectRepository,
)
from app.schemas.foundation import (
AccountCreate,
AccountRead,
AccountUpdate,
ChartOfAccountsCreate,
ChartOfAccountsRead,
ChartOfAccountsUpdate,
CoaTemplateImportRequest,
CoaTemplateRead,
CostCenterCreate,
CostCenterRead,
CostCenterUpdate,
CurrencyCreate,
CurrencyRead,
CurrencyUpdate,
ProjectCreate,
ProjectRead,
ProjectUpdate,
)
from app.services.coa_templates import COA_TEMPLATES, import_coa_template
from shared.exceptions import AppError, NotFoundError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
# ── Charts ──────────────────────────────────────────────────────────────────
@router.post("/charts", response_model=ChartOfAccountsRead, status_code=status.HTTP_201_CREATED)
async def create_chart(
body: ChartOfAccountsCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
if body.is_default:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_default = False
entity = ChartOfAccounts(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/charts", response_model=list[ChartOfAccountsRead])
async def list_charts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/charts/{chart_id}", response_model=ChartOfAccountsRead)
async def get_chart(
chart_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = await ChartOfAccountsRepository(db).get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
return entity
@router.patch("/charts/{chart_id}", response_model=ChartOfAccountsRead)
async def update_chart(
chart_id: UUID,
body: ChartOfAccountsUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
entity = await repo.get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_default") is True:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
if existing.id != chart_id:
existing.is_default = False
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/charts/{chart_id}/archive", response_model=ChartOfAccountsRead)
async def archive_chart(
chart_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ChartOfAccountsRepository(db)
entity = await repo.get(tenant_id, chart_id)
if entity is None:
raise NotFoundError("دفتر حساب یافت نشد", error_code="chart_not_found")
entity.is_active = False
entity.is_default = False
await db.commit()
await db.refresh(entity)
return entity
# ── Templates (static paths before /{account_id}) ────────────────────────────
@router.get("/templates", response_model=list[CoaTemplateRead])
async def list_coa_templates(_user: CurrentUser = Depends(get_current_user)):
return list(COA_TEMPLATES.values())
@router.get("/templates/{template_id}", response_model=CoaTemplateRead)
async def get_coa_template(template_id: str, _user: CurrentUser = Depends(get_current_user)):
tpl = COA_TEMPLATES.get(template_id)
if tpl is None:
raise NotFoundError("قالب یافت نشد", error_code="template_not_found")
return tpl
@router.post(
"/templates/{template_id}/import",
response_model=list[AccountRead],
status_code=status.HTTP_201_CREATED,
)
async def import_template(
template_id: str,
body: CoaTemplateImportRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
accounts = await import_coa_template(
db,
tenant_id=tenant_id,
template_id=template_id,
chart_id=body.chart_id,
chart_name=body.chart_name,
chart_code=body.chart_code,
)
await db.commit()
return accounts
# ── Currencies ──────────────────────────────────────────────────────────────
@router.post("/currencies", response_model=CurrencyRead, status_code=status.HTTP_201_CREATED)
async def create_currency(
body: CurrencyCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
if body.is_base:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_base = False
entity = Currency(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/currencies", response_model=list[CurrencyRead])
async def list_currencies(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/currencies/{currency_id}", response_model=CurrencyRead)
async def update_currency(
currency_id: UUID,
body: CurrencyUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
data = body.model_dump(exclude_unset=True)
if data.get("is_base") is True:
for existing in await repo.list_by_tenant(tenant_id, limit=500):
if existing.id != currency_id:
existing.is_base = False
_apply_update(entity, data)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/currencies/{currency_id}/set-base", response_model=CurrencyRead)
async def set_base_currency(
currency_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
if not entity.is_active:
raise AppError(
"ارز غیرفعال نمی‌تواند پایه باشد",
status_code=422,
error_code="currency_inactive",
)
for existing in await repo.list_by_tenant(tenant_id, limit=500):
existing.is_base = existing.id == currency_id
await db.commit()
await db.refresh(entity)
return entity
@router.post("/currencies/{currency_id}/archive", response_model=CurrencyRead)
async def archive_currency(
currency_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CurrencyRepository(db)
entity = await repo.get(tenant_id, currency_id)
if entity is None:
raise NotFoundError("ارز یافت نشد", error_code="currency_not_found")
if entity.is_base:
raise AppError(
"ارز پایه قابل آرشیو نیست",
status_code=422,
error_code="base_currency_archive",
)
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Cost centers ────────────────────────────────────────────────────────────
@router.post("/cost-centers", response_model=CostCenterRead, status_code=status.HTTP_201_CREATED)
async def create_cost_center(
body: CostCenterCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = CostCenter(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/cost-centers", response_model=list[CostCenterRead])
async def list_cost_centers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterRead)
async def update_cost_center(
cost_center_id: UUID,
body: CostCenterUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = await repo.get(tenant_id, cost_center_id)
if entity is None:
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/cost-centers/{cost_center_id}/archive", response_model=CostCenterRead)
async def archive_cost_center(
cost_center_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = CostCenterRepository(db)
entity = await repo.get(tenant_id, cost_center_id)
if entity is None:
raise NotFoundError("مرکز هزینه یافت نشد", error_code="cost_center_not_found")
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Projects ────────────────────────────────────────────────────────────────
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = Project(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/projects", response_model=list[ProjectRead])
async def list_projects(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.patch("/projects/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: UUID,
body: ProjectUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = await repo.get(tenant_id, project_id)
if entity is None:
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/projects/{project_id}/archive", response_model=ProjectRead)
async def archive_project(
project_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = ProjectRepository(db)
entity = await repo.get(tenant_id, project_id)
if entity is None:
raise NotFoundError("پروژه یافت نشد", error_code="project_not_found")
entity.is_active = False
await db.commit()
await db.refresh(entity)
return entity
# ── Accounts (param routes last) ────────────────────────────────────────────
@router.post("", response_model=AccountRead, status_code=status.HTTP_201_CREATED)
async def create_account(
body: AccountCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = Account(tenant_id=tenant_id, **body.model_dump())
await repo.add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("", response_model=list[AccountRead])
async def list_accounts(
chart_id: UUID | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
if chart_id is not None:
return await repo.list_by_chart(
tenant_id, chart_id, offset=pagination.offset, limit=pagination.page_size
)
return await repo.list_by_tenant(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{account_id}", response_model=AccountRead)
async def get_account(
account_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
return entity
@router.patch("/{account_id}", response_model=AccountRead)
async def update_account(
account_id: UUID,
body: AccountUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
_apply_update(entity, body.model_dump(exclude_unset=True))
await db.commit()
await db.refresh(entity)
return entity
@router.post("/{account_id}/archive", response_model=AccountRead)
async def archive_account(
account_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AccountRepository(db)
entity = await repo.get(tenant_id, account_id)
if entity is None:
raise NotFoundError("حساب یافت نشد", error_code="account_not_found")
entity.status = AccountStatus.INACTIVE
await db.commit()
await db.refresh(entity)
return entity