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

7644 lines
279 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
API endpoints برای مدیریت اسناد حسابداری (General Accounting Documents)
"""
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, Request, Body, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from sqlalchemy import and_
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_access, require_business_management_dep, require_business_permission_dep, require_business_permission_by_entity_dep
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.core.datetime_utils import (
export_filename_timestamp,
format_generated_at_for_pdf,
resolve_calendar_type_for_request,
)
from app.core.cache import get_cache
from app.core.response_cache import cache_response
from app.services.document_service import (
list_documents,
get_document,
delete_document,
delete_multiple_documents,
get_document_types_summary,
export_documents_excel,
create_manual_document,
update_manual_document,
)
from app.services.invoice_service import get_daily_sales_report, get_monthly_sales_report, get_top_customers_report, get_daily_purchases_report, get_top_suppliers_report, get_materials_consumption_report, get_production_report
from app.services.trial_balance_service import get_trial_balance_report
from app.services.balance_sheet_service import get_balance_sheet_report
from app.services.balance_sheet_export_service import balance_sheet_excel_response, balance_sheet_pdf_response
from app.services.financial_package_service import get_financial_package_report
from app.services.financial_package_export_service import (
financial_package_excel_response,
financial_package_pdf_response,
)
from app.services.general_ledger_service import get_general_ledger_report
from app.services.pnl_service import get_pnl_period_report, get_pnl_cumulative_report
from app.services.pnl_export_service import pnl_excel_response, pnl_pdf_response
from app.services.account_review_service import get_accounts_review_report
from app.services.journal_ledger_service import get_journal_ledger_report
from app.services.journal_ledger_electronic_export_service import export_electronic_journal_books
from app.services.general_ledger_electronic_export_service import export_electronic_general_ledger_books
from app.core.cache import get_cache
from app.core.i18n import negotiate_locale
from app.services.pdf.template_renderer import render_template, load_farsi_font_data_uris
from adapters.api.v1.list_query_common import DocumentListQueryBody, document_list_query_to_dict
from adapters.api.v1.schema_models.document import (
CreateManualDocumentRequest,
UpdateManualDocumentRequest,
)
router = APIRouter(tags=["حسابداری"])
@router.post(
"/businesses/{business_id}/documents",
summary="لیست اسناد حسابداری",
description="دریافت لیست تمام اسناد حسابداری (عمومی و اتوماتیک) با فیلتر و صفحه‌بندی",
)
@require_business_access("business_id")
async def list_documents_endpoint(
request: Request,
business_id: int,
body: DocumentListQueryBody,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""لیست اسناد حسابداری با QueryInfo و فیلترهای تخت (document_type، بازه تاریخ، …)."""
query_dict = document_list_query_to_dict(body, request=request)
if query_dict.get("sort_by") is None:
query_dict["sort_by"] = "document_date"
if "sort_desc" not in query_dict or query_dict.get("sort_desc") is None:
query_dict["sort_desc"] = True
# کش نتایج لیست اسناد بر اساس پارامترها
cache = get_cache()
cache_key = None
fiscal_year_id = query_dict.get("fiscal_year_id")
document_type = query_dict.get("document_type")
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"documents_list:{key_hash}"
cached = cache.get(cache_key)
if cached is not None:
return success_response(
data=cached,
request=request,
message="DOCUMENTS_LIST_FETCHED"
)
result = list_documents(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_documents_tag(
key=cache_key,
value=result,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
document_type=document_type,
ttl=60
)
return success_response(
data=result,
request=request,
message="DOCUMENTS_LIST_FETCHED"
)
@router.post(
"/businesses/{business_id}/documents/export/pdf",
summary="خروجی PDF لیست اسناد حسابداری",
description="دریافت فایل PDF لیست اسناد حسابداری با پشتیبانی از قالب سفارشی (documents/list)",
)
@require_business_access("business_id")
async def export_documents_pdf_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی PDF لیست اسناد حسابداری"""
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
# فیلترهایی مشابه export_documents_excel
filters = {}
for key in [
"document_type",
"from_date",
"to_date",
"currency_id",
"is_proforma",
"search",
"search_fields",
"filters",
"project_id",
"person_id",
]:
if key in body:
filters[key] = body[key]
try:
if body.get("fiscal_year_id") is not None:
filters["fiscal_year_id"] = int(body["fiscal_year_id"])
else:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
except Exception:
pass
# دریافت داده‌ها (شامل مرتب‌سازی چندستونه اگر ارسال شده باشد)
_sort_extra: Dict[str, Any] = {"take": body.get("take", 1000), "skip": body.get("skip", 0)}
for _k in ("sort_by", "sort_desc", "sort"):
if _k in body:
_sort_extra[_k] = body[_k]
result = list_documents(db, business_id, {**filters, **_sort_extra})
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_debit", "جمع بدهکار"),
("total_credit", "جمع بستانکار"),
("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:
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 ""
except Exception:
business_name = ""
# Locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
calendar_type = resolve_calendar_type_for_request(request, is_fa)
now = format_generated_at_for_pdf(business_id, calendar_type)
title_text = "لیست اسناد حسابداری" if is_fa else "Documents 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="documents",
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
html_content = resolved_html or render_template(
"pdf/documents/list.html",
{
**template_context,
"title_text": title_text,
"paper_size": paper_size,
"orientation": orientation,
"footer_text": footer_text,
},
)
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
filename = f"documents_{business_id}_{export_filename_timestamp(business_id)}.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(
"/documents/{document_id}",
summary="جزئیات سند حسابداری",
description="دریافت جزئیات کامل یک سند شامل تمام سطرهای سند",
)
async def get_document_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت جزئیات کامل سند"""
result = get_document(db, document_id)
if not result:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Document not found",
http_status=404
)
# بررسی دسترسی
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="DOCUMENT_DETAILS_FETCHED"
)
@router.delete(
"/documents/{document_id}",
summary="حذف سند حسابداری",
description="حذف یک سند حسابداری (فقط اسناد عمومی manual قابل حذف هستند)",
)
async def delete_document_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
حذف سند حسابداری
توجه: فقط اسناد عمومی (manual) قابل حذف هستند.
اسناد اتوماتیک (expense, income, receipt, payment, ...) باید از منبع اصلی حذف شوند.
"""
# دریافت سند برای بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# حذف سند
success = delete_document(db, document_id)
return success_response(
data={"deleted": success, "document_id": document_id},
request=request,
message="DOCUMENT_DELETED"
)
@router.post(
"/documents/bulk-delete",
summary="حذف گروهی اسناد",
description="حذف گروهی اسناد حسابداری (فقط اسناد manual حذف می‌شوند)",
)
async def bulk_delete_documents_endpoint(
request: Request,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
حذف گروهی اسناد
Body:
document_ids: لیست شناسه‌های سند
توجه: اسناد اتوماتیک نادیده گرفته می‌شوند و باید از منبع اصلی حذف شوند.
"""
document_ids = body.get("document_ids", [])
if not document_ids:
raise ApiError(
"INVALID_REQUEST",
"document_ids is required",
http_status=400
)
# بررسی دسترسی برای هر document
# اگر business_id ندارند یا دسترسی ندارند، خطا برمی‌گردانیم
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: accounting_documents.delete for document {doc_id}", http_status=403)
permissions = ctx._normalize_permissions_value(permission_obj.business_permissions)
if "accounting_documents" not in permissions:
raise ApiError("FORBIDDEN", f"Missing permission: accounting_documents.delete for document {doc_id}", http_status=403)
section_perms = permissions.get("accounting_documents", {})
if not section_perms.get("delete", False):
raise ApiError("FORBIDDEN", f"Missing permission: accounting_documents.delete for document {doc_id}", http_status=403)
except ApiError:
raise
except Exception:
continue
result = delete_multiple_documents(db, document_ids)
return success_response(
data=result,
request=request,
message="DOCUMENTS_BULK_DELETED"
)
@router.get(
"/businesses/{business_id}/documents/types-summary",
summary="خلاصه آماری انواع اسناد",
description="دریافت خلاصه آماری تعداد هر نوع سند",
)
@require_business_access("business_id")
async def get_document_types_summary_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت خلاصه آماری انواع اسناد"""
summary = get_document_types_summary(db, business_id)
total = sum(summary.values())
return success_response(
data={"summary": summary, "total": total},
request=request,
message="DOCUMENT_TYPES_SUMMARY_FETCHED"
)
@router.post(
"/businesses/{business_id}/documents/export/excel",
summary="خروجی Excel اسناد",
description="دریافت فایل Excel لیست اسناد حسابداری",
)
@require_business_access("business_id")
async def export_documents_excel_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
خروجی Excel لیست اسناد
Body: فیلترهای مشابه لیست اسناد
"""
filters = {}
# فیلترها
for key in [
"document_type",
"from_date",
"to_date",
"currency_id",
"is_proforma",
"sort_by",
"sort_desc",
"sort",
"search",
"search_fields",
"filters",
"project_id",
"person_id",
]:
if key in body:
filters[key] = body[key]
try:
if body.get("fiscal_year_id") is not None:
filters["fiscal_year_id"] = int(body["fiscal_year_id"])
else:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
except Exception:
pass
excel_data = export_documents_excel(db, business_id, filters)
return Response(
content=excel_data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename=documents_{business_id}.xlsx"
}
)
@router.get(
"/documents/{document_id}/pdf",
summary="PDF یک سند",
description="دریافت فایل PDF یک سند حسابداری",
)
async def get_document_pdf_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
template_id: int | None = None,
):
"""PDF یک سند حسابداری (حداقل اطلاعات ضروری)."""
# بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# رندر با قالب سفارشی (documents/detail) یا خروجی پیش‌فرض
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, re
# اطلاعات کسب‌وکار
business_name = ""
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 ""
except Exception:
business_name = ""
# Locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
from app.core.datetime_utils import (
localize_assumed_utc_naive_for_display,
resolve_display_timezone_name,
)
from app.core.business_calendar import business_now
tz_name = resolve_display_timezone_name(business_id)
now_dt = business_now(business_id).replace(tzinfo=None)
# فونت فارسی (data URI) برای خروجی بهتر در PDF
fa_font_url_regular, fa_font_url_bold = load_farsi_font_data_uris()
# تقویم انتخابی کاربر (X-Calendar-Type) یا پیش‌فرض بر اساس زبان
try:
from app.core.calendar import CalendarConverter, get_calendar_type_from_header
calendar_type = None
try:
if hasattr(request.state, "calendar_type") and request.state.calendar_type:
calendar_type = request.state.calendar_type
except Exception:
calendar_type = None
if not calendar_type:
cal_header = request.headers.get("X-Calendar-Type")
if cal_header:
calendar_type = get_calendar_type_from_header(cal_header)
else:
calendar_type = "jalali" if is_fa else "gregorian"
except Exception:
calendar_type = "jalali" if is_fa else "gregorian"
def _trim_seconds(s: str | None) -> str | None:
if not s:
return s
# YYYY/MM/DD HH:MM:SS -> YYYY/MM/DD HH:MM
# YYYY-MM-DD HH:MM:SS -> YYYY-MM-DD HH:MM
if len(s) >= 3 and s[-3] == ":" and s[-2:].isdigit():
return s[:-3]
return s
formatted_now = None
try:
formatted_now = CalendarConverter.format_datetime(now_dt, calendar_type)
except Exception:
formatted_now = None
generated_at_display = _trim_seconds(
(formatted_now or {}).get("formatted") if formatted_now else None
) or now_dt.strftime("%Y-%m-%d %H:%M")
# تاریخ سند (شمسی/میلادی بر اساس تقویم انتخابی)
document_date_jalali = None
document_date_display = None
if doc.get("document_date"):
try:
dt = datetime.datetime.fromisoformat(str(doc.get("document_date")).replace("Z", "+00:00"))
# برای سازگاری با خروجی قبلی
try:
formatted_j = CalendarConverter.format_datetime(dt, "jalali")
document_date_jalali = formatted_j.get("date_only") or formatted_j.get("formatted")
except Exception:
document_date_jalali = None
formatted_sel = CalendarConverter.format_datetime(dt, calendar_type)
document_date_display = formatted_sel.get("date_only") or formatted_sel.get("formatted")
except Exception:
document_date_display = str(doc.get("document_date"))
# زمان ثبت سند (registered_at) بر اساس تقویم انتخابی
registered_at_display = None
reg_raw = doc.get("registered_at")
if reg_raw:
try:
if isinstance(reg_raw, datetime.datetime):
reg_dt = reg_raw
else:
reg_dt = datetime.datetime.fromisoformat(str(reg_raw).replace("Z", "+00:00"))
if reg_dt.tzinfo is not None:
reg_dt = reg_dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
reg_dt = localize_assumed_utc_naive_for_display(reg_dt, tz_name)
reg_fmt = CalendarConverter.format_datetime(reg_dt, calendar_type)
registered_at_display = _trim_seconds(reg_fmt.get("formatted"))
except Exception:
registered_at_display = str(reg_raw)
# جمع‌آوری اطلاعات assets (مثلاً لوگو کسب‌وکار)
business_logo = None
try:
from adapters.db.models.business import Business
b = db.query(Business).filter(Business.id == business_id).first()
if b and hasattr(b, 'logo_url') and b.logo_url:
business_logo = b.logo_url
except Exception:
pass
# امضای تهیه‌کننده (کاربری که سند را ایجاد کرده)
prepared_by_signature_data_uri = None
try:
import base64
from uuid import UUID
from adapters.db.models.user import User
from app.services.file_storage_service import FileStorageService
created_by_user_id = doc.get("created_by_user_id")
if created_by_user_id:
u = db.query(User).filter(User.id == int(created_by_user_id)).first()
else:
u = None
sig_file_id = getattr(u, "signature_file_id", None) if u else None
if sig_file_id:
storage = FileStorageService(db)
try:
file_data = await storage.download_file(UUID(str(sig_file_id)))
content: bytes = file_data.get("content") or b""
if content:
mime = file_data.get("mime_type") or "image/png"
prepared_by_signature_data_uri = (
f"data:{mime};base64,{base64.b64encode(content).decode('ascii')}"
)
except Exception:
prepared_by_signature_data_uri = None
except Exception:
prepared_by_signature_data_uri = None
# غنی‌سازی خطوط سند برای PDF:
# - ساخت مسیر حساب از ریشه تا حساب فعلی (برای راهنمایی حسابدار)
try:
from adapters.db.models.account import Account
raw_lines = doc.get("lines", []) or []
account_ids: set[int] = set()
for ln in raw_lines:
try:
aid = ln.get("account_id")
if aid is not None:
account_ids.add(int(aid))
except Exception:
continue
nodes: dict[int, dict[str, Any]] = {}
missing: set[int] = set(account_ids)
# Load all needed accounts and their ancestors (iteratively) to avoid N+1
safety_iters = 0
while missing and safety_iters < 50:
safety_iters += 1
rows = (
db.query(Account.id, Account.code, Account.name, Account.parent_id)
.filter(Account.id.in_(list(missing)))
.all()
)
missing = set()
for r in rows:
nodes[int(r.id)] = {
"id": int(r.id),
"code": (r.code or "").strip(),
"name": (r.name or "").strip(),
"parent_id": int(r.parent_id) if r.parent_id else None,
}
# add parents not loaded yet
for n in list(nodes.values()):
pid = n.get("parent_id")
if pid and pid not in nodes:
missing.add(int(pid))
_path_memo: dict[int, list[dict[str, Any]]] = {}
def _account_chain(aid: int) -> list[dict[str, Any]]:
if aid in _path_memo:
return _path_memo[aid]
chain: list[dict[str, Any]] = []
seen: set[int] = set()
cur = aid
depth = 0
while cur and cur not in seen and depth < 30:
depth += 1
seen.add(cur)
n = nodes.get(cur)
if not n:
break
chain.append(n)
cur = n.get("parent_id")
chain = list(reversed(chain))
_path_memo[aid] = chain
return chain
def _fmt_node(n: dict[str, Any]) -> str:
code = (n.get("code") or "").strip()
name = (n.get("name") or "").strip()
if code and name:
return f"{code} {name}"
return code or name or "-"
for ln in raw_lines:
try:
aid = ln.get("account_id")
if aid is None:
continue
aid_i = int(aid)
chain = _account_chain(aid_i)
if not chain or len(chain) <= 1:
ln["account_tree_ancestors"] = ""
else:
ancestors = chain[:-1]
ln["account_tree_ancestors"] = " / ".join([_fmt_node(n) for n in ancestors])
except Exception:
ln["account_tree_ancestors"] = ""
except Exception:
# اگر هرگونه خطایی در ساخت مسیر حساب رخ دهد، PDF همچنان باید تولید شود
pass
# کانتکست قالب (حداقل اطلاعات ضروری برای حسابداری)
template_context = {
"business_id": business_id,
"business_name": business_name,
"document": doc,
"lines": doc.get("lines", []),
"code": doc.get("code"),
"document_type": doc.get("document_type"),
"document_date": doc.get("document_date"),
"document_date_jalali": document_date_jalali,
"document_date_display": document_date_display,
"description": doc.get("description"),
"fiscal_year_title": doc.get("fiscal_year_title"),
"currency_code": doc.get("currency_code"),
"currency_symbol": doc.get("currency_symbol"),
"created_by_name": doc.get("created_by_name"),
"created_by_user_id": doc.get("created_by_user_id"),
"registered_at": doc.get("registered_at"),
"registered_at_display": registered_at_display,
"project_name": doc.get("project_name"),
"total_debit": doc.get("total_debit"),
"total_credit": doc.get("total_credit"),
"lines_count": doc.get("lines_count"),
"generated_at": generated_at_display,
"generated_at_display": generated_at_display,
"is_fa": is_fa,
"calendar_type": calendar_type,
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"assets": {
"images": {
"logo": business_logo or "",
}
},
"prepared_by_signature_data_uri": prepared_by_signature_data_uri,
}
# تلاش برای رندر با قالب سفارشی
resolved_html = None
try:
from app.services.report_template_service import ReportTemplateService
explicit_template_id = None
try:
if template_id is not None:
explicit_template_id = int(template_id)
# همچنین می‌توان از query parameter استفاده کرد
elif request.query_params.get("template_id"):
explicit_template_id = int(request.query_params.get("template_id"))
except (ValueError, TypeError):
explicit_template_id = None
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="documents",
subtype="detail",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception as e:
# اگر خطای قالب باشد، لاگ می‌کنیم اما به قالب پیش‌فرض می‌رویم
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Custom template rendering failed, using default: {e}", exc_info=True)
resolved_html = None
# پیش‌فرض: قالب فایل + پارامترها
try:
qp = request.query_params
paper_size = qp.get("paper_size")
orientation = qp.get("orientation")
disposition = qp.get("disposition") or "attachment"
except Exception:
paper_size = None
orientation = None
disposition = "attachment"
if not orientation:
# برای سند حسابداری (جدول عریض) پیش‌فرض landscape مناسب‌تر است
orientation = "landscape"
base_footer = (
f"{'سند' if is_fa else 'Document'}: {doc.get('code') or '-'}"
+ (f" • {business_name}" if business_name else "")
)
try:
from app.services.print_footer_settings import (
build_print_meta_footer_line,
merge_document_code_footer_with_meta,
)
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"
meta_ft = build_print_meta_footer_line(
db,
business_id,
now=now_dt,
preparer_name=(doc.get("created_by_name") or None),
is_fa=is_fa,
calendar_type=cal_str,
)
footer_text = merge_document_code_footer_with_meta(
base_footer=base_footer,
meta_line=meta_ft,
)
except Exception:
footer_text = base_footer
html_content = resolved_html or render_template(
"pdf/documents/detail.html",
{
**template_context,
"title_text": doc.get("document_type_name") or ("سند" if is_fa else "Document"),
"paper_size": paper_size,
"orientation": orientation,
"footer_text": footer_text,
},
)
# تولید PDF با پیکربندی فونت
def get_font_config():
"""پیکربندی فونت برای PDF فارسی"""
return FontConfiguration()
try:
pdf_bytes = HTML(string=html_content).write_pdf(font_config=get_font_config())
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"PDF generation failed: {e}", exc_info=True)
raise ApiError("PDF_GENERATION_ERROR", "خطا در تولید فایل PDF", http_status=500)
def _slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "document"
filename = f"document_{_slugify(doc.get('code'))}_{export_filename_timestamp(business_id)}.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.post(
"/businesses/{business_id}/documents/manual",
summary="ایجاد سند حسابداری دستی",
description="ایجاد یک سند حسابداری دستی جدید با سطرهای مورد نظر",
)
@require_business_access("business_id")
async def create_manual_document_endpoint(
request: Request,
business_id: int,
body: CreateManualDocumentRequest,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("accounting_documents", "add")),
):
"""
ایجاد سند حسابداری دستی
Body:
- code: کد سند (اختیاری - خودکار تولید می‌شود)
- document_date: تاریخ سند
- fiscal_year_id: شناسه سال مالی (اختیاری - اگر نباشد، سال مالی فعال استفاده می‌شود)
- currency_id: شناسه ارز
- is_proforma: پیش‌فاکتور یا قطعی
- description: توضیحات سند
- lines: سطرهای سند (حداقل 2 سطر)
- extra_info: اطلاعات اضافی
نکته: اگر fiscal_year_id ارسال نشود، سیستم به ترتیب زیر عمل می‌کند:
1. از X-Fiscal-Year-ID header می‌خواند
2. سال مالی فعال (is_last=True) را انتخاب می‌کند
3. اگر سال مالی فعال نداشت، خطا برمی‌گرداند
اعتبارسنجی‌ها:
- سند باید متوازن باشد (مجموع بدهکار = مجموع بستانکار)
- حداقل 2 سطر داشته باشد
- هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر)
"""
# الزام: ایجاد سند دستی فقط در سال مالی جاری مجاز است.
# سال مالی جاری = FiscalYear.is_last == True
from adapters.db.models.fiscal_year import FiscalYear
active_fy = db.query(FiscalYear).filter(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True # noqa: E712
).first()
if not active_fy:
raise ApiError(
"FISCAL_YEAR_REQUIRED",
"سال مالی جاری برای این کسب‌وکار یافت نشد. ابتدا سال مالی ایجاد/فعال کنید.",
http_status=400,
)
# اگر کاربر تلاش کند سال مالی دیگری را از طریق body/header تحمیل کند، خطا بده.
requested_fy = body.fiscal_year_id
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
header_fy = int(fy_header) if fy_header else None
except Exception:
header_fy = None
if requested_fy is not None and int(requested_fy) != int(active_fy.id):
raise ApiError(
"FISCAL_YEAR_NOT_CURRENT",
"ثبت سند فقط در سال مالی جاری مجاز است.",
http_status=400,
)
if header_fy is not None and int(header_fy) != int(active_fy.id):
raise ApiError(
"FISCAL_YEAR_NOT_CURRENT",
"ثبت سند فقط در سال مالی جاری مجاز است.",
http_status=400,
)
fiscal_year_id = active_fy.id
# تبدیل Pydantic model به dict
data = body.model_dump()
data["lines"] = [line.model_dump() for line in body.lines]
# ایجاد سند
result = create_manual_document(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
user_id=ctx.get_user_id(),
data=data,
)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="MANUAL_DOCUMENT_CREATED"
)
@router.put(
"/documents/{document_id}",
summary="ویرایش سند حسابداری دستی",
description="ویرایش یک سند حسابداری دستی (فقط اسناد manual قابل ویرایش هستند)",
)
async def update_manual_document_endpoint(
request: Request,
document_id: int,
body: UpdateManualDocumentRequest,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("accounting_documents", "edit", Document, "document_id")),
):
"""
ویرایش سند حسابداری دستی
Body:
- code: کد سند
- document_date: تاریخ سند
- currency_id: شناسه ارز
- is_proforma: پیش‌فاکتور یا قطعی
- description: توضیحات سند
- lines: سطرهای سند (اختیاری - اگر ارسال شود جایگزین سطرهای قبلی می‌شود)
- extra_info: اطلاعات اضافی
توجه:
- فقط اسناد manual قابل ویرایش هستند
- اسناد اتوماتیک باید از منبع اصلی ویرایش شوند
"""
# بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# الزام: ویرایش سند دستی فقط در سال مالی جاری مجاز است
from adapters.db.models.fiscal_year import FiscalYear
active_fy = db.query(FiscalYear).filter(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True # noqa: E712
).first()
if not active_fy:
raise ApiError(
"FISCAL_YEAR_REQUIRED",
"سال مالی جاری برای این کسب‌وکار یافت نشد.",
http_status=400,
)
doc_fy_id = doc.get("fiscal_year_id")
if doc_fy_id is not None and int(doc_fy_id) != int(active_fy.id):
raise ApiError(
"FISCAL_YEAR_NOT_CURRENT",
"ویرایش سند فقط در سال مالی جاری مجاز است.",
http_status=400,
)
# تبدیل Pydantic model به dict (فقط فیلدهای set شده)
data = body.model_dump(exclude_unset=True)
if "lines" in data and data["lines"] is not None:
data["lines"] = [line.model_dump() for line in body.lines]
# ویرایش سند
result = update_manual_document(
db=db,
document_id=document_id,
data=data,
)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="MANUAL_DOCUMENT_UPDATED"
)
@router.post(
"/businesses/{business_id}/reports/cash-flow",
summary="صورت جریان وجوه نقد",
description="گردش حساب‌های نقدی/بانک/تنخواه با قرارداد چندارزی (بدون فیلتر = معادل پایه)",
)
@require_business_access("business_id")
async def cash_flow_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
from app.services.cash_flow_report_service import get_cash_flow_report
fiscal_year_id = body.get("fiscal_year_id")
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (ValueError, TypeError):
fiscal_year_id = None
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
result = get_cash_flow_report(
db,
business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=body.get("date_from"),
date_to=body.get("date_to"),
include_indirect=bool(body.get("include_indirect", True)),
)
return success_response(data=result, request=request, message="گزارش جریان وجوه دریافت شد")
@router.post(
"/businesses/{business_id}/reports/fx-revaluation",
summary="گزارش تسعیر ارز",
description="اسناد تسعیر پایان‌دوره و پیش‌نمایش موقعیت‌های ارزی باز",
)
@require_business_access("business_id")
async def fx_revaluation_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
from app.services.cash_flow_report_service import get_fx_revaluation_report
fiscal_year_id = body.get("fiscal_year_id")
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (ValueError, TypeError):
fiscal_year_id = None
result = get_fx_revaluation_report(
db,
business_id,
fiscal_year_id=fiscal_year_id,
date_from=body.get("date_from"),
date_to=body.get("date_to"),
skip=int(body.get("skip", 0) or 0),
take=int(body.get("take", 50) or 50),
)
return success_response(data=result, request=request, message="گزارش تسعیر ارز دریافت شد")
@router.post(
"/businesses/{business_id}/reports/daily-sales",
summary="گزارش فروش روزانه",
description="گزارش فروش روزانه با گروه‌بندی بر اساس تاریخ",
)
@require_business_access("business_id")
async def daily_sales_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش فروش روزانه"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_daily_sales_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Daily sales report retrieved successfully" if locale != 'fa' else "گزارش فروش روزانه با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/monthly-sales",
summary="گزارش فروش ماهانه",
description="گزارش فروش ماهانه با گروه‌بندی بر اساس ماه",
)
@require_business_access("business_id")
async def monthly_sales_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش فروش ماهانه"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_monthly_sales_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
skip=skip,
take=take,
)
items = result.get('items', [])
# برای گزارش ماهانه، format_datetime_fields اعمال نمی‌شود
# چون month_key یک رشته است و نیازی به فرمت کردن تاریخ نیست
# فقط فیلد date را برای استفاده در frontend نگه می‌داریم
formatted_items = []
for item in items:
formatted_item = dict(item)
# اگر فیلد date وجود دارد و یک datetime یا date object است، به ISO string تبدیل کن
if 'date' in formatted_item and formatted_item['date']:
try:
from datetime import date as date_class, datetime as dt_class
if isinstance(formatted_item['date'], dt_class):
formatted_item['date'] = formatted_item['date'].date().isoformat()
elif isinstance(formatted_item['date'], date_class):
formatted_item['date'] = formatted_item['date'].isoformat()
except Exception:
pass
formatted_items.append(formatted_item)
result['items'] = formatted_items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Monthly sales report retrieved successfully" if locale != 'fa' else "گزارش فروش ماهانه با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/top-customers",
summary="گزارش برترین مشتریان",
description="گزارش برترین مشتریان بر اساس مبلغ فروش",
)
@require_business_access("business_id")
async def top_customers_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش برترین مشتریان"""
# Extract filters
fiscal_year_id = body.get('fiscal_year_id')
currency_id = body.get('currency_id')
date_from = body.get('date_from')
date_to = body.get('date_to')
limit = body.get('limit')
# Convert fiscal_year_id
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (ValueError, TypeError):
fiscal_year_id = None
# Convert currency_id
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Convert limit
if limit is not None:
try:
limit = int(limit)
if limit < 1:
limit = None
except (ValueError, TypeError):
limit = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_top_customers_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
limit=limit,
skip=skip,
take=take,
)
items = result.get('items', [])
# برای گزارش برترین مشتریان، format_datetime_fields روی فیلد last_sale_date اعمال می‌شود
formatted_items = []
for item in items:
formatted_item = dict(item)
# اگر فیلد last_sale_date وجود دارد، آن را فرمت کن
if 'last_sale_date' in formatted_item and formatted_item['last_sale_date']:
try:
from datetime import date as date_class
if isinstance(formatted_item['last_sale_date'], str):
date_obj = date_class.fromisoformat(formatted_item['last_sale_date'])
formatted_dict = format_datetime_fields({'date': date_obj}, request)
formatted_item['last_sale_date'] = formatted_dict.get('date', formatted_item['last_sale_date'])
except Exception:
pass
formatted_items.append(formatted_item)
result['items'] = formatted_items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Top customers report retrieved successfully" if locale != 'fa' else "گزارش برترین مشتریان با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/top-suppliers",
summary="گزارش برترین تامین‌کنندگان",
description="گزارش برترین تامین‌کنندگان بر اساس مبلغ خرید",
)
@require_business_access("business_id")
async def top_suppliers_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش برترین تامین‌کنندگان"""
# Extract filters
fiscal_year_id = body.get('fiscal_year_id')
currency_id = body.get('currency_id')
date_from = body.get('date_from')
date_to = body.get('date_to')
limit = body.get('limit')
# Convert fiscal_year_id
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (ValueError, TypeError):
fiscal_year_id = None
# Convert currency_id
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Get fiscal year from header if not in body
fy_header = request.headers.get('X-Fiscal-Year-ID')
if not fiscal_year_id and fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
# Convert limit
if limit is not None:
try:
limit = int(limit)
if limit < 1:
limit = None
except (ValueError, TypeError):
limit = None
result = get_top_suppliers_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
limit=limit,
skip=skip,
take=take,
)
items = result.get('items', [])
# برای گزارش برترین تامین‌کنندگان، format_datetime_fields روی فیلد last_purchase_date اعمال می‌شود
formatted_items = []
for item in items:
formatted_item = dict(item)
# اگر فیلد last_purchase_date وجود دارد، آن را فرمت کن
if 'last_purchase_date' in formatted_item and formatted_item['last_purchase_date']:
try:
from datetime import date as date_class
if isinstance(formatted_item['last_purchase_date'], str):
date_obj = date_class.fromisoformat(formatted_item['last_purchase_date'])
formatted_dict = format_datetime_fields({'date': date_obj}, request)
formatted_item['last_purchase_date'] = formatted_dict.get('date', formatted_item['last_purchase_date'])
except Exception:
pass
formatted_items.append(formatted_item)
result['items'] = formatted_items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Top suppliers report retrieved successfully" if locale != 'fa' else "گزارش برترین تامین‌کنندگان با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/top-suppliers/export/excel",
summary="خروجی Excel گزارش برترین تامین‌کنندگان",
description="خروجی Excel گزارش برترین تامین‌کنندگان با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_top_suppliers_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی Excel گزارش برترین تامین‌کنندگان"""
import io
import json
import datetime
import re
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from fastapi.responses import Response
from adapters.db.models.business import Business
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# Extract filters
fiscal_year_id = body.get('fiscal_year_id')
currency_id = body.get('currency_id')
date_from = body.get('date_from')
date_to = body.get('date_to')
limit = body.get('limit')
# Convert fiscal_year_id
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (ValueError, TypeError):
fiscal_year_id = None
# Convert currency_id
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Get fiscal year from header if not in body
fy_header = request.headers.get('X-Fiscal-Year-ID')
if not fiscal_year_id and fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
# Convert limit
if limit is not None:
try:
limit = int(limit)
if limit < 1:
limit = None
except (ValueError, TypeError):
limit = None
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_top_suppliers_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
limit=limit,
skip=0,
take=max_export_records,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Get calendar type
calendar_type = "gregorian"
if hasattr(request.state, 'calendar_type'):
calendar_type = request.state.calendar_type
# Helper function to format date based on calendar type
def format_date_for_export(item_dict: dict, date_key: str) -> str:
"""Format date based on calendar type (date only, no time)"""
from app.core.calendar import CalendarConverter
# First check if there's a _formatted field (from format_datetime_fields)
formatted_key = f"{date_key}_formatted"
if formatted_key in item_dict:
formatted_value = item_dict.get(formatted_key)
if isinstance(formatted_value, dict):
date_only = formatted_value.get("date_only")
if date_only:
return str(date_only)
formatted = formatted_value.get("formatted", "")
if formatted:
# Extract date part only (remove time)
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# Get the main field value
value = item_dict.get(date_key)
if value is None:
return ""
# If it's a dict (from _formatted field), use date_only
if isinstance(value, dict):
date_only = value.get("date_only")
if date_only:
return str(date_only)
formatted = value.get("formatted", "")
if formatted:
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# If it's a datetime object, format it based on calendar type
if isinstance(value, datetime.datetime):
try:
formatted = CalendarConverter.format_datetime(value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a date object, format it based on calendar type
if isinstance(value, datetime.date):
try:
dt_value = datetime.datetime.combine(value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a string, check if it's already formatted (contains / separator for Jalali)
if isinstance(value, str):
# Check if it looks like a Jalali date (contains / and has YYYY/MM/DD format)
if '/' in value and (len(value.split('/')) == 3):
# Might be already formatted, but check if it's ISO format (YYYY-MM-DD) or Jalali (YYYY/MM/DD)
if '-' in value:
# ISO format (YYYY-MM-DD), parse and format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
else:
# Might be Jalali format (YYYY/MM/DD), return as is but remove time if exists
if ' ' in value:
return value.split(' ')[0]
return value
else:
# Try to parse as ISO format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
# If parsing fails, return as is (might already be formatted)
if ' ' in value or 'T' in value:
date_part = value.split(' ')[0].split('T')[0]
return date_part
return value
# Fallback
return str(value) if value else ""
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Get locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == 'fa'
# Prepare headers based on export_columns
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
default_columns = [
('person_code', 'کد تامین‌کننده' if is_fa else 'Supplier Code'),
('person_name', 'نام تامین‌کننده' if is_fa else 'Supplier Name'),
('invoice_count', 'تعداد فاکتور' if is_fa else 'Invoice Count'),
('total_purchases', 'جمع خرید' if is_fa else 'Total Purchases'),
('last_purchase_date', 'آخرین تاریخ خرید' if is_fa else 'Last Purchase Date'),
]
for key, label in default_columns:
keys.append(key)
headers.append(label)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "گزارش برترین تامین‌کنندگان" if is_fa else "Top Suppliers Report"
# RTL handling for Persian
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Write header row
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(keys, 1):
value = item.get(key, "")
# Format numbers
if key in ['invoice_count', 'total_purchases'] and value:
try:
num_value = float(value) if not isinstance(value, (int, float)) else value
value = num_value
except (ValueError, TypeError):
pass
# Format dates
if key == 'last_purchase_date' and value:
value = format_date_for_export(item, 'last_purchase_date')
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
# RTL alignment for Persian text and numbers
if locale == 'fa':
if isinstance(value, (int, float)):
cell.alignment = Alignment(horizontal="right")
elif isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in str(value)):
cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
cell_length = len(str(cell.value))
if cell_length > max_length:
max_length = cell_length
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "top_suppliers_report"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/daily-purchases",
summary="گزارش خرید روزانه",
description="گزارش خرید روزانه با گروه‌بندی بر اساس تاریخ",
)
@require_business_access("business_id")
async def daily_purchases_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش خرید روزانه"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_daily_purchases_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Daily purchases report retrieved successfully" if locale != 'fa' else "گزارش خرید روزانه با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/daily-purchases/export/excel",
summary="خروجی Excel گزارش خرید روزانه",
description="خروجی Excel گزارش خرید روزانه با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_daily_purchases_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی Excel گزارش خرید روزانه"""
import io
import json
import datetime
import re
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from fastapi.responses import Response
from adapters.db.models.business import Business
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
if not fiscal_year_id:
from adapters.db.models.fiscal_year import FiscalYear
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
fiscal_year_id = fiscal_year.id
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_daily_purchases_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
skip=0,
take=max_export_records,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Get calendar type
calendar_type = "gregorian"
if hasattr(request.state, 'calendar_type'):
calendar_type = request.state.calendar_type
# Helper function to format date based on calendar type
def format_date_for_export(item_dict: dict, date_key: str) -> str:
"""Format date based on calendar type (date only, no time)"""
from app.core.calendar import CalendarConverter
# First check if there's a _formatted field (from format_datetime_fields)
formatted_key = f"{date_key}_formatted"
if formatted_key in item_dict:
formatted_value = item_dict.get(formatted_key)
if isinstance(formatted_value, dict):
date_only = formatted_value.get("date_only")
if date_only:
return str(date_only)
formatted = formatted_value.get("formatted", "")
if formatted:
# Extract date part only (remove time)
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# Get the main field value
value = item_dict.get(date_key)
if value is None:
return ""
# If it's a dict (from _formatted field), use date_only
if isinstance(value, dict):
date_only = value.get("date_only")
if date_only:
return str(date_only)
formatted = value.get("formatted", "")
if formatted:
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# If it's a datetime object, format it based on calendar type
if isinstance(value, datetime.datetime):
try:
formatted = CalendarConverter.format_datetime(value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a date object, format it based on calendar type
if isinstance(value, datetime.date):
try:
dt_value = datetime.datetime.combine(value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a string, check if it's already formatted (contains / separator for Jalali)
if isinstance(value, str):
# Check if it looks like a Jalali date (contains / and has YYYY/MM/DD format)
if '/' in value and (len(value.split('/')) == 3):
# Might be already formatted, but check if it's ISO format (YYYY-MM-DD) or Jalali (YYYY/MM/DD)
if '-' in value:
# ISO format (YYYY-MM-DD), parse and format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
else:
# Might be Jalali format (YYYY/MM/DD), return as is but remove time if exists
if ' ' in value:
return value.split(' ')[0]
return value
else:
# Try to parse as ISO format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
# If parsing fails, return as is (might already be formatted)
if ' ' in value or 'T' in value:
date_part = value.split(' ')[0].split('T')[0]
return date_part
return value
# Fallback
return str(value) if value else ""
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Get locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == 'fa'
# Prepare headers based on export_columns
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
default_columns = [
('date', 'تاریخ' if is_fa else 'Date'),
('invoice_count', 'تعداد فاکتور' if is_fa else 'Invoice Count'),
('total_gross', 'جمع کل' if is_fa else 'Total Gross'),
('total_discount', 'جمع تخفیف' if is_fa else 'Total Discount'),
('total_tax', 'جمع مالیات' if is_fa else 'Total Tax'),
('total_net', 'جمع خالص' if is_fa else 'Total Net'),
]
for key, label in default_columns:
keys.append(key)
headers.append(label)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "گزارش خرید روزانه" if is_fa else "Daily Purchases Report"
# RTL handling for Persian
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Write header row
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(keys, 1):
value = item.get(key, "")
# Format numbers
if key in ['invoice_count', 'total_gross', 'total_discount', 'total_tax', 'total_net'] and value:
try:
num_value = float(value) if not isinstance(value, (int, float)) else value
value = num_value
except (ValueError, TypeError):
pass
# Format dates
if key == 'date' and value:
value = format_date_for_export(item, 'date')
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
# RTL alignment for Persian text and numbers
if locale == 'fa':
if isinstance(value, (int, float)):
cell.alignment = Alignment(horizontal="right")
elif isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in str(value)):
cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
cell_length = len(str(cell.value))
if cell_length > max_length:
max_length = cell_length
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "daily_purchases_report"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/daily-purchases/export/pdf",
summary="خروجی PDF گزارش خرید روزانه",
description="خروجی PDF گزارش خرید روزانه با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_daily_purchases_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش خرید روزانه"""
import datetime
import json
import re
from fastapi.responses import Response
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from app.core.calendar import CalendarConverter
from app.services.pdf.template_renderer import render_template
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت calendar_type
calendar_type = "gregorian"
try:
if hasattr(request.state, "calendar_type") and request.state.calendar_type:
calendar_type = request.state.calendar_type
except Exception:
calendar_type = "gregorian"
# دریافت سال مالی از header یا body (fallback: آخرین سال مالی)
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
fiscal_year_id = None
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
fiscal_year_id = fiscal_year_id
if not fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True, # noqa: E712
)
).first()
if fiscal_year:
fiscal_year_id = fiscal_year.id
# پارامترها
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# داده‌ها (بدون pagination)
max_export_records = 10000
result = get_daily_purchases_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
skip=0,
take=max_export_records,
)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# تاریخ نمایشی برای PDF
def _date_text(item_dict: dict) -> str:
try:
formatted_key = "date_formatted"
if formatted_key in item_dict and isinstance(item_dict.get(formatted_key), dict):
d = item_dict.get(formatted_key) or {}
return str(d.get("date_only") or d.get("formatted") or "").split(" ")[0].split("T")[0]
except Exception:
pass
value = item_dict.get("date")
if value is None:
return ""
try:
# CalendarConverter.format_datetime expects datetime
if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime):
value = datetime.datetime.combine(value, datetime.datetime.min.time())
if isinstance(value, str):
s = value.strip()
if not s:
return ""
if "T" in s:
value = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
else:
d = datetime.date.fromisoformat(s)
value = datetime.datetime.combine(d, datetime.datetime.min.time())
if isinstance(value, datetime.datetime):
out = CalendarConverter.format_datetime(value, calendar_type)
return str(out.get("date_only") or out.get("formatted") or "").split(" ")[0]
except Exception:
pass
return str(value).split(" ")[0].split("T")[0]
for it in items:
try:
it["date_text"] = _date_text(it)
except Exception:
pass
# انتخاب سطرها
selected_only = bool(body.get("selected_only", False))
selected_indices = body.get("selected_indices")
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# ستون‌ها
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
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 = [
("date", "تاریخ" if is_fa else "Date"),
("invoice_count", "تعداد فاکتور" if is_fa else "Invoice Count"),
("total_gross", "جمع کل" if is_fa else "Total Gross"),
("total_discount", "جمع تخفیف" if is_fa else "Total Discount"),
("total_tax", "جمع مالیات" if is_fa else "Total Tax"),
("total_net", "جمع خالص" if is_fa else "Total Net"),
]
for k, label in default_columns:
keys.append(k)
headers.append(label)
numeric_keys = {"invoice_count", "total_gross", "total_discount", "total_tax", "total_net"}
# اطلاعات کسب‌وکار / سال مالی
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 = ""
fiscal_year_name = ""
try:
if fiscal_year_id:
fy = db.query(FiscalYear).filter(
and_(
FiscalYear.id == fiscal_year_id,
FiscalYear.business_id == business_id,
)
).first()
if fy is not None:
fiscal_year_name = getattr(fy, "name", "") or ""
except Exception:
fiscal_year_name = ""
# فونت فارسی (data-uri)
fa_font_url_regular = None
fa_font_url_bold = None
try:
if is_fa:
from app.services.pdf.template_renderer import load_farsi_font_data_uris
fa_font_url_regular, fa_font_url_bold = load_farsi_font_data_uris()
except Exception:
fa_font_url_regular = None
fa_font_url_bold = None
# disposition / paper settings
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:
paper_size = None
orientation = None
generated_at = format_generated_at_for_pdf(business_id, calendar_type)
title_text = "گزارش خرید روزانه" if is_fa else "Daily Purchases Report"
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=generated_at,
preparer_name=pn or None,
is_fa=is_fa,
)
except Exception:
footer_text = ""
final_html = render_template(
"pdf/daily_purchases/list.html",
{
"is_fa": is_fa,
"title_text": title_text,
"footer_text": footer_text,
"paper_size": paper_size,
"orientation": orientation,
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"business_name": business_name,
"fiscal_year_name": fiscal_year_name,
"generated_at": generated_at,
"headers": headers,
"keys": keys,
"numeric_keys": numeric_keys,
"items": items,
"date_from": date_from,
"date_to": date_to,
},
)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=FontConfiguration())
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "daily_purchases_report"
if business_name:
base += f"_{slugify(business_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.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.post(
"/businesses/{business_id}/reports/materials-consumption",
summary="گزارش مصرف مواد",
description="گزارش مصرف مواد از فاکتورهای تولید",
)
@require_business_access("business_id")
async def materials_consumption_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش مصرف مواد از فاکتورهای تولید"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
product_id = body.get('product_id')
warehouse_id = body.get('warehouse_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if product_id is not None:
try:
product_id = int(product_id)
except (ValueError, TypeError):
product_id = None
if warehouse_id is not None:
try:
warehouse_id = int(warehouse_id)
except (ValueError, TypeError):
warehouse_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_materials_consumption_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
product_id=product_id,
warehouse_id=warehouse_id,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Materials consumption report retrieved successfully" if locale != 'fa' else "گزارش مصرف مواد با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/production",
summary="گزارش تولید",
description="گزارش تولید (کالاهای ساخته شده) از فاکتورهای تولید",
)
@require_business_access("business_id")
async def production_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش تولید (کالاهای ساخته شده) از فاکتورهای تولید"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
product_id = body.get('product_id')
warehouse_id = body.get('warehouse_id')
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if product_id is not None:
try:
product_id = int(product_id)
except (ValueError, TypeError):
product_id = None
if warehouse_id is not None:
try:
warehouse_id = int(warehouse_id)
except (ValueError, TypeError):
warehouse_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_production_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
product_id=product_id,
warehouse_id=warehouse_id,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Production report retrieved successfully" if locale != 'fa' else "گزارش تولید با موفقیت دریافت شد",
request=request
)
def _parse_fiscal_year_id(request: Request, body: Dict[str, Any]) -> int | None:
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
return fiscal_year_id
def _parse_trial_balance_body(request: Request, body: Dict[str, Any]) -> Dict[str, Any]:
fiscal_year_id = _parse_fiscal_year_id(request, body)
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
project_id = body.get("project_id")
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
account_ids = body.get("account_ids")
if account_ids is not None and not isinstance(account_ids, list):
account_ids = None
if account_ids:
try:
account_ids = [int(aid) for aid in account_ids if aid is not None]
except (ValueError, TypeError):
account_ids = None
try:
column_mode = int(body.get("column_mode", 8))
if column_mode not in (2, 4, 6, 8):
column_mode = 8
except (ValueError, TypeError):
column_mode = 8
display_mode = body.get("display_mode", "flat")
if display_mode not in ("flat", "tree"):
display_mode = "flat"
try:
account_level = int(body.get("account_level", 4))
if account_level not in (1, 2, 3, 4):
account_level = 4
except (ValueError, TypeError):
account_level = 4
skip = body.get("skip", 0)
take = body.get("take", 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
return {
"fiscal_year_id": fiscal_year_id,
"date_from": body.get("date_from"),
"date_to": body.get("date_to"),
"currency_id": currency_id,
"account_type": body.get("account_type"),
"account_ids": account_ids,
"project_id": project_id,
"include_zero_balance": bool(body.get("include_zero_balance", False)),
"column_mode": column_mode,
"display_mode": display_mode,
"account_level": account_level,
"skip": skip,
"take": take,
}
def _parse_balance_sheet_body(request: Request, body: Dict[str, Any]) -> Dict[str, Any]:
fiscal_year_id = _parse_fiscal_year_id(request, body)
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
project_id = body.get("project_id")
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
try:
account_level = int(body.get("account_level", 4))
if account_level not in (1, 2, 3, 4):
account_level = 4
except (ValueError, TypeError):
account_level = 4
compare_mode = body.get("compare_mode")
if compare_mode is not None:
compare_mode = str(compare_mode).strip() or None
return {
"fiscal_year_id": fiscal_year_id,
"date_from": body.get("date_from"),
"date_to": body.get("date_to"),
"currency_id": currency_id,
"project_id": project_id,
"include_zero_balance": bool(body.get("include_zero_balance", False)),
"account_level": account_level,
"compare_prior_period": bool(body.get("compare_prior_period", False)),
"compare_mode": compare_mode,
"include_base_equivalent": bool(body.get("include_base_equivalent", False)),
}
def _parse_financial_package_body(request: Request, body: Dict[str, Any]) -> Dict[str, Any]:
params = _parse_balance_sheet_body(request, body)
try:
column_mode = int(body.get("column_mode", 8))
if column_mode not in (2, 4, 6, 8):
column_mode = 8
except (ValueError, TypeError):
column_mode = 8
params["column_mode"] = column_mode
return params
@router.post(
"/businesses/{business_id}/reports/trial-balance",
summary="گزارش تراز آزمایشی",
description="گزارش تراز آزمایشی برای حساب‌های حسابداری",
)
@require_business_access("business_id")
async def trial_balance_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش تراز آزمایشی برای حساب‌های حسابداری"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
account_type = body.get('account_type')
account_ids = body.get('account_ids')
include_zero_balance = body.get('include_zero_balance', False)
project_id = body.get('project_id')
column_mode = body.get('column_mode', 8)
display_mode = body.get('display_mode', 'flat')
account_level = body.get('account_level', 4)
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
try:
column_mode = int(column_mode)
if column_mode not in (2, 4, 6, 8):
column_mode = 8
except (ValueError, TypeError):
column_mode = 8
if display_mode not in ('flat', 'tree'):
display_mode = 'flat'
try:
account_level = int(account_level)
if account_level not in (1, 2, 3, 4):
account_level = 4
except (ValueError, TypeError):
account_level = 4
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if account_ids is not None and not isinstance(account_ids, list):
account_ids = None
if account_ids:
try:
account_ids = [int(aid) for aid in account_ids if aid is not None]
except (ValueError, TypeError):
account_ids = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_trial_balance_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
account_ids=account_ids,
project_id=project_id,
include_zero_balance=include_zero_balance,
column_mode=column_mode,
display_mode=display_mode,
account_level=account_level,
skip=skip,
take=take,
include_base_equivalent=bool(body.get("include_base_equivalent", False)),
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Trial balance report retrieved successfully" if locale != 'fa' else "گزارش تراز آزمایشی با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/trial-balance/export/excel",
summary="خروجی Excel گزارش تراز آزمایشی",
description="خروجی Excel گزارش تراز آزمایشی با قابلیت انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_trial_balance_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی Excel گزارش تراز آزمایشی"""
import io
import json
import datetime
import re
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from adapters.db.models.business import Business
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_trial_balance_body(request, body)
fiscal_year_id = params["fiscal_year_id"]
date_from = params["date_from"]
date_to = params["date_to"]
currency_id = params["currency_id"]
account_type = params["account_type"]
account_ids = params["account_ids"]
include_zero_balance = params["include_zero_balance"]
project_id = params["project_id"]
column_mode = params["column_mode"]
display_mode = params["display_mode"]
account_level = params["account_level"]
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_trial_balance_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
account_ids=account_ids,
project_id=project_id,
include_zero_balance=include_zero_balance,
column_mode=column_mode,
display_mode=display_mode,
account_level=account_level,
skip=0,
take=max_export_records,
)
items = result.get('items', []) or result.get('accounts', [])
items = [format_datetime_fields(item, request) for item in items]
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers based on export_columns
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 = [
("account_code", "کد حساب"),
("account_name", "نام حساب"),
("account_type", "نوع حساب"),
("opening_debit", "مانده ابتدای دوره (بدهکار)"),
("opening_credit", "مانده ابتدای دوره (بستانکار)"),
("period_debit", "جمع بدهکار دوره"),
("period_credit", "جمع بستانکار دوره"),
("closing_debit", "مانده انتهای دوره (بدهکار)"),
("closing_credit", "مانده انتهای دوره (بستانکار)"),
]
if items:
for key, label in default_columns:
if key in items[0]:
keys.append(key)
headers.append(label)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Trial Balance"
# Styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4F81BD", end_color="4F81BD", fill_type="solid")
thin = Side(border_style="thin", color="D9D9D9")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
def map_account_type_label(raw_value: object) -> str:
"""
Map account_type codes to user-friendly labels (fa/en) for exports.
Supports both legacy string types and seeded numeric codes stored as strings.
"""
if raw_value is None:
return ""
key = str(raw_value).strip()
if not key:
return ""
mapping_fa = {
# string types (common in services/migrations)
"accounting_document": "حسابداری",
"bank": "بانک",
"cash_register": "صندوق",
"cashdesk": "صندوق",
"cash": "نقد",
"petty_cash": "تنخواه",
"check": "چک",
"person": "شخص",
"product": "کالا",
# numeric types (seeded chart-of-accounts)
"0": "سایر",
"1": "صندوق",
"2": "تنخواه",
"3": "بانک",
"4": "حساب‌های دریافتنی",
"5": "اسناد دریافتنی",
"6": "اسناد در جریان وصول",
"7": "موجودی کالا",
"8": "مالیات ارزش افزوده خرید",
"9": "حساب‌های پرداختنی",
"10": "اسناد پرداختنی",
"11": "مالیات ارزش افزوده فروش",
"12": "مالیات بر درآمد پرداختنی",
"13": "سرمایه اولیه",
"14": "افزایش/کاهش سرمایه",
"15": "اندوخته قانونی",
"16": "برداشت‌ها",
"17": "سهم سود و زیان",
"18": "سود و زیان انباشته",
"19": "بهای تمام‌شده کالای فروش‌رفته",
"20": "برگشت از خرید",
"21": "تخفیفات نقدی خرید",
"22": "فروش کالا",
"23": "برگشت از فروش",
"24": "تخفیفات نقدی فروش",
"25": "درآمد فروش خدمات",
"26": "برگشت از خرید خدمات",
"27": "درآمد اضافه کالا",
"28": "درآمد حمل کالا",
"29": "برگشت از فروش خدمات",
"30": "خرید خدمات",
"31": "هزینه حمل کالا",
"32": "هزینه کسری و ضایعات کالا",
"33": "کارمزد خدمات بانکی",
"34": "کنترل کسری/اضافه کالا",
"35": "خلاصه سود و زیان",
"36": "درآمد تسعیر ارز",
"37": "هزینه تسعیر ارز",
"38": "سود فروش اقساطی",
"39": "سود تحقق‌نیافته فروش اقساطی",
"40": "ذخیره مالیات بر درآمد پرداختنی",
"41": "موجودی کالای در جریان ساخت",
"42": "حقوق و دستمزد پرداختنی",
"43": "سربار تولید پرداختنی",
}
mapping_en = {
# string types
"accounting_document": "Accounting",
"bank": "Bank",
"cash_register": "Cash Register",
"cashdesk": "Cash Register",
"cash": "Cash",
"petty_cash": "Petty Cash",
"check": "Check",
"person": "Person",
"product": "Product",
# numeric types
"0": "Other",
"1": "Cash Register",
"2": "Petty Cash",
"3": "Bank",
"4": "Accounts Receivable",
"5": "Notes Receivable",
"6": "Notes in Collection",
"7": "Inventory",
"8": "Input VAT",
"9": "Accounts Payable",
"10": "Notes Payable",
"11": "Output VAT",
"12": "Income Tax Payable",
"13": "Initial Capital",
"14": "Capital Increase/Decrease",
"15": "Legal Reserve",
"16": "Drawings",
"17": "Profit/Loss Share",
"18": "Retained Earnings",
"19": "Cost of Goods Sold",
"20": "Purchase Returns",
"21": "Purchase Discounts",
"22": "Goods Sales",
"23": "Sales Returns",
"24": "Sales Discounts",
"25": "Service Revenue",
"26": "Service Purchase Returns",
"27": "Inventory Surplus Income",
"28": "Freight Income",
"29": "Service Sales Returns",
"30": "Service Purchases",
"31": "Freight Expense",
"32": "Inventory Shortage/Waste",
"33": "Bank Fees",
"34": "Inventory Variance Control",
"35": "Income Summary",
"36": "FX Gain",
"37": "FX Loss",
"38": "Installment Sales Profit",
"39": "Unearned Installment Profit",
"40": "Provision for Income Tax",
"41": "Work in Process Inventory",
"42": "Salaries Payable",
"43": "Manufacturing Overhead Payable",
}
return (mapping_fa if is_fa else mapping_en).get(key, key)
numeric_keys = {
"opening_debit",
"opening_credit",
"period_debit",
"period_credit",
"closing_debit",
"closing_credit",
}
def format_number_for_export(raw_value: object) -> object:
"""Hide useless .0 for integer-like values (keep real decimals)."""
if raw_value is None:
return ""
if isinstance(raw_value, bool):
return raw_value
if isinstance(raw_value, int):
return raw_value
if isinstance(raw_value, float):
try:
if raw_value.is_integer():
return int(raw_value)
except Exception:
pass
return raw_value
if isinstance(raw_value, str):
s = raw_value.strip()
if not s:
return ""
# Avoid messing with non-numeric strings
try:
f = float(s)
if f.is_integer():
return int(f)
except Exception:
return raw_value
return raw_value
return raw_value
# Header row
ws.append(headers)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col_idx)
cell.font = header_font
cell.fill = header_fill
cell.border = border
cell.alignment = Alignment(horizontal="center", vertical="center")
# Data rows
for item in items:
row = []
for key in keys:
value = item.get(key, "")
if key == "account_type":
value = map_account_type_label(value)
if key in numeric_keys:
value = format_number_for_export(value)
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
row.append(value)
ws.append(row)
# Basic formatting: borders + alignment
for r in range(2, ws.max_row + 1):
for c in range(1, ws.max_column + 1):
cell = ws.cell(row=r, column=c)
cell.border = border
# پیش‌فرض: وسط‌چین
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 60)
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "trial_balance_report"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/trial-balance/export/pdf",
summary="خروجی PDF گزارش تراز آزمایشی",
description="خروجی PDF گزارش تراز آزمایشی با قابلیت انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_trial_balance_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش تراز آزمایشی"""
import json
import datetime
import re
import logging
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from html import escape
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
logger = logging.getLogger(__name__)
debug_font = str(request.headers.get("X-Debug-Pdf-Font", "") or "").strip().lower() in ("1", "true", "yes", "y", "on")
params = _parse_trial_balance_body(request, body)
fiscal_year_id = params["fiscal_year_id"]
date_from = params["date_from"]
date_to = params["date_to"]
currency_id = params["currency_id"]
account_type = params["account_type"]
account_ids = params["account_ids"]
include_zero_balance = params["include_zero_balance"]
project_id = params["project_id"]
column_mode = params["column_mode"]
display_mode = params["display_mode"]
account_level = params["account_level"]
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_trial_balance_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
account_ids=account_ids,
project_id=project_id,
include_zero_balance=include_zero_balance,
column_mode=column_mode,
display_mode=display_mode,
account_level=account_level,
skip=0,
take=max_export_records,
)
items = result.get('items', []) or result.get('accounts', [])
items = [format_datetime_fields(item, request) for item in items]
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers based on export_columns
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 = [
("account_code", "کد حساب"),
("account_name", "نام حساب"),
("account_type", "نوع حساب"),
("opening_debit", "مانده ابتدای دوره (بدهکار)"),
("opening_credit", "مانده ابتدای دوره (بستانکار)"),
("period_debit", "جمع بدهکار دوره"),
("period_credit", "جمع بستانکار دوره"),
("closing_debit", "مانده انتهای دوره (بدهکار)"),
("closing_credit", "مانده انتهای دوره (بستانکار)"),
]
if items:
for key, label in default_columns:
if key in items[0]:
keys.append(key)
headers.append(label)
# Business name
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 = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
html_lang = "fa" if is_fa else "en"
html_dir = "rtl" if is_fa else "ltr"
# Embed Farsi fonts (shared helper)
fa_font_url_regular = None
fa_font_url_bold = None
try:
if is_fa:
from app.services.pdf.template_renderer import load_farsi_font_data_uris
fa_font_url_regular, fa_font_url_bold = load_farsi_font_data_uris()
except Exception:
fa_font_url_regular = None
fa_font_url_bold = None
if debug_font:
logger.info(
"trial_balance_export_pdf: locale=%s is_fa=%s accept_language=%s",
locale,
is_fa,
request.headers.get("Accept-Language"),
)
logger.info(
"trial_balance_export_pdf: font_uris present regular=%s bold=%s (len=%s/%s)",
bool(fa_font_url_regular),
bool(fa_font_url_bold),
len(fa_font_url_regular or ""),
len(fa_font_url_bold or ""),
)
def map_account_type_label(raw_value: object) -> str:
"""Map account_type codes to user-friendly labels (fa/en) for exports."""
if raw_value is None:
return ""
key = str(raw_value).strip()
if not key:
return ""
mapping_fa = {
"accounting_document": "حسابداری",
"bank": "بانک",
"cash_register": "صندوق",
"cashdesk": "صندوق",
"cash": "نقد",
"petty_cash": "تنخواه",
"check": "چک",
"person": "شخص",
"product": "کالا",
"0": "سایر",
"1": "صندوق",
"2": "تنخواه",
"3": "بانک",
"4": "حساب‌های دریافتنی",
"5": "اسناد دریافتنی",
"6": "اسناد در جریان وصول",
"7": "موجودی کالا",
"8": "مالیات ارزش افزوده خرید",
"9": "حساب‌های پرداختنی",
"10": "اسناد پرداختنی",
"11": "مالیات ارزش افزوده فروش",
"12": "مالیات بر درآمد پرداختنی",
"13": "سرمایه اولیه",
"14": "افزایش/کاهش سرمایه",
"15": "اندوخته قانونی",
"16": "برداشت‌ها",
"17": "سهم سود و زیان",
"18": "سود و زیان انباشته",
"19": "بهای تمام‌شده کالای فروش‌رفته",
"20": "برگشت از خرید",
"21": "تخفیفات نقدی خرید",
"22": "فروش کالا",
"23": "برگشت از فروش",
"24": "تخفیفات نقدی فروش",
"25": "درآمد فروش خدمات",
"26": "برگشت از خرید خدمات",
"27": "درآمد اضافه کالا",
"28": "درآمد حمل کالا",
"29": "برگشت از فروش خدمات",
"30": "خرید خدمات",
"31": "هزینه حمل کالا",
"32": "هزینه کسری و ضایعات کالا",
"33": "کارمزد خدمات بانکی",
"34": "کنترل کسری/اضافه کالا",
"35": "خلاصه سود و زیان",
"36": "درآمد تسعیر ارز",
"37": "هزینه تسعیر ارز",
"38": "سود فروش اقساطی",
"39": "سود تحقق‌نیافته فروش اقساطی",
"40": "ذخیره مالیات بر درآمد پرداختنی",
"41": "موجودی کالای در جریان ساخت",
"42": "حقوق و دستمزد پرداختنی",
"43": "سربار تولید پرداختنی",
}
mapping_en = {
"accounting_document": "Accounting",
"bank": "Bank",
"cash_register": "Cash Register",
"cashdesk": "Cash Register",
"cash": "Cash",
"petty_cash": "Petty Cash",
"check": "Check",
"person": "Person",
"product": "Product",
"0": "Other",
"1": "Cash Register",
"2": "Petty Cash",
"3": "Bank",
"4": "Accounts Receivable",
"5": "Notes Receivable",
"6": "Notes in Collection",
"7": "Inventory",
"8": "Input VAT",
"9": "Accounts Payable",
"10": "Notes Payable",
"11": "Output VAT",
"12": "Income Tax Payable",
"13": "Initial Capital",
"14": "Capital Increase/Decrease",
"15": "Legal Reserve",
"16": "Drawings",
"17": "Profit/Loss Share",
"18": "Retained Earnings",
"19": "Cost of Goods Sold",
"20": "Purchase Returns",
"21": "Purchase Discounts",
"22": "Goods Sales",
"23": "Sales Returns",
"24": "Sales Discounts",
"25": "Service Revenue",
"26": "Service Purchase Returns",
"27": "Inventory Surplus Income",
"28": "Freight Income",
"29": "Service Sales Returns",
"30": "Service Purchases",
"31": "Freight Expense",
"32": "Inventory Shortage/Waste",
"33": "Bank Fees",
"34": "Inventory Variance Control",
"35": "Income Summary",
"36": "FX Gain",
"37": "FX Loss",
"38": "Installment Sales Profit",
"39": "Unearned Installment Profit",
"40": "Provision for Income Tax",
"41": "Work in Process Inventory",
"42": "Salaries Payable",
"43": "Manufacturing Overhead Payable",
}
return (mapping_fa if is_fa else mapping_en).get(key, key)
numeric_keys = {
"opening_debit",
"opening_credit",
"period_debit",
"period_credit",
"closing_debit",
"closing_credit",
}
def format_number_for_export(raw_value: object) -> str:
"""Hide useless .0 and add thousand separators for readability."""
if raw_value is None:
return ""
if isinstance(raw_value, bool):
return "1" if raw_value else "0"
def _apply_grouping(num_str: str) -> str:
# group integer part, keep fractional part as-is
s = (num_str or "").strip()
if not s:
return ""
sign = ""
if s.startswith("-"):
sign = "-"
s = s[1:]
if s.startswith("+"):
s = s[1:]
if "." in s:
int_part, frac_part = s.split(".", 1)
else:
int_part, frac_part = s, ""
try:
grouped = f"{int(int_part):,}" if int_part else "0"
except Exception:
# fallback: don't modify if int parsing fails
grouped = int_part
if frac_part:
out = f"{sign}{grouped}.{frac_part}"
else:
out = f"{sign}{grouped}"
# Persian thousands separator if locale is fa
if is_fa:
out = out.replace(",", "٬")
return out
if isinstance(raw_value, int):
return _apply_grouping(str(raw_value))
if isinstance(raw_value, float):
try:
if raw_value.is_integer():
return _apply_grouping(str(int(raw_value)))
except Exception:
pass
# Keep decimals but add grouping to integer part
return _apply_grouping(str(raw_value))
if isinstance(raw_value, str):
s = raw_value.strip()
if not s:
return ""
try:
f = float(s)
if f.is_integer():
return _apply_grouping(str(int(f)))
except Exception:
return s
return _apply_grouping(s)
return str(raw_value)
def format_date_for_report(value: object, calendar: str) -> str:
"""Format a date/datetime/ISO string to selected calendar (date only)."""
if value is None:
return ""
import datetime as _dt
dt_obj: _dt.datetime | None = None
if isinstance(value, _dt.datetime):
dt_obj = value
elif isinstance(value, _dt.date):
dt_obj = _dt.datetime.combine(value, _dt.datetime.min.time())
elif isinstance(value, str):
s = value.strip()
if not s:
return ""
try:
if "T" in s:
dt_obj = _dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
else:
d = _dt.date.fromisoformat(s.split(" ")[0].split("T")[0])
dt_obj = _dt.datetime.combine(d, _dt.datetime.min.time())
except Exception:
return s
else:
return str(value)
try:
from app.core.calendar import CalendarConverter
out = CalendarConverter.format_datetime(dt_obj, calendar)
return out.get("date_only") or (out.get("formatted", "").split(" ")[0]) or dt_obj.strftime("%Y/%m/%d")
except Exception:
return dt_obj.strftime("%Y/%m/%d")
def esc(v: object) -> str:
return escape("" if v is None else str(v))
rows_html = []
for item in items:
tds = []
for key in keys:
value = item.get(key, "")
if key == "account_type":
value = map_account_type_label(value)
if key in numeric_keys:
value = format_number_for_export(value)
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
tds.append(f"<td>{esc(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
# Format report datetime based on selected calendar (X-Calendar-Type or middleware state)
calendar_type = None
try:
if hasattr(request.state, "calendar_type") and request.state.calendar_type:
calendar_type = request.state.calendar_type
except Exception:
calendar_type = None
if not calendar_type:
cal_header = (request.headers.get("X-Calendar-Type", "jalali") or "jalali").lower()
calendar_type = "jalali" if cal_header in ["jalali", "persian", "shamsi"] else "gregorian"
now = format_generated_at_for_pdf(business_id, calendar_type)
# Build filters summary (show selected filters in PDF)
filters: list[tuple[str, str]] = []
# Fiscal year + (optional) date range
fy_obj = None
if fiscal_year_id:
try:
fy_obj = db.query(FiscalYear).filter(
FiscalYear.id == int(fiscal_year_id),
FiscalYear.business_id == int(business_id),
).first()
except Exception:
fy_obj = None
fy_title = (fy_obj.title if fy_obj else str(fiscal_year_id))
filters.append(("سال مالی" if is_fa else "Fiscal Year", str(fy_title)))
# Date range: if user provided date_from/to show them; otherwise show FY period when available
if date_from or date_to:
if date_from:
filters.append(("از تاریخ" if is_fa else "From", format_date_for_report(date_from, calendar_type)))
if date_to:
filters.append(("تا تاریخ" if is_fa else "To", format_date_for_report(date_to, calendar_type)))
elif fy_obj is not None:
try:
dr = f"{format_date_for_report(fy_obj.start_date, calendar_type)} — {format_date_for_report(fy_obj.end_date, calendar_type)}"
filters.append(("بازه" if is_fa else "Period", dr))
except Exception:
pass
# Currency
if currency_id:
cur_label = ""
try:
cur = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if cur is not None:
if is_fa:
cur_label = (cur.title or cur.name or "")
else:
cur_label = (cur.code or cur.name or cur.title or "")
if cur.symbol:
cur_label = f"{cur_label} ({cur.symbol})" if cur_label else cur.symbol
except Exception:
cur_label = ""
filters.append(("ارز" if is_fa else "Currency", cur_label or str(currency_id)))
# Account type
if account_type:
filters.append(("نوع حساب" if is_fa else "Account Type", map_account_type_label(account_type)))
# Include zero balance (only if present in request or enabled)
if ("include_zero_balance" in body) or include_zero_balance:
filters.append((
"شامل مانده صفر" if is_fa else "Include Zero Balance",
("بله" if include_zero_balance else "خیر") if is_fa else ("Yes" if include_zero_balance else "No"),
))
# Project
if project_id:
filters.append(("پروژه" if is_fa else "Project", str(project_id)))
filters_html = ""
if filters:
parts = []
for k, v in filters:
if v:
parts.append(f"<span class='filter-item'><strong>{esc(k)}:</strong> {esc(v)}</span>")
if parts:
filters_html = f"<div class='filters'>{''.join(parts)}</div>"
title_text = "گزارش تراز آزمایشی" if is_fa else "Trial Balance Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
# تلاش برای رندر با قالب سفارشی (trial_balance/reports/trial-balance)
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
template_context = {
"title_text": title_text,
"business_name": business_name,
"generated_at": now,
"is_fa": is_fa,
"calendar_type": calendar_type,
"filters": [{"label": k, "value": v} for k, v in filters],
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="trial_balance",
subtype="reports/trial-balance",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang="{html_lang}" dir="{html_dir}">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="header">
<div class="title">{esc(title_text)}</div>
<div class="meta">
<div><strong>{esc(label_biz)}:</strong> {esc(business_name)}</div>
<div><strong>{esc(label_date)}:</strong> {esc(now)}</div>
</div>
</div>
{filters_html}
<div class="table-wrapper">
<table class="report-table">
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
</div>
</body>
</html>
"""
html_content = resolved_html or table_html
# Build font-face CSS BEFORE injecting into HTML
font_face_css = ""
if fa_font_url_regular:
font_face_css += f"""
@font-face {{
font-family: 'YekanBakhFaNum';
src: url("{fa_font_url_regular}") format('truetype');
font-weight: 400;
font-style: normal;
}}
"""
if fa_font_url_bold:
font_face_css += f"""
@font-face {{
font-family: 'YekanBakhFaNum';
src: url("{fa_font_url_bold}") format('truetype');
font-weight: 700;
font-style: normal;
}}
"""
# Important: some custom templates include their own CSS (sometimes with !important)
# To match invoices behavior and guarantee font is applied, inject font CSS at the end of <head>.
try:
# If locale isn't fa but document is RTL (custom templates), still prefer Persian font stack.
force_fa = bool(is_fa) or ('dir="rtl"' in html_content) or ("dir='rtl'" in html_content)
if force_fa:
preferred_stack = "YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif"
else:
preferred_stack = "Arial, sans-serif"
injected_style = (
"<style id=\"hesabix-font-inject\">"
+ (font_face_css or "")
+ f"\nhtml, body, body * {{ font-family: {preferred_stack} !important; }}\n"
+ "</style>"
)
if "</head>" in html_content:
html_content = html_content.replace("</head>", injected_style + "</head>")
else:
html_content = injected_style + html_content
except Exception:
# Don't fail PDF generation if injection fails
pass
if debug_font:
logger.info(
"trial_balance_export_pdf: resolved_html=%s head_tag=%s injected_style=%s rtl_in_html=%s font_face_css_len=%s",
bool(resolved_html),
("</head>" in html_content),
("hesabix-font-inject" in html_content),
('dir="rtl"' in html_content) or ("dir='rtl'" in html_content),
len(font_face_css or ""),
)
# Keep stylesheet as well (layout + additional rules)
force_font_css = ""
if is_fa:
force_font_css = "\nhtml, body, body * { font-family: YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif !important; }\n"
else:
force_font_css = "\nhtml, body, body * { font-family: Arial, sans-serif !important; }\n"
css = CSS(string=(font_face_css or "") + force_font_css + f"""
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{'left' if is_fa else 'right'} {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
font-family: {'YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif' if is_fa else 'Arial, sans-serif'} !important;
}}
}}
body {{
font-family: {'YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif' if is_fa else 'Arial, sans-serif'} !important;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
gap: 12px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
text-align: {'right' if is_fa else 'left'};
}}
.filters {{
margin: 8px 0 10px;
font-size: 10.5px;
color: #444;
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
}}
.filters .filter-item {{
background: #f7f9fc;
border: 1px solid #e2e8f0;
padding: 3px 6px;
border-radius: 6px;
white-space: nowrap;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 5px 4px;
text-align: center;
font-weight: 700;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.2;
font-size: 10px;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
/* پیش‌فرض: وسط‌چین */
text-align: center;
}}
""")
font_config = FontConfiguration()
pdf_bytes = HTML(string=html_content).write_pdf(
stylesheets=[css],
font_config=font_config,
)
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "trial_balance_report"
if business_name:
base += f"_{slugify(business_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/general-ledger",
summary="گزارش دفتر کل",
description="گزارش دفتر کل برای حساب‌های حسابداری",
)
@require_business_access("business_id")
async def general_ledger_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش دفتر کل برای حساب‌های حسابداری"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# account_ids اجباری است
account_ids = body.get('account_ids')
if not account_ids or not isinstance(account_ids, list) or len(account_ids) == 0:
raise ApiError("ACCOUNT_IDS_REQUIRED", "account_ids الزامی است و باید لیستی از شناسه‌های حساب باشد", http_status=400)
try:
account_ids = [int(aid) for aid in account_ids if aid is not None]
except (ValueError, TypeError):
raise ApiError("INVALID_ACCOUNT_IDS", "account_ids باید لیستی از اعداد صحیح باشد", http_status=400)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
person_id = body.get('person_id')
project_id = body.get('project_id') # 🆕 فیلتر پروژه
include_proforma = body.get('include_proforma', False)
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if person_id is not None:
try:
person_id = int(person_id)
except (ValueError, TypeError):
person_id = None
# 🆕 تبدیل نوع project_id
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_general_ledger_report(
db=db,
business_id=business_id,
account_ids=account_ids,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
person_id=person_id,
project_id=project_id, # 🆕 پاس دادن به سرویس
include_proforma=include_proforma,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="General ledger report retrieved successfully" if locale != 'fa' else "گزارش دفتر کل با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/general-ledger/export/excel",
summary="خروجی Excel گزارش دفتر کل",
description="خروجی Excel گزارش دفتر کل با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_general_ledger_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی Excel گزارش دفتر کل"""
import io
import json
import datetime
import re
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from adapters.db.models.business import Business
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# account_ids اجباری است
account_ids = body.get("account_ids")
if not account_ids or not isinstance(account_ids, list) or len(account_ids) == 0:
raise ApiError("ACCOUNT_IDS_REQUIRED", "account_ids الزامی است و باید لیستی از شناسه‌های حساب باشد", http_status=400)
try:
account_ids = [int(aid) for aid in account_ids if aid is not None]
except (ValueError, TypeError):
raise ApiError("INVALID_ACCOUNT_IDS", "account_ids باید لیستی از اعداد صحیح باشد", http_status=400)
# Fiscal year from header/body
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
# Filters
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
person_id = body.get("person_id")
project_id = body.get("project_id")
include_proforma = bool(body.get("include_proforma", False))
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if person_id is not None:
try:
person_id = int(person_id)
except (ValueError, TypeError):
person_id = None
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
# Fetch all (limited)
max_export_records = 10000
result = get_general_ledger_report(
db=db,
business_id=business_id,
account_ids=account_ids,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
person_id=person_id,
project_id=project_id,
include_proforma=include_proforma,
skip=0,
take=max_export_records,
)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Selected rows
selected_only = bool(body.get("selected_only", False))
selected_indices = body.get("selected_indices")
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Columns
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 = [
("document_date", "تاریخ سند"),
("document_code", "کد سند"),
("document_type_name", "نوع سند"),
("account_code", "کد حساب"),
("account_name", "نام حساب"),
("counterpart_code", "کد طرف حساب"),
("counterpart_name", "نام طرف حساب"),
("description", "شرح"),
("debit", "بدهکار"),
("credit", "بستانکار"),
("balance", "مانده"),
]
if items:
for k, label in default_columns:
if k in items[0]:
keys.append(k)
headers.append(label)
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
wb = Workbook()
ws = wb.active
ws.title = "General Ledger"
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4F81BD", end_color="4F81BD", fill_type="solid")
thin = Side(border_style="thin", color="D9D9D9")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
ws.append(headers)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col_idx)
cell.font = header_font
cell.fill = header_fill
cell.border = border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
numeric_keys = {"debit", "credit", "balance"}
def _normalize_number(val: object) -> object:
if val is None or val == "":
return ""
if isinstance(val, bool):
return 1 if val else 0
if isinstance(val, int):
return val
if isinstance(val, float):
try:
return int(val) if val.is_integer() else val
except Exception:
return val
if isinstance(val, str):
s = val.strip()
if not s:
return ""
try:
f = float(s)
return int(f) if f.is_integer() else f
except Exception:
return val
return val
for item in items:
row_values = []
for key in keys:
value = item.get(key, "")
if key in numeric_keys:
value = _normalize_number(value)
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
row_values.append(value)
ws.append(row_values)
# Format cells
for r in range(2, ws.max_row + 1):
for c in range(1, ws.max_column + 1):
cell = ws.cell(row=r, column=c)
cell.border = border
if keys[c - 1] in numeric_keys and isinstance(cell.value, (int, float)):
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
else:
# پیش‌فرض: وسط‌چین
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
# Auto width
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 60)
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
content = buffer.getvalue()
# Filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "general_ledger_report"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.xlsx"
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/general-ledger/export/electronic-books",
summary="خروجی دفتر کل الکترونیکی (سازمان امور مالیاتی)",
description=(
"خروجی Excel یا CSV دفتر کل مطابق قالب سامانه دفاتر تجاری الکترونیکی. "
"شامل حساب کل، معین و تفصیلی به‌همراه گردش بدهکار/بستانکار و تاریخ گردش."
),
)
@require_business_access("business_id")
async def export_general_ledger_electronic_books(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی دفتر کل الکترونیکی برای بارگذاری در سامانه مالیاتی"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
document_type = body.get("document_type")
include_proforma = body.get("include_proforma", False)
export_format = body.get("format", "auto")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
return export_electronic_general_ledger_books(
db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
document_type=document_type,
include_proforma=include_proforma,
export_format=str(export_format),
)
@router.post(
"/businesses/{business_id}/reports/general-ledger/export/pdf",
summary="خروجی PDF گزارش دفتر کل",
description="خروجی PDF گزارش دفتر کل با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_general_ledger_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش دفتر کل"""
import json
import datetime
import re
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from html import escape
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# account_ids اجباری
account_ids = body.get("account_ids")
if not account_ids or not isinstance(account_ids, list) or len(account_ids) == 0:
raise ApiError("ACCOUNT_IDS_REQUIRED", "account_ids الزامی است و باید لیستی از شناسه‌های حساب باشد", http_status=400)
try:
account_ids = [int(aid) for aid in account_ids if aid is not None]
except (ValueError, TypeError):
raise ApiError("INVALID_ACCOUNT_IDS", "account_ids باید لیستی از اعداد صحیح باشد", http_status=400)
# Fiscal year from header/body
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
# Filters
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
person_id = body.get("person_id")
project_id = body.get("project_id")
include_proforma = bool(body.get("include_proforma", False))
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if person_id is not None:
try:
person_id = int(person_id)
except (ValueError, TypeError):
person_id = None
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
# Fetch all (limited)
max_export_records = 10000
result = get_general_ledger_report(
db=db,
business_id=business_id,
account_ids=account_ids,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
person_id=person_id,
project_id=project_id,
include_proforma=include_proforma,
skip=0,
take=max_export_records,
)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Selected rows
selected_only = bool(body.get("selected_only", False))
selected_indices = body.get("selected_indices")
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Columns
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 = [
("document_date", "تاریخ سند"),
("document_code", "کد سند"),
("document_type_name", "نوع سند"),
("account_code", "کد حساب"),
("account_name", "نام حساب"),
("counterpart_code", "کد طرف حساب"),
("counterpart_name", "نام طرف حساب"),
("description", "شرح"),
("debit", "بدهکار"),
("credit", "بستانکار"),
("balance", "مانده"),
]
if items:
for k, label in default_columns:
if k in items[0]:
keys.append(k)
headers.append(label)
# Calendar type + locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
calendar_type = None
try:
if hasattr(request.state, "calendar_type") and request.state.calendar_type:
calendar_type = request.state.calendar_type
except Exception:
calendar_type = None
if not calendar_type:
cal_header = (request.headers.get("X-Calendar-Type", "jalali") or "jalali").lower()
calendar_type = "jalali" if cal_header in ["jalali", "persian", "shamsi"] else "gregorian"
def esc(v: object) -> str:
return escape("" if v is None else str(v))
def format_date_for_report(value: object) -> str:
if value is None:
return ""
import datetime as _dt
dt_obj: _dt.datetime | None = None
if isinstance(value, _dt.datetime):
dt_obj = value
elif isinstance(value, _dt.date):
dt_obj = _dt.datetime.combine(value, _dt.datetime.min.time())
elif isinstance(value, str):
s = value.strip()
if not s:
return ""
try:
if "T" in s:
dt_obj = _dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
else:
d = _dt.date.fromisoformat(s.split(" ")[0].split("T")[0])
dt_obj = _dt.datetime.combine(d, _dt.datetime.min.time())
except Exception:
return s
else:
return str(value)
try:
from app.core.calendar import CalendarConverter
out = CalendarConverter.format_datetime(dt_obj, calendar_type)
return out.get("date_only") or (out.get("formatted", "").split(" ")[0]) or dt_obj.strftime("%Y/%m/%d")
except Exception:
return dt_obj.strftime("%Y/%m/%d")
def format_num(value: object) -> str:
if value is None or value == "":
return ""
try:
n = float(value)
if n.is_integer():
s = f"{int(n):,}"
else:
s = f"{n:,.2f}"
if is_fa:
s = s.replace(",", "٬")
return s
except Exception:
return str(value)
# Business name
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 = ""
# Filters summary
filters: list[tuple[str, str]] = []
fy_obj = None
if fiscal_year_id:
try:
fy_obj = db.query(FiscalYear).filter(FiscalYear.id == int(fiscal_year_id), FiscalYear.business_id == int(business_id)).first()
except Exception:
fy_obj = None
filters.append(("سال مالی" if is_fa else "Fiscal Year", (fy_obj.title if fy_obj else str(fiscal_year_id))))
if date_from:
filters.append(("از تاریخ" if is_fa else "From", format_date_for_report(date_from)))
if date_to:
filters.append(("تا تاریخ" if is_fa else "To", format_date_for_report(date_to)))
if currency_id:
cur_label = ""
try:
cur = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if cur is not None:
cur_label = (cur.title or cur.name or "") if is_fa else (cur.code or cur.name or cur.title or "")
if cur.symbol:
cur_label = f"{cur_label} ({cur.symbol})" if cur_label else cur.symbol
except Exception:
cur_label = ""
filters.append(("ارز" if is_fa else "Currency", cur_label or str(currency_id)))
if person_id:
filters.append(("شخص" if is_fa else "Person", str(person_id)))
if project_id:
filters.append(("پروژه" if is_fa else "Project", str(project_id)))
filters.append(("تعداد حساب" if is_fa else "Accounts", str(len(account_ids))))
if ("include_proforma" in body) or include_proforma:
filters.append((
"شامل پیش‌نویس" if is_fa else "Include Proforma",
("بله" if include_proforma else "خیر") if is_fa else ("Yes" if include_proforma else "No"),
))
filters_html = ""
if filters:
parts = []
for k, v in filters:
if v:
parts.append(f"<span class='filter-item'><strong>{esc(k)}:</strong> {esc(v)}</span>")
if parts:
filters_html = f"<div class='filters'>{''.join(parts)}</div>"
# Rows html
rows_html = []
numeric_keys = {"debit", "credit", "balance"}
for item in items:
tds = []
for k in keys:
v = item.get(k, "")
if k == "document_date":
v = format_date_for_report(v)
if k in numeric_keys:
v = format_num(v)
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
elif isinstance(v, dict):
v = json.dumps(v, ensure_ascii=False)
tds.append(f"<td>{esc(v)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = "".join(f"<th>{esc(h)}</th>" for h in headers)
# Date report (calendar-aware)
now_str = format_generated_at_for_pdf(business_id, calendar_type)
title_text = "گزارش دفتر کل" if is_fa else "General Ledger Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
# Embed fonts like invoice PDFs
fa_reg, fa_bold = None, None
try:
if is_fa:
from app.services.pdf.template_renderer import load_farsi_font_data_uris
fa_reg, fa_bold = load_farsi_font_data_uris()
except Exception:
fa_reg, fa_bold = None, None
font_face_css = ""
if fa_reg:
font_face_css += f"""
@font-face {{
font-family: 'YekanBakhFaNum';
src: url("{fa_reg}") format('truetype');
font-weight: 400;
font-style: normal;
}}
"""
if fa_bold:
font_face_css += f"""
@font-face {{
font-family: 'YekanBakhFaNum';
src: url("{fa_bold}") format('truetype');
font-weight: 700;
font-style: normal;
}}
"""
preferred_stack = "YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif" if (is_fa or html_dir == "rtl") else "Arial, sans-serif"
injected_style = (
"<style id=\"hesabix-font-inject\">"
+ (font_face_css or "")
+ f"\nhtml, body, body * {{ font-family: {preferred_stack} !important; }}\n"
+ "</style>"
)
table_html = f"""
<html lang="{ 'fa' if is_fa else 'en' }" dir="{ 'rtl' if is_fa else 'ltr' }">
<head>
<meta charset="utf-8">
{injected_style}
</head>
<body>
<div class="header">
<div class="title">{esc(title_text)}</div>
<div class="meta">
<div><strong>{esc(label_biz)}:</strong> {esc(business_name)}</div>
<div><strong>{esc(label_date)}:</strong> {esc(now_str)}</div>
</div>
</div>
{filters_html}
<div class="table-wrapper">
<table class="report-table">
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
</div>
</body>
</html>
"""
css = CSS(string=f"""
{font_face_css}
html, body, body * {{ font-family: {preferred_stack} !important; }}
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{'left' if is_fa else 'right'} {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
font-family: {preferred_stack} !important;
}}
}}
body {{
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
gap: 12px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
text-align: {'right' if is_fa else 'left'};
}}
.filters {{
margin: 8px 0 10px;
font-size: 10.5px;
color: #444;
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
}}
.filters .filter-item {{
background: #f7f9fc;
border: 1px solid #e2e8f0;
padding: 3px 6px;
border-radius: 6px;
white-space: nowrap;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 5px 4px;
text-align: center;
font-weight: 700;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.2;
font-size: 10px;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
/* پیش‌فرض: وسط‌چین */
text-align: center;
}}
""")
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(stylesheets=[css], font_config=font_config)
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_")
base = "general_ledger_report"
if business_name:
base += f"_{slugify(business_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/pnl-period",
summary="گزارش سود و زیان دوره‌ای",
description="گزارش سود و زیان دوره‌ای (Profit & Loss Period)",
)
@require_business_access("business_id")
async def pnl_period_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش سود و زیان دوره‌ای"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
project_id = body.get('project_id') # 🆕 فیلتر پروژه
include_zero_balance = bool(body.get('include_zero_balance', False))
compare_prior_period = bool(body.get('compare_prior_period', False))
compare_mode = body.get('compare_mode')
if compare_mode is not None:
compare_mode = str(compare_mode).strip() or None
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# 🆕 تبدیل نوع project_id
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 100)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 100
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 100
result = get_pnl_period_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
project_id=project_id, # 🆕 پاس دادن به سرویس
include_zero_balance=include_zero_balance,
compare_prior_period=compare_prior_period,
compare_mode=compare_mode,
skip=skip,
take=take,
include_base_equivalent=bool(body.get("include_base_equivalent", False)),
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="PnL Period report retrieved successfully" if locale != 'fa' else "گزارش سود و زیان دوره‌ای با موفقیت دریافت شد",
request=request
)
def _parse_pnl_period_export_params(request: Request, body: Dict[str, Any]) -> Dict[str, Any]:
"""استخراج پارامترهای مشترک خروجی/گزارش سود و زیان دوره‌ای."""
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
project_id = body.get("project_id")
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
return {
"fiscal_year_id": fiscal_year_id,
"date_from": body.get("date_from"),
"date_to": body.get("date_to"),
"currency_id": currency_id,
"project_id": project_id,
"include_zero_balance": bool(body.get("include_zero_balance", False)),
"compare_prior_period": bool(body.get("compare_prior_period", False)),
"compare_mode": (str(body.get("compare_mode")).strip() or None) if body.get("compare_mode") is not None else None,
}
@router.post(
"/businesses/{business_id}/reports/pnl-period/export/excel",
summary="خروجی Excel گزارش سود و زیان دوره‌ای",
description="خروجی Excel گزارش سود و زیان دوره‌ای (درآمد، هزینه، سود/زیان خالص)",
)
@require_business_access("business_id")
async def export_pnl_period_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی Excel گزارش سود و زیان دوره‌ای"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_pnl_period_export_params(request, body)
result = get_pnl_period_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params.get("compare_mode"),
skip=0,
take=10000,
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return pnl_excel_response(
result,
db=db,
business_id=business_id,
filename_prefix="pnl_period",
is_fa=locale == "fa",
export_filename_timestamp=export_filename_timestamp,
)
@router.post(
"/businesses/{business_id}/reports/pnl-period/export/pdf",
summary="خروجی PDF گزارش سود و زیان دوره‌ای",
description="خروجی PDF گزارش سود و زیان دوره‌ای (درآمد، هزینه، سود/زیان خالص)",
)
@require_business_access("business_id")
async def export_pnl_period_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش سود و زیان دوره‌ای"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_pnl_period_export_params(request, body)
result = get_pnl_period_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params.get("compare_mode"),
skip=0,
take=10000,
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
calendar_type = resolve_calendar_type_for_request(request)
generated_at = format_generated_at_for_pdf(business_id, calendar_type)
return pnl_pdf_response(
result,
db=db,
business_id=business_id,
filename_prefix="pnl_period",
is_fa=locale == "fa",
report_title_fa="گزارش سود و زیان دوره‌ای",
report_title_en="Period Profit & Loss Report",
generated_at=generated_at,
fiscal_year_id=params["fiscal_year_id"],
date_from=params["date_from"],
date_to=params["date_to"],
currency_id=params["currency_id"],
project_id=params["project_id"],
export_filename_timestamp=export_filename_timestamp,
load_farsi_font_data_uris=load_farsi_font_data_uris,
)
@router.post(
"/businesses/{business_id}/reports/balance-sheet",
summary="گزارش ترازنامه",
description="صورت وضعیت مالی (ترازنامه) با طبقه‌بندی دارایی، بدهی و حقوق صاحبان سهام",
)
@require_business_access("business_id")
async def balance_sheet_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش ترازنامه (صورت وضعیت مالی)"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_balance_sheet_body(request, body)
result = get_balance_sheet_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
account_level=params["account_level"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params["compare_mode"],
include_base_equivalent=params["include_base_equivalent"],
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Balance sheet report retrieved successfully" if locale != "fa" else "گزارش ترازنامه با موفقیت دریافت شد",
request=request,
)
@router.post(
"/businesses/{business_id}/reports/balance-sheet/export/excel",
summary="خروجی Excel گزارش ترازنامه",
)
@require_business_access("business_id")
async def export_balance_sheet_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_balance_sheet_body(request, body)
result = get_balance_sheet_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
account_level=params["account_level"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params["compare_mode"],
include_base_equivalent=params["include_base_equivalent"],
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return balance_sheet_excel_response(
result,
db=db,
business_id=business_id,
filename_prefix="balance_sheet",
is_fa=locale == "fa",
export_filename_timestamp=export_filename_timestamp,
)
@router.post(
"/businesses/{business_id}/reports/balance-sheet/export/pdf",
summary="خروجی PDF گزارش ترازنامه",
)
@require_business_access("business_id")
async def export_balance_sheet_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_balance_sheet_body(request, body)
result = get_balance_sheet_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
account_level=params["account_level"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params["compare_mode"],
include_base_equivalent=params["include_base_equivalent"],
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
calendar_type = resolve_calendar_type_for_request(request)
generated_at = format_generated_at_for_pdf(business_id, calendar_type)
return balance_sheet_pdf_response(
result,
db=db,
business_id=business_id,
filename_prefix="balance_sheet",
is_fa=locale == "fa",
report_title_fa="گزارش ترازنامه (صورت وضعیت مالی)",
report_title_en="Balance Sheet Report",
generated_at=generated_at,
fiscal_year_id=params["fiscal_year_id"],
date_from=params["date_from"],
date_to=params["date_to"],
currency_id=params["currency_id"],
project_id=params["project_id"],
account_level=params["account_level"],
export_filename_timestamp=export_filename_timestamp,
load_farsi_font_data_uris=load_farsi_font_data_uris,
)
@router.post(
"/businesses/{business_id}/reports/financial-package/export/pdf",
summary="خروجی PDF یکجای بسته گزارش‌های مالی",
description="تراز آزمایشی، صورت سود و زیان و ترازنامه در یک فایل PDF",
)
@require_business_access("business_id")
async def export_financial_package_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_financial_package_body(request, body)
package = get_financial_package_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
account_level=params["account_level"],
column_mode=params["column_mode"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params["compare_mode"],
include_base_equivalent=params.get("include_base_equivalent", False),
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
calendar_type = resolve_calendar_type_for_request(request)
generated_at = format_generated_at_for_pdf(business_id, calendar_type)
return financial_package_pdf_response(
package,
db=db,
business_id=business_id,
is_fa=locale == "fa",
generated_at=generated_at,
fiscal_year_id=params["fiscal_year_id"],
date_from=params["date_from"],
date_to=params["date_to"],
currency_id=params["currency_id"],
project_id=params["project_id"],
account_level=params["account_level"],
column_mode=params["column_mode"],
export_filename_timestamp=export_filename_timestamp,
load_farsi_font_data_uris=load_farsi_font_data_uris,
)
@router.post(
"/businesses/{business_id}/reports/financial-package/export/excel",
summary="خروجی Excel یکجای بسته گزارش‌های مالی",
description="تراز آزمایشی، صورت سود و زیان و ترازنامه در یک فایل Excel با چند برگه",
)
@require_business_access("business_id")
async def export_financial_package_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_financial_package_body(request, body)
package = get_financial_package_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_from=params["date_from"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
account_level=params["account_level"],
column_mode=params["column_mode"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params["compare_mode"],
include_base_equivalent=params.get("include_base_equivalent", False),
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return financial_package_excel_response(
package,
db=db,
business_id=business_id,
is_fa=locale == "fa",
column_mode=params["column_mode"],
export_filename_timestamp=export_filename_timestamp,
)
@router.post(
"/businesses/{business_id}/reports/pnl-cumulative",
summary="گزارش سود و زیان تجمعی",
description="گزارش سود و زیان تجمعی از ابتدای سال مالی (Profit & Loss Cumulative)",
)
@require_business_access("business_id")
async def pnl_cumulative_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش سود و زیان تجمعی از ابتدای سال مالی"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_to = body.get('date_to') # فقط date_to (date_from همیشه ابتدای سال مالی است)
currency_id = body.get('currency_id')
project_id = body.get('project_id') # 🆕 فیلتر پروژه
include_zero_balance = bool(body.get('include_zero_balance', False))
compare_prior_period = bool(body.get('compare_prior_period', False))
compare_mode = body.get('compare_mode')
if compare_mode is not None:
compare_mode = str(compare_mode).strip() or None
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# 🆕 تبدیل نوع project_id
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 100)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 100
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 100
result = get_pnl_cumulative_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_to=date_to,
project_id=project_id, # 🆕 پاس دادن به سرویس
include_zero_balance=include_zero_balance,
compare_prior_period=compare_prior_period,
compare_mode=compare_mode,
skip=skip,
take=take,
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="PnL Cumulative report retrieved successfully" if locale != 'fa' else "گزارش سود و زیان تجمعی با موفقیت دریافت شد",
request=request
)
def _parse_pnl_cumulative_export_params(request: Request, body: Dict[str, Any]) -> Dict[str, Any]:
"""استخراج پارامترهای مشترک خروجی/گزارش سود و زیان تجمعی."""
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
currency_id = body.get("currency_id")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
project_id = body.get("project_id")
if project_id is not None:
try:
project_id = int(project_id)
except (ValueError, TypeError):
project_id = None
return {
"fiscal_year_id": fiscal_year_id,
"date_to": body.get("date_to"),
"currency_id": currency_id,
"project_id": project_id,
"include_zero_balance": bool(body.get("include_zero_balance", False)),
"compare_prior_period": bool(body.get("compare_prior_period", False)),
"compare_mode": (str(body.get("compare_mode")).strip() or None) if body.get("compare_mode") is not None else None,
}
@router.post(
"/businesses/{business_id}/reports/pnl-cumulative/export/excel",
summary="خروجی Excel گزارش سود و زیان تجمعی",
description="خروجی Excel گزارش سود و زیان تجمعی از ابتدای سال مالی",
)
@require_business_access("business_id")
async def export_pnl_cumulative_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی Excel گزارش سود و زیان تجمعی"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_pnl_cumulative_export_params(request, body)
result = get_pnl_cumulative_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params.get("compare_mode"),
skip=0,
take=10000,
)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return pnl_excel_response(
result,
db=db,
business_id=business_id,
filename_prefix="pnl_cumulative",
is_fa=locale == "fa",
export_filename_timestamp=export_filename_timestamp,
)
@router.post(
"/businesses/{business_id}/reports/pnl-cumulative/export/pdf",
summary="خروجی PDF گزارش سود و زیان تجمعی",
description="خروجی PDF گزارش سود و زیان تجمعی از ابتدای سال مالی",
)
@require_business_access("business_id")
async def export_pnl_cumulative_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش سود و زیان تجمعی"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
params = _parse_pnl_cumulative_export_params(request, body)
result = get_pnl_cumulative_report(
db=db,
business_id=business_id,
fiscal_year_id=params["fiscal_year_id"],
currency_id=params["currency_id"],
date_to=params["date_to"],
project_id=params["project_id"],
include_zero_balance=params["include_zero_balance"],
compare_prior_period=params["compare_prior_period"],
compare_mode=params.get("compare_mode"),
skip=0,
take=10000,
)
summary = result.get("summary") or {}
locale = negotiate_locale(request.headers.get("Accept-Language"))
calendar_type = resolve_calendar_type_for_request(request)
generated_at = format_generated_at_for_pdf(business_id, calendar_type)
return pnl_pdf_response(
result,
db=db,
business_id=business_id,
filename_prefix="pnl_cumulative",
is_fa=locale == "fa",
report_title_fa="گزارش سود و زیان تجمعی",
report_title_en="Cumulative Profit & Loss Report",
generated_at=generated_at,
fiscal_year_id=params["fiscal_year_id"],
date_from=summary.get("date_from"),
date_to=summary.get("date_to") or params["date_to"],
currency_id=params["currency_id"],
project_id=params["project_id"],
export_filename_timestamp=export_filename_timestamp,
load_farsi_font_data_uris=load_farsi_font_data_uris,
)
@router.post(
"/businesses/{business_id}/reports/accounts-review",
summary="گزارش مرور حساب‌ها",
description="گزارش مرور حساب‌ها با ساختار درختی و مانده‌ها (Account Review Report)",
)
@require_business_access("business_id")
async def accounts_review_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش مرور حساب‌ها با ساختار درختی"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "دسترسی مجاز نیست", http_status=403)
# بررسی دسترسی - گزارشات معمولاً نیاز به دسترسی خواندن دارند
# اما اگر این endpoint خاص نیاز به دسترسی خاصی ندارد، می‌توانیم این بررسی را حذف کنیم
# یا دسترسی را به صورت optional در نظر بگیریم
# برای اکنون، بررسی می‌کنیم که آیا کاربر دسترسی خواندن گزارشات دارد یا نه
try:
if ctx and hasattr(ctx, 'can_read_section'):
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "دسترسی مجاز نیست", http_status=403)
except AttributeError:
# اگر متد can_read_section وجود نداشت، به کاربر اجازه می‌دهیم
pass
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
account_type = body.get('account_type')
include_zero_balance = body.get('include_zero_balance', False)
account_id = body.get('account_id') # برای دریافت جزئیات یک حساب خاص
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
if account_id is not None:
try:
account_id = int(account_id)
except (ValueError, TypeError):
account_id = None
# Pagination (فقط برای جزئیات)
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
result = get_accounts_review_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
include_zero_balance=include_zero_balance,
account_id=account_id,
skip=skip,
take=take,
)
# Format datetime fields
def _format_accounts(accounts: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
formatted = []
for acc in accounts:
formatted_acc = dict(acc)
if formatted_acc.get('children'):
formatted_acc['children'] = _format_accounts(formatted_acc['children'])
formatted.append(formatted_acc)
return formatted
result['accounts'] = _format_accounts(result.get('accounts', []))
if result.get('account_details'):
result['account_details'] = [format_datetime_fields(item, request) for item in result['account_details']]
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Accounts review report retrieved successfully" if locale != 'fa' else "گزارش مرور حساب‌ها با موفقیت دریافت شد",
request=request
)
def _flatten_accounts_review_tree(accounts: list) -> list:
"""تبدیل درخت حساب‌های گزارش مرور به لیست تخت برای export."""
rows = []
for acc in accounts:
row = {k: v for k, v in acc.items() if k != "children"}
rows.append(row)
if acc.get("children"):
rows.extend(_flatten_accounts_review_tree(acc["children"]))
return rows
@router.post(
"/businesses/{business_id}/reports/accounts-review/export/excel",
summary="خروجی Excel گزارش مرور حساب‌ها",
description="خروجی Excel گزارش مرور حساب‌ها با ساختار درختی و مانده‌ها",
)
@require_business_access("business_id")
async def export_accounts_review_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی Excel گزارش مرور حساب‌ها"""
import io
import re
import datetime
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from adapters.db.models.business import Business
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
account_type = body.get("account_type")
include_zero_balance = bool(body.get("include_zero_balance", False))
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
result = get_accounts_review_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
include_zero_balance=include_zero_balance,
account_id=None,
skip=0,
take=50,
)
accounts_tree = result.get("accounts", [])
items = _flatten_accounts_review_tree(accounts_tree)
items = [format_datetime_fields(item, request) for item in items]
keys = [
"account_code",
"account_name",
"account_type",
"opening_debit",
"opening_credit",
"period_debit",
"period_credit",
"closing_debit",
"closing_credit",
]
headers_fa = [
"کد حساب",
"نام حساب",
"نوع حساب",
"مانده ابتدای دوره (بدهکار)",
"مانده ابتدای دوره (بستانکار)",
"جمع بدهکار دوره",
"جمع بستانکار دوره",
"مانده انتهای دوره (بدهکار)",
"مانده انتهای دوره (بستانکار)",
]
headers_en = [
"Account Code",
"Account Name",
"Account Type",
"Opening Debit",
"Opening Credit",
"Period Debit",
"Period Credit",
"Closing Debit",
"Closing Credit",
]
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
headers = headers_fa if is_fa else headers_en
wb = Workbook()
ws = wb.active
ws.title = "Accounts Review"
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4F81BD", end_color="4F81BD", fill_type="solid")
thin = Side(border_style="thin", color="D9D9D9")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
mapping_fa = {
"accounting_document": "حسابداری",
"bank": "بانک",
"cash_register": "صندوق",
"cashdesk": "صندوق",
"cash": "نقد",
"petty_cash": "تنخواه",
"check": "چک",
"person": "شخص",
"product": "کالا",
}
mapping_en = {
"accounting_document": "Accounting",
"bank": "Bank",
"cash_register": "Cash Register",
"cashdesk": "Cash Register",
"cash": "Cash",
"petty_cash": "Petty Cash",
"check": "Check",
"person": "Person",
"product": "Product",
}
for k in [str(i) for i in range(44)]:
mapping_fa.setdefault(k, k)
mapping_en.setdefault(k, k)
def map_account_type(v):
if v is None:
return ""
key = str(v).strip()
return (mapping_fa if is_fa else mapping_en).get(key, key)
numeric_keys = {"opening_debit", "opening_credit", "period_debit", "period_credit", "closing_debit", "closing_credit"}
ws.append(headers)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col_idx)
cell.font = header_font
cell.fill = header_fill
cell.border = border
cell.alignment = Alignment(horizontal="center", vertical="center")
for item in items:
row = []
for key in keys:
value = item.get(key, "")
if key == "account_type":
value = map_account_type(value)
if key in numeric_keys and value != "":
try:
if isinstance(value, float) and value == int(value):
value = int(value)
except Exception:
pass
row.append(value)
ws.append(row)
for r in range(2, ws.max_row + 1):
for c in range(1, ws.max_column + 1):
cell = ws.cell(row=r, column=c)
cell.border = border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 60)
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", str(text)).strip("_")
filename = f"accounts_review_{slugify(biz_name) or business_id}_{export_filename_timestamp(business_id)}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/accounts-review/export/pdf",
summary="خروجی PDF گزارش مرور حساب‌ها",
description="خروجی PDF گزارش مرور حساب‌ها با ساختار درختی و مانده‌ها",
)
@require_business_access("business_id")
async def export_accounts_review_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
_: None = Depends(require_business_permission_dep("reports", "export")),
):
"""خروجی PDF گزارش مرور حساب‌ها"""
import datetime
import re
from html import escape
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
account_type = body.get("account_type")
include_zero_balance = bool(body.get("include_zero_balance", False))
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
result = get_accounts_review_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
account_type=account_type,
include_zero_balance=include_zero_balance,
account_id=None,
skip=0,
take=50,
)
accounts_tree = result.get("accounts", [])
items = _flatten_accounts_review_tree(accounts_tree)
items = [format_datetime_fields(item, request) for item in items]
keys = [
"account_code",
"account_name",
"account_type",
"opening_debit",
"opening_credit",
"period_debit",
"period_credit",
"closing_debit",
"closing_credit",
]
headers_fa = [
"کد حساب",
"نام حساب",
"نوع حساب",
"مانده ابتدای دوره (بدهکار)",
"مانده ابتدای دوره (بستانکار)",
"جمع بدهکار دوره",
"جمع بستانکار دوره",
"مانده انتهای دوره (بدهکار)",
"مانده انتهای دوره (بستانکار)",
]
headers_en = [
"Account Code",
"Account Name",
"Account Type",
"Opening Debit",
"Opening Credit",
"Period Debit",
"Period Credit",
"Closing Debit",
"Closing Credit",
]
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == "fa"
headers = headers_fa if is_fa else headers_en
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 = ""
mapping_fa = {
"accounting_document": "حسابداری",
"bank": "بانک",
"cash_register": "صندوق",
"cashdesk": "صندوق",
"cash": "نقد",
"petty_cash": "تنخواه",
"check": "چک",
"person": "شخص",
"product": "کالا",
}
mapping_en = {
"accounting_document": "Accounting",
"bank": "Bank",
"cash_register": "Cash Register",
"cashdesk": "Cash Register",
"cash": "Cash",
"petty_cash": "Petty Cash",
"check": "Check",
"person": "Person",
"product": "Product",
}
for k in [str(i) for i in range(44)]:
mapping_fa.setdefault(k, k)
mapping_en.setdefault(k, k)
def map_account_type_label(raw_value):
if raw_value is None:
return ""
key = str(raw_value).strip()
return (mapping_fa if is_fa else mapping_en).get(key, key)
numeric_keys = {"opening_debit", "opening_credit", "period_debit", "period_credit", "closing_debit", "closing_credit"}
def format_number_for_export(raw_value):
if raw_value is None:
return ""
if isinstance(raw_value, bool):
return "1" if raw_value else "0"
s = (str(raw_value) or "").strip()
if not s:
return ""
try:
f = float(s)
if f == int(f):
s = str(int(f))
except Exception:
pass
if is_fa and "," in s:
s = s.replace(",", "٬")
return s
def esc(v):
return escape("" if v is None else str(v))
rows_html = []
for item in items:
tds = []
for key in keys:
value = item.get(key, "")
if key == "account_type":
value = map_account_type_label(value)
if key in numeric_keys:
value = format_number_for_export(value)
tds.append(f"<td>{esc(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = "".join(f"<th>{esc(h)}</th>" for h in headers)
calendar_type = None
try:
if hasattr(request.state, "calendar_type") and request.state.calendar_type:
calendar_type = request.state.calendar_type
except Exception:
calendar_type = None
if not calendar_type:
cal_header = (request.headers.get("X-Calendar-Type", "jalali") or "jalali").lower()
calendar_type = "jalali" if cal_header in ["jalali", "persian", "shamsi"] else "gregorian"
now = format_generated_at_for_pdf(business_id, calendar_type)
filters = []
fy_obj = None
if fiscal_year_id:
try:
fy_obj = db.query(FiscalYear).filter(
FiscalYear.id == int(fiscal_year_id),
FiscalYear.business_id == int(business_id),
).first()
except Exception:
fy_obj = None
fy_title = (fy_obj.title if fy_obj else str(fiscal_year_id))
filters.append(("سال مالی" if is_fa else "Fiscal Year", str(fy_title)))
if date_from or date_to:
if date_from:
filters.append(("از تاریخ" if is_fa else "From", date_from))
if date_to:
filters.append(("تا تاریخ" if is_fa else "To", date_to))
if currency_id:
cur_label = ""
try:
cur = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if cur is not None:
cur_label = (cur.title or cur.name or "") if is_fa else (cur.code or cur.name or cur.title or "")
if cur.symbol:
cur_label = f"{cur_label} ({cur.symbol})" if cur_label else cur.symbol
except Exception:
cur_label = ""
filters.append(("ارز" if is_fa else "Currency", cur_label or str(currency_id)))
if account_type:
filters.append(("نوع حساب" if is_fa else "Account Type", map_account_type_label(account_type)))
filters.append((
"شامل مانده صفر" if is_fa else "Include Zero Balance",
("بله" if include_zero_balance else "خیر") if is_fa else ("Yes" if include_zero_balance else "No"),
))
filters_html = ""
if filters:
parts = [f"<span class='filter-item'><strong>{esc(k)}:</strong> {esc(v)}</span>" for k, v in filters if v]
if parts:
filters_html = f"<div class='filters'>{''.join(parts)}</div>"
title_text = "گزارش مرور حساب‌ها" if is_fa else "Accounts Review Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
html_lang = "fa" if is_fa else "en"
html_dir = "rtl" if is_fa else "ltr"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
fa_font_url_regular = None
fa_font_url_bold = None
try:
if is_fa:
from app.services.pdf.template_renderer import load_farsi_font_data_uris
fa_font_url_regular, fa_font_url_bold = load_farsi_font_data_uris()
except Exception:
pass
table_html = f"""
<html lang="{html_lang}" dir="{html_dir}">
<head><meta charset="utf-8"></head>
<body>
<div class="header">
<div class="title">{esc(title_text)}</div>
<div class="meta">
<div><strong>{esc(label_biz)}:</strong> {esc(business_name)}</div>
<div><strong>{esc(label_date)}:</strong> {esc(now)}</div>
</div>
</div>
{filters_html}
<div class="table-wrapper">
<table class="report-table">
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
</div>
</body>
</html>
"""
font_face_css = ""
if fa_font_url_regular:
font_face_css += f'@font-face {{ font-family: \'YekanBakhFaNum\'; src: url("{fa_font_url_regular}") format(\'truetype\'); font-weight: 400; font-style: normal; }}\n'
if fa_font_url_bold:
font_face_css += f'@font-face {{ font-family: \'YekanBakhFaNum\'; src: url("{fa_font_url_bold}") format(\'truetype\'); font-weight: 700; font-style: normal; }}\n'
try:
force_fa = is_fa or ('dir="rtl"' in table_html) or ("dir='rtl'" in table_html)
preferred_stack = "YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif" if force_fa else "Arial, sans-serif"
injected = "<style id=\"hesabix-font-inject\">" + (font_face_css or "") + f"\nhtml, body, body * {{ font-family: {preferred_stack} !important; }}\n</style>"
if "</head>" in table_html:
table_html = table_html.replace("</head>", injected + "</head>")
else:
table_html = injected + table_html
except Exception:
pass
force_font_css = "\nhtml, body, body * { font-family: YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif !important; }\n" if is_fa else "\nhtml, body, body * { font-family: Arial, sans-serif !important; }\n"
css = CSS(string=(font_face_css or "") + force_font_css + f"""
@page {{ size: A4 landscape; margin: 12mm;
@bottom-{'left' if is_fa else 'right'} {{ content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages); font-size: 10px; color: #666; font-family: {'YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif' if is_fa else 'Arial, sans-serif'} !important; }}
}}
body {{ font-size: 11px; color: #222; }}
.header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; border-bottom: 2px solid #444; padding-bottom: 6px; gap: 12px; }}
.title {{ font-size: 16px; font-weight: 700; }}
.meta {{ font-size: 11px; color: #555; text-align: {'right' if is_fa else 'left'}; }}
.filters {{ margin: 8px 0 10px; font-size: 10.5px; color: #444; display: flex; flex-wrap: wrap; gap: 6px 10px; }}
.filters .filter-item {{ background: #f7f9fc; border: 1px solid #e2e8f0; padding: 3px 6px; border-radius: 6px; white-space: nowrap; }}
table.report-table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
thead th {{ background: #f0f3f7; border: 1px solid #c7cdd6; padding: 5px 4px; text-align: center; font-weight: 700; white-space: normal; font-size: 10px; }}
tbody td {{ border: 1px solid #d7dde6; padding: 5px 4px; vertical-align: top; overflow-wrap: anywhere; word-break: break-word; text-align: center; }}
""")
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(stylesheets=[css], font_config=font_config)
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", str(text)).strip("_")
filename = f"accounts_review_{slugify(business_name) or business_id}_{export_filename_timestamp(business_id)}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/journal-ledger",
summary="گزارش دفتر روزنامه",
description="گزارش دفتر روزنامه - تمام تراکنش‌های مالی به ترتیب تاریخ (Journal Ledger)",
)
@require_business_access("business_id")
async def journal_ledger_report_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""گزارش دفتر روزنامه - تمام تراکنش‌های مالی به ترتیب تاریخ"""
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
document_type = body.get('document_type')
include_proforma = body.get('include_proforma', False)
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# Pagination
skip = body.get('skip', 0)
take = body.get('take', 50)
try:
skip = int(skip)
take = int(take)
if take > 500:
take = 500
if take < 1:
take = 50
if skip < 0:
skip = 0
except (ValueError, TypeError):
skip = 0
take = 50
# کش نتایج گزارش دفتر روزنامه
cache = get_cache()
cache_key = None
if cache.enabled:
import json, hashlib
key_payload = {
"business_id": business_id,
"fiscal_year_id": fiscal_year_id,
"currency_id": currency_id,
"date_from": date_from,
"date_to": date_to,
"document_type": document_type,
"include_proforma": include_proforma,
"skip": skip,
"take": take,
}
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"journal_ledger:{key_hash}"
cached = cache.get(cache_key)
if cached is not None:
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=cached,
message="Journal ledger report retrieved successfully" if locale != 'fa' else "گزارش دفتر روزنامه با موفقیت دریافت شد",
request=request
)
result = get_journal_ledger_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
document_type=document_type,
include_proforma=include_proforma,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
if cache.enabled and cache_key:
cache.set(cache_key, result, ttl=60)
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Journal ledger report retrieved successfully" if locale != 'fa' else "گزارش دفتر روزنامه با موفقیت دریافت شد",
request=request
)
@router.post(
"/businesses/{business_id}/reports/journal-ledger/export/excel",
summary="خروجی Excel گزارش دفتر روزنامه",
description="خروجی Excel گزارش دفتر روزنامه با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_journal_ledger_report_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی Excel گزارش دفتر روزنامه"""
import io
import json
import datetime
import re
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from fastapi.responses import Response
from adapters.db.models.business import Business
from app.core.calendar import CalendarConverter, CalendarType
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت calendar_type از request state
calendar_type: CalendarType = "gregorian"
if hasattr(request.state, 'calendar_type'):
calendar_type = request.state.calendar_type
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
document_type = body.get('document_type')
include_proforma = body.get('include_proforma', False)
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_journal_ledger_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
document_type=document_type,
include_proforma=include_proforma,
skip=0,
take=max_export_records,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Helper function to format date based on calendar type
def format_date_for_export(item_dict: dict, date_key: str) -> str:
"""Format date based on calendar type (date only, no time)"""
# First check if there's a _formatted field (from format_datetime_fields)
formatted_key = f"{date_key}_formatted"
if formatted_key in item_dict:
formatted_value = item_dict.get(formatted_key)
if isinstance(formatted_value, dict):
date_only = formatted_value.get("date_only")
if date_only:
return str(date_only)
formatted = formatted_value.get("formatted", "")
if formatted:
# Extract date part only (remove time)
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# Get the main field value
value = item_dict.get(date_key)
if value is None:
return ""
# If it's a dict (from _formatted field), use date_only
if isinstance(value, dict):
date_only = value.get("date_only")
if date_only:
return str(date_only)
formatted = value.get("formatted", "")
if formatted:
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
# If it's a datetime object, format it based on calendar type
if isinstance(value, datetime.datetime):
try:
formatted = CalendarConverter.format_datetime(value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a date object, format it based on calendar type
if isinstance(value, datetime.date):
try:
dt_value = datetime.datetime.combine(value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
# If it's a string, check if it's already formatted (contains Persian or has / separator for Jalali)
if isinstance(value, str):
# Check if it looks like a Jalali date (contains / and Persian numbers or has YYYY/MM/DD format)
if '/' in value and (len(value.split('/')) == 3):
# Might be already formatted, but check if it's ISO format (YYYY-MM-DD) or Jalali (YYYY/MM/DD)
if '-' in value:
# ISO format (YYYY-MM-DD), parse and format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
else:
# Might be Jalali format (YYYY/MM/DD), return as is but remove time if exists
if ' ' in value:
return value.split(' ')[0]
return value
else:
# Try to parse as ISO format
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
# If parsing fails, return as is (might already be formatted)
if ' ' in value or 'T' in value:
date_part = value.split(' ')[0].split('T')[0]
return date_part
return value
# Fallback
return str(value) if value else ""
# Helper function to get document type name in Persian
def get_document_type_name_fa(doc_type: str | None) -> str:
"""تبدیل نوع سند به نام فارسی"""
if not doc_type:
return ''
mapping = {
'invoice_sales': 'فاکتور فروش',
'invoice_sales_return': 'برگشت از فروش',
'invoice_purchase': 'فاکتور خرید',
'invoice_purchase_return': 'برگشت از خرید',
'invoice_production': 'فاکتور تولید',
'invoice_direct_consumption': 'مصرف مستقیم',
'invoice_waste': 'ضایعات',
'receipt': 'دریافت',
'payment': 'پرداخت',
'transfer': 'انتقال',
'expense': 'هزینه',
'income': 'درآمد',
'expense_income': 'درآمد/هزینه',
'opening_balance': 'تراز افتتاحیه',
'manual': 'سند دستی',
'manual_document': 'سند دستی',
'received_loan_facility': 'تسهیلات دریافتی',
'check_endorse': 'پاسخگویی چک',
'check_clear': 'وصول چک',
'check_pay': 'پرداخت چک',
'check_return': 'برگشت چک',
'check_bounce': 'برگشت خوردن چک',
'check_deposit': 'واریز به حساب',
'check_delete': 'حذف چک',
}
return mapping.get(doc_type, doc_type)
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Check if we hit the limit
if len(items) >= max_export_records:
warning_item = {
'document_date': '',
'document_code': '⚠️',
'document_type_name': 'حداکثر ۱۰,۰۰۰ رکورد قابل export است',
'description': '',
'general_account_code': '',
'general_account_name': '',
'subsidiary_account_code': '',
'subsidiary_account_name': '',
'debit_account_code': '',
'debit_account_name': '',
'debit_amount': '',
'credit_account_code': '',
'credit_account_name': '',
'credit_amount': '',
'person_name': '',
}
items.append(warning_item)
# Get locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == 'fa'
# Prepare headers based on export_columns
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
default_columns = [
('document_date', 'تاریخ سند' if is_fa else 'Document Date'),
('document_type_name', 'نوع سند' if is_fa else 'Document Type'),
('document_code', 'شماره سند' if is_fa else 'Document Code'),
('description', 'شرح' if is_fa else 'Description'),
('general_account_code', 'کد حساب کل' if is_fa else 'General Account Code'),
('general_account_name', 'عنوان حساب کل' if is_fa else 'General Account Name'),
('subsidiary_account_code', 'کد حساب معین' if is_fa else 'Subsidiary Account Code'),
('subsidiary_account_name', 'عنوان حساب معین' if is_fa else 'Subsidiary Account Name'),
('debit_account_code', 'کد حساب بدهکار' if is_fa else 'Debit Account Code'),
('debit_account_name', 'نام حساب بدهکار' if is_fa else 'Debit Account Name'),
('debit_amount', 'مبلغ بدهکار' if is_fa else 'Debit Amount'),
('credit_account_code', 'کد حساب بستانکار' if is_fa else 'Credit Account Code'),
('credit_account_name', 'نام حساب بستانکار' if is_fa else 'Credit Account Name'),
('credit_amount', 'مبلغ بستانکار' if is_fa else 'Credit Amount'),
('person_name', 'طرف حساب' if is_fa else 'Counterpart'),
]
for key, label in default_columns:
keys.append(key)
headers.append(label)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "دفتر روزنامه" if is_fa else "Journal Ledger"
# RTL handling for Persian
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Write header row
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(keys, 1):
value = item.get(key, "")
# Format dates based on calendar type
if key == 'document_date' and value:
value = format_date_for_export(item, 'document_date')
# Format document type name (ensure it's in Persian)
if key == 'document_type_name':
# Always use document_type to get the correct translation
doc_type = item.get('document_type', '')
if doc_type:
value = get_document_type_name_fa(doc_type)
elif value:
# If document_type is not available, check if value is English and translate
# Check if value contains Persian characters
has_persian = any('\u0600' <= c <= '\u06FF' for c in str(value))
if not has_persian:
# Value is likely in English, try to translate it
value = get_document_type_name_fa(value)
else:
value = ""
# Format numbers
if key in ['debit_amount', 'credit_amount'] and value:
try:
num_value = float(value) if not isinstance(value, (int, float)) else value
value = num_value
except (ValueError, TypeError):
pass
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
# RTL alignment for Persian text and numbers
if locale == 'fa':
if isinstance(value, (int, float)):
cell.alignment = Alignment(horizontal="right")
elif isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in str(value)):
cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
cell_length = len(str(cell.value))
if max_length < cell_length:
max_length = cell_length
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Add summary row at the end
summary_row = len(items) + 3
ws.cell(row=summary_row, column=1, value="جمع" if is_fa else "Total").font = Font(bold=True)
ws.cell(row=summary_row, column=1).border = border
# Find debit_amount and credit_amount column indices
debit_col_idx = None
credit_col_idx = None
for idx, key in enumerate(keys, 1):
if key == 'debit_amount':
debit_col_idx = idx
elif key == 'credit_amount':
credit_col_idx = idx
if debit_col_idx:
total_debit = result.get('summary', {}).get('total_debit', 0)
ws.cell(row=summary_row, column=debit_col_idx, value=float(total_debit)).font = Font(bold=True)
ws.cell(row=summary_row, column=debit_col_idx).border = border
if locale == 'fa':
ws.cell(row=summary_row, column=debit_col_idx).alignment = Alignment(horizontal="right")
if credit_col_idx:
total_credit = result.get('summary', {}).get('total_credit', 0)
ws.cell(row=summary_row, column=credit_col_idx, value=float(total_credit)).font = Font(bold=True)
ws.cell(row=summary_row, column=credit_col_idx).border = border
if locale == 'fa':
ws.cell(row=summary_row, column=credit_col_idx).alignment = Alignment(horizontal="right")
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "journal_ledger_report"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/journal-ledger/export/electronic-books",
summary="خروجی دفتر روزنامه الکترونیکی (سازمان امور مالیاتی)",
description=(
"خروجی Excel یا CSV دفتر روزنامه مطابق قالب سامانه دفاتر تجاری الکترونیکی. "
"ستون‌ها، ترتیب و فرمت تاریخ/مبالغ ریالی مطابق استاندارد رسمی است."
),
)
@require_business_access("business_id")
async def export_journal_ledger_electronic_books(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی دفتر روزنامه الکترونیکی برای بارگذاری در سامانه مالیاتی"""
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
fiscal_year_id = None
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get("fiscal_year_id"):
try:
fiscal_year_id = int(body["fiscal_year_id"])
except (ValueError, TypeError):
pass
date_from = body.get("date_from")
date_to = body.get("date_to")
currency_id = body.get("currency_id")
document_type = body.get("document_type")
include_proforma = body.get("include_proforma", False)
export_format = body.get("format", "auto")
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
return export_electronic_journal_books(
db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
document_type=document_type,
include_proforma=include_proforma,
export_format=str(export_format),
)
@router.post(
"/businesses/{business_id}/reports/journal-ledger/export/pdf",
summary="خروجی PDF گزارش دفتر روزنامه",
description="خروجی PDF گزارش دفتر روزنامه با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_journal_ledger_report_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی PDF گزارش دفتر روزنامه"""
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from html import escape
import datetime
import re
import json
from adapters.db.models.business import Business
from app.core.calendar import CalendarConverter, CalendarType
# بررسی دسترسی
if not ctx.can_read_section("reports"):
raise ApiError("FORBIDDEN", "Missing business permission: reports.read", http_status=403)
# دریافت calendar_type از request state
calendar_type: CalendarType = "gregorian"
if hasattr(request.state, 'calendar_type'):
calendar_type = request.state.calendar_type
# دریافت سال مالی از header یا body
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
if body.get('fiscal_year_id'):
try:
fiscal_year_id = int(body['fiscal_year_id'])
except (ValueError, TypeError):
pass
# استخراج پارامترها از body
date_from = body.get('date_from')
date_to = body.get('date_to')
currency_id = body.get('currency_id')
document_type = body.get('document_type')
include_proforma = body.get('include_proforma', False)
if currency_id is not None:
try:
currency_id = int(currency_id)
except (ValueError, TypeError):
currency_id = None
# برای export، همه رکوردها را بدون pagination می‌گیریم
max_export_records = 10000
result = get_journal_ledger_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from=date_from,
date_to=date_to,
document_type=document_type,
include_proforma=include_proforma,
skip=0,
take=max_export_records,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Helper function to format date based on calendar type
def format_date_for_export(item_dict: dict, date_key: str) -> str:
"""Format date based on calendar type (date only, no time)"""
formatted_key = f"{date_key}_formatted"
if formatted_key in item_dict:
formatted_value = item_dict.get(formatted_key)
if isinstance(formatted_value, dict):
date_only = formatted_value.get("date_only")
if date_only:
return str(date_only)
formatted = formatted_value.get("formatted", "")
if formatted:
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
value = item_dict.get(date_key)
if value is None:
return ""
if isinstance(value, dict):
date_only = value.get("date_only")
if date_only:
return str(date_only)
formatted = value.get("formatted", "")
if formatted:
date_part = str(formatted).split(' ')[0].split('T')[0]
return date_part
if isinstance(value, datetime.datetime):
try:
formatted = CalendarConverter.format_datetime(value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
if isinstance(value, datetime.date):
try:
dt_value = datetime.datetime.combine(value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
if isinstance(value, str):
if '/' in value and (len(value.split('/')) == 3):
if '-' in value:
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
pass
else:
if ' ' in value:
return value.split(' ')[0]
return value
else:
try:
if 'T' in value:
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
date_value = datetime.date.fromisoformat(value)
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
except Exception:
if ' ' in value or 'T' in value:
date_part = value.split(' ')[0].split('T')[0]
return date_part
return value
return str(value) if value else ""
# Helper function to get document type name in Persian
def get_document_type_name_fa(doc_type: str | None) -> str:
"""تبدیل نوع سند به نام فارسی"""
if not doc_type:
return ''
mapping = {
'invoice_sales': 'فاکتور فروش',
'invoice_sales_return': 'برگشت از فروش',
'invoice_purchase': 'فاکتور خرید',
'invoice_purchase_return': 'برگشت از خرید',
'invoice_production': 'فاکتور تولید',
'invoice_direct_consumption': 'مصرف مستقیم',
'invoice_waste': 'ضایعات',
'receipt': 'دریافت',
'payment': 'پرداخت',
'transfer': 'انتقال',
'expense': 'هزینه',
'income': 'درآمد',
'expense_income': 'درآمد/هزینه',
'opening_balance': 'تراز افتتاحیه',
'manual': 'سند دستی',
'manual_document': 'سند دستی',
'received_loan_facility': 'تسهیلات دریافتی',
'check_endorse': 'پاسخگویی چک',
'check_clear': 'وصول چک',
'check_pay': 'پرداخت چک',
'check_return': 'برگشت چک',
'check_bounce': 'برگشت خوردن چک',
'check_deposit': 'واریز به حساب',
'check_delete': 'حذف چک',
}
return mapping.get(doc_type, doc_type)
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Check if we hit the limit
if len(items) >= max_export_records:
warning_item = {
'document_date': '',
'document_code': '⚠️',
'document_type_name': 'حداکثر ۱۰,۰۰۰ رکورد قابل export است',
'description': '',
'general_account_code': '',
'general_account_name': '',
'subsidiary_account_code': '',
'subsidiary_account_name': '',
'debit_account_code': '',
'debit_account_name': '',
'debit_amount': '',
'credit_account_code': '',
'credit_account_name': '',
'credit_amount': '',
'person_name': '',
}
items.append(warning_item)
# Get locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == 'fa'
# Prepare headers based on export_columns
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
default_columns = [
('document_date', 'تاریخ سند' if is_fa else 'Document Date'),
('document_type_name', 'نوع سند' if is_fa else 'Document Type'),
('document_code', 'شماره سند' if is_fa else 'Document Code'),
('description', 'شرح' if is_fa else 'Description'),
('general_account_code', 'کد حساب کل' if is_fa else 'General Account Code'),
('general_account_name', 'عنوان حساب کل' if is_fa else 'General Account Name'),
('subsidiary_account_code', 'کد حساب معین' if is_fa else 'Subsidiary Account Code'),
('subsidiary_account_name', 'عنوان حساب معین' if is_fa else 'Subsidiary Account Name'),
('debit_account', 'حساب بدهکار' if is_fa else 'Debit Account'),
('debit_amount', 'مبلغ بدهکار' if is_fa else 'Debit Amount'),
('credit_account', 'حساب بستانکار' if is_fa else 'Credit Account'),
('credit_amount', 'مبلغ بستانکار' if is_fa else 'Credit Amount'),
('person_name', 'طرف حساب' if is_fa else 'Counterpart'),
]
for key, label in default_columns:
keys.append(key)
headers.append(label)
# Get business name
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 = ""
# Prepare data for HTML
calendar_type = resolve_calendar_type_for_request(request, is_fa)
now = format_generated_at_for_pdf(business_id, calendar_type)
title_text = "گزارش دفتر روزنامه" if is_fa else "Journal Ledger Report"
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,
get_default_print_footer_flags,
)
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,
)
journal_show_gen_date_header, _ = get_default_print_footer_flags(db, business_id)
except Exception:
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
journal_show_gen_date_header = True
# Create headers HTML
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
# Create rows HTML
rows_html = []
for item in items:
row_cells = []
for key in keys:
value = item.get(key, "")
# Format dates based on calendar type
if key == 'document_date' and value:
value = format_date_for_export(item, 'document_date')
# Format document type name (ensure it's in Persian)
if key == 'document_type_name':
doc_type = item.get('document_type', '')
if doc_type:
value = get_document_type_name_fa(doc_type)
elif value:
has_persian = any('\u0600' <= c <= '\u06FF' for c in str(value))
if not has_persian:
value = get_document_type_name_fa(value)
else:
value = ""
# Format numbers
if key in ['debit_amount', 'credit_amount'] and value:
try:
num_value = float(value) if not isinstance(value, (int, float)) else value
value = f"{num_value:,.0f}" if is_fa else f"{num_value:,.2f}"
except (ValueError, TypeError):
pass
# Handle debit_account and credit_account (they might be formatted as "code - name")
if key in ['debit_account', 'credit_account']:
debit_code = item.get('debit_account_code', '')
debit_name = item.get('debit_account_name', '')
credit_code = item.get('credit_account_code', '')
credit_name = item.get('credit_account_name', '')
if key == 'debit_account':
if debit_code and debit_name:
value = f"{debit_code} - {debit_name}"
elif debit_code:
value = debit_code
elif debit_name:
value = debit_name
elif key == 'credit_account':
if credit_code and credit_name:
value = f"{credit_code} - {credit_name}"
elif credit_code:
value = credit_code
elif credit_name:
value = credit_name
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# Add summary row
summary = result.get('summary', {})
total_debit = summary.get('total_debit', 0)
total_credit = summary.get('total_credit', 0)
# Find debit_amount and credit_amount column indices
debit_col_idx = None
credit_col_idx = None
for idx, key in enumerate(keys):
if key == 'debit_amount':
debit_col_idx = idx
elif key == 'credit_amount':
credit_col_idx = idx
# ساخت summary row (جدا از rows_html تا در آخرین صفحه قرار بگیرد)
summary_row_cells = ['<td></td>'] * len(keys)
summary_row_cells[0] = f'<td><strong>{"جمع" if is_fa else "Total"}</strong></td>'
if debit_col_idx is not None:
debit_format = f"{total_debit:,.0f}" if is_fa else f"{total_debit:,.2f}"
summary_row_cells[debit_col_idx] = f'<td><strong>{debit_format}</strong></td>'
if credit_col_idx is not None:
credit_format = f"{total_credit:,.0f}" if is_fa else f"{total_credit:,.2f}"
summary_row_cells[credit_col_idx] = f'<td><strong>{credit_format}</strong></td>'
summary_row_html = f'<tr class="summary-row">{"".join(summary_row_cells)}</tr>'
# کانتکست برای قالب سفارشی لیست
template_context: Dict[str, Any] = {
"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),
"summary": summary,
}
# تلاش برای رندر با قالب سفارشی (journal_ledger/list)
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="journal_ledger",
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 = body.get("paper_size") or "A4"
orientation = body.get("orientation") or "landscape" # پیش‌فرض افقی
# تقسیم سطرها به صفحات (حداکثر 25 سطر در هر صفحه برای A4 landscape)
rows_per_page = 25
total_rows = len(rows_html)
pages_data = []
for i in range(0, total_rows, rows_per_page):
page_rows = rows_html[i:i + rows_per_page]
pages_data.append(page_rows)
# ساخت HTML برای هر صفحه با header در هر صفحه
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
pages_html = []
for page_num, page_rows in enumerate(pages_data, 1):
is_last_page = page_num == len(pages_data)
# در آخرین صفحه، summary row را اضافه می‌کنیم
tbody_content = "".join(page_rows)
if is_last_page:
tbody_content += summary_row_html
page_html = f"""
<div class="page-container">
<div class="header">
<div>
<div class="title">{title_text}</div>
{f'<div class="meta"><strong>{label_biz}:</strong> {escape(business_name)}</div>' if business_name else ''}
</div>
{f'<div class="meta">{label_date}: {now}</div>' if journal_show_gen_date_header else ''}
</div>
<table class="report-table">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{tbody_content}
</tbody>
</table>
{f'<div class="footer">{footer_text}</div>' if is_last_page else ''}
</div>
"""
pages_html.append(page_html)
# Use default template if custom template not found
final_html = resolved_html or f"""
<!DOCTYPE html>
<html lang="{'fa' if is_fa else 'en'}" dir="{'rtl' if is_fa else 'ltr'}">
<head>
<meta charset="UTF-8">
<title>{title_text}</title>
<style>
@page {{
size: {paper_size} {orientation};
margin: 12mm;
@bottom-{'left' if is_fa else 'right'} {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
direction: {'rtl' if is_fa else 'ltr'};
font-size: 11px;
color: #222;
margin: 0;
padding: 0;
}}
.page-container {{
page-break-after: always;
min-height: 100%;
}}
.page-container:last-child {{
page-break-after: auto;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
color: #366092;
margin-bottom: 4px;
}}
.meta {{
font-size: 11px;
color: #555;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 10px;
}}
thead {{
display: table-header-group;
}}
thead th {{
background: #366092;
color: white;
border: 1px solid #2a4a6e;
padding: 8px 6px;
text-align: {'right' if is_fa else 'left'};
font-weight: 700;
white-space: nowrap;
font-size: 10px;
}}
tbody tr {{
page-break-inside: avoid;
break-inside: avoid;
}}
tbody tr:nth-child(even) {{
background-color: #f2f2f2;
}}
tbody tr.summary-row {{
background-color: #e8f4f8;
font-weight: bold;
page-break-inside: avoid;
}}
tbody tr.summary-row td {{
border-top: 2px solid #366092;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 6px 5px;
text-align: {'right' if is_fa else 'left'};
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
font-size: 10px;
}}
.footer {{
margin-top: 10px;
font-size: 10px;
color: #666;
text-align: center;
padding-top: 10px;
border-top: 1px solid #ddd;
}}
</style>
</head>
<body>
{"".join(pages_html)}
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
# Build meaningful filename
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "journal_ledger_report"
if business_name:
base += f"_{slugify(business_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{export_filename_timestamp(business_id)}.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",
},
)
from adapters.api.v1.report_list_exports import register_document_list_exports
register_document_list_exports(router)