364 lines
13 KiB
Python
364 lines
13 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'),
|
|
business_id=n.get('business_id'),
|
|
is_public=n.get('is_public'),
|
|
has_children=n.get('has_children'),
|
|
can_edit=n.get('can_edit'),
|
|
can_delete=n.get('can_delete'),
|
|
)
|
|
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()
|
|
# محاسبه has_children با شمارش فرزندان در مجموعه
|
|
children_map: dict[int, int] = {}
|
|
for r in rows:
|
|
if r.parent_id:
|
|
children_map[r.parent_id] = children_map.get(r.parent_id, 0) + 1
|
|
flat: list[Dict[str, Any]] = []
|
|
for r in rows:
|
|
is_public = r.business_id is None
|
|
has_children = children_map.get(r.id, 0) > 0
|
|
can_edit = (r.business_id == business_id) and True # شرط دسترسی نوشتن پایینتر بررسی میشود در UI/Endpoint
|
|
can_delete = can_edit and (not has_children)
|
|
flat.append({
|
|
"id": r.id,
|
|
"code": r.code,
|
|
"name": r.name,
|
|
"account_type": r.account_type,
|
|
"parent_id": r.parent_id,
|
|
"business_id": r.business_id,
|
|
"is_public": is_public,
|
|
"has_children": has_children,
|
|
"can_edit": can_edit,
|
|
"can_delete": can_delete,
|
|
})
|
|
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)
|
|
# والد اجباری است
|
|
if body.parent_id is None:
|
|
raise ApiError("PARENT_REQUIRED", "Parent account is required", http_status=400)
|
|
# اگر والد عمومی است باید قبلا دارای زیرمجموعه باشد (اجازه ایجاد زیر شاخه برای برگ عمومی را نمیدهیم)
|
|
parent = db.get(Account, int(body.parent_id)) if body.parent_id is not None else None
|
|
if parent is None:
|
|
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
|
|
if parent.business_id is None:
|
|
# lazy-load children count
|
|
if not parent.children or len(parent.children) == 0:
|
|
raise ApiError("INVALID_PUBLIC_PARENT", "Cannot add child under a public leaf account", http_status=400)
|
|
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:
|
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", 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)
|
|
if code == "PUBLIC_IMMUTABLE":
|
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
|
|
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:
|
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", 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)
|
|
try:
|
|
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")
|
|
except ValueError as e:
|
|
code = str(e)
|
|
if code == "ACCOUNT_HAS_CHILDREN":
|
|
raise ApiError("ACCOUNT_HAS_CHILDREN", "Cannot delete account with children", http_status=400)
|
|
if code == "ACCOUNT_IN_USE":
|
|
raise ApiError("ACCOUNT_IN_USE", "Cannot delete account that is referenced by documents", http_status=400)
|
|
raise
|
|
|
|
|