hesabixArc/hesabixAPI/adapters/api/v1/documents.py
2025-11-09 05:16:37 +00:00

725 lines
26 KiB
Python

"""
API endpoints برای مدیریت اسناد حسابداری (General Accounting Documents)
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access, require_business_management_dep
from app.core.responses import success_response, format_datetime_fields, ApiError
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 adapters.api.v1.schema_models.document import (
CreateManualDocumentRequest,
UpdateManualDocumentRequest,
)
router = APIRouter(tags=["documents"])
@router.post(
"/businesses/{business_id}/documents",
summary="لیست اسناد حسابداری",
description="دریافت لیست تمام اسناد حسابداری (عمومی و اتوماتیک) با فیلتر و صفحه‌بندی",
)
@require_business_access("business_id")
async def list_documents_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
لیست اسناد حسابداری
Body parameters:
- document_type: نوع سند (expense, income, receipt, payment, transfer, manual)
- fiscal_year_id: شناسه سال مالی
- from_date: از تاریخ (ISO format)
- to_date: تا تاریخ (ISO format)
- currency_id: شناسه ارز
- is_proforma: پیش‌فاکتور یا قطعی
- search: جستجو در کد سند و توضیحات
- sort_by: فیلد مرتب‌سازی (document_date, code, document_type, created_at)
- sort_desc: ترتیب نزولی (true/false)
- take: تعداد رکورد (1-1000)
- skip: تعداد رکورد صرف‌نظر شده
"""
query_dict: Dict[str, Any] = {
"take": body.get("take", 50),
"skip": body.get("skip", 0),
"sort_by": body.get("sort_by", "document_date"),
"sort_desc": body.get("sort_desc", True),
"search": body.get("search"),
}
# فیلترهای اضافی
for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]:
if key in body:
query_dict[key] = body[key]
# سال مالی از header
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
query_dict["fiscal_year_id"] = body["fiscal_year_id"]
except Exception:
pass
result = list_documents(db, business_id, query_dict)
# فرمت کردن تاریخ‌ها
result["items"] = [
format_datetime_fields(item, request) for item in result.get("items", [])
]
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"]:
if key in body:
filters[key] = body[key]
# سال مالی از header یا body
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
filters["fiscal_year_id"] = body["fiscal_year_id"]
except Exception:
pass
# دریافت داده‌ها
result = list_documents(db, business_id, {**filters, "take": body.get("take", 1000), "skip": body.get("skip", 0)})
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"
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "لیست اسناد حسابداری" if is_fa else "Documents List"
label_biz = "کسب و کار" if is_fa else "Business"
label_date = "تاریخ تولید" if is_fa else "Generated Date"
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 پیش‌فرض
default_html = f"""
<!DOCTYPE html>
<html dir='{"rtl" if is_fa else "ltr"}'>
<head>
<meta charset="utf-8" />
<style>
@page {{ margin: 1cm; size: A4; }}
body {{ font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'}; font-size: 12px; color: #222; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
thead {{ background: #f6f6f6; }}
.meta {{ font-size: 11px; color: #666; }}
</style>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;border-bottom:2px solid #366092;padding-bottom:8px">
<div>
<div style="font-size:18px;font-weight:bold;color:#366092">{title_text}</div>
<div class="meta">{label_biz}: {escape(business_name)}</div>
</div>
<div class="meta">{label_date}: {escape(now)}</div>
</div>
<table>
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
<div class="meta" style="margin-top:8px;text-align:{'left' if is_fa else 'right'}">{footer_text}</div>
</body>
</html>
"""
html_content = resolved_html or default_html
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
filename = f"documents_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; 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),
_: None = Depends(require_business_management_dep),
):
"""
حذف گروهی اسناد
Body:
document_ids: لیست شناسه‌های سند
توجه: اسناد اتوماتیک نادیده گرفته می‌شوند و باید از منبع اصلی حذف شوند.
"""
document_ids = body.get("document_ids", [])
if not document_ids:
raise ApiError(
"INVALID_REQUEST",
"document_ids is required",
http_status=400
)
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"]:
if key in body:
filters[key] = body[key]
# سال مالی از header
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
filters["fiscal_year_id"] = body["fiscal_year_id"]
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 یک سند
TODO: پیاده‌سازی تولید 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"
now = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
# کانتکست قالب
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"),
"description": doc.get("description"),
"generated_at": now,
"is_fa": is_fa,
}
# تلاش برای رندر
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)
except Exception:
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:
resolved_html = None
# پیش‌فرض
default_html = f"""
<!DOCTYPE html>
<html dir='{"rtl" if is_fa else "ltr"}'>
<head>
<meta charset="utf-8" />
<style>
body {{ font-family: Tahoma, Arial, sans-serif; font-size: 12px; color: #222; }}
h1 {{ font-size: 18px; margin: 0 0 12px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
th {{ background: #f6f6f6; }}
.meta .label {{ color: #666; }}
</style>
</head>
<body>
<h1>{escape(doc.get("document_type_name") or ("سند" if is_fa else "Document"))}</h1>
<div class="meta">
<div><span class="label">{'کسب‌وکار' if is_fa else 'Business'}:</span> {escape(business_name or "-")}</div>
<div><span class="label">{'کد' if is_fa else 'Code'}:</span> {escape(doc.get("code") or "-")}</div>
<div><span class="label">{'تاریخ' if is_fa else 'Date'}:</span> {escape(doc.get("document_date") or "-")}</div>
</div>
<table>
<thead>
<tr>
<th>{'شرح' if is_fa else 'Description'}</th>
<th>{'بدهکار' if is_fa else 'Debit'}</th>
<th>{'بستانکار' if is_fa else 'Credit'}</th>
</tr>
</thead>
<tbody>
{''.join([
f"<tr><td>{escape(str(line.get('description') or '-'))}</td><td>{escape(str(line.get('debit') or ''))}</td><td>{escape(str(line.get('credit') or ''))}</td></tr>"
for line in (doc.get('lines') or [])
])}
</tbody>
</table>
</body>
</html>
"""
html_content = resolved_html or default_html
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
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'))}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.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}/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_management_dep),
):
"""
ایجاد سند حسابداری دستی
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 سطر داشته باشد
- هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر)
"""
# دریافت سال مالی از header یا body
fiscal_year_id = body.fiscal_year_id
if not fiscal_year_id:
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
fiscal_year_id = int(fy_header)
except Exception:
pass
# اگر fiscal_year_id نبود، سال مالی فعال (is_last=True) را پیدا کن
if not fiscal_year_id:
from adapters.db.models.fiscal_year import FiscalYear
active_fy = db.query(FiscalYear).filter(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
).first()
if active_fy:
fiscal_year_id = active_fy.id
else:
raise ApiError(
"FISCAL_YEAR_REQUIRED",
"No active fiscal year found for this business. Please create a fiscal year first.",
http_status=400
)
# تبدیل 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_management_dep),
):
"""
ویرایش سند حسابداری دستی
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)
# تبدیل 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"
)