Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/year_end_closing_service.py

1885 lines
80 KiB
Python
Executable file

"""
سرویس بستن سال مالی (Year End Closing)
این سرویس برای بستن سال مالی و انتقال مانده‌ها به سال جدید استفاده می‌شود.
"""
from __future__ import annotations
import io
import json
import zipfile
from typing import Any, Dict, List, Optional, Tuple
from datetime import date, timedelta, datetime, timezone
from decimal import Decimal
import calendar
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, or_, inspect, text, update, cast, select
from sqlalchemy.types import Date as SQLADate
from fastapi import HTTPException, UploadFile
from adapters.db.repositories.document_repository import DocumentRepository
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.business import Business
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.warehouse import Warehouse
from adapters.db.models.warehouse_document import WarehouseDocument
from app.core.responses import ApiError
from app.services.opening_balance_service import post_opening_balance, upsert_opening_balance
from app.services.pnl_account_classification import (
is_pnl_expense_account,
is_pnl_expense_gl_account,
is_pnl_revenue_account,
is_pnl_revenue_gl_account,
)
from app.services.invoice_service import _compute_available_stock, _iter_product_movements
from app.services.file_storage_service import FileStorageService
from collections import defaultdict, deque
def _json_default(o: Any):
"""تابع helper برای JSON serialization"""
if isinstance(o, (datetime, date)):
return o.isoformat()
if isinstance(o, Decimal):
return str(o)
return str(o)
def _gregorian_same_calendar_day_next_year(d: date) -> date:
"""همان روز تقویمی در سال میلادی بعد (۲۹ فوریهٔ سال غیر کبیسه به آخر فوریه آن سال)."""
y = d.year + 1
if d.month == 2 and d.day == 29:
rd = 29 if calendar.isleap(y) else 28
return date(y, 2, rd)
return date(y, d.month, d.day)
def _fiscal_year_end_inclusive_from_start_gregorian(start: date) -> date:
"""پایان سال مالی شامل: سالگرد میلادی یک سال بعد منهای یک روز."""
return _gregorian_same_calendar_day_next_year(start) - timedelta(days=1)
def _normalize_relocation_basis(document_relocation_basis: str) -> str:
b = (document_relocation_basis or "document_date").strip().lower()
if b not in ("document_date", "registered_at"):
return "document_date"
return b
def _resolve_closing_period_end(
fiscal_year: FiscalYear,
closing_fiscal_end_date: Optional[date],
) -> Tuple[date, date]:
"""
original_end: پایان تقویمی ذخیره‌شده روی رکورد سال مالی
effective_end: پایان دورهٔ بستن (شامل) — برای تقطیع سال، زودتر از original_end
"""
original_end = fiscal_year.end_date
if closing_fiscal_end_date is None:
return original_end, original_end
if closing_fiscal_end_date < fiscal_year.start_date:
raise ApiError(
"INVALID_CLOSING_END_DATE",
"تاریخ پایان دورهٔ بستن نمی‌تواند قبل از تاریخ شروع سال مالی باشد",
http_status=400,
)
if closing_fiscal_end_date > original_end:
raise ApiError(
"INVALID_CLOSING_END_DATE",
"تاریخ پایان دورهٔ بستن نمی‌تواند بعد از پایان تقویمی سال مالی جاری باشد",
http_status=400,
)
return original_end, closing_fiscal_end_date
def _post_cutoff_document_predicate(effective_end: date, document_relocation_basis: str):
"""شرط «سند بعد از پایان دورهٔ بستن» برای انتقال به سال جدید."""
basis = _normalize_relocation_basis(document_relocation_basis)
if basis == "registered_at":
return cast(Document.registered_at, SQLADate) > effective_end
return Document.document_date > effective_end
def _post_cutoff_warehouse_document_predicate(effective_end: date, document_relocation_basis: str):
"""همان سیاست تاریخ، برای حواله/رسید انبار (تاریخ سند انبار / روز ثبت)."""
basis = _normalize_relocation_basis(document_relocation_basis)
if basis == "registered_at":
return cast(WarehouseDocument.created_at, SQLADate) > effective_end
return WarehouseDocument.document_date > effective_end
def _discover_scoped_tables(db: Session) -> Dict[str, Dict[str, Any]]:
"""کشف جداول مرتبط با business_id"""
engine = db.get_bind()
inspector = inspect(engine)
tables_info: Dict[str, Dict[str, Any]] = {}
for table_name in inspector.get_table_names():
try:
cols = inspector.get_columns(table_name)
except Exception:
continue
col_names = {c["name"] for c in cols}
if "business_id" in col_names or table_name == "businesses":
pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns") or []
tables_info[table_name] = {
"columns": [c["name"] for c in cols],
"pk": pk_cols,
}
return tables_info
def _dump_business_data(db: Session, business_id: int) -> Dict[str, Any]:
"""استخراج داده‌های کسب‌وکار برای پشتیبان‌گیری (بدون داده مالی/اعتباری)"""
from adapters.db.models.business import Business
from app.services.business_backup_financial_policy import (
build_backup_metadata,
is_backup_excluded_table,
)
tables = _discover_scoped_tables(db)
data_out: Dict[str, List[Dict[str, Any]]] = {}
owner_id = None
biz = db.get(Business, int(business_id))
if biz is not None:
owner_id = getattr(biz, "owner_id", None)
for table_name, meta in tables.items():
if is_backup_excluded_table(table_name):
continue
if table_name == "businesses":
stmt = text(f"SELECT * FROM {table_name} WHERE id = :bid")
rows = [dict(r._mapping) for r in db.execute(stmt, {"bid": business_id}).all()]
else:
stmt = text(f"SELECT * FROM {table_name} WHERE business_id = :bid")
try:
rows = [dict(r._mapping) for r in db.execute(stmt, {"bid": business_id}).all()]
except Exception:
rows = []
data_out[table_name] = rows
metadata = build_backup_metadata(
business_id=int(business_id),
table_names=data_out.keys(),
owner_id=owner_id,
)
metadata["backup_reason"] = "year_end_closing"
return {"metadata": metadata, "tables": data_out}
def _user_facing_http_detail(exc: HTTPException) -> str:
"""متن خطای قابل‌فهم برای کاربر از HTTPException (بدون repr دیکشنری/JSON)."""
d = exc.detail
if isinstance(d, dict):
msg = d.get("message")
if isinstance(msg, str) and msg.strip():
return msg.strip()
if isinstance(d, str) and d.strip():
return d.strip()
return str(d)
async def _create_backup_before_closing(
db: Session,
business_id: int,
user_id: int,
fiscal_year_id: int,
) -> Dict[str, Any]:
"""
ایجاد پشتیبان همزمان قبل از بستن سال مالی
Returns:
dict: {"success": True, "backup_id": "...", "filename": "..."} یا {"success": False, "error": "..."}
"""
try:
# استخراج داده‌ها
snapshot = _dump_business_data(db, business_id)
# ساخت فایل ZIP
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("metadata.json", json.dumps(snapshot["metadata"], ensure_ascii=False, indent=2, default=_json_default))
for table_name, rows in snapshot["tables"].items():
out = io.StringIO()
for row in rows:
out.write(json.dumps(row, ensure_ascii=False, default=_json_default))
out.write("\n")
zf.writestr(f"tables/{table_name}.jsonl", out.getvalue().encode("utf-8"))
buf.seek(0)
filename = f"business_{business_id}_pre_closing_fy_{fiscal_year_id}_{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}.hbx"
# ذخیره فایل
storage = FileStorageService(db)
faux_upload = UploadFile(filename=filename, file=io.BytesIO(buf.getvalue()))
saved = await storage.upload_file(
faux_upload,
user_id=user_id,
module_context="business_backup",
developer_data={
"business_id": business_id,
"schema_version": snapshot["metadata"]["schema_version"],
"backup_reason": "year_end_closing",
"fiscal_year_id": fiscal_year_id,
},
is_temporary=False,
expires_in_days=3650,
business_id=business_id,
check_storage_limit=True,
)
return {
"success": True,
"backup_id": str(saved.get("id", "")),
"filename": filename,
"file_info": saved,
}
except HTTPException as e:
return {
"success": False,
"error": _user_facing_http_detail(e),
}
except Exception as e:
return {
"success": False,
"error": str(e),
}
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
"""دریافت حساب عمومی بر اساس کد"""
account = db.query(Account).filter(
and_(
Account.business_id == None, # noqa: E711
Account.code == str(account_code),
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", f"حساب با کد {account_code} یافت نشد", http_status=500)
return account
def _is_permanent_account(code: str) -> bool:
"""تشخیص اینکه آیا یک حساب، حساب دائمی است یا نه (گروه 1، 2، 3)"""
if not code:
return False
code_clean = str(code).strip()
return code_clean.startswith('1') or code_clean.startswith('2') or code_clean.startswith('3')
def _treasury_gl_code_for_cash_bank_line(
*,
bank_account_id: Optional[int],
cash_register_id: Optional[int],
petty_cash_id: Optional[int],
account_code: Optional[str],
) -> Optional[str]:
"""
فقط سطرهایی که روی حساب خزانهٔ متناظر (۱۰۲۰۳ بانک / ۱۰۲۰۲ صندوق / ۱۰۲۰۱ تنخواه)
ثبت شده‌اند برای انتقال ماندهٔ بانک/صندوق/تنخواه به تراز افتتاحیه لحاظ می‌شوند.
سطرهای موقت مثل کارمزد (۷۰۹۰۲) ممکن است bank_account_id داشته باشند ولی ماندهٔ بانک نیستند
و نباید سطر جداگانهٔ افتتاحیه با همان bank_account_id بسازند.
"""
code = (account_code or "").strip()
if not code:
return None
if bank_account_id is not None:
if code == "10203" or code.startswith("10203"):
return "10203"
return None
if cash_register_id is not None:
if code == "10202" or code.startswith("10202"):
return "10202"
return None
if petty_cash_id is not None:
if code == "10201" or code.startswith("10201"):
return "10201"
return None
return None
def _calculate_account_balance(
db: Session,
business_id: int,
account_id: int,
fiscal_year_id: int,
date_to: date,
) -> Dict[str, Decimal]:
"""
محاسبه مانده یک حساب از ابتدای سال مالی تا تاریخ مشخص
Returns:
{
'opening_debit': Decimal,
'opening_credit': Decimal,
'period_debit': Decimal,
'period_credit': Decimal,
'closing_balance': Decimal # برای درآمد: credit - debit، برای هزینه: debit - credit
}
"""
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
if not fiscal_year:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی یافت نشد", http_status=404)
date_from = fiscal_year.start_date
business = db.get(Business, int(business_id))
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
# کلید کش برای کاهش resolve نرخ در حلقه‌ها
fx_rate_cache: Dict[int, Decimal] = {}
def _line_amount_to_base(document: Document, amount: Decimal) -> Decimal:
amt = Decimal(str(amount or 0))
if amt == 0:
return Decimal(0)
base_currency_id = business.default_currency_id
if not base_currency_id:
return amt
if int(document.currency_id or 0) == int(base_currency_id):
return amt
doc_id = int(document.id)
cached_rate = fx_rate_cache.get(doc_id)
if cached_rate is not None:
return amt * cached_rate
rate = Decimal(1)
extra = document.extra_info or {}
fx = extra.get("fx") if isinstance(extra, dict) else None
if isinstance(fx, dict) and not fx.get("skipped") and fx.get("rate") is not None:
try:
candidate = Decimal(str(fx.get("rate")))
if candidate > 0:
rate = candidate
except Exception:
rate = Decimal(1)
else:
# در صورت نبود snapshot نرخ روی سند، همان سیاست تسعیر فاکتور را برای fallback استفاده می‌کنیم.
try:
from app.services.invoice_fx_revaluation import compute_fx_as_of_utc, get_fx_revaluation_policy
from app.services.business_currency_rate_service import resolve_rate_to_base
reg = document.registered_at or datetime.now(timezone.utc)
if reg.tzinfo is None:
reg = reg.replace(tzinfo=timezone.utc)
policy = get_fx_revaluation_policy(business)
as_of = compute_fx_as_of_utc(document.document_date, reg, policy)
rate_res = resolve_rate_to_base(
db, int(business_id), int(document.currency_id), as_of
)
candidate = rate_res.get("rate")
candidate_dec = candidate if isinstance(candidate, Decimal) else Decimal(str(candidate))
if candidate_dec > 0:
rate = candidate_dec
except Exception:
rate = Decimal(1)
fx_rate_cache[doc_id] = rate
return amt * rate
# محاسبه مانده ابتدای دوره (از تراز افتتاحیه) به ارز پایه
opening_rows = db.query(DocumentLine, Document).join(
Document, DocumentLine.document_id == Document.id
).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
Document.document_type == "opening_balance",
DocumentLine.account_id == account_id,
Document.is_proforma == False,
)
).all()
opening_debit = Decimal(0)
opening_credit = Decimal(0)
for line, document in opening_rows:
opening_debit += _line_amount_to_base(document, Decimal(str(line.debit or 0)))
opening_credit += _line_amount_to_base(document, Decimal(str(line.credit or 0)))
# محاسبه گردش دوره به ارز پایه
period_rows = db.query(DocumentLine, Document).join(
Document, DocumentLine.document_id == Document.id
).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
DocumentLine.account_id == account_id,
Document.document_date >= date_from,
Document.document_date <= date_to,
Document.is_proforma == False,
Document.document_type != "opening_balance", # تراز افتتاحیه جدا محاسبه شد
)
).all()
period_debit = Decimal(0)
period_credit = Decimal(0)
for line, document in period_rows:
period_debit += _line_amount_to_base(document, Decimal(str(line.debit or 0)))
period_credit += _line_amount_to_base(document, Decimal(str(line.credit or 0)))
# محاسبه مانده نهایی
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
closing_balance = Decimal(0)
elif is_pnl_revenue_account(account.code):
# برای حساب‌های درآمد: مانده = Credit - Debit
closing_balance = (opening_credit + period_credit) - (opening_debit + period_debit)
elif is_pnl_expense_account(account.code):
# برای حساب‌های هزینه: مانده = Debit - Credit
closing_balance = (opening_debit + period_debit) - (opening_credit + period_credit)
else:
closing_balance = Decimal(0)
return {
'opening_debit': opening_debit,
'opening_credit': opening_credit,
'period_debit': period_debit,
'period_credit': period_credit,
'closing_balance': closing_balance,
}
def preview_year_end_closing(
db: Session,
business_id: int,
fiscal_year_id: int,
closing_fiscal_end_date: Optional[date] = None,
document_relocation_basis: str = "document_date",
) -> Dict[str, Any]:
"""
پیش‌نمایش بستن سال مالی
این تابع مانده حساب‌های موقت را محاسبه می‌کند و اطلاعات لازم برای بستن را برمی‌گرداند.
"""
fy_repo = FiscalYearRepository(db)
fiscal_year = fy_repo.get_by_id(fiscal_year_id)
if not fiscal_year:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی یافت نشد", http_status=404)
if int(fiscal_year.business_id) != int(business_id):
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی متعلق به این کسب‌وکار نیست", http_status=404)
if not fiscal_year.is_last:
raise ApiError("FISCAL_YEAR_NOT_CURRENT", "این سال مالی سال جاری نیست", http_status=400)
original_fy_end, effective_end = _resolve_closing_period_end(fiscal_year, closing_fiscal_end_date)
basis = _normalize_relocation_basis(document_relocation_basis)
post_cutoff_pred = _post_cutoff_document_predicate(effective_end, basis)
documents_to_relocate = int(
db.query(func.count(Document.id))
.filter(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
post_cutoff_pred,
)
.scalar()
or 0
)
doc_ids_pending_move = select(Document.id).where(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
post_cutoff_pred,
)
wh_post = _post_cutoff_warehouse_document_predicate(effective_end, basis)
warehouse_docs_to_relocate = int(
db.query(func.count(WarehouseDocument.id))
.filter(
WarehouseDocument.business_id == business_id,
WarehouseDocument.fiscal_year_id == fiscal_year_id,
or_(
WarehouseDocument.source_document_id.in_(doc_ids_pending_move),
wh_post,
),
)
.scalar()
or 0
)
existing_closing = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
Document.document_type == "year_end_closing",
)
).first()
if existing_closing:
raise ApiError("FISCAL_YEAR_ALREADY_CLOSED", "این سال مالی قبلاً بسته شده است", http_status=400)
# دریافت تمام حساب‌های کسب‌وکار
all_accounts = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
# جدا کردن حساب‌های درآمد و هزینه
revenue_accounts = [acc for acc in all_accounts if is_pnl_revenue_gl_account(acc)]
expense_accounts = [acc for acc in all_accounts if is_pnl_expense_gl_account(acc)]
# محاسبه مانده حساب‌های درآمد
revenue_items = []
total_revenue = Decimal(0)
total_expense = Decimal(0) # برای مدیریت مانده منفی حساب‌های درآمد
for account in revenue_accounts:
balance_info = _calculate_account_balance(
db, business_id, account.id, fiscal_year_id, effective_end
)
closing_balance = balance_info['closing_balance']
# بستن همه حساب‌های درآمد (چه مثبت چه منفی)
if closing_balance != 0: # فقط حساب‌هایی که مانده دارند
revenue_items.append({
'account_id': account.id,
'account_code': account.code,
'account_name': account.name,
'opening_debit': float(balance_info['opening_debit']),
'opening_credit': float(balance_info['opening_credit']),
'period_debit': float(balance_info['period_debit']),
'period_credit': float(balance_info['period_credit']),
'closing_balance': float(closing_balance),
})
# فقط مانده مثبت به درآمد اضافه می‌شود (مانده منفی در واقع هزینه است)
if closing_balance > 0:
total_revenue += closing_balance
else:
# مانده منفی در حساب درآمد = هزینه، باید به هزینه اضافه شود
total_expense += abs(closing_balance)
# محاسبه مانده حساب‌های هزینه
expense_items = []
# total_expense قبلاً در بخش درآمد تعریف شده (برای مدیریت مانده منفی)
for account in expense_accounts:
balance_info = _calculate_account_balance(
db, business_id, account.id, fiscal_year_id, effective_end
)
closing_balance = balance_info['closing_balance']
# بستن همه حساب‌های هزینه (چه مثبت چه منفی)
if closing_balance != 0: # فقط حساب‌هایی که مانده دارند
expense_items.append({
'account_id': account.id,
'account_code': account.code,
'account_name': account.name,
'opening_debit': float(balance_info['opening_debit']),
'opening_credit': float(balance_info['opening_credit']),
'period_debit': float(balance_info['period_debit']),
'period_credit': float(balance_info['period_credit']),
'closing_balance': float(closing_balance),
})
# فقط مانده مثبت به هزینه اضافه می‌شود (مانده منفی در واقع درآمد است)
if closing_balance > 0:
total_expense += closing_balance
else:
# مانده منفی در حساب هزینه = درآمد، باید به درآمد اضافه شود
total_revenue += abs(closing_balance)
# محاسبه سود/زیان خالص
net_profit_loss = total_revenue - total_expense
# محاسبه مانده حساب سود یا زیان انباشته
retained_earnings_account = _get_fixed_account_by_code(db, "30106")
retained_earnings_balance = _calculate_account_balance(
db, business_id, retained_earnings_account.id, fiscal_year_id, effective_end
)
# مانده ابتدای سال = مانده از تراز افتتاحیه + گردش دوره تا قبل از بستن
retained_earnings_opening = (
retained_earnings_balance['opening_debit'] - retained_earnings_balance['opening_credit']
) + (
retained_earnings_balance['period_debit'] - retained_earnings_balance['period_credit']
)
# مانده انتهای سال = مانده ابتدای سال + سود/زیان سال جاری
retained_earnings_closing = retained_earnings_opening + net_profit_loss
return {
'fiscal_year': {
'id': fiscal_year.id,
'title': fiscal_year.title,
'start_date': fiscal_year.start_date, # date object برای format_datetime_fields
# پایان دوره‌ای که بسته می‌شود (برای تقطیع، زودتر از پایان تقویمی ذخیره‌شده است)
'end_date': effective_end,
'calendar_period_end_date': original_fy_end,
},
'closing_options': {
'calendar_period_end_date': original_fy_end,
'closing_period_end_date': effective_end,
'is_period_shortened': effective_end < original_fy_end,
'document_relocation_basis': basis,
'post_cutoff_documents_to_relocate': documents_to_relocate,
'post_cutoff_warehouse_documents_to_relocate': warehouse_docs_to_relocate,
},
'revenue_accounts': revenue_items,
'expense_accounts': expense_items,
'summary': {
'total_revenue': float(total_revenue),
'total_expense': float(total_expense),
'net_profit_loss': float(net_profit_loss),
},
'retained_earnings': {
'account_id': retained_earnings_account.id,
'account_code': retained_earnings_account.code,
'account_name': retained_earnings_account.name,
'opening_balance': float(retained_earnings_opening),
'current_year_profit_loss': float(net_profit_loss),
'closing_balance': float(retained_earnings_closing),
},
}
async def close_fiscal_year(
db: Session,
business_id: int,
fiscal_year_id: int,
user_id: int,
new_fiscal_year_title: str,
auto_create_opening_balance: bool = True,
# مالیات
tax_percentage: Optional[float] = None,
tax_amount: Optional[float] = None,
# تقسیم سود
profit_distribution_percentage: Optional[float] = None,
profit_distribution_amount: Optional[float] = None,
shareholder_profit_account_id: Optional[int] = None,
# سود انباشته سنواتی
retained_earnings_from_previous_years: Optional[float] = None,
# تنظیمات
auto_issue_person_balance_document: bool = False,
# تنظیمات سال مالی جدید
new_fiscal_year_start_date: Optional[date] = None,
new_fiscal_year_end_date: Optional[date] = None,
inventory_valuation_method: str = "FIFO",
# تقسیم سود بین سهامداران
shareholder_distributions: Optional[List[Dict[str, Any]]] = None,
# تقطیع / پایان دورهٔ بستن و انتقال اسناد به سال جدید
closing_fiscal_end_date: Optional[date] = None,
document_relocation_basis: str = "document_date",
move_post_cutoff_documents_to_new_fiscal_year: bool = True,
) -> Dict[str, Any]:
"""
بستن سال مالی
این تابع:
1. از دیتابیس پشتیبان تهیه می‌کند
2. حساب‌های درآمد و هزینه را می‌بندد
3. مالیات بر درآمد را محاسبه و ثبت می‌کند
4. سود را بین سهامداران تقسیم می‌کند
5. سود/زیان را به حساب سود یا زیان انباشته منتقل می‌کند
6. سند توازن اشخاص را ایجاد می‌کند (در صورت نیاز)
7. سال مالی جدید ایجاد می‌کند
8. تراز افتتاحیه سال جدید را ایجاد می‌کند (در صورت نیاز)
"""
# مرحله 1: ایجاد پشتیبان قبل از بستن
backup_result = await _create_backup_before_closing(db, business_id, user_id, fiscal_year_id)
if not backup_result.get("success", False):
raise ApiError(
"BACKUP_FAILED",
f"خطا در ایجاد پشتیبان قبل از بستن سال مالی: {backup_result.get('error', 'خطای نامشخص')}. عملیات بستن سال مالی لغو شد.",
http_status=500
)
backup_id = backup_result.get("backup_id")
backup_filename = backup_result.get("filename", "")
fy_repo = FiscalYearRepository(db)
fiscal_year = fy_repo.get_by_id(fiscal_year_id)
if not fiscal_year:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی یافت نشد", http_status=404)
if int(fiscal_year.business_id) != int(business_id):
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی متعلق به این کسب‌وکار نیست", http_status=404)
original_fy_calendar_end, effective_end = _resolve_closing_period_end(fiscal_year, closing_fiscal_end_date)
preview_data = preview_year_end_closing(
db,
business_id,
fiscal_year_id,
closing_fiscal_end_date=closing_fiscal_end_date,
document_relocation_basis=document_relocation_basis,
)
# دریافت حساب‌های لازم
summary_account = _get_fixed_account_by_code(db, "80301") # خلاصه سود و زیان
retained_earnings_account = _get_fixed_account_by_code(db, "30106") # سود یا زیان انباشته
# بررسی مانده سود یا زیان انباشته قبل از بستن
retained_earnings_balance_check = _calculate_account_balance(
db, business_id, retained_earnings_account.id, fiscal_year_id, effective_end
)
retained_earnings_current = (
retained_earnings_balance_check['opening_debit'] - retained_earnings_balance_check['opening_credit']
) + (
retained_earnings_balance_check['period_debit'] - retained_earnings_balance_check['period_credit']
)
# ارز سند اختتامیه باید ارز پایه کسب‌وکار باشد تا با مانده‌های تسعیرشده هم‌راستا بماند.
business = db.get(Business, int(business_id))
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
currency_id = business.default_currency_id
if not currency_id:
# fallback برای کسب‌وکارهای قدیمی بدون ارز پایه
first_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
)
).order_by(Document.id.asc()).first()
if not first_doc:
raise ApiError("NO_DOCUMENTS_FOUND", "هیچ سندی در این سال مالی یافت نشد", http_status=400)
currency_id = first_doc.currency_id
# ساخت خطوط سند بستن سال مالی
doc_repo = DocumentRepository(db)
lines: List[Dict[str, Any]] = []
total_revenue = Decimal(0)
total_expense = Decimal(0)
# بستن حساب‌های درآمد (هم مثبت هم منفی)
for revenue_item in preview_data['revenue_accounts']:
balance = Decimal(str(revenue_item['closing_balance']))
if balance != 0: # بستن همه حساب‌هایی که مانده دارند
if balance > 0:
# مانده مثبت = درآمد، باید بدهکار شود
lines.append({
'account_id': revenue_item['account_id'],
'debit': float(balance),
'credit': 0.0,
'description': f"بستن حساب {revenue_item['account_name']}",
})
total_revenue += balance
else:
# مانده منفی = هزینه، باید بستانکار شود
lines.append({
'account_id': revenue_item['account_id'],
'debit': 0.0,
'credit': float(abs(balance)),
'description': f"بستن حساب {revenue_item['account_name']} (مانده منفی)",
})
total_expense += abs(balance)
# بستن حساب‌های هزینه (هم مثبت هم منفی)
for expense_item in preview_data['expense_accounts']:
balance = Decimal(str(expense_item['closing_balance']))
if balance != 0: # بستن همه حساب‌هایی که مانده دارند
if balance > 0:
# مانده مثبت = هزینه، باید بستانکار شود
lines.append({
'account_id': expense_item['account_id'],
'debit': 0.0,
'credit': float(balance),
'description': f"بستن حساب {expense_item['account_name']}",
})
total_expense += balance
else:
# مانده منفی = درآمد، باید بدهکار شود
lines.append({
'account_id': expense_item['account_id'],
'debit': float(abs(balance)),
'credit': 0.0,
'description': f"بستن حساب {expense_item['account_name']} (مانده منفی)",
})
total_revenue += abs(balance)
# خطوط خلاصه سود و زیان پس از محاسبه نهایی درآمد/هزینه (شامل مانده‌های منفی)
if total_revenue > 0:
lines.append({
'account_id': summary_account.id,
'debit': 0.0,
'credit': float(total_revenue),
'description': 'جمع درآمدهای سال',
})
if total_expense > 0:
lines.append({
'account_id': summary_account.id,
'debit': float(total_expense),
'credit': 0.0,
'description': 'جمع هزینه‌های سال',
})
# محاسبه سود/زیان خالص قبل از مالیات
net_profit_loss_before_tax = total_revenue - total_expense
# محاسبه مالیات
tax_amount_calculated = Decimal(0)
if net_profit_loss_before_tax > 0: # فقط در صورت سود مالیات محاسبه می‌شود
if tax_percentage is not None:
tax_amount_calculated = Decimal(str(net_profit_loss_before_tax)) * Decimal(str(tax_percentage)) / Decimal(100)
elif tax_amount is not None:
tax_amount_calculated = Decimal(str(tax_amount))
# سود/زیان خالص پس از مالیات
net_profit_loss_after_tax = net_profit_loss_before_tax - tax_amount_calculated
# ثبت مالیات: شناسایی هزینه + بدهی پرداختنی + بستن هزینه در خلاصه سود و زیان
if tax_amount_calculated > 0:
tax_expense_account = _get_fixed_account_by_code(db, "50101") # هزینه مالیات بر درآمد
tax_payable_account = _get_fixed_account_by_code(db, "20302") # مالیات بر درآمد پرداختنی
tax_amount_f = float(tax_amount_calculated)
lines.extend([
{
'account_id': tax_expense_account.id,
'debit': tax_amount_f,
'credit': 0.0,
'description': 'هزینه مالیات بر درآمد',
},
{
'account_id': tax_payable_account.id,
'debit': 0.0,
'credit': tax_amount_f,
'description': 'مالیات بر درآمد پرداختنی',
},
{
'account_id': summary_account.id,
'debit': tax_amount_f,
'credit': 0.0,
'description': 'بستن هزینه مالیات در خلاصه سود و زیان',
},
{
'account_id': tax_expense_account.id,
'debit': 0.0,
'credit': tax_amount_f,
'description': 'بستن حساب هزینه مالیات بر درآمد',
},
])
# بستن خلاصه سود و زیان
if net_profit_loss_after_tax > 0: # سود
# بستن خلاصه سود و زیان (سود)
lines.append({
'account_id': summary_account.id,
'debit': float(net_profit_loss_after_tax),
'credit': 0.0,
'description': 'بستن خلاصه سود و زیان (سود پس از مالیات)',
})
# خط مقابل: انتقال سود به سود یا زیان انباشته
lines.append({
'account_id': retained_earnings_account.id,
'debit': 0.0,
'credit': float(net_profit_loss_after_tax),
'description': 'انتقال سود به سود یا زیان انباشته',
})
elif net_profit_loss_after_tax < 0: # زیان
# بستن خلاصه سود و زیان (زیان)
lines.append({
'account_id': summary_account.id,
'debit': 0.0,
'credit': float(abs(net_profit_loss_after_tax)),
'description': 'بستن خلاصه سود و زیان (زیان پس از مالیات)',
})
# خط مقابل: انتقال زیان به سود یا زیان انباشته
lines.append({
'account_id': retained_earnings_account.id,
'debit': float(abs(net_profit_loss_after_tax)),
'credit': 0.0,
'description': 'انتقال زیان به سود یا زیان انباشته',
})
# محاسبه تقسیم سود
profit_to_distribute = Decimal(0)
if net_profit_loss_after_tax > 0: # فقط در صورت سود تقسیم می‌شود
if profit_distribution_percentage is not None:
profit_to_distribute = Decimal(str(net_profit_loss_after_tax)) * Decimal(str(profit_distribution_percentage)) / Decimal(100)
elif profit_distribution_amount is not None:
profit_to_distribute = Decimal(str(profit_distribution_amount))
# بررسی محدودیت تقسیم سود
if profit_to_distribute > net_profit_loss_after_tax:
profit_to_distribute = net_profit_loss_after_tax
# سود باقی‌مانده برای انباشته
profit_to_retained = net_profit_loss_after_tax - profit_to_distribute
if retained_earnings_from_previous_years is not None:
profit_to_retained += Decimal(str(retained_earnings_from_previous_years))
# بررسی مانده نهایی سود یا زیان انباشته
final_retained_earnings = retained_earnings_current + profit_to_retained
# هشدار در صورت زیان زیاد (اگر زیان انباشته از سرمایه بیشتر شود)
capital_account = _get_fixed_account_by_code(db, "30101") # سرمایه اولیه
capital_balance = _calculate_account_balance(
db, business_id, capital_account.id, fiscal_year_id, effective_end
)
capital_amount = (
capital_balance['opening_debit'] - capital_balance['opening_credit']
) + (
capital_balance['period_debit'] - capital_balance['period_credit']
)
if final_retained_earnings < 0 and abs(final_retained_earnings) > capital_amount:
# هشدار: زیان انباشته از سرمایه بیشتر شده است
import logging
logging.warning(
f"هشدار: زیان انباشته ({final_retained_earnings}) از سرمایه ({capital_amount}) بیشتر شده است. "
f"کسب‌وکار {business_id} ممکن است ورشکسته باشد."
)
# تقسیم سود بین سهامداران (اگر وجود داشته باشد)
shareholder_profit_account = None
if profit_to_distribute > 0 and shareholder_profit_account_id:
shareholder_profit_account = db.query(Account).filter(Account.id == shareholder_profit_account_id).first()
if not shareholder_profit_account:
raise ApiError("ACCOUNT_NOT_FOUND", "حساب سود سهامداران یافت نشد", http_status=400)
# دریافت سهامداران
from adapters.db.models.person import PersonType
# دریافت تمام اشخاص و فیلتر کردن سهامداران با استفاده از JSON parsing
all_persons = db.query(Person).filter(
Person.business_id == business_id
).all()
shareholders = []
for person in all_persons:
try:
# پارس کردن person_types از JSON
person_types_list = json.loads(person.person_types) if person.person_types else []
if isinstance(person_types_list, list) and "سهامدار" in person_types_list:
shareholders.append(person)
except (json.JSONDecodeError, TypeError):
# اگر JSON معتبر نبود، از contains استفاده می‌کنیم (fallback)
if person.person_types and "سهامدار" in person.person_types:
shareholders.append(person)
# محاسبه سهم هر سهامدار
total_shares = sum(Decimal(str(sh.share_count or 0)) for sh in shareholders if sh.share_count)
# اگر تقسیم دستی وارد شده، از آن استفاده می‌شود
if shareholder_distributions:
total_manual_distribution = sum(Decimal(str(dist.get('profit_amount', 0))) for dist in shareholder_distributions)
if total_manual_distribution != profit_to_distribute:
raise ApiError(
"INVALID_PROFIT_DISTRIBUTION",
f"جمع تقسیم دستی سود ({total_manual_distribution}) باید برابر با سود قابل تقسیم ({profit_to_distribute}) باشد",
http_status=400
)
# استفاده از تقسیم دستی
for dist in shareholder_distributions:
person_id = dist.get('person_id')
profit_amt = Decimal(str(dist.get('profit_amount', 0)))
if profit_amt > 0:
shareholder = next((sh for sh in shareholders if sh.id == person_id), None)
if shareholder:
# ثبت تقسیم سود طبق اصول حسابداری:
# Dr. سود یا زیان انباشته - Cr. سود تقسیم شده (Dividends Payable)
# برای هر سهامدار به تفکیک ثبت می‌شود
lines.append({
'account_id': retained_earnings_account.id,
'person_id': person_id,
'debit': float(profit_amt),
'credit': 0.0,
'description': f'تقسیم سود به {shareholder.alias_name}',
})
lines.append({
'account_id': shareholder_profit_account.id,
'person_id': person_id,
'debit': 0.0,
'credit': float(profit_amt),
'description': f'سود تقسیم شده - {shareholder.alias_name}',
})
elif total_shares > 0:
# تقسیم بر اساس درصد سهام
for shareholder in shareholders:
if shareholder.share_count and shareholder.share_count > 0:
share_ratio = Decimal(str(shareholder.share_count)) / total_shares
shareholder_profit = profit_to_distribute * share_ratio
if shareholder_profit > 0:
# ثبت تقسیم سود طبق اصول حسابداری:
# Dr. سود یا زیان انباشته - Cr. سود تقسیم شده (Dividends Payable)
# برای هر سهامدار به تفکیک ثبت می‌شود
lines.append({
'account_id': retained_earnings_account.id,
'person_id': shareholder.id,
'debit': float(shareholder_profit),
'credit': 0.0,
'description': f'تقسیم سود به {shareholder.alias_name}',
})
lines.append({
'account_id': shareholder_profit_account.id,
'person_id': shareholder.id,
'debit': 0.0,
'credit': float(shareholder_profit),
'description': f'سود تقسیم شده - {shareholder.alias_name}',
})
# توجه: ثبت تقسیم سود قبلاً برای هر سهامدار به تفکیک انجام شده است
# در اینجا نیازی به ثبت اضافی نیست، چون هر سهامدار به صورت جداگانه ثبت شده است
# توجه: انتقال سود/زیان به سود یا زیان انباشته قبلاً در بستن خلاصه سود و زیان انجام شده است
# تقسیم سود نیز قبلاً انجام شده است (کسر از سود یا زیان انباشته و اضافه به حساب سود تقسیم شده)
# سود باقی‌مانده (profit_to_retained) قبلاً در سود یا زیان انباشته است و نیازی به ثبت اضافی ندارد
# ایجاد سند بستن سال مالی
closing_document_code = doc_repo.generate_document_code(business_id, "year_end_closing")
closing_document_payload = {
'code': closing_document_code,
'business_id': business_id,
'fiscal_year_id': fiscal_year_id,
'currency_id': currency_id,
'created_by_user_id': user_id,
'document_date': effective_end,
'document_type': 'year_end_closing',
'is_proforma': False,
'description': f'بستن سال مالی {fiscal_year.title}',
'lines': lines,
}
is_valid, balance_error = doc_repo.validate_document_balance(lines)
if not is_valid:
raise ApiError(
"UNBALANCED_CLOSING_DOCUMENT",
f"سند بستن سال مالی متوازن نیست: {balance_error}",
http_status=400,
)
closing_document = doc_repo.create_document(closing_document_payload)
# ایجاد سند توازن اشخاص (در صورت نیاز)
person_balance_document = None
if auto_issue_person_balance_document:
try:
person_balance_document = _create_person_balance_document(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
user_id=user_id,
currency_id=currency_id,
fiscal_year_end_date=effective_end,
)
except Exception as e:
# در صورت خطا، فقط log می‌کنیم و ادامه می‌دهیم
import logging
logging.error(f"خطا در ایجاد سند توازن اشخاص: {str(e)}")
# ایجاد سال مالی جدید
if new_fiscal_year_start_date and new_fiscal_year_end_date:
new_start_date = new_fiscal_year_start_date
new_end_date = new_fiscal_year_end_date
# بررسی اعتبار تاریخ‌ها
if new_start_date >= new_end_date:
raise ApiError("INVALID_DATE_RANGE", "تاریخ شروع باید قبل از تاریخ پایان باشد", http_status=400)
if new_start_date <= effective_end:
raise ApiError("INVALID_START_DATE", "تاریخ شروع سال مالی جدید باید بعد از تاریخ پایان دورهٔ بسته‌شده باشد", http_status=400)
else:
# استفاده از منطق خودکار در صورت عدم ارسال تاریخ
new_start_date = effective_end + timedelta(days=1)
new_end_date = _fiscal_year_end_inclusive_from_start_gregorian(new_start_date)
# به‌روزرسانی پایان تقویمی سال جاری به پایان دورهٔ بستن؛ سپس غیرفعال کردن به‌عنوان سال جاری
fiscal_year.end_date = effective_end
fiscal_year.is_last = False
db.commit()
db.refresh(fiscal_year)
# ایجاد سال مالی جدید
new_fiscal_year = fy_repo.create_fiscal_year(
business_id=business_id,
title=new_fiscal_year_title,
start_date=new_start_date,
end_date=new_end_date,
is_last=True,
)
# تنظیم روش ارزیابی انبار
new_fiscal_year.inventory_valuation_method = inventory_valuation_method
db.commit()
db.refresh(new_fiscal_year)
result = {
'closing_document': doc_repo.get_document_details(closing_document.id) or {},
'new_fiscal_year': {
'id': new_fiscal_year.id,
'title': new_fiscal_year.title,
'start_date': new_fiscal_year.start_date.isoformat(),
'end_date': new_fiscal_year.end_date.isoformat(),
'inventory_valuation_method': new_fiscal_year.inventory_valuation_method,
},
'backup_info': {
'backup_id': backup_id,
'filename': backup_filename,
},
'tax_info': {
'tax_amount': float(tax_amount_calculated),
},
'profit_distribution_info': {
'distributed_amount': float(profit_to_distribute),
'retained_amount': float(profit_to_retained),
},
}
if person_balance_document:
result['person_balance_document'] = doc_repo.get_document_details(person_balance_document.id) or {}
# انتقال اسناد بعد از پایان دوره باید قبل از افتتاحیه انجام شود تا اگر افتتاحیه خطا داد،
# سال جدید بدون اسناد پس از cutoff نماند. افتتاحیه فقط تا date_to سال قبل را می‌خواند.
moved_documents = 0
moved_warehouse_documents = 0
if move_post_cutoff_documents_to_new_fiscal_year:
pred = _post_cutoff_document_predicate(effective_end, document_relocation_basis)
ids_to_move = [
int(row[0])
for row in db.query(Document.id)
.filter(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
pred,
)
.all()
]
res = db.execute(
update(Document)
.where(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
pred,
)
.values(fiscal_year_id=new_fiscal_year.id)
)
rc = getattr(res, "rowcount", None)
moved_documents = int(rc) if rc is not None and int(rc) >= 0 else len(ids_to_move)
wh_post = _post_cutoff_warehouse_document_predicate(effective_end, document_relocation_basis)
wh_conds = [wh_post]
if ids_to_move:
wh_conds.append(WarehouseDocument.source_document_id.in_(ids_to_move))
wh_res = db.execute(
update(WarehouseDocument)
.where(
WarehouseDocument.business_id == business_id,
WarehouseDocument.fiscal_year_id == fiscal_year_id,
or_(*wh_conds),
)
.values(fiscal_year_id=new_fiscal_year.id)
)
wh_rc = getattr(wh_res, "rowcount", None)
moved_warehouse_documents = int(wh_rc) if wh_rc is not None and int(wh_rc) >= 0 else 0
db.commit()
result["document_relocation"] = {
"moved_documents_count": moved_documents,
"moved_warehouse_documents_count": moved_warehouse_documents,
"effective_closing_end_date": effective_end.isoformat(),
"original_calendar_end_date": original_fy_calendar_end.isoformat(),
"basis": _normalize_relocation_basis(document_relocation_basis),
"performed": bool(move_post_cutoff_documents_to_new_fiscal_year),
}
# ایجاد تراز افتتاحیه سال جدید (در صورت نیاز)
if auto_create_opening_balance:
try:
opening_balance_doc = _create_opening_balance_for_new_fiscal_year(
db=db,
business_id=business_id,
old_fiscal_year_id=fiscal_year_id,
new_fiscal_year_id=new_fiscal_year.id,
user_id=user_id,
currency_id=currency_id,
)
result['opening_balance_created'] = True
result['opening_balance_document'] = doc_repo.get_document_details(opening_balance_doc.id) or {}
result['opening_balance_note'] = f'تراز افتتاحیه سال جدید با کد {opening_balance_doc.code} ایجاد شد'
except ApiError:
raise
except Exception as e:
raise ApiError(
"OPENING_BALANCE_CREATION_FAILED",
f"ایجاد تراز افتتاحیه سال جدید ناموفق بود: {str(e)}",
http_status=500,
) from e
return result
def _create_person_balance_document(
db: Session,
business_id: int,
fiscal_year_id: int,
user_id: int,
currency_id: int,
fiscal_year_end_date: date,
) -> Document:
"""
ایجاد سند توازن اشخاص در پایان سال مالی
این تابع مانده تمام اشخاص را محاسبه کرده و یک سند برای بستن آن‌ها ایجاد می‌کند.
"""
doc_repo = DocumentRepository(db)
# دریافت تمام اشخاص
persons = db.query(Person).filter(Person.business_id == business_id).all()
person_lines: List[Dict[str, Any]] = []
for person in persons:
# محاسبه مانده شخص تا پایان سال مالی
person_balance_query = db.query(
func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit'),
func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit')
).join(
Document, DocumentLine.document_id == Document.id
).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == fiscal_year_id,
DocumentLine.person_id == person.id,
Document.document_date <= fiscal_year_end_date,
Document.is_proforma == False,
)
)
person_result = person_balance_query.first()
person_debit = Decimal(str(person_result.total_debit or 0))
person_credit = Decimal(str(person_result.total_credit or 0))
person_balance = person_debit - person_credit
# اگر مانده صفر است، از لیست خارج کن
if person_balance == 0:
continue
# حساب کنترل اشخاص (مدل Person فیلد account_id ندارد؛ از حساب‌های ثابت نمودار استفاده می‌شود)
if person_balance > 0:
person_account = _get_fixed_account_by_code(db, "13101")
else:
person_account = _get_fixed_account_by_code(db, "21101")
person_account_id = person_account.id
# اضافه کردن به خطوط سند
if person_balance > 0:
person_lines.append({
'account_id': person_account_id,
'person_id': person.id,
'debit': 0.0,
'credit': float(person_balance),
'description': f'بستن مانده {person.alias_name} (بدهکار)',
})
else:
person_lines.append({
'account_id': person_account_id,
'person_id': person.id,
'debit': float(-person_balance),
'credit': 0.0,
'description': f'بستن مانده {person.alias_name} (بستانکار)',
})
# اگر هیچ مانده‌ای وجود ندارد، سند ایجاد نمی‌کنیم (به صورت graceful)
if not person_lines:
return None # به جای خطا، None برمی‌گردانیم
# ایجاد سند توازن اشخاص
person_balance_doc_code = doc_repo.generate_document_code(business_id, "person_balance")
person_balance_doc_payload = {
'code': person_balance_doc_code,
'business_id': business_id,
'fiscal_year_id': fiscal_year_id,
'currency_id': currency_id,
'created_by_user_id': user_id,
'document_date': fiscal_year_end_date,
'document_type': 'person_balance',
'is_proforma': False,
'description': 'بستن مانده اشخاص پایان سال مالی',
'lines': person_lines,
}
person_balance_document = doc_repo.create_document(person_balance_doc_payload)
return person_balance_document
def _calculate_remaining_inventory_cost(
db: Session,
business_id: int,
product_id: int,
warehouse_id: int,
up_to_date: date,
valuation_method: str,
) -> Tuple[Decimal, Decimal]:
"""
محاسبه قیمت تمام شده موجودی باقی‌مانده بر اساس روش ارزیابی انبار
Args:
db: Session دیتابیس
business_id: شناسه کسب‌وکار
product_id: شناسه کالا
warehouse_id: شناسه انبار
up_to_date: تاریخ تا
valuation_method: روش ارزیابی انبار (FIFO, LIFO, WeightedAverage)
Returns:
Tuple[quantity, cost_price]: مقدار موجودی و قیمت تمام شده
"""
# دریافت حرکات موجودی
movements = _iter_product_movements(
db=db,
business_id=business_id,
product_ids=[product_id],
warehouse_ids=[warehouse_id],
up_to_date=up_to_date,
exclude_document_id=None,
)
# فیلتر حرکات برای این انبار
filtered_movements = [
mv for mv in movements
if mv.get("warehouse_id") is not None and int(mv.get("warehouse_id")) == int(warehouse_id)
]
if not filtered_movements:
return Decimal(0), Decimal(0)
# محاسبه موجودی باقی‌مانده
remaining_quantity = _compute_available_stock(
db=db,
business_id=business_id,
product_id=product_id,
warehouse_id=warehouse_id,
up_to_date=up_to_date,
)
if remaining_quantity <= 0:
return Decimal(0), Decimal(0)
# محاسبه قیمت تمام شده بر اساس روش ارزیابی
if valuation_method.upper() == "FIFO":
# FIFO: قیمت آخرین ورودی‌ها
return _calculate_fifo_cost(filtered_movements, remaining_quantity)
elif valuation_method.upper() == "LIFO":
# LIFO: قیمت اولین ورودی‌ها
return _calculate_lifo_cost(filtered_movements, remaining_quantity)
elif valuation_method.upper() == "WEIGHTEDAVERAGE":
# WeightedAverage: میانگین موزون
return _calculate_weighted_average_cost(filtered_movements, remaining_quantity)
else:
# پیش‌فرض: WeightedAverage
return _calculate_weighted_average_cost(filtered_movements, remaining_quantity)
def _calculate_fifo_cost(movements: List[Dict[str, Any]], remaining_quantity: Decimal) -> Tuple[Decimal, Decimal]:
"""محاسبه قیمت تمام شده بر اساس FIFO"""
# ساخت لایه‌های FIFO
layers = deque()
for mv in movements:
if mv["movement"] == "in":
cost_price = mv.get("cost_price") or Decimal(0)
if cost_price > 0:
layers.append({
"qty": mv["quantity"],
"cost": cost_price,
})
elif mv["movement"] == "out":
remain = mv["quantity"]
while remain > 0 and layers:
top = layers[0]
take = min(remain, top["qty"])
top["qty"] -= take
remain -= take
if top["qty"] <= 0:
layers.popleft()
# محاسبه قیمت برای موجودی باقی‌مانده
if not layers:
return remaining_quantity, Decimal(0)
total_cost = Decimal(0)
remain = remaining_quantity
for layer in layers:
if remain <= 0:
break
take = min(remain, layer["qty"])
total_cost += take * layer["cost"]
remain -= take
if remaining_quantity > 0:
avg_cost = total_cost / remaining_quantity
else:
avg_cost = Decimal(0)
return remaining_quantity, avg_cost
def _calculate_lifo_cost(movements: List[Dict[str, Any]], remaining_quantity: Decimal) -> Tuple[Decimal, Decimal]:
"""محاسبه قیمت تمام شده بر اساس LIFO"""
# ساخت لایه‌های LIFO (معکوس FIFO)
layers = deque()
for mv in movements:
if mv["movement"] == "in":
cost_price = mv.get("cost_price") or Decimal(0)
if cost_price > 0:
layers.append({
"qty": mv["quantity"],
"cost": cost_price,
})
elif mv["movement"] == "out":
remain = mv["quantity"]
while remain > 0 and layers:
top = layers[-1] # از آخرین لایه
take = min(remain, top["qty"])
top["qty"] -= take
remain -= take
if top["qty"] <= 0:
layers.pop()
# محاسبه قیمت برای موجودی باقی‌مانده (از اولین لایه‌ها)
if not layers:
return remaining_quantity, Decimal(0)
total_cost = Decimal(0)
remain = remaining_quantity
# از اولین لایه‌ها شروع می‌کنیم (قدیمی‌ترین)
for layer in layers:
if remain <= 0:
break
take = min(remain, layer["qty"])
total_cost += take * layer["cost"]
remain -= take
if remaining_quantity > 0:
avg_cost = total_cost / remaining_quantity
else:
avg_cost = Decimal(0)
return remaining_quantity, avg_cost
def _calculate_weighted_average_cost(movements: List[Dict[str, Any]], remaining_quantity: Decimal) -> Tuple[Decimal, Decimal]:
"""محاسبه قیمت تمام شده بر اساس میانگین موزون"""
total_cost = Decimal(0)
total_quantity = Decimal(0)
# محاسبه میانگین موزون از تمام ورودی‌هایی که cost_price دارند
for mv in movements:
if mv["movement"] == "in":
cost_price = mv.get("cost_price")
if cost_price is not None and cost_price > 0:
qty = mv["quantity"]
total_cost += qty * Decimal(str(cost_price))
total_quantity += qty
if total_quantity > 0:
avg_cost = total_cost / total_quantity
else:
avg_cost = Decimal(0)
return remaining_quantity, avg_cost
def _create_opening_balance_for_new_fiscal_year(
db: Session,
business_id: int,
old_fiscal_year_id: int,
new_fiscal_year_id: int,
user_id: int,
currency_id: int,
) -> Document:
"""
ایجاد سند تراز افتتاحیه برای سال مالی جدید
برای جلوگیری از دوبارشماری در دفتر کل، هر مانده فقط یک بار منتقل می‌شود:
- مانده تجمیعی حساب‌های کنترلی بدهکاران/بستانکاران (13101، 21101) در حلقهٔ ماندهٔ دائمی
لحاظ نمی‌شود؛ فقط سطرهای ریز به تفکیک شخص ثبت می‌شوند.
- حساب‌هایی که سطرشان دارای بانک/صندوق/تنخواه است از ماندهٔ تجمیعی کنار گذاشته می‌شوند و
فقط به صورت ریز (با همان شناسه‌های فرعی) منتقل می‌شوند.
- اگر برای موجودی، ارزش ریالی از محاسبهٔ انبار (خطوط موجودی) داشته باشیم، ماندهٔ تجمیعی
12101 از حلقهٔ دائمی حذف می‌شود و «upsert_opening_balance» همان ارزش را یک‌جا بدهکار می‌کند
(همراه با خطوط کالا؛ دیگر بدهکار تک‌تک به ازای کالا تکرار نمی‌شود).
"""
old_fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == old_fiscal_year_id).first()
new_fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == new_fiscal_year_id).first()
if not old_fiscal_year or not new_fiscal_year:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی یافت نشد", http_status=404)
# تاریخ پایان سال مالی قبلی
date_to = old_fiscal_year.end_date
from app.services.person_service import amount_in_document_currency_to_base
fx_rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
ar_account = _get_fixed_account_by_code(db, "13101")
ap_account = _get_fixed_account_by_code(db, "21101")
inventory_account_fixed = _get_fixed_account_by_code(db, "12101")
bank_gl_account = _get_fixed_account_by_code(db, "10203")
cash_gl_account = _get_fixed_account_by_code(db, "10202")
petty_gl_account = _get_fixed_account_by_code(db, "10201")
# فقط حساب‌های خزانهٔ بانک/صندوق/تنخواه که واقعاً با همان شناسهٔ فرعی منتقل می‌شوند
# از ماندهٔ تجمیعی حلقهٔ دائمی کنار گذاشته می‌شوند (نه حساب‌های موقت مثل ۷۰۹۰۲).
treasury_subledger_rows = (
db.query(DocumentLine.account_id, DocumentLine.bank_account_id, DocumentLine.cash_register_id, DocumentLine.petty_cash_id, Account.code)
.join(Document, DocumentLine.document_id == Document.id)
.join(Account, Account.id == DocumentLine.account_id)
.filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == old_fiscal_year_id,
Document.document_date <= date_to,
Document.is_proforma == False,
or_(
DocumentLine.bank_account_id.isnot(None),
DocumentLine.cash_register_id.isnot(None),
DocumentLine.petty_cash_id.isnot(None),
),
)
)
.distinct()
.all()
)
bank_cash_gl_account_ids: set[int] = set()
for account_id, bank_id, cash_id, petty_id, acc_code in treasury_subledger_rows:
if account_id is None:
continue
if _treasury_gl_code_for_cash_bank_line(
bank_account_id=bank_id,
cash_register_id=cash_id,
petty_cash_id=petty_id,
account_code=acc_code,
):
bank_cash_gl_account_ids.add(int(account_id))
# دریافت تمام حساب‌های دائمی (گروه 1، 2، 3)
permanent_accounts = db.query(Account).filter(
and_(
or_(
Account.business_id == None, # noqa: E711
Account.business_id == business_id,
),
or_(
Account.code.like('1%'),
Account.code.like('2%'),
Account.code.like('3%'),
),
)
).all()
account_lines: List[Dict[str, Any]] = []
inventory_lines: List[Dict[str, Any]] = []
# موجودی از انبار (خطوط کالا؛ ارزش ریالی مانند upsert_opening_balance فقط با cost_price > 0 جمع می‌شود)
inventory_monetary_total = Decimal(0)
products = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.track_inventory == True,
)
).all()
warehouses = db.query(Warehouse).filter(Warehouse.business_id == business_id).all()
valuation_method = old_fiscal_year.inventory_valuation_method or "FIFO"
for product in products:
for warehouse in warehouses:
stock, cost_price = _calculate_remaining_inventory_cost(
db=db,
business_id=business_id,
product_id=product.id,
warehouse_id=warehouse.id,
up_to_date=date_to,
valuation_method=valuation_method,
)
if stock <= 0:
continue
if cost_price <= 0:
cost_price = Decimal(str(product.purchase_price or 0))
if cost_price <= 0:
extra_info = product.extra_info or {}
cost_price = Decimal(str(extra_info.get('cost_price', 0) or 0))
if cost_price <= 0:
cost_price = Decimal(0)
inventory_value = stock * cost_price
inventory_lines.append({
'product_id': product.id,
'quantity': float(stock),
'description': f'موجودی ابتدای دوره - {product.name}',
'extra_info': {
'movement': 'in',
'warehouse_id': warehouse.id,
'cost_price': float(cost_price),
},
})
if cost_price > 0:
inventory_monetary_total += inventory_value
omit_inventory_gl_aggregate = inventory_monetary_total > 0
# محاسبه مانده حساب‌های دائمی (بدون دوباره‌کاری با سطرهای ریز اشخاص، بانک/صندوق و موجودی)
for account in permanent_accounts:
if not _is_permanent_account(account.code):
continue
acc_id = account.id
if acc_id == ar_account.id or acc_id == ap_account.id:
continue
if acc_id in bank_cash_gl_account_ids:
continue
if omit_inventory_gl_aggregate and acc_id == inventory_account_fixed.id:
continue
# محاسبه مانده حساب تا پایان سال مالی قبلی (از تمام اسناد) به ارز پایه
account_rows = db.query(DocumentLine, Document).join(
Document, DocumentLine.document_id == Document.id
).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == old_fiscal_year_id,
DocumentLine.account_id == account.id,
Document.document_date <= date_to,
Document.is_proforma == False,
)
).all()
total_debit = Decimal(0)
total_credit = Decimal(0)
for line, doc in account_rows:
total_debit += amount_in_document_currency_to_base(
db,
doc,
line.debit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
total_credit += amount_in_document_currency_to_base(
db,
doc,
line.credit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
closing_balance = total_debit - total_credit
# اگر مانده صفر است، از لیست خارج کن
if closing_balance == 0:
continue
# اضافه کردن به خطوط سند
if closing_balance > 0:
account_lines.append({
'account_id': account.id,
'debit': float(closing_balance),
'credit': 0.0,
'description': f'مانده ابتدای دوره - {account.name}',
})
else:
account_lines.append({
'account_id': account.id,
'debit': 0.0,
'credit': float(-closing_balance),
'description': f'مانده ابتدای دوره - {account.name}',
})
# محاسبه مانده اشخاص (بدهکاران و بستانکاران)
persons = db.query(Person).filter(Person.business_id == business_id).all()
for person in persons:
# محاسبه مانده شخص تا پایان سال مالی قبلی به ارز پایه
person_rows = db.query(DocumentLine, Document).join(
Document, DocumentLine.document_id == Document.id
).filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == old_fiscal_year_id,
DocumentLine.person_id == person.id,
Document.document_date <= date_to,
Document.is_proforma == False,
)
).all()
person_debit = Decimal(0)
person_credit = Decimal(0)
for line, doc in person_rows:
person_debit += amount_in_document_currency_to_base(
db,
doc,
line.debit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
person_credit += amount_in_document_currency_to_base(
db,
doc,
line.credit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
person_balance = person_debit - person_credit
# اگر مانده صفر است، از لیست خارج کن
if person_balance == 0:
continue
# حساب کنترل اشخاص (مدل Person فیلد account_id ندارد؛ از حساب‌های ثابت نمودار استفاده می‌شود)
person_account_id = ar_account.id if person_balance > 0 else ap_account.id
# اضافه کردن به خطوط سند
if person_balance > 0:
account_lines.append({
'account_id': person_account_id,
'person_id': person.id,
'debit': float(person_balance),
'credit': 0.0,
'description': f'مانده ابتدای دوره - {person.alias_name}',
})
else:
account_lines.append({
'account_id': person_account_id,
'person_id': person.id,
'debit': 0.0,
'credit': float(-person_balance),
'description': f'مانده ابتدای دوره - {person.alias_name}',
})
# محاسبه مانده حساب‌های بانکی و صندوق به ارز پایه
# فقط سطرهای حساب خزانه (۱۰۲۰۱/۱۰۲۰۲/۱۰۲۰۳ و زیرمجموعه‌ها)؛ نه کارمزد و سایر حساب‌های موقت.
bank_cash_rows = (
db.query(DocumentLine, Document, Account)
.join(Document, DocumentLine.document_id == Document.id)
.join(Account, Account.id == DocumentLine.account_id)
.filter(
and_(
Document.business_id == business_id,
Document.fiscal_year_id == old_fiscal_year_id,
Document.document_date <= date_to,
Document.is_proforma == False,
or_(
DocumentLine.bank_account_id.isnot(None),
DocumentLine.cash_register_id.isnot(None),
DocumentLine.petty_cash_id.isnot(None),
),
)
)
.all()
)
# یک سطر افتتاحیه به ازای هر بانک/صندوق/تنخواه (نه به ازای هر account_id)
bank_cash_totals: Dict[Tuple[Optional[int], Optional[int], Optional[int]], Dict[str, Decimal]] = defaultdict(
lambda: {"debit": Decimal(0), "credit": Decimal(0)}
)
for line, doc, account in bank_cash_rows:
treasury_code = _treasury_gl_code_for_cash_bank_line(
bank_account_id=line.bank_account_id,
cash_register_id=line.cash_register_id,
petty_cash_id=line.petty_cash_id,
account_code=account.code,
)
if not treasury_code:
continue
key = (
line.bank_account_id,
line.cash_register_id,
line.petty_cash_id,
)
bank_cash_totals[key]["debit"] += amount_in_document_currency_to_base(
db,
doc,
line.debit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
bank_cash_totals[key]["credit"] += amount_in_document_currency_to_base(
db,
doc,
line.credit,
rate_cache=fx_rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
for (bank_account_id, cash_register_id, petty_cash_id), totals in bank_cash_totals.items():
total_debit = totals["debit"]
total_credit = totals["credit"]
balance = total_debit - total_credit
# اگر مانده صفر است، از لیست خارج کن
if balance == 0:
continue
if bank_account_id:
gl_account_id = bank_gl_account.id
description = 'مانده ابتدای دوره - حساب بانکی'
elif cash_register_id:
gl_account_id = cash_gl_account.id
description = 'مانده ابتدای دوره - صندوق'
elif petty_cash_id:
gl_account_id = petty_gl_account.id
description = 'مانده ابتدای دوره - تنخواه'
else:
continue
line_data = {
'account_id': gl_account_id,
'debit': float(balance) if balance > 0 else 0.0,
'credit': float(-balance) if balance < 0 else 0.0,
'description': description,
}
if bank_account_id:
line_data['bank_account_id'] = bank_account_id
elif cash_register_id:
line_data['cash_register_id'] = cash_register_id
elif petty_cash_id:
line_data['petty_cash_id'] = petty_cash_id
account_lines.append(line_data)
# ایجاد سند تراز افتتاحیه
opening_balance_data = {
'fiscal_year_id': new_fiscal_year_id,
'currency_id': currency_id,
'document_date': new_fiscal_year.start_date,
'description': f'تراز افتتاحیه سال مالی {new_fiscal_year.title}',
'account_lines': account_lines,
'inventory_lines': inventory_lines,
'inventory_account_id': inventory_account_fixed.id if inventory_lines else None,
'auto_balance_to_equity': True,
'equity_account_id': _get_fixed_account_by_code(db, "30106").id, # سود یا زیان انباشته
}
created_doc_dict = upsert_opening_balance(
db=db,
business_id=business_id,
user_id=user_id,
data=opening_balance_data,
)
post_opening_balance(db, business_id, user_id, new_fiscal_year_id)
# دریافت سند ایجاد شده
doc_repo = DocumentRepository(db)
created_doc = db.query(Document).filter(Document.id == created_doc_dict.get('id')).first()
if not created_doc:
raise ApiError("OPENING_BALANCE_CREATION_FAILED", "ایجاد سند تراز افتتاحیه ناموفق بود", http_status=500)
return created_doc