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

445 lines
16 KiB
Python

"""
ثبت بهای تمام‌شده فروش (COGS) در دفتر کل حسابداری.
سطرهای Dr 40001 / Cr موجودی (یا معکوس در برگشت از فروش) روی همان سند فاکتور ثبت می‌شوند
تا در گزارش سود و زیان و بستن سال مالی لحاظ شوند.
زمان شناسایی هم‌راستا با invoice_profit_ledger_recognition_basis:
- sales_invoice_document: با ثبت فاکتور قطعی
- warehouse_document_posting: پس از قطعی شدن حواله انبار مرتبط
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, Optional, Tuple
from sqlalchemy.orm import Session
from adapters.db.models.account import Account
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from app.services.invoice_profit_ledger_service import (
LEDGER_BASIS_SALES_INVOICE_DOCUMENT,
LEDGER_BASIS_WAREHOUSE_DOCUMENT_POSTING,
document_has_any_posted_warehouse,
normalize_invoice_profit_ledger_basis,
)
logger = logging.getLogger(__name__)
COGS_GL_SIDE = "gl_cogs"
INVENTORY_COGS_GL_SIDE = "gl_inventory_cogs"
COGS_GL_DOCUMENT_TYPES = frozenset({
"invoice_sales",
"invoice_sales_return",
})
def _is_cogs_gl_line(line: DocumentLine) -> bool:
extra = line.extra_info or {}
if not isinstance(extra, dict):
return False
return extra.get("side") in (COGS_GL_SIDE, INVENTORY_COGS_GL_SIDE)
def _existing_cogs_gl_amount(lines: list[DocumentLine]) -> Decimal:
"""مبلغ COGS ثبت‌شده در دفتر (بدهکار حساب ۴۰۰۰۱)."""
total = Decimal(0)
for ln in lines:
if not _is_cogs_gl_line(ln):
continue
extra = ln.extra_info or {}
if extra.get("side") == COGS_GL_SIDE:
total += Decimal(str(ln.debit or 0)) - Decimal(str(ln.credit or 0))
return total
def remove_invoice_cogs_gl_lines(db: Session, document_id: int) -> int:
"""حذف سطرهای COGS/موجودی مرتبط با شناسایی دفتر کل."""
lines = db.query(DocumentLine).filter(DocumentLine.document_id == int(document_id)).all()
removed = 0
for ln in lines:
if _is_cogs_gl_line(ln):
db.delete(ln)
removed += 1
if removed:
db.flush()
return removed
def _evaluate_should_have_cogs_gl(db: Session, document: Document) -> bool:
if bool(getattr(document, "is_proforma", False)):
return False
if str(document.document_type or "") not in COGS_GL_DOCUMENT_TYPES:
return False
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == int(document.business_id)).first()
if not biz:
return False
basis = normalize_invoice_profit_ledger_basis(
getattr(biz, "invoice_profit_ledger_recognition_basis", None)
)
if basis == LEDGER_BASIS_SALES_INVOICE_DOCUMENT:
return True
if basis == LEDGER_BASIS_WAREHOUSE_DOCUMENT_POSTING:
if document_has_any_posted_warehouse(db, document_id=int(document.id)):
return True
# اگر ثبت انبار از فاکتور غیرفعال است، حواله‌ای برای شناسایی وجود ندارد؛
# COGS باید هم‌زمان با ثبت فاکتور قطعی در دفتر کل بیاید.
extra_info = document.extra_info if isinstance(document.extra_info, dict) else {}
return not bool(extra_info.get("post_inventory", True))
return False
def _total_cost_from_profit_data(profit_data: Dict[str, Any]) -> Decimal:
"""استخراج بهای تمام‌شده کل از خروجی محاسبه سود (با fallback به جمع خطوط)."""
raw_total = profit_data.get("total_cost")
if raw_total is not None:
total_cost = Decimal(str(raw_total or 0))
else:
total_cost = Decimal(0)
for line in profit_data.get("line_profits") or []:
total_cost += Decimal(str(line.get("total_cost", 0) or 0))
if total_cost < 0:
total_cost = abs(total_cost)
return total_cost
def _recognized_ledger_cogs_total_from_document(db: Session, document_id: int) -> Decimal:
"""
جمع بهای تمام‌شده شناسایی‌شده روی خطوط فاکتور (ستون ledger_line_cogs).
وقتی محاسبهٔ زندهٔ سود (مثلاً fifo_jbfn) مقدار صفر برمی‌گرداند ولی شناسایی قطعی
قبلاً روی خطوط ذخیره شده، مبنای ثبت COGS در دفتر کل همان مقادیر شناسایی‌شده است.
"""
from adapters.db.models.invoice_item_line import InvoiceItemLine
rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == int(document_id))
.all()
)
total = Decimal(0)
for row in rows:
if getattr(row, "ledger_recognized_at", None) is None:
continue
if row.ledger_line_cogs is None:
continue
total += abs(Decimal(str(row.ledger_line_cogs)))
return total
def _calculate_invoice_cogs_amount_for_gl(
db: Session,
business_id: int,
document_id: int,
) -> Decimal:
"""محاسبه بهای تمام‌شده فروش برای ثبت دفتر کل (مستقل از غیرفعال بودن نمایش سود تحلیلی)."""
from adapters.db.models.business import Business
from app.services.invoice_service import _calculate_invoice_profit
biz = db.query(Business).filter(Business.id == int(business_id)).first()
if not biz:
return Decimal(0)
method = getattr(biz, "invoice_profit_calculation_method", None) or "automatic"
if str(method).strip().lower() == "disabled":
method = "automatic"
profit_data = _calculate_invoice_profit(
db,
int(business_id),
int(document_id),
method,
getattr(biz, "invoice_profit_calculation_basis", None) or "purchase_price",
bool(getattr(biz, "invoice_profit_include_overhead", False)),
getattr(biz, "invoice_profit_overhead_type", None) or "none",
(
Decimal(str(getattr(biz, "invoice_profit_overhead_percent", 0) or 0))
if getattr(biz, "invoice_profit_overhead_percent", None) is not None
else None
),
getattr(biz, "invoice_profit_calculation_type", None) or "gross",
fifo_shortage_mode=getattr(biz, "invoice_profit_fifo_shortage_mode", None),
)
calculated = _total_cost_from_profit_data(profit_data)
if calculated > 0:
return calculated
return _recognized_ledger_cogs_total_from_document(db, int(document_id))
def _resolve_cogs_gl_accounts(db: Session, document: Document) -> Tuple[Account, Account]:
from app.services.invoice_service import _resolve_accounts_for_invoice
data: Dict[str, Any] = {
"invoice_type": document.document_type,
"business_id": document.business_id,
"extra_info": document.extra_info or {},
}
accounts = _resolve_accounts_for_invoice(db, data)
return accounts["cogs"], accounts["inventory"]
def _amounts_close(a: Decimal, b: Decimal, tolerance: Decimal = Decimal("0.01")) -> bool:
return abs(a - b) <= tolerance
def _post_cogs_gl_lines(
db: Session,
document: Document,
cogs_amount: Decimal,
cogs_account: Account,
inventory_account: Account,
) -> None:
if cogs_amount <= 0:
return
amount_f = float(cogs_amount)
is_return = str(document.document_type or "") == "invoice_sales_return"
if is_return:
db.add(
DocumentLine(
document_id=document.id,
account_id=inventory_account.id,
debit=amount_f,
credit=0.0,
description="بهای تمام‌شده برگشت از فروش — ورود به موجودی",
extra_info={"side": INVENTORY_COGS_GL_SIDE, "source": "invoice_cogs_gl"},
)
)
db.add(
DocumentLine(
document_id=document.id,
account_id=cogs_account.id,
debit=0.0,
credit=amount_f,
description="بهای تمام‌شده برگشت از فروش",
extra_info={"side": COGS_GL_SIDE, "source": "invoice_cogs_gl"},
)
)
else:
db.add(
DocumentLine(
document_id=document.id,
account_id=cogs_account.id,
debit=amount_f,
credit=0.0,
description="بهای تمام‌شده کالای فروخته‌شده",
extra_info={"side": COGS_GL_SIDE, "source": "invoice_cogs_gl"},
)
)
db.add(
DocumentLine(
document_id=document.id,
account_id=inventory_account.id,
debit=0.0,
credit=amount_f,
description="خروج کالا از موجودی (فروش)",
extra_info={"side": INVENTORY_COGS_GL_SIDE, "source": "invoice_cogs_gl"},
)
)
db.flush()
def resync_invoice_cogs_gl_lines(db: Session, document_id: int) -> bool:
"""
هم‌رسانی سطرهای COGS دفتر کل با محاسبهٔ جاری و مبنای شناسایی کسب‌وکار.
Returns:
True اگر پس از هم‌رسانی سطر COGS با مبلغ مثبت روی سند باشد.
"""
document = db.query(Document).filter(Document.id == int(document_id)).first()
if not document:
return False
existing_lines = (
db.query(DocumentLine).filter(DocumentLine.document_id == int(document_id)).all()
)
existing_amount = _existing_cogs_gl_amount(existing_lines)
if not _evaluate_should_have_cogs_gl(db, document):
if existing_amount > 0:
remove_invoice_cogs_gl_lines(db, int(document_id))
return False
new_amount = _calculate_invoice_cogs_amount_for_gl(
db, int(document.business_id), int(document.id)
)
if new_amount <= 0:
if existing_amount > 0:
remove_invoice_cogs_gl_lines(db, int(document_id))
return False
if _amounts_close(existing_amount, new_amount):
return True
remove_invoice_cogs_gl_lines(db, int(document_id))
try:
cogs_account, inventory_account = _resolve_cogs_gl_accounts(db, document)
except Exception as exc:
logger.warning(
"invoice_cogs_gl: account resolution failed doc_id=%s err=%s",
document_id,
exc,
exc_info=True,
)
return False
_post_cogs_gl_lines(db, document, new_amount, cogs_account, inventory_account)
try:
from app.services.document_line_fx_service import stamp_document_lines_fx_base
stamp_document_lines_fx_base(db, document, only_missing=False, allow_infer=True)
except Exception as fx_stamp_ex:
logger.warning(
"invoice_cogs_gl: fx base stamp failed doc_id=%s err=%s",
document_id,
fx_stamp_ex,
exc_info=True,
)
from adapters.db.repositories.document_repository import DocumentRepository
doc_repo = DocumentRepository(db)
all_lines = [
{
"account_id": ln.account_id,
"debit": float(ln.debit or 0),
"credit": float(ln.credit or 0),
}
for ln in db.query(DocumentLine).filter(DocumentLine.document_id == int(document_id)).all()
]
is_valid, balance_error = doc_repo.validate_document_balance(all_lines)
if not is_valid:
logger.error(
"invoice_cogs_gl: document unbalanced after COGS sync doc_id=%s err=%s",
document_id,
balance_error,
)
remove_invoice_cogs_gl_lines(db, int(document_id))
db.flush()
return False
return True
def on_sales_invoice_cogs_gl_sync(db: Session, invoice_document_id: int) -> None:
"""فراخوانی پس از ثبت/ویرایش فاکتور قطعی."""
try:
resync_invoice_cogs_gl_lines(db, int(invoice_document_id))
except Exception as exc:
logger.warning(
"invoice_cogs_gl sync on invoice finalize failed doc_id=%s err=%s",
invoice_document_id,
exc,
exc_info=True,
)
def on_warehouse_invoice_cogs_gl_sync(db: Session, warehouse_document_id: int) -> None:
"""فراخوانی پس از قطعی شدن حواله با منبع فاکتور."""
from adapters.db.models.warehouse_document import WarehouseDocument
wh = (
db.query(WarehouseDocument)
.filter(WarehouseDocument.id == int(warehouse_document_id))
.first()
)
if not wh or getattr(wh, "status", None) != "posted":
return
src_type = str(getattr(wh, "source_type", "") or "").strip().lower()
src_doc_id = getattr(wh, "source_document_id", None)
if src_type != "invoice" or not src_doc_id:
return
try:
resync_invoice_cogs_gl_lines(db, int(src_doc_id))
except Exception as exc:
logger.warning(
"invoice_cogs_gl sync after warehouse post failed wh_id=%s invoice_id=%s err=%s",
warehouse_document_id,
src_doc_id,
exc,
exc_info=True,
)
def on_warehouse_invoice_cogs_gl_cancel_sync(
db: Session,
*,
source_type: Optional[str],
source_document_id: Optional[int],
) -> None:
"""فراخوانی پس از لغو حوالهٔ قطعی مرتبط با فاکتور."""
if str(source_type or "").strip().lower() != "invoice" or not source_document_id:
return
try:
resync_invoice_cogs_gl_lines(db, int(source_document_id))
except Exception as exc:
logger.warning(
"invoice_cogs_gl sync after warehouse cancel failed invoice_id=%s err=%s",
source_document_id,
exc,
exc_info=True,
)
def backfill_cogs_gl_for_business(
db: Session,
business_id: int,
*,
fiscal_year_id: Optional[int] = None,
invoice_ids: Optional[list[int]] = None,
limit: Optional[int] = None,
) -> Dict[str, Any]:
"""
هم‌رسانی سطرهای COGS دفتر کل برای فاکتورهای فروش/برگشت قطعی قبلی.
برای اسنادی که قبل از پیاده‌سازی این قابلیت ثبت شده‌اند استفاده می‌شود.
"""
q = (
db.query(Document)
.filter(
Document.business_id == int(business_id),
Document.document_type.in_(tuple(COGS_GL_DOCUMENT_TYPES)),
Document.is_proforma == False, # noqa: E712
)
)
if fiscal_year_id is not None:
q = q.filter(Document.fiscal_year_id == int(fiscal_year_id))
if invoice_ids:
q = q.filter(Document.id.in_([int(x) for x in invoice_ids]))
docs = q.order_by(Document.document_date.asc(), Document.id.asc()).all()
if limit is not None:
docs = docs[: max(0, int(limit))]
processed = 0
skipped = 0
errors: list[Dict[str, Any]] = []
for doc in docs:
try:
if resync_invoice_cogs_gl_lines(db, int(doc.id)):
processed += 1
else:
skipped += 1
except Exception as exc:
errors.append({"document_id": doc.id, "code": doc.code, "error": str(exc)})
skipped += 1
db.commit()
return {
"processed": processed,
"skipped": skipped,
"total_candidates": len(docs),
"errors": errors[:50],
}