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

1085 lines
40 KiB
Python
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Request, Body, HTTPException
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.bank_account import BankAccount
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.core.permissions import require_business_management_dep, require_business_access, require_business_permission_dep, require_business_permission_by_entity_dep
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.list_query_common import QueryInfoBody, query_info_to_service_dict, apply_fiscal_year_from_request
from adapters.api.v1.schema_models.bank_account import (
BankAccountCreateRequest,
BankAccountUpdateRequest,
)
from app.services.bank_account_service import (
create_bank_account,
update_bank_account,
delete_bank_account,
get_bank_account_by_id,
list_bank_accounts,
bulk_delete_bank_accounts,
get_bank_accounts_turnover_report,
)
from app.core.cache import get_cache
router = APIRouter(prefix="/bank-accounts", tags=["مدیریت مالی"])
@router.post(
"/businesses/{business_id}/bank-accounts",
summary="لیست حساب‌های بانکی کسب‌وکار",
description="دریافت لیست حساب‌های بانکی یک کسب و کار با امکان جستجو و فیلتر",
)
@require_business_access("business_id")
async def list_bank_accounts_endpoint(
request: Request,
business_id: int,
query_info: QueryInfoBody,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
query_dict = query_info_to_service_dict(query_info)
apply_fiscal_year_from_request(request, query_dict)
fiscal_year_id = query_dict.get("fiscal_year_id")
# کش لیست حساب‌های بانکی
cache = get_cache()
cache_key = None
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"bank_accounts_list:{key_hash}"
cached = cache.get(cache_key)
if cached is not None:
return success_response(data=cached, request=request, message="BANK_ACCOUNTS_LIST_FETCHED")
result = list_bank_accounts(db, business_id, query_dict)
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
if cache.enabled and cache_key:
cache.set(cache_key, result, ttl=60)
return success_response(data=result, request=request, message="BANK_ACCOUNTS_LIST_FETCHED")
@router.post(
"/businesses/{business_id}/bank-accounts/create",
summary="ایجاد حساب بانکی جدید",
description="ایجاد حساب بانکی برای یک کسب‌وکار مشخص",
)
@require_business_access("business_id")
async def create_bank_account_endpoint(
request: Request,
business_id: int,
body: BankAccountCreateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("bank_accounts", "add")),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
created = create_bank_account(db, business_id, payload)
return success_response(data=format_datetime_fields(created, request), request=request, message="BANK_ACCOUNT_CREATED")
@router.get(
"/bank-accounts/{account_id}",
summary="جزئیات حساب بانکی",
description="دریافت جزئیات حساب بانکی بر اساس شناسه",
)
async def get_bank_account_endpoint(
request: Request,
account_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("bank_accounts", "view", BankAccount, "account_id")),
):
result = get_bank_account_by_id(db, account_id)
if not result:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
# بررسی دسترسی به کسب وکار مرتبط
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_DETAILS")
@router.put(
"/bank-accounts/{account_id}",
summary="ویرایش حساب بانکی",
description="ویرایش اطلاعات حساب بانکی",
)
async def update_bank_account_endpoint(
request: Request,
account_id: int,
body: BankAccountUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("bank_accounts", "edit", BankAccount, "account_id")),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
result = update_bank_account(db, account_id, payload)
if result is None:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
# بررسی دسترسی به کسب وکار مرتبط
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_UPDATED")
@router.delete(
"/bank-accounts/{account_id}",
summary="حذف حساب بانکی",
description="حذف یک حساب بانکی",
)
async def delete_bank_account_endpoint(
request: Request,
account_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_by_entity_dep("bank_accounts", "delete", BankAccount, "account_id")),
):
# ابتدا بررسی دسترسی بر اساس business مربوط به حساب
result = get_bank_account_by_id(db, account_id)
if result:
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
from fastapi import HTTPException
success, error_message = delete_bank_account(db, account_id)
if not success:
if error_message:
raise HTTPException(status_code=400, detail=error_message)
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
return success_response(data=None, request=request, message="BANK_ACCOUNT_DELETED")
@router.post(
"/businesses/{business_id}/bank-accounts/bulk-delete",
summary="حذف گروهی حساب‌های بانکی",
description="حذف چندین حساب بانکی بر اساس شناسه‌ها",
)
@require_business_access("business_id")
async def bulk_delete_bank_accounts_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("bank_accounts", "delete")),
):
ids = body.get("ids")
if not isinstance(ids, list):
ids = []
try:
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
except Exception:
ids = []
if not ids:
return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
# فراخوانی تابع حذف گروهی از سرویس
result = bulk_delete_bank_accounts(db, business_id, ids)
return success_response(result, request, message="BANK_ACCOUNTS_BULK_DELETE_DONE")
@router.post(
"/businesses/{business_id}/bank-accounts/export/excel",
summary="خروجی Excel لیست حساب‌های بانکی",
description="خروجی Excel لیست حساب‌های بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_bank_accounts_excel(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from app.core.i18n import negotiate_locale
# دریافت داده‌ها از سرویس
query_dict = {
"take": int(body.get("take", 1000)), # برای export همه داده‌ها
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"sort": body.get("sort") if isinstance(body.get("sort"), list) else None,
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = list_bank_accounts(db, business_id, query_dict)
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Map currency_id -> currency title for display
try:
from adapters.db.models.currency import Currency
currency_ids = set()
for it in items:
cid = it.get("currency_id")
try:
if cid is not None:
currency_ids.add(int(cid))
except Exception:
pass
currency_map: Dict[int, str] = {}
if currency_ids:
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
for it in items:
cid = it.get("currency_id")
it["currency"] = currency_map.get(cid, cid)
except Exception:
for it in items:
if "currency" not in it and "currency_id" in it:
it["currency"] = it.get("currency_id")
headers: List[str] = [
"code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "currency", "is_active", "is_default"
]
wb = Workbook()
ws = wb.active
ws.title = "BankAccounts"
# RTL/LTR
locale = negotiate_locale(request.headers.get("Accept-Language"))
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'))
# Header
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
# Rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(headers, 1):
value = item.get(key, "")
if key in ("is_active", "is_default"):
try:
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
except Exception:
truthy = bool(value)
value = "✓" if truthy else "✗"
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# Auto-width
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": "attachment; filename=bank_accounts.xlsx",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/bank-accounts/export/pdf",
summary="خروجی PDF لیست حساب‌های بانکی",
description="خروجی PDF لیست حساب‌های بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_bank_accounts_pdf(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
# Build query dict similar to persons export
query_dict = {
"take": int(body.get("take", 1000)), # برای export همه داده‌ها
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"sort": body.get("sort") if isinstance(body.get("sort"), list) else None,
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
# دریافت داده‌ها از سرویس
result = list_bank_accounts(db, business_id, query_dict)
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Map currency_id -> currency title for display
try:
from adapters.db.models.currency import Currency
currency_ids = set()
for it in items:
cid = it.get("currency_id")
try:
if cid is not None:
currency_ids.add(int(cid))
except Exception:
pass
currency_map: Dict[int, str] = {}
if currency_ids:
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
for it in items:
cid = it.get("currency_id")
it["currency"] = currency_map.get(cid, cid)
except Exception:
for it in items:
if "currency" not in it and "currency_id" in it:
it["currency"] = it.get("currency_id")
# Selection handling
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):
import json
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/keys from export_columns (order + visibility)
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:
if str(key) == 'currency_id':
keys.append('currency')
else:
keys.append(str(key))
headers.append(str(label))
else:
if items:
keys = list(items[0].keys())
headers = keys
else:
keys = [
"code", "name", "branch", "account_number", "sheba_number",
"card_number", "owner_name", "pos_number", "currency", "is_active", "is_default",
]
headers = keys
# Load business info
business_name = ""
try:
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == business_id).first()
if biz is not None:
business_name = biz.name or ""
except Exception:
business_name = ""
# Locale and calendar-aware date
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'
try:
from app.core.calendar import CalendarConverter
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
formatted_now = CalendarConverter.format_datetime(
__import__("datetime").datetime.now(),
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian",
)
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
from datetime import datetime
now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
# Labels
title_text = "گزارش لیست حساب‌های بانکی" if is_fa else "Bank Accounts List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
def escape_val(v: Any) -> str:
try:
return str(v).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
except Exception:
return str(v)
rows_html: List[str] = []
for item in items:
tds = []
for key in keys:
value = item.get(key)
if value is None:
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif key in ("is_active", "is_default"):
try:
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
except Exception:
truthy = bool(value)
value = "✓" if truthy else "✗"
tds.append(f"<td>{escape_val(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape_val(h)}</th>" for h in headers)
# تلاش برای رندر با قالب سفارشی (bank_accounts/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
template_context = {
"title_text": title_text,
"business_name": business_name,
"generated_at": now_str,
"is_fa": is_fa,
"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="bank_accounts",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# Inject Persian fonts (same as invoice) for better rendering in reports
fa_font_url_regular = ""
fa_font_url_bold = ""
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()
fa_font_url_regular = fa_reg or ""
fa_font_url_bold = fa_bold or ""
except Exception:
fa_font_url_regular = ""
fa_font_url_bold = ""
font_face_css = ""
if is_fa and 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 is_fa and 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;
}}
"""
body_font_family = "YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif" if is_fa else "Arial, sans-serif"
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
{font_face_css}
@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: {body_font_family};
}}
}}
body {{
font-family: {body_font_family};
font-size: 11px;
color: #222;
}}
.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;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape_val(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape_val(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": "attachment; filename=bank_accounts.pdf",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/businesses/{business_id}/reports/bank-accounts-turnover",
summary="گزارش گردش حساب‌های بانکی",
description="گزارش برداشت‌ها و واریزهای هر حساب بانکی در یک بازه زمانی",
)
@require_business_access("business_id")
async def bank_accounts_turnover_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
if not fiscal_year_id:
from adapters.db.models.fiscal_year import FiscalYear
from sqlalchemy import and_
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
bank_account_ids = body.get('bank_account_ids')
if bank_account_ids is not None and not isinstance(bank_account_ids, list):
bank_account_ids = None
search = body.get('search')
# 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_bank_accounts_turnover_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,
bank_account_ids=bank_account_ids,
search=search,
skip=skip,
take=take,
)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
result['items'] = items
from app.core.i18n import negotiate_locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
return success_response(
data=result,
message="Bank accounts turnover report retrieved successfully" if locale != 'fa' else "گزارش گردش حساب‌های بانکی با موفقیت دریافت شد",
request=request
)
@router.post("/businesses/{business_id}/reports/bank-accounts-turnover/export/excel",
summary="خروجی Excel گزارش گردش حساب‌های بانکی",
description="خروجی Excel گزارش گردش حساب‌های بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_bank_accounts_turnover_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی Excel گزارش گردش حساب‌های بانکی"""
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
import io
import datetime
from app.core.i18n import negotiate_locale
max_export_records = 10000
# بررسی دسترسی
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
from sqlalchemy import and_
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
bank_account_ids = body.get('bank_account_ids')
if bank_account_ids is not None and not isinstance(bank_account_ids, list):
bank_account_ids = None
search = body.get('search')
# دریافت همه رکوردها برای export
result = get_bank_accounts_turnover_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,
bank_account_ids=bank_account_ids,
search=search,
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 ""
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
# تعریف ستون‌ها
columns = [
('document_date', 'تاریخ' if is_fa else 'Date'),
('document_type_name', 'نوع سند' if is_fa else 'Document Type'),
('document_code', 'شماره سند' if is_fa else 'Document Code'),
('bank_account_code', 'کد حساب' if is_fa else 'Account Code'),
('bank_account_name', 'نام حساب' if is_fa else 'Account Name'),
('deposit', 'واریز' if is_fa else 'Deposit'),
('withdrawal', 'برداشت' if is_fa else 'Withdrawal'),
('balance', 'مانده' if is_fa else 'Balance'),
('description', 'توضیحات' if is_fa else 'Description'),
]
wb = Workbook()
ws = wb.active
ws.title = "BankAccountsTurnover"
# RTL برای فارسی
if is_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'))
# Header
for col_idx, (key, label) in enumerate(columns, 1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Rows
for row_idx, item in enumerate(items, 2):
for col_idx, (key, _) in enumerate(columns, 1):
value = item.get(key, '')
# Format dates
if key == 'document_date' and value:
value = format_date_for_export(item, 'document_date')
if isinstance(value, (int, float)):
if key in ('deposit', 'withdrawal', 'balance'):
# Format numbers with thousand separators
value = f"{value:,.2f}"
elif value is None:
value = ''
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
if is_fa:
cell.alignment = Alignment(horizontal="right")
elif isinstance(value, str) and value.replace(',', '').replace('.', '').isdigit():
cell.alignment = Alignment(horizontal="right")
# Auto-width
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": "attachment; filename=bank_accounts_turnover.xlsx",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/reports/bank-accounts-turnover/export/pdf",
summary="خروجی PDF گزارش گردش حساب‌های بانکی",
)
@require_business_access("business_id")
async def export_bank_accounts_turnover_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")),
):
from app.services.turnover_report_export_service import (
BANK_TURNOVER_COLUMNS,
turnover_pdf_response,
)
bank_account_ids = body.get("bank_account_ids")
if bank_account_ids is not None and not isinstance(bank_account_ids, list):
bank_account_ids = None
return turnover_pdf_response(
request,
business_id,
body,
ctx,
db,
fetch_fn=get_bank_accounts_turnover_report,
columns=BANK_TURNOVER_COLUMNS,
filename_prefix="bank_accounts_turnover",
title_fa="گزارش گردش حساب‌های بانکی",
title_en="Bank Accounts Turnover Report",
fetch_kwargs={"bank_account_ids": bank_account_ids},
)