537 lines
17 KiB
Python
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
|
|
)
|
|
|