Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/expense_income.py

748 lines
28 KiB
Python
Executable file

"""
API endpoints برای هزینه و درآمد (Expense & Income)
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.document import Document
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management_dep, require_business_access, require_business_permission_dep, require_business_permission_by_entity_dep
from app.core.responses import success_response, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.list_query_common import DocumentListQueryBody, document_list_query_to_dict
from app.core.cache import get_cache
from app.services.expense_income_service import (
create_expense_income,
list_expense_income,
get_expense_income,
update_expense_income,
delete_expense_income,
delete_multiple_expense_income,
)
from app.services.pdf.template_renderer import render_template
router = APIRouter(tags=["expense-income"])
@router.post(
"/businesses/{business_id}/expense-income/create",
summary="ایجاد سند هزینه یا درآمد",
description="ایجاد سند هزینه/درآمد با چند سطر حساب و چند طرف‌حساب",
)
@require_business_access("business_id")
async def create_expense_income_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("expenses_income", "add")),
):
created = create_expense_income(db, business_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(created, request),
request=request,
message="EXPENSE_INCOME_CREATED",
)
@router.post(
"/businesses/{business_id}/expense-income/bulk-upsert",
summary="ایجاد/ویرایش گروهی هزینه و درآمد",
description=(
"بدنه: `{ items:[{client_ref?, document_id?, payload}], migration_mode? }`؛ "
"حداکثر ۲۰۰ آیتم؛ payload همان بدنه ایجاد تکی است."
),
)
@require_business_access("business_id")
async def bulk_upsert_expense_income_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from app.services.expense_income_bulk_upsert_service import (
bulk_upsert_expense_income_integration,
)
data = bulk_upsert_expense_income_integration(db, business_id, ctx, body)
return success_response(
data=data,
request=request,
message="BULK_EXPENSE_INCOME_UPSERT_COMPLETED",
)
@router.post(
"/businesses/{business_id}/expense-income",
summary="لیست اسناد هزینه/درآمد",
description="دریافت لیست اسناد هزینه/درآمد با جستجو و صفحه‌بندی",
)
@require_business_access("business_id")
async def list_expense_income_endpoint(
request: Request,
business_id: int,
body: DocumentListQueryBody,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
query_dict = document_list_query_to_dict(body, request=request)
# کش نتایج لیست هزینه/درآمد
cache = get_cache()
cache_key = None
fiscal_year_id = query_dict.get("fiscal_year_id")
if cache.enabled:
import json, hashlib
key_payload = {
"business_id": business_id,
"query": query_dict,
}
key_str = json.dumps(key_payload, sort_keys=True, ensure_ascii=False)
key_hash = hashlib.sha256(key_str.encode("utf-8")).hexdigest()[:16]
cache_key = f"expense_income_list:{key_hash}"
cached = cache.get(cache_key)
if cached is not None:
return success_response(data=cached, request=request, message="EXPENSE_INCOME_LIST_FETCHED")
result = list_expense_income(db, business_id, query_dict)
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
# ذخیره در cache با tag-based caching
if cache.enabled and cache_key:
cache.set_with_expense_income_tag(
key=cache_key,
value=result,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
ttl=60
)
return success_response(data=result, request=request, message="EXPENSE_INCOME_LIST_FETCHED")
@router.get(
"/expense-income/{document_id}",
summary="جزئیات سند هزینه/درآمد",
description="دریافت جزئیات یک سند هزینه یا درآمد",
)
async def get_expense_income_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت جزئیات سند"""
result = get_expense_income(db, document_id)
if not result:
from app.core.responses import ApiError
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Expense/Income document not found",
http_status=404
)
# بررسی دسترسی
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="EXPENSE_INCOME_DETAILS"
)
@router.put(
"/expense-income/{document_id}",
summary="ویرایش سند هزینه/درآمد",
description="ویرایش یک سند هزینه یا درآمد",
)
async def update_expense_income_endpoint(
request: Request,
document_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("expenses_income", "edit", Document, "document_id")),
):
"""ویرایش سند هزینه/درآمد"""
updated = update_expense_income(db, document_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(updated, request),
request=request,
message="EXPENSE_INCOME_UPDATED"
)
@router.delete(
"/expense-income/{document_id}",
summary="حذف سند هزینه/درآمد",
description="حذف یک سند هزینه یا درآمد",
)
async def delete_expense_income_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("expenses_income", "delete", Document, "document_id")),
):
"""حذف سند هزینه/درآمد"""
success = delete_expense_income(db, document_id)
if not success:
from app.core.responses import ApiError
raise ApiError("DELETE_FAILED", "Failed to delete document", http_status=500)
return success_response(
data={"deleted": True},
request=request,
message="EXPENSE_INCOME_DELETED"
)
@router.post(
"/expense-income/bulk-delete",
summary="حذف گروهی اسناد هزینه/درآمد",
description="حذف چندین سند هزینه یا درآمد",
)
async def delete_multiple_expense_income_endpoint(
request: Request,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""حذف گروهی اسناد"""
document_ids = body.get("document_ids", [])
if not document_ids:
from app.core.responses import ApiError
raise ApiError("INVALID_REQUEST", "document_ids is required", http_status=400)
# بررسی دسترسی برای هر document
from adapters.db.models.document import Document as DocumentModel
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
permission_repo = BusinessPermissionRepository(db)
for doc_id in document_ids:
try:
doc = db.get(DocumentModel, doc_id)
if doc:
business_id = doc.business_id
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", f"No access to document {doc_id}", http_status=403)
# بررسی دسترسی جزئی برای business_id مشخص
if ctx.is_superadmin() or ctx.is_business_owner(business_id):
continue # SuperAdmin و مالک تمام دسترسی‌ها را دارند
permission_obj = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
if not permission_obj or not permission_obj.business_permissions:
raise ApiError("FORBIDDEN", f"Missing permission: expenses_income.delete for document {doc_id}", http_status=403)
permissions = ctx._normalize_permissions_value(permission_obj.business_permissions)
if "expenses_income" not in permissions:
raise ApiError("FORBIDDEN", f"Missing permission: expenses_income.delete for document {doc_id}", http_status=403)
section_perms = permissions.get("expenses_income", {})
if not section_perms.get("delete", False):
raise ApiError("FORBIDDEN", f"Missing permission: expenses_income.delete for document {doc_id}", http_status=403)
except ApiError:
raise
except Exception:
continue
success = delete_multiple_expense_income(db, document_ids)
if not success:
from app.core.responses import ApiError
raise ApiError("DELETE_FAILED", "Failed to delete documents", http_status=500)
return success_response(
data={"deleted_count": len(document_ids)},
request=request,
message="EXPENSE_INCOME_BULK_DELETED"
)
@router.post(
"/businesses/{business_id}/expense-income/export/excel",
summary="خروجی Excel اسناد هزینه/درآمد",
description="دریافت فایل Excel لیست اسناد هزینه/درآمد",
)
@require_business_access("business_id")
async def export_expense_income_excel_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی Excel"""
from app.services.expense_income_service import export_expense_income_excel
from fastapi.responses import Response
# دریافت پارامترهای فیلتر
query_dict = {}
try:
body_json = await request.json()
if isinstance(body_json, dict):
for key in [
"document_type", "from_date", "to_date", "project_id",
"take", "skip", "sort_by", "sort_desc", "sort",
"search", "search_fields", "filters",
"fiscal_year_id", "account_id",
]:
if key in body_json:
query_dict[key] = body_json[key]
except Exception:
pass
if query_dict.get("fiscal_year_id") is None:
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
excel_data = export_expense_income_excel(db, business_id, query_dict)
return Response(
content=excel_data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.xlsx"}
)
@router.post(
"/businesses/{business_id}/expense-income/export/pdf",
summary="خروجی PDF اسناد هزینه/درآمد",
description="دریافت فایل PDF لیست اسناد هزینه/درآمد",
)
@require_business_access("business_id")
async def export_expense_income_pdf_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی PDF (با پشتیبانی قالب سفارشی expense_income/list)"""
from fastapi.responses import Response
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, json
# دریافت پارامترهای فیلتر و تنظیمات
try:
body = await request.json()
except Exception:
body = {}
# ساخت query برای لیست
query_dict = {
"take": int(body.get("take", 1000)),
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"sort": body.get("sort") if isinstance(body.get("sort"), list) else None,
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
"document_type": body.get("document_type"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
"project_id": body.get("project_id"),
"account_id": body.get("account_id"),
}
try:
if body.get("fiscal_year_id") is not None:
query_dict["fiscal_year_id"] = int(body.get("fiscal_year_id"))
else:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
# دریافت داده‌ها
from app.services.expense_income_service import list_expense_income
from adapters.db.models.business import Business
from app.core.responses import format_datetime_fields
result = list_expense_income(db, business_id, query_dict)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# ستون‌ها
headers: list[str] = []
keys: list[str] = []
export_columns = body.get("export_columns")
if export_columns:
for col in export_columns:
key = col.get("key")
label = col.get("label", key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
default_columns = [
("code", "کد سند"),
("document_type_name", "نوع سند"),
("document_date", "تاریخ سند"),
("total_amount", "مبلغ کل"),
("created_by_name", "ایجادکننده"),
("registered_at", "تاریخ ثبت"),
]
for key, label in default_columns:
if items and key in items[0]:
keys.append(key)
headers.append(label)
# اطلاعات کسب‌وکار
business_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
business_name = b.name or ""
except Exception:
business_name = ""
# Locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "لیست اسناد هزینه/درآمد" if is_fa else "Expense/Income List"
label_biz = "کسب و کار" if is_fa else "Business"
label_date = "تاریخ تولید" if is_fa else "Generated Date"
try:
from app.services.print_footer_settings import build_generated_at_pdf_footer
pn = ""
try:
pn = ctx.get_user_name() or ""
except Exception:
pn = ""
footer_text = build_generated_at_pdf_footer(
db,
business_id,
formatted_generated_at=now,
preparer_name=pn or None,
is_fa=is_fa,
)
except Exception:
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
rows_html = []
for item in items:
row_cells = []
for key in keys:
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# کانتکست برای قالب سفارشی
template_context = {
"title_text": title_text,
"business_name": business_name,
"generated_at": now,
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
# تلاش برای رندر با قالب سفارشی
resolved_html = None
try:
from app.services.report_template_service import ReportTemplateService
explicit_template_id = None
try:
if body.get("template_id") is not None:
explicit_template_id = int(body.get("template_id"))
except Exception:
explicit_template_id = None
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="expense_income",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# HTML پیش‌فرض با قالب فایل
disposition = "attachment"
try:
disposition = str(body.get("disposition") or "attachment")
except Exception:
disposition = "attachment"
paper_size = None
orientation = None
try:
paper_size = body.get("paper_size")
orientation = body.get("orientation")
except Exception:
pass
final_html = resolved_html or render_template(
"pdf/expense_income/list.html",
{
**template_context,
"title_text": title_text,
"paper_size": paper_size,
"orientation": orientation,
"footer_text": footer_text,
},
)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=FontConfiguration())
filename = f"expense_income_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"{disposition}; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.get(
"/expense-income/{document_id}/pdf",
summary="PDF یک سند هزینه/درآمد",
description="دریافت فایل PDF یک سند هزینه یا درآمد",
)
async def get_expense_income_pdf_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""PDF یک سند (سبک فاکتور: قالب سفارشی یا پیش‌فرض Jinja + WeasyPrint)"""
from fastapi.responses import Response
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, re
# دریافت سند و بررسی دسترسی
item = get_expense_income(db, document_id)
if not item:
from app.core.responses import ApiError
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = item.get("business_id")
if business_id and not ctx.can_access_business(business_id):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# اطلاعات کسب‌وکار
business_name = ""
business_info: Dict[str, Any] = {}
try:
from adapters.db.models.business import Business
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
business_name = b.name or ""
business_info = {
"name": b.name,
"economic_code": getattr(b, "economic_code", None),
"registration_number": getattr(b, "registration_number", None),
"phone": getattr(b, "phone", None),
"address": getattr(b, "address", None),
}
except Exception:
business_name = business_name or ""
# فونت فارسی (YekanBakhFaNum) مشابه فاکتورها
fa_font_url_regular = None
fa_font_url_bold = None
try:
from pathlib import Path
project_root = Path(__file__).resolve().parents[4]
fonts_dir = project_root / "hesabixUI" / "hesabix_ui" / "assets" / "fonts"
# Preferred: YekanBakhFaNum
regular_path = fonts_dir / "YekanBakhFaNum-Regular.ttf"
bold_path = fonts_dir / "YekanBakhFaNum-Bold.ttf"
# Fallbacks: Vazirmatn
regular_fallback = fonts_dir / "Vazirmatn-Regular.ttf"
bold_fallback = fonts_dir / "Vazirmatn-Bold.ttf"
import base64 as _b64
if regular_path.is_file():
_data = regular_path.read_bytes()
fa_font_url_regular = f"data:font/ttf;base64,{_b64.b64encode(_data).decode('ascii')}"
elif regular_fallback.is_file():
_data = regular_fallback.read_bytes()
fa_font_url_regular = f"data:font/ttf;base64,{_b64.b64encode(_data).decode('ascii')}"
if bold_path.is_file():
_data_b = bold_path.read_bytes()
fa_font_url_bold = f"data:font/ttf;base64,{_b64.b64encode(_data_b).decode('ascii')}"
elif bold_fallback.is_file():
_data_b = bold_fallback.read_bytes()
fa_font_url_bold = f"data:font/ttf;base64,{_b64.b64encode(_data_b).decode('ascii')}"
except Exception:
fa_font_url_regular = None
fa_font_url_bold = None
# Locale و Calendar
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
from app.core.calendar import CalendarConverter, get_calendar_type_from_header
from datetime import datetime as _dt
def _parse_dt(val: str | None) -> _dt | None:
if not val:
return None
try:
# handle possible timezone 'Z'
v = str(val).replace("Z", "+00:00")
return _dt.fromisoformat(v)
except Exception:
return None
calendar_type = get_calendar_type_from_header(request.headers.get("X-Calendar-Type"))
# تاریخ‌های هدر
doc_dt = _parse_dt(item.get("document_date"))
doc_date_g = CalendarConverter.to_gregorian(doc_dt)["date_only"] if doc_dt else ""
doc_date_j = CalendarConverter.to_jalali(doc_dt)["date_only"] if doc_dt else ""
# محاسبه مبلغ کل درصورت نبود
def _as_num(x):
try:
return float(x)
except Exception:
return 0.0
total_amount = item.get("total_amount")
if total_amount is None:
il = item.get("item_lines") or item.get("items") or []
if il:
# اگر amount بود جمع می‌زنیم؛ در غیر اینصورت از debit/credit با توجه به نوع سند
if any("amount" in r for r in il):
total_amount = sum(_as_num(r.get("amount")) for r in il)
else:
if str(item.get("document_type")) == "income":
total_amount = sum(_as_num(r.get("credit")) for r in il)
else:
total_amount = sum(_as_num(r.get("debit")) for r in il)
else:
cps = item.get("counterparty_lines") or item.get("counterparties") or []
if cps:
if any("amount" in r for r in cps):
total_amount = sum(_as_num(r.get("amount")) for r in cps)
else:
if str(item.get("document_type")) == "income":
total_amount = sum(_as_num(r.get("debit")) for r in cps)
else:
total_amount = sum(_as_num(r.get("credit")) for r in cps)
item["total_amount"] = total_amount
# قالب‌بندی تاریخ تراکنش‌های طرف‌حساب
raw_cps = item.get("counterparty_lines") or item.get("counterparties") or []
counterparty_lines_fmt = []
for cp in raw_cps:
tx_raw = (cp.get("transaction_date") or (cp.get("extra_info") or {}).get("transaction_date"))
tx_dt = _parse_dt(tx_raw)
cp_copy = dict(cp)
cp_copy["txn_date_g"] = CalendarConverter.to_gregorian(tx_dt)["date_only"] if tx_dt else ""
cp_copy["txn_date_j"] = CalendarConverter.to_jalali(tx_dt)["date_only"] if tx_dt else ""
counterparty_lines_fmt.append(cp_copy)
# زمینه قالب
try:
from app.services.print_footer_settings import build_print_meta_footer_line
_now_ft = datetime.datetime.now()
cal_str = str(calendar_type) if calendar_type else ("jalali" if is_fa else "gregorian")
if cal_str not in ("jalali", "gregorian"):
cal_str = "jalali" if is_fa else "gregorian"
_footer_text = build_print_meta_footer_line(
db,
business_id,
now=_now_ft,
preparer_name=(item.get("created_by_name") or None),
is_fa=is_fa,
calendar_type=cal_str,
)
except Exception:
_footer_text = ""
template_context = {
"business_id": business_id,
"business_name": business_name,
"business": business_info,
"document": item,
"item_lines": item.get("item_lines") or item.get("items") or [],
"counterparty_lines": raw_cps,
"counterparty_lines_fmt": counterparty_lines_fmt,
"is_fa": is_fa,
"is_jalali": (calendar_type == "jalali"),
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"generated_at": datetime.datetime.now(),
"document_date_jalali": doc_date_j,
"document_date_gregorian": doc_date_g,
"footer_text": _footer_text,
}
# تلاش برای استفاده از قالب سفارشی
resolved_html = None
try:
from app.services.report_template_service import ReportTemplateService
explicit_template_id = None
try:
# پشتیبانی از ?template_id=...
if request.query_params.get("template_id") is not None:
explicit_template_id = int(request.query_params.get("template_id"))
except Exception:
explicit_template_id = None
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="expense_income",
subtype="detail",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# پارامترهای نمایشی
disposition = request.query_params.get("disposition") or "attachment"
paper_size = request.query_params.get("paper_size")
orientation = request.query_params.get("orientation")
# HTML پیش‌فرض
html_content = resolved_html or render_template(
"pdf/expense_income/detail.html",
{
**template_context,
"title_text": ("سند هزینه/درآمد" if is_fa else "Expense/Income"),
"paper_size": paper_size,
"orientation": orientation,
},
)
font_config = FontConfiguration()
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
# نام فایل
def _slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "expense_income"
filename = f"expense_income_{_slugify(str(item.get('code') or document_id))}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"{disposition}; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)