hesabixArc/hesabixAPI/app/services/document_service.py
2025-10-27 18:47:45 +00:00

537 lines
17 KiB
Python

"""
سرویس مدیریت اسناد حسابداری عمومی (General Accounting Documents)
این سرویس برای مدیریت تمام اسناد حسابداری (عمومی و اتوماتیک) استفاده می‌شود.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime, date
import logging
from sqlalchemy.orm import Session
from adapters.db.repositories.document_repository import DocumentRepository
from app.core.responses import ApiError
logger = logging.getLogger(__name__)
def list_documents(
db: Session,
business_id: int,
query: Dict[str, Any],
) -> Dict[str, Any]:
"""
دریافت لیست اسناد حسابداری با فیلتر و صفحه‌بندی
Args:
db: جلسه دیتابیس
business_id: شناسه کسب‌وکار
query: فیلترها و تنظیمات:
- document_type: نوع سند (expense, income, receipt, payment, transfer, manual, ...)
- fiscal_year_id: شناسه سال مالی
- from_date: از تاریخ
- to_date: تا تاریخ
- currency_id: شناسه ارز
- is_proforma: پیش‌فاکتور یا قطعی
- search: عبارت جستجو
- sort_by: فیلد مرتب‌سازی
- sort_desc: ترتیب نزولی
- take: تعداد رکورد در هر صفحه
- skip: تعداد رکورد صرف‌نظر شده
Returns:
دیکشنری حاوی items و pagination
"""
repo = DocumentRepository(db)
# دریافت لیست اسناد
documents, total = repo.list_documents_with_filters(business_id, query)
# محاسبه pagination
take = query.get("take", 50)
skip = query.get("skip", 0)
page = (skip // take) + 1
total_pages = (total + take - 1) // take
return {
"items": documents,
"pagination": {
"total": total,
"page": page,
"per_page": take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
}
def get_document(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
"""
دریافت جزئیات کامل یک سند شامل سطرهای سند
Args:
db: جلسه دیتابیس
document_id: شناسه سند
Returns:
دیکشنری حاوی اطلاعات کامل سند یا None
"""
repo = DocumentRepository(db)
return repo.get_document_details(document_id)
def delete_document(db: Session, document_id: int) -> bool:
"""
حذف یک سند حسابداری
توجه: فقط اسناد عمومی (manual) قابل حذف هستند
Args:
db: جلسه دیتابیس
document_id: شناسه سند
Returns:
True در صورت موفقیت، False در غیر این صورت
Raises:
ApiError: در صورت عدم وجود سند یا عدم امکان حذف
"""
repo = DocumentRepository(db)
# بررسی وجود سند
document = repo.get_document(document_id)
if not document:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Document not found",
http_status=404
)
# بررسی نوع سند - فقط اسناد manual قابل حذف هستند
if document.document_type != "manual":
raise ApiError(
"CANNOT_DELETE_AUTO_DOCUMENT",
"Cannot delete automatically generated documents. Please delete from the original source.",
http_status=400
)
# حذف سند
success = repo.delete_document(document_id)
if not success:
raise ApiError(
"DELETE_FAILED",
"Failed to delete document",
http_status=500
)
return True
def delete_multiple_documents(db: Session, document_ids: List[int]) -> Dict[str, Any]:
"""
حذف گروهی اسناد حسابداری
فقط اسناد عمومی (manual) حذف می‌شوند، اسناد اتوماتیک نادیده گرفته می‌شوند
Args:
db: جلسه دیتابیس
document_ids: لیست شناسه‌های سند
Returns:
دیکشنری حاوی تعداد حذف شده و خطاها
"""
repo = DocumentRepository(db)
deleted_count = 0
errors = []
skipped_auto = []
for doc_id in document_ids:
try:
document = repo.get_document(doc_id)
if not document:
errors.append({"id": doc_id, "error": "Document not found"})
continue
# بررسی نوع سند
if document.document_type != "manual":
skipped_auto.append({
"id": doc_id,
"type": document.document_type,
"code": document.code
})
continue
# حذف سند
if repo.delete_document(doc_id):
deleted_count += 1
else:
errors.append({"id": doc_id, "error": "Delete failed"})
except Exception as e:
logger.error(f"Error deleting document {doc_id}: {str(e)}")
errors.append({"id": doc_id, "error": str(e)})
return {
"deleted_count": deleted_count,
"total_requested": len(document_ids),
"errors": errors,
"skipped_auto_documents": skipped_auto,
}
def get_document_types_summary(db: Session, business_id: int) -> Dict[str, Any]:
"""
دریافت خلاصه آماری انواع اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسب‌وکار
Returns:
دیکشنری حاوی آمار هر نوع سند
"""
from adapters.db.models.document import Document
from sqlalchemy import func
results = (
db.query(
Document.document_type,
func.count(Document.id).label("count")
)
.filter(Document.business_id == business_id)
.group_by(Document.document_type)
.all()
)
summary = {}
for row in results:
summary[row.document_type] = row.count
return summary
def export_documents_excel(
db: Session,
business_id: int,
filters: Dict[str, Any],
) -> bytes:
"""
خروجی Excel لیست اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسب‌وکار
filters: فیلترها
Returns:
محتوای فایل Excel به صورت bytes
"""
try:
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
repo = DocumentRepository(db)
# دریافت تمام اسناد (بدون pagination)
filters_copy = filters.copy()
filters_copy["take"] = 10000
filters_copy["skip"] = 0
documents, _ = repo.list_documents_with_filters(business_id, filters_copy)
# ایجاد Workbook
wb = Workbook()
ws = wb.active
ws.title = "Documents"
# تنظیم راست‌چین برای فارسی
ws.sheet_view.rightToLeft = True
# هدر
headers = [
"شماره سند",
"نوع سند",
"تاریخ سند",
"سال مالی",
"ارز",
"بدهکار",
"بستانکار",
"وضعیت",
"توضیحات",
"تاریخ ثبت",
]
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF", size=12)
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# داده‌ها
for row_num, doc in enumerate(documents, 2):
ws.cell(row=row_num, column=1, value=doc.get("code"))
ws.cell(row=row_num, column=2, value=doc.get("document_type"))
ws.cell(row=row_num, column=3, value=str(doc.get("document_date")))
ws.cell(row=row_num, column=4, value=doc.get("fiscal_year_title"))
ws.cell(row=row_num, column=5, value=doc.get("currency_code"))
ws.cell(row=row_num, column=6, value=doc.get("total_debit", 0))
ws.cell(row=row_num, column=7, value=doc.get("total_credit", 0))
ws.cell(row=row_num, column=8, value="پیش‌فاکتور" if doc.get("is_proforma") else "قطعی")
ws.cell(row=row_num, column=9, value=doc.get("description", ""))
ws.cell(row=row_num, column=10, value=str(doc.get("created_at")))
# تنظیم عرض ستون‌ها
ws.column_dimensions["A"].width = 15
ws.column_dimensions["B"].width = 15
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 20
ws.column_dimensions["E"].width = 10
ws.column_dimensions["F"].width = 15
ws.column_dimensions["G"].width = 15
ws.column_dimensions["H"].width = 12
ws.column_dimensions["I"].width = 30
ws.column_dimensions["J"].width = 20
# ذخیره در حافظه
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
except ImportError:
raise ApiError(
"OPENPYXL_NOT_INSTALLED",
"openpyxl library is not installed",
http_status=500
)
except Exception as e:
logger.error(f"Error generating Excel: {str(e)}")
raise ApiError(
"EXCEL_GENERATION_FAILED",
f"Failed to generate Excel file: {str(e)}",
http_status=500
)
def export_documents_pdf(
db: Session,
business_id: int,
filters: Dict[str, Any],
) -> bytes:
"""
خروجی PDF لیست اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسب‌وکار
filters: فیلترها
Returns:
محتوای فایل PDF به صورت bytes
"""
# TODO: پیاده‌سازی export PDF
# می‌توان از WeasyPrint یا ReportLab استفاده کرد
raise ApiError(
"NOT_IMPLEMENTED",
"PDF export is not implemented yet",
http_status=501
)
def create_manual_document(
db: Session,
business_id: int,
fiscal_year_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""
ایجاد سند حسابداری دستی جدید
Args:
db: جلسه دیتابیس
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی
user_id: شناسه کاربر ایجادکننده
data: اطلاعات سند و سطرها
Returns:
دیکشنری حاوی اطلاعات کامل سند ایجاد شده
Raises:
ApiError: در صورت بروز خطا
"""
repo = DocumentRepository(db)
# اعتبارسنجی سطرهای سند
lines_data = data.get("lines", [])
is_valid, error_msg = repo.validate_document_balance(lines_data)
if not is_valid:
raise ApiError(
"INVALID_DOCUMENT",
error_msg,
http_status=400
)
# تولید کد سند اگر وجود نداشت
code = data.get("code")
if not code:
code = repo.generate_document_code(business_id, "manual")
# تبدیل lines به فرمت مناسب repository
lines_for_db = []
for line in lines_data:
line_dict = {
"account_id": line.get("account_id"),
"person_id": line.get("person_id"),
"product_id": line.get("product_id"),
"bank_account_id": line.get("bank_account_id"),
"cash_register_id": line.get("cash_register_id"),
"petty_cash_id": line.get("petty_cash_id"),
"check_id": line.get("check_id"),
"quantity": line.get("quantity"),
"debit": line.get("debit", 0),
"credit": line.get("credit", 0),
"description": line.get("description"),
"extra_info": line.get("extra_info"),
}
lines_for_db.append(line_dict)
# آماده‌سازی داده‌های سند
document_data = {
"code": code,
"business_id": business_id,
"fiscal_year_id": fiscal_year_id,
"currency_id": data.get("currency_id"),
"created_by_user_id": user_id,
"document_date": data.get("document_date"),
"document_type": "manual",
"is_proforma": data.get("is_proforma", False),
"description": data.get("description"),
"extra_info": data.get("extra_info"),
"lines": lines_for_db,
}
try:
# ایجاد سند
document = repo.create_document(document_data)
# دریافت جزئیات کامل سند (با روابط)
return repo.get_document_details(document.id)
except Exception as e:
logger.error(f"Error creating manual document: {str(e)}")
db.rollback()
raise ApiError(
"CREATE_DOCUMENT_FAILED",
f"Failed to create document: {str(e)}",
http_status=500
)
def update_manual_document(
db: Session,
document_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""
ویرایش سند حسابداری دستی
Args:
db: جلسه دیتابیس
document_id: شناسه سند
data: اطلاعات جدید سند
Returns:
دیکشنری حاوی اطلاعات کامل سند ویرایش شده
Raises:
ApiError: در صورت بروز خطا
"""
repo = DocumentRepository(db)
# بررسی وجود سند
document = repo.get_document(document_id)
if not document:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Document not found",
http_status=404
)
# بررسی اینکه فقط اسناد manual قابل ویرایش هستند
if document.document_type != "manual":
raise ApiError(
"CANNOT_EDIT_AUTO_DOCUMENT",
"Cannot edit automatically generated documents. Please edit from the original source.",
http_status=400
)
# اگر سطرها ارسال شده، اعتبارسنجی کن
if "lines" in data and data["lines"] is not None:
lines_data = data["lines"]
is_valid, error_msg = repo.validate_document_balance(lines_data)
if not is_valid:
raise ApiError(
"INVALID_DOCUMENT",
error_msg,
http_status=400
)
# تبدیل lines به فرمت مناسب repository
lines_for_db = []
for line in lines_data:
line_dict = {
"account_id": line.get("account_id"),
"person_id": line.get("person_id"),
"product_id": line.get("product_id"),
"bank_account_id": line.get("bank_account_id"),
"cash_register_id": line.get("cash_register_id"),
"petty_cash_id": line.get("petty_cash_id"),
"check_id": line.get("check_id"),
"quantity": line.get("quantity"),
"debit": line.get("debit", 0),
"credit": line.get("credit", 0),
"description": line.get("description"),
"extra_info": line.get("extra_info"),
}
lines_for_db.append(line_dict)
data["lines"] = lines_for_db
try:
# ویرایش سند
updated_document = repo.update_document(document_id, data)
if not updated_document:
raise ApiError(
"UPDATE_FAILED",
"Failed to update document",
http_status=500
)
# دریافت جزئیات کامل سند
return repo.get_document_details(updated_document.id)
except ApiError:
raise
except Exception as e:
logger.error(f"Error updating manual document: {str(e)}")
db.rollback()
raise ApiError(
"UPDATE_DOCUMENT_FAILED",
f"Failed to update document: {str(e)}",
http_status=500
)