hesabixArc/hesabixAPI/adapters/api/v1/accounts.py
2025-10-31 18:02:23 +00:00

314 lines
11 KiB
Python

from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from pydantic import BaseModel
from adapters.db.session import get_db
from adapters.api.v1.schemas import SuccessResponse
from adapters.api.v1.schema_models.account import AccountTreeNode, AccountCreateRequest, AccountUpdateRequest
from app.core.responses import success_response, ApiError
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from adapters.db.models.account import Account
from app.services.account_service import create_account, update_account, delete_account, get_account
router = APIRouter(prefix="/accounts", tags=["accounts"])
class SearchAccountsRequest(BaseModel):
"""درخواست جستجوی حساب‌ها"""
take: int = 50
skip: int = 0
search: Optional[str] = None
sort_by: Optional[str] = "code"
sort_desc: bool = False
def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
by_id: dict[int, AccountTreeNode] = {}
roots: list[AccountTreeNode] = []
for n in nodes:
node = AccountTreeNode(
id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
)
by_id[node.id] = node
for node in list(by_id.values()):
pid = node.parent_id
if pid and pid in by_id:
by_id[pid].children.append(node)
else:
roots.append(node)
return roots
@router.get("/business/{business_id}/tree",
summary="دریافت درخت حساب‌ها برای یک کسب و کار",
description="لیست حساب‌های عمومی و حساب‌های اختصاصی کسب و کار به صورت درختی",
)
@require_business_access("business_id")
def get_accounts_tree(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
# دریافت حساب‌های عمومی (business_id IS NULL) و حساب‌های مختص این کسب و کار
rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
flat = [
{"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
for r in rows
]
tree = _build_tree(flat)
return success_response({"items": [n.model_dump() for n in tree]}, request)
@router.get("/business/{business_id}",
summary="دریافت لیست حساب‌ها برای یک کسب و کار",
description="لیست تمام حساب‌های عمومی و حساب‌های اختصاصی کسب و کار (بدون ساختار درختی)",
)
@require_business_access("business_id")
def get_accounts_list(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت لیست ساده حساب‌ها"""
rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
items = [
{
"id": r.id,
"code": r.code,
"name": r.name,
"account_type": r.account_type,
"parent_id": r.parent_id,
"business_id": r.business_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
return success_response({"items": items}, request)
@router.get("/business/{business_id}/account/{account_id}",
summary="دریافت جزئیات یک حساب خاص",
description="دریافت اطلاعات کامل یک حساب بر اساس ID",
)
@require_business_access("business_id")
def get_account_by_id(
request: Request,
business_id: int,
account_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت یک حساب خاص"""
account = db.query(Account).filter(
Account.id == account_id,
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).first()
if not account:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="حساب یافت نشد")
account_data = {
"id": account.id,
"code": account.code,
"name": account.name,
"account_type": account.account_type,
"parent_id": account.parent_id,
"business_id": account.business_id,
"created_at": account.created_at.isoformat() if account.created_at else None,
"updated_at": account.updated_at.isoformat() if account.updated_at else None,
}
return success_response(account_data, request)
@router.post("/business/{business_id}",
summary="جستجو و فیلتر حساب‌ها",
description="جستجو در حساب‌ها با قابلیت فیلتر، مرتب‌سازی و صفحه‌بندی",
)
@require_business_access("business_id")
def search_accounts(
request: Request,
business_id: int,
search_request: SearchAccountsRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""جستجوی حساب‌ها با فیلتر"""
query = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
)
# اعمال جستجو
if search_request.search:
search_term = f"%{search_request.search}%"
query = query.filter(
(Account.code.ilike(search_term)) | (Account.name.ilike(search_term))
)
# شمارش کل
total = query.count()
# مرتب‌سازی
if search_request.sort_by == "name":
order_col = Account.name
else:
order_col = Account.code
if search_request.sort_desc:
query = query.order_by(order_col.desc())
else:
query = query.order_by(order_col.asc())
# صفحه‌بندی
query = query.offset(search_request.skip).limit(search_request.take)
rows = query.all()
items = [
{
"id": r.id,
"code": r.code,
"name": r.name,
"account_type": r.account_type,
"parent_id": r.parent_id,
"business_id": r.business_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
return success_response({
"items": items,
"total": total,
"skip": search_request.skip,
"take": search_request.take,
}, request)
@router.post(
"/business/{business_id}/create",
summary="ایجاد حساب جدید برای یک کسب‌وکار",
description="ایجاد حساب اختصاصی (business-specific).",
)
@require_business_access("business_id")
def create_business_account(
request: Request,
business_id: int,
body: AccountCreateRequest = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
# اجازه نوشتن در بخش حسابداری لازم است
if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
try:
created = create_account(
db,
name=body.name,
code=body.code,
account_type=body.account_type,
business_id=business_id,
parent_id=body.parent_id,
)
return success_response(created, request, message="ACCOUNT_CREATED")
except ValueError as e:
code = str(e)
if code == "ACCOUNT_CODE_NOT_UNIQUE":
raise ApiError("ACCOUNT_CODE_NOT_UNIQUE", "Account code must be unique per business", http_status=400)
if code == "PARENT_NOT_FOUND":
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
if code == "INVALID_PARENT_BUSINESS":
raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400)
raise
@router.put(
"/account/{account_id}",
summary="ویرایش حساب",
description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
)
def update_account_endpoint(
request: Request,
account_id: int,
body: AccountUpdateRequest = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = get_account(db, account_id)
if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
acc_business_id = data.get("business_id")
# اگر عمومی است، فقط سوپرادمین
if acc_business_id is None and not ctx.is_superadmin():
raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403)
# اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد
if acc_business_id is not None:
if not ctx.can_access_business(int(acc_business_id)):
raise ApiError("FORBIDDEN", "No access to business", http_status=403)
if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
try:
updated = update_account(
db,
account_id,
name=body.name,
code=body.code,
account_type=body.account_type,
parent_id=body.parent_id,
)
if updated is None:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
return success_response(updated, request, message="ACCOUNT_UPDATED")
except ValueError as e:
code = str(e)
if code == "ACCOUNT_CODE_NOT_UNIQUE":
raise ApiError("ACCOUNT_CODE_NOT_UNIQUE", "Account code must be unique per business", http_status=400)
if code == "PARENT_NOT_FOUND":
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
if code == "INVALID_PARENT_BUSINESS":
raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400)
raise
@router.delete(
"/account/{account_id}",
summary="حذف حساب",
description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
)
def delete_account_endpoint(
request: Request,
account_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = get_account(db, account_id)
if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
acc_business_id = data.get("business_id")
if acc_business_id is None and not ctx.is_superadmin():
raise ApiError("FORBIDDEN", "Only superadmin can delete public accounts", http_status=403)
if acc_business_id is not None:
if not ctx.can_access_business(int(acc_business_id)):
raise ApiError("FORBIDDEN", "No access to business", http_status=403)
if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
ok = delete_account(db, account_id)
if not ok:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
return success_response(None, request, message="ACCOUNT_DELETED")