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>
483 lines
17 KiB
Python
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
|