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

10400 lines
418 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, date, timedelta, timezone
from app.core.datetime_utils import utc_now, utc_now_aware, utc_naive_to_iso_z
from decimal import Decimal, ROUND_HALF_UP
import logging
import re
from collections import defaultdict, deque
from sqlalchemy.orm import Session, aliased, selectinload
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy import and_, or_, func, cast, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
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.currency import Currency
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
from adapters.db.models.check import Check, CheckType
from adapters.db.models.user import User
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.invoice_item_line import InvoiceItemLine
from adapters.db.models.tax_unit import TaxUnit
from adapters.db.models.product_bom import ProductBOM, ProductBOMOutput
from adapters.db.models.business import Business
from app.core.calendar import CalendarConverter, CalendarType
from app.core.responses import ApiError, success_response
from app.services.document_monetization_service import ensure_document_policy_allows_creation
from app.services.credit_service import get_business_credit_settings
import jdatetime
import io
import csv
logger = logging.getLogger(__name__)
def _format_quantity_for_user_message(value: Any) -> str:
"""نمایش تعداد/موجودی در پیام کاربر؛ اگر مقدار صحیح است بدون قسمت اعشاری."""
try:
d = Decimal(str(value))
except Exception:
return str(value)
try:
iv = d.to_integral_value(rounding=ROUND_HALF_UP)
if d == iv:
return str(int(iv))
except Exception:
pass
s = format(d, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
def _validate_invoice_line_custom_attributes(db: Session, business_id: int, line: Dict[str, Any]) -> None:
"""اعتبارسنجی extra_info.line_custom_attributes نسبت به ویژگی‌های لینک‌شده به کالا."""
from app.services.product_attribute_service import validate_custom_attributes
product_id = line.get("product_id")
if not product_id:
return
extra_info = line.get("extra_info") or {}
attrs = extra_info.get("line_custom_attributes")
if not attrs:
return
if not isinstance(attrs, dict):
raise ApiError("INVALID_LINE_ATTRIBUTES", "ساختار ویژگی‌های سطر فاکتور نامعتبر است", http_status=400)
ok, err = validate_custom_attributes(db, business_id, int(product_id), attrs)
if not ok:
raise ApiError("INVALID_LINE_ATTRIBUTES", err or "ویژگی‌های سطر فاکتور معتبر نیستند", http_status=400)
def invalidate_invoices_cache(business_id: int, fiscal_year_id: Optional[int] = None, invoice_id: Optional[int] = None, document_type: Optional[str] = None, project_id: Optional[int] = None):
"""
حذف تمام کش‌های مربوط به لیست فاکتورها یک کسب‌وکار
این تابع از چند روش استفاده می‌کند:
1. Tag-based invalidation با set ردیس: حذف انتخابی بر اساس business_id, fiscal_year_id, document_type و project_id (بهینه‌تر)
2. Pattern-based invalidation: حذف تمام کلیدهای invoices_search:* (fallback برای اطمینان)
3. Redis Pub/Sub: انتشار پیام invalidation برای تمام instanceها
Args:
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری - بسیار مهم)
- اگر None باشد، تمام کش‌های مربوط به business_id حذف می‌شوند
- اگر مشخص باشد، فقط کش‌های مربوط به آن fiscal_year_id حذف می‌شوند
invoice_id: شناسه فاکتور خاص (اختیاری)
document_type: نوع فاکتور (invoice_sales, invoice_purchase, ...) (اختیاری)
project_id: شناسه پروژه (اختیاری)
"""
from app.core.cache import get_cache
cache = get_cache()
if not cache.enabled:
return
try:
# روش 1: استفاده از invalidate_invoices_by_business (بهینه‌ترین روش)
deleted_count = cache.invalidate_invoices_by_business(business_id, fiscal_year_id, invoice_id, document_type, project_id)
if deleted_count > 0:
logger.info(f"Invalidated {deleted_count} cache keys for business_id {business_id}, fiscal_year_id {fiscal_year_id}, invoice_id {invoice_id}, document_type {document_type}, project_id {project_id}")
# روش 2: حذف تمام کلیدهای invoices_search:* (fallback برای اطمینان کامل)
pattern = f"invoices_search:{business_id}:*"
deleted_pattern = cache.delete_pattern(pattern)
if deleted_pattern > 0:
logger.info(f"Invalidated {deleted_pattern} cache keys using pattern: {pattern}")
# حذف کش فاکتور خاص اگر مشخص شده باشد
if invoice_id:
invoice_pattern = f"invoice:{business_id}:{invoice_id}*"
deleted_invoice = cache.delete_pattern(invoice_pattern)
if deleted_invoice > 0:
logger.info(f"Invalidated {deleted_invoice} cache keys for invoice_id {invoice_id} using pattern: {invoice_pattern}")
# روش 3: انتشار پیام invalidation از طریق Redis Pub/Sub
invalidation_message = {
"type": "invoices_cache_invalidation",
"business_id": business_id,
"fiscal_year_id": fiscal_year_id,
"invoice_id": invoice_id,
"document_type": document_type,
"project_id": project_id,
"timestamp": None
}
try:
import time
invalidation_message["timestamp"] = time.time()
cache.publish_invalidation("cache_invalidation", invalidation_message)
logger.info(f"Published invalidation message for business_id {business_id}, fiscal_year_id {fiscal_year_id}, invoice_id {invoice_id}")
except Exception as pub_error:
logger.warning(f"Error publishing invalidation message: {pub_error}")
except Exception as e:
# خطا در invalidate نباید مانع عملیات اصلی شود
logger.warning(f"Error invalidating invoices cache for business_id {business_id}: {e}")
# Supported invoice types (Document.document_type)
INVOICE_SALES = "invoice_sales"
INVOICE_SALES_RETURN = "invoice_sales_return"
INVOICE_PURCHASE = "invoice_purchase"
INVOICE_PURCHASE_RETURN = "invoice_purchase_return"
INVOICE_DIRECT_CONSUMPTION = "invoice_direct_consumption"
INVOICE_PRODUCTION = "invoice_production"
INVOICE_WASTE = "invoice_waste"
SUPPORTED_INVOICE_TYPES = {
INVOICE_SALES,
INVOICE_SALES_RETURN,
INVOICE_PURCHASE,
INVOICE_PURCHASE_RETURN,
INVOICE_DIRECT_CONSUMPTION,
INVOICE_PRODUCTION,
INVOICE_WASTE,
}
# اسنادی که اقلام کالا در invoice_item_lines نگه داشته می‌شود (نه DocumentLine.product_id)
_INVOICE_DOC_TYPES_WITH_ITEM_LINES = frozenset(SUPPORTED_INVOICE_TYPES)
ALLOWED_INVOICE_PROFIT_METHODS = {"automatic", "manual", "disabled"}
ALLOWED_INVOICE_PROFIT_BASIS = {
"purchase_price",
"cost_price",
"average_cost",
"fifo",
"fifo_jbfn",
"lifo",
"weighted_average",
"moving_weighted_average",
"standard_cost",
"actual_cost",
}
ALLOWED_INVOICE_PROFIT_TYPES = {"gross", "net", "both"}
ALLOWED_INVOICE_PROFIT_OVERHEAD_TYPES = {"none", "production_overhead", "all_overhead", "custom_percent"}
# کلیدهای لینک اسناد جانبی روی extra_info.links فاکتور
INVOICE_LINK_RECEIPT_PAYMENT_IDS = "receipt_payment_document_ids"
INVOICE_LINK_EXPENSE_INCOME_IDS = "expense_income_document_ids"
def _strip_invoice_side_document_links_from_document(document: Document) -> None:
"""حذف لینک‌های دریافت/پرداخت و هزینه/درآمد از extra فاکتور (پیش‌فاکتور نباید این لینک‌ها را نگه دارد)."""
extra = dict(document.extra_info or {})
links = dict(extra.get("links") or {})
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
links.pop(INVOICE_LINK_EXPENSE_INCOME_IDS, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
def _delete_invoice_linked_side_documents(
db: Session,
*,
receipt_payment_ids: List[Any],
expense_income_ids: List[Any],
) -> None:
"""
حذف اسناد دریافت/پرداخت و هزینه/درآمد پیوندخورده به فاکتور (بدون commit؛ در همان تراکنش caller).
"""
if receipt_payment_ids:
from app.services.receipt_payment_service import delete_receipt_payment
for rid in receipt_payment_ids:
if rid is None:
continue
delete_receipt_payment(db, int(rid), commit=False)
if expense_income_ids:
from app.services.expense_income_service import delete_expense_income
for eid in expense_income_ids:
if eid is None:
continue
delete_expense_income(db, int(eid), commit=False)
def _normalize_invoice_profit_method(value: Optional[str]) -> str:
method = str(value or "automatic").strip().lower()
if method not in ALLOWED_INVOICE_PROFIT_METHODS:
return "automatic"
return method
def _normalize_invoice_profit_basis(value: Optional[str]) -> str:
basis = str(value or "purchase_price").strip().lower()
if basis in ("wma", "moving_wavg", "mwa"):
basis = "moving_weighted_average"
if basis not in ALLOWED_INVOICE_PROFIT_BASIS:
return "purchase_price"
return basis
def _normalize_invoice_profit_type(value: Optional[str]) -> str:
calc_type = str(value or "gross").strip().lower()
if calc_type not in ALLOWED_INVOICE_PROFIT_TYPES:
return "gross"
return calc_type
def _normalize_invoice_profit_overhead_type(value: Optional[str]) -> str:
overhead_type = str(value or "none").strip().lower()
if overhead_type not in ALLOWED_INVOICE_PROFIT_OVERHEAD_TYPES:
return "none"
return overhead_type
ALLOWED_INVOICE_PROFIT_FIFO_SHORTAGE_MODES = frozenset({"perpetual_mixed", "average_purchase_on_shortage"})
def _normalize_invoice_profit_fifo_shortage_mode(value: Optional[str]) -> str:
"""
سیاست هزینه‌گذاری وقتی لایه‌های FIFO/LIFO به اندازه خروج کفایت نکند.
- perpetual_mixed: همان رفتار قبلی (باقی‌مانده با آخرین قیمت لایه؛ بدون لایه → صفر)
- average_purchase_on_shortage: مقدار بدون لایه با میانگین خرید تا تاریخ سند
"""
s = str(value or "perpetual_mixed").strip().lower()
if s in ("avg", "average", "avg_shortage"):
s = "average_purchase_on_shortage"
if s not in ALLOWED_INVOICE_PROFIT_FIFO_SHORTAGE_MODES:
return "perpetual_mixed"
return s
def _distinct_product_ids_for_invoice(db: Session, document_id: int) -> List[int]:
rows = (
db.query(InvoiceItemLine.product_id)
.filter(
InvoiceItemLine.document_id == int(document_id),
InvoiceItemLine.product_id.isnot(None),
)
.distinct()
.all()
)
return [int(r[0]) for r in rows]
def _movement_sort_key(mv: Dict[str, Any]) -> Tuple[Any, Any, int, int]:
"""ترتیب زمانی حرکات؛ در یک روز ابتدا زمان ثبت سند، سپس شناسه سند، سپس خط فاکتور."""
ra = mv.get("registered_at")
if ra is None:
ra = datetime.min
return (
mv["document_date"],
ra,
int(mv["document_id"]),
int(mv.get("invoice_item_line_id") or 0),
)
def _apply_movement_to_layers(mv: Dict[str, Any], layers: deque, *, reverse: bool) -> None:
"""اعمال یک حرکت in/out روی لایه‌های هزینه (همان منطق FIFO/LIFO انبار دائمی)."""
mv_type = mv.get("movement")
qty = Decimal(str(mv.get("quantity") or 0))
if qty <= 0:
return
if mv_type == "in":
layer = {
"qty": qty,
"cost": Decimal(str(mv.get("cost_price") or 0)),
}
if reverse:
layers.appendleft(layer)
else:
layers.append(layer)
return
if mv_type == "out":
remain = qty
while remain > 0 and layers:
top = layers[0]
take = min(remain, Decimal(str(top.get("qty") or 0)))
top["qty"] = Decimal(str(top.get("qty") or 0)) - take
remain -= take
if top["qty"] <= 0:
layers.popleft()
def _copy_cost_layers(layers: deque) -> deque:
"""کپی عمیق لایه‌ها برای محاسبه بهای تمام‌شده بدون تخریب وضعیت."""
return deque(
{"qty": Decimal(str(top.get("qty") or 0)), "cost": Decimal(str(top.get("cost") or 0))}
for top in layers
)
def _build_cost_layers_from_movements(
movements: List[Dict[str, Any]],
*,
reverse: bool = False,
) -> deque:
"""
ساخت لایه‌های هزینه از حرکات تاریخی واقعی (in/out).
reverse=False => FIFO ، reverse=True => LIFO
"""
sorted_movements = sorted(movements, key=_movement_sort_key)
layers: deque = deque()
for mv in sorted_movements:
_apply_movement_to_layers(mv, layers, reverse=reverse)
return layers
def _unit_cost_at_target_outbound_line(
movements: List[Dict[str, Any]],
quantity: Decimal,
document_id: int,
invoice_item_line_id: int,
*,
reverse: bool,
fifo_shortage_mode: str = "perpetual_mixed",
average_unit_for_shortage: Optional[Decimal] = None,
) -> Optional[Decimal]:
"""
بهای تمام‌شده میانگین واحد برای «همان» ردیف خروج، بلافاصله قبل از اعمال آن خروج (FIFO/LIFO دائمی).
اگر ردیف در حرکات نباشد یا خروج نباشد → None.
"""
qty = Decimal(str(quantity or 0))
if qty <= 0:
return Decimal(0)
sorted_movements = sorted(movements, key=_movement_sort_key)
layers: deque = deque()
target_mv: Optional[Dict[str, Any]] = None
for mv in sorted_movements:
if int(mv["document_id"]) == int(document_id) and int(mv.get("invoice_item_line_id") or 0) == int(
invoice_item_line_id
):
target_mv = mv
break
_apply_movement_to_layers(mv, layers, reverse=reverse)
if target_mv is None or target_mv.get("movement") != "out":
return None
return _consume_cost_layers_for_quantity(
_copy_cost_layers(layers),
qty,
fifo_shortage_mode=fifo_shortage_mode,
average_unit_for_shortage=average_unit_for_shortage,
)
def _consume_cost_layers_for_quantity(
layers: deque,
quantity: Decimal,
*,
fifo_shortage_mode: str = "perpetual_mixed",
average_unit_for_shortage: Optional[Decimal] = None,
) -> Decimal:
"""
مصرف تعداد موردنیاز از لایه‌ها و بازگرداندن هزینه هر واحد.
در کمبود لایه: perpetual_mixed با آخرین هزینه لایه؛ اگر لایه‌ای نبود صفر.
average_purchase_on_shortage: باقی‌مانده با average_unit_for_shortage (یا صفر اگر None).
"""
qty = Decimal(str(quantity or 0))
if qty <= 0:
return Decimal(0)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
use_avg = sm == "average_purchase_on_shortage" and average_unit_for_shortage is not None
av = Decimal(str(average_unit_for_shortage or 0)) if use_avg else None
remain = qty
total_cost = Decimal(0)
last_cost = Decimal(0)
used_any = False
while remain > 0 and layers:
top = layers[0]
layer_qty = Decimal(str(top.get("qty") or 0))
if layer_qty <= 0:
layers.popleft()
continue
layer_cost = Decimal(str(top.get("cost") or 0))
take = min(remain, layer_qty)
total_cost += take * layer_cost
top["qty"] = layer_qty - take
remain -= take
last_cost = layer_cost
used_any = True
if top["qty"] <= 0:
layers.popleft()
if remain > 0:
if use_avg and av is not None:
total_cost += remain * av
elif used_any:
total_cost += remain * last_cost
return total_cost / qty if qty > 0 else Decimal(0)
def _wma_resolve_wac_zero_stock(
last_wac: Optional[Decimal],
*,
use_avg_shortage: bool,
average_unit_for_shortage: Optional[Decimal],
) -> Decimal:
if use_avg_shortage and average_unit_for_shortage is not None:
return Decimal(str(average_unit_for_shortage))
if last_wac is not None:
return Decimal(str(last_wac))
return Decimal(0)
def _wma_apply_movement_to_running_state(
state: Tuple[Decimal, Decimal, Optional[Decimal]],
mv: Dict[str, Any],
*,
fifo_shortage_mode: str,
average_unit_for_shortage: Optional[Decimal],
) -> Tuple[Decimal, Decimal, Optional[Decimal]]:
"""به‌روزرسانی موجودی دائمی میانگین موزون (مقدار، ارزش، آخرین WAC اعمال‌شده)."""
q, v, last_wac = state
qty = Decimal(str(mv.get("quantity") or 0))
if qty <= 0:
return (q, v, last_wac)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
use_avg = sm == "average_purchase_on_shortage" and average_unit_for_shortage is not None
if mv.get("movement") == "in":
unit_cost = Decimal(str(mv.get("cost_price") or 0))
new_q = q + qty
new_v = v + qty * unit_cost
new_last = (new_v / new_q) if new_q > 0 else last_wac
return (new_q, new_v, new_last)
if mv.get("movement") == "out":
if q > 0:
wac = v / q
else:
wac = _wma_resolve_wac_zero_stock(
last_wac,
use_avg_shortage=use_avg,
average_unit_for_shortage=average_unit_for_shortage,
)
cogs = qty * wac
return (q - qty, v - cogs, wac)
return (q, v, last_wac)
def _wma_total_cost_for_quantity_from_state(
q: Decimal,
v: Decimal,
last_wac: Optional[Decimal],
sell_qty: Decimal,
*,
fifo_shortage_mode: str,
average_unit_for_shortage: Optional[Decimal],
) -> Decimal:
"""هزینه کل برای یک خروج به میزان sell_qty بلافاصله قبل از اعمال آن خروج."""
sell_qty = Decimal(str(sell_qty or 0))
if sell_qty <= 0:
return Decimal(0)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
use_avg = sm == "average_purchase_on_shortage" and average_unit_for_shortage is not None
if q >= sell_qty:
if q > 0:
wac = v / q
else:
wac = _wma_resolve_wac_zero_stock(
last_wac,
use_avg_shortage=use_avg,
average_unit_for_shortage=average_unit_for_shortage,
)
return sell_qty * wac
total = Decimal(0)
if q > 0:
wac = v / q
total += q * wac
short = sell_qty - q
if short > 0:
if use_avg and average_unit_for_shortage is not None:
total += short * Decimal(str(average_unit_for_shortage))
else:
total += short * wac
return total
wac = _wma_resolve_wac_zero_stock(
last_wac,
use_avg_shortage=use_avg,
average_unit_for_shortage=average_unit_for_shortage,
)
return sell_qty * wac
def _unit_wma_cost_at_target_outbound_line(
movements: List[Dict[str, Any]],
quantity: Decimal,
document_id: int,
invoice_item_line_id: int,
*,
fifo_shortage_mode: str = "perpetual_mixed",
average_unit_for_shortage: Optional[Decimal] = None,
) -> Optional[Decimal]:
"""
بهای تمام‌شده هر واحد برای همان ردیف خروج با میانگین موزون متحرک دائمی،
بلافاصله قبل از اعمال آن خروج.
"""
qty = Decimal(str(quantity or 0))
if qty <= 0:
return Decimal(0)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
sorted_movements = sorted(movements, key=_movement_sort_key)
state: Tuple[Decimal, Decimal, Optional[Decimal]] = (Decimal(0), Decimal(0), None)
for mv in sorted_movements:
if int(mv["document_id"]) == int(document_id) and int(mv.get("invoice_item_line_id") or 0) == int(
invoice_item_line_id
):
if mv.get("movement") != "out":
return None
q, v, last_wac = state
total = _wma_total_cost_for_quantity_from_state(
q,
v,
last_wac,
qty,
fifo_shortage_mode=sm,
average_unit_for_shortage=average_unit_for_shortage,
)
return total / qty if qty > 0 else Decimal(0)
state = _wma_apply_movement_to_running_state(
state,
mv,
fifo_shortage_mode=sm,
average_unit_for_shortage=average_unit_for_shortage,
)
return None
# --- Inventory & Costing helpers ---
def _get_costing_method(data: Dict[str, Any]) -> str:
try:
method = ((data.get("extra_info") or {}).get("costing_method") or "average").strip().lower()
if method not in ("average", "fifo"):
method = "average"
return method
except Exception:
return "average"
def _is_inventory_posting_enabled(data: Dict[str, Any]) -> bool:
"""خواندن فلگ ثبت اسناد انبار از extra_info. پیش‌فرض: فعال (True)."""
try:
extra = data.get("extra_info") or {}
val = extra.get("post_inventory")
if val is None:
return True
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return bool(val)
s = str(val).strip().lower()
return s not in ("false", "0", "no", "off")
except Exception:
return True
def _int_or_none(val: Any) -> Optional[int]:
if val is None:
return None
try:
i = int(val)
return i if i > 0 else None
except (TypeError, ValueError):
return None
def _invoice_line_has_resolvable_warehouse(line: Dict[str, Any], document_extra: Dict[str, Any]) -> bool:
extra = dict(line.get("extra_info") or {})
if _int_or_none(line.get("warehouse_id")) is not None or _int_or_none(extra.get("warehouse_id")) is not None:
return True
return _int_or_none(document_extra.get("warehouse_id")) is not None
def _apply_invoice_missing_line_warehouse_policy(
db: Session,
business_id: int,
lines_input: List[Dict[str, Any]],
document_extra_info: Dict[str, Any],
is_proforma: bool,
post_inventory_enabled: bool,
) -> None:
"""اگر ثبت انبار فعال باشد، برای کالاهای انبارداری بدون انبار، سیاست کسب‌وکار اعمال یا خطای راهنما داده می‌شود."""
if is_proforma or not post_inventory_enabled:
return
from adapters.db.models.warehouse import Warehouse as _WarehouseModel
biz = db.query(Business).filter(Business.id == int(business_id)).first()
if not biz:
return
policy = str(getattr(biz, "invoice_missing_line_warehouse_policy", None) or "reject").strip().lower()
if policy not in ("reject", "use_default_warehouse"):
policy = "reject"
default_wh = _int_or_none(getattr(biz, "invoice_default_warehouse_id", None))
fill_header = bool(getattr(biz, "invoice_default_warehouse_fill_document_header", True))
missing_rows: List[int] = []
for idx, ln in enumerate(lines_input, start=1):
pid = ln.get("product_id")
if not pid:
continue
qty = Decimal(str(ln.get("quantity", 0) or 0))
if qty <= 0:
continue
info = dict(ln.get("extra_info") or {})
if not bool(info.get("inventory_tracked", True)):
continue
if _invoice_line_has_resolvable_warehouse(ln, document_extra_info):
continue
missing_rows.append(idx)
if not missing_rows:
return
if policy == "use_default_warehouse":
if default_wh is None:
raise ApiError(
"INVOICE_DEFAULT_WAREHOUSE_NOT_SET",
"سیاست «استفاده از انبار پیش‌فرض» انتخاب شده اما انبار پیش‌فرض در تنظیمات کسب‌وکار (اطلاعات کسب‌وکار، بخش انبار فاکتور) تعریف نشده است. یک انبار انتخاب کنید یا سیاست را روی «جلوگیری از ثبت» بگذارید.",
http_status=400,
)
wh_row = (
db.query(_WarehouseModel)
.filter(
_WarehouseModel.id == int(default_wh),
_WarehouseModel.business_id == int(business_id),
)
.first()
)
if not wh_row:
raise ApiError(
"INVOICE_DEFAULT_WAREHOUSE_INVALID",
"انبار پیش‌فرض انتخاب‌شده معتبر نیست یا متعلق به این کسب‌وکار نیست.",
http_status=400,
)
for ln in lines_input:
pid = ln.get("product_id")
if not pid:
continue
qty = Decimal(str(ln.get("quantity", 0) or 0))
if qty <= 0:
continue
info = dict(ln.get("extra_info") or {})
if not bool(info.get("inventory_tracked", True)):
continue
if _invoice_line_has_resolvable_warehouse(ln, document_extra_info):
continue
info["warehouse_id"] = int(default_wh)
ln["extra_info"] = info
if fill_header and _int_or_none(document_extra_info.get("warehouse_id")) is None:
document_extra_info["warehouse_id"] = int(default_wh)
return
rows_txt = "، ".join(str(x) for x in missing_rows[:15])
more = f" و {len(missing_rows) - 15} ردیف دیگر" if len(missing_rows) > 15 else ""
raise ApiError(
"INVOICE_WAREHOUSE_LINE_REQUIRED",
(
f"برای ثبت یا به‌روزرسانی فاکتور با «ثبت انبار»، باید برای کالاهای انبارداری، ردیف‌های {rows_txt}{more} "
"انبار در همان ردیف یا «انبار فاکتور» در سربرگ مشخص شود. "
"فاکتور را ویرایش کرده و انبار را انتخاب کنید؛ یا در تنظیمات > اطلاعات کسب‌وکار، بخش «سیاست انبار برای ردیف‌های بدون انبار»، "
"گزینه «استفاده از انبار پیش‌فرض» را با تعیین انبار پیش‌فرض فعال کنید."
),
http_status=400,
details={"rows": missing_rows, "policy": "reject"},
)
def _normalize_invoice_warehouse_release_mode(value: Any) -> str:
"""none | draft | posted"""
if value is None:
return "draft"
s = str(value).strip().lower()
if s in ("none", "off", "no", "disabled"):
return "none"
if s in ("posted", "final", "confirmed"):
return "posted"
if s == "draft":
return "draft"
return "draft"
def _extra_invoice_flag_unset(extra: Dict[str, Any], key: str) -> bool:
"""کلید ارسال نشده یا مقدارش null باشد → از تنظیم کسب‌وکار پر شود."""
return key not in extra or extra.get(key) is None
def _apply_business_defaults_invoice_warehouse(db: Session, business_id: int, data: Dict[str, Any]) -> None:
"""
اگر کلاینت post_inventory یا auto_post_warehouse را نفرستد (یا null)،
از فیلد invoice_warehouse_release_mode روی کسب‌وکار استفاده می‌شود.
"""
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == int(business_id)).first()
mode = _normalize_invoice_warehouse_release_mode(
getattr(biz, "invoice_warehouse_release_mode", None) if biz else "draft"
)
raw = data.get("extra_info")
extra: Dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
if _extra_invoice_flag_unset(extra, "post_inventory"):
extra["post_inventory"] = mode != "none"
if _extra_invoice_flag_unset(extra, "auto_post_warehouse"):
extra["auto_post_warehouse"] = mode == "posted"
data["extra_info"] = extra
def _remove_old_invoice_warehouse_documents(
db: Session,
business_id: int,
invoice_document_id: int,
warehouse_document_ids: List[int],
user_id: int,
) -> None:
"""
حذف حوالهٔ پیش‌نویس یا لغو+پست معکوس برای حوالهٔ قطعی.
شناسه‌ها از links فاکتور می‌آید؛ اگر source_document_id با فاکتور دیگری ست شده باشد رد می‌شود.
(source_type قدیمی اجباری نیست تا حواله‌های قدیمی بدون متادیتای درست لغو شوند.)
"""
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from app.services.warehouse_service import cancel_warehouse_document, post_warehouse_document
seen: set[int] = set()
for raw_id in warehouse_document_ids:
try:
wid = int(raw_id)
except (TypeError, ValueError):
continue
if wid in seen:
continue
seen.add(wid)
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wid).first()
if not wh or int(wh.business_id) != int(business_id):
continue
if wh.source_document_id is not None and int(wh.source_document_id) != int(invoice_document_id):
continue
st = (wh.status or "").strip().lower()
if st == "draft":
db.query(WarehouseDocumentLine).filter(
WarehouseDocumentLine.warehouse_document_id == wh.id
).delete(synchronize_session=False)
db.delete(wh)
elif st == "posted":
cancel_wh = cancel_warehouse_document(db, business_id, int(wh.id), user_id)
post_warehouse_document(db, int(cancel_wh.id))
# cancelled: بدون اقدام
db.flush()
def _persisted_invoice_lines_for_warehouse(db: Session, document_id: int) -> List[Dict[str, Any]]:
"""
اقلام ذخیره‌شده برای ساخت حواله؛ شامل invoice_item_line_id برای پیوند قطعی سود/حواله.
"""
rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == int(document_id))
.order_by(InvoiceItemLine.id.asc())
.all()
)
lines_for_wh: List[Dict[str, Any]] = []
for row in rows:
extra = dict(row.extra_info or {})
lines_for_wh.append(
{
"product_id": row.product_id,
"quantity": float(row.quantity),
"description": row.description,
"extra_info": extra,
"invoice_item_line_id": int(row.id),
}
)
return lines_for_wh
def _create_warehouse_documents_for_invoice(
db: Session,
business_id: int,
document: Document,
user_id: int,
lines_input: List[Dict[str, Any]],
invoice_type: str,
*,
stock_exclude_warehouse_document_ids: Optional[List[int]] = None,
) -> List[int]:
"""
ایجاد حواله(های) انبار از روی فاکتور در صورت فعال بودن post_inventory.
در صورت ایجاد حواله، extra_info.links.warehouse_document_ids روی سند فاکتور به‌روز می‌شود.
از اقلام پایدار دیتابیس استفاده می‌شود تا شناسه ردیف فاکتور روی خط حواله ذخیره شود.
پیش‌فاکتور: حواله ساخته نمی‌شود؛ حرکات موجودی گزارش‌ها از invoice_item_lines تنها برای اسناد قطعی
محاسبه می‌شود و حوالهٔ وابسته به فاکتور در همان محاسبه حذف می‌شود تا دوبرابر نشود؛
ثبت حوالهٔ قطعی برای پیش‌فاکتور موجودی را با گزارش ناسازگار می‌کرد.
"""
if bool(getattr(document, "is_proforma", False)):
return []
if not bool((document.extra_info or {}).get("post_inventory", True)):
return []
from app.services.warehouse_service import (
create_from_invoice,
invoice_lines_have_trackable_inventory_products,
post_warehouse_document,
)
from adapters.db.models.product_instance import ProductInstance
lines_for_wh = _persisted_invoice_lines_for_warehouse(db, int(document.id))
if not lines_for_wh:
return []
_stock_ex_wh: Optional[List[int]] = None
if stock_exclude_warehouse_document_ids:
_stock_ex_wh = sorted({int(x) for x in stock_exclude_warehouse_document_ids if x is not None})
created_wh_ids: List[int] = []
auto_post_warehouse = bool((document.extra_info or {}).get("auto_post_warehouse", False))
if invoice_type == INVOICE_PRODUCTION:
out_lines = [ln for ln in lines_for_wh if (ln.get("extra_info") or {}).get("movement") == "out"]
in_lines = [ln for ln in lines_for_wh if (ln.get("extra_info") or {}).get("movement") == "in"]
if out_lines and invoice_lines_have_trackable_inventory_products(db, business_id, out_lines):
wh_issue = create_from_invoice(db, business_id, document, out_lines, "issue", user_id)
created_wh_ids.append(int(wh_issue.id))
if auto_post_warehouse:
post_warehouse_document(
db, int(wh_issue.id), stock_exclude_warehouse_document_ids=_stock_ex_wh
)
if in_lines and invoice_lines_have_trackable_inventory_products(db, business_id, in_lines):
wh_receipt = create_from_invoice(db, business_id, document, in_lines, "receipt", user_id)
created_wh_ids.append(int(wh_receipt.id))
if auto_post_warehouse:
post_warehouse_document(
db, int(wh_receipt.id), stock_exclude_warehouse_document_ids=_stock_ex_wh
)
elif invoice_lines_have_trackable_inventory_products(db, business_id, lines_for_wh):
if invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN, INVOICE_WASTE, INVOICE_DIRECT_CONSUMPTION}:
wh_type = "issue"
elif invoice_type in {INVOICE_PURCHASE, INVOICE_SALES_RETURN}:
wh_type = "receipt"
else:
wh_type = "issue"
wh = create_from_invoice(db, business_id, document, lines_for_wh, wh_type, user_id)
created_wh_ids.append(int(wh.id))
if auto_post_warehouse:
post_warehouse_document(
db, int(wh.id), stock_exclude_warehouse_document_ids=_stock_ex_wh
)
if invoice_type == INVOICE_SALES:
for line in lines_for_wh:
instance_id = (line.get("extra_info") or {}).get("instance_id")
if instance_id:
instance = db.query(ProductInstance).filter(
ProductInstance.id == int(instance_id),
ProductInstance.business_id == business_id,
).first()
if instance:
instance.status = "sold"
instance.current_invoice_id = document.id
instance.warehouse_id = None
db.flush()
if created_wh_ids:
extra = dict(document.extra_info) if document.extra_info else {}
links = dict((extra.get("links") or {}))
links["warehouse_document_ids"] = created_wh_ids
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
db.flush()
return created_wh_ids
def _build_product_tax_snapshot_map(
db: Session,
business_id: int,
product_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
"""
تهیه اطلاعات مالیاتی کالا برای ذخیره در snapshot خطوط فاکتور.
"""
if not product_ids:
return {}
unique_ids = list({int(pid) for pid in product_ids if pid})
if not unique_ids:
return {}
rows = db.query(
Product.id,
Product.tax_code,
Product.tax_unit_id,
Product.main_unit,
).filter(
Product.business_id == business_id,
Product.id.in_(unique_ids),
).all()
unit_ids = {row.tax_unit_id for row in rows if row.tax_unit_id}
unit_map: Dict[int, Dict[str, Any]] = {}
if unit_ids:
units = db.query(TaxUnit.id, TaxUnit.code, TaxUnit.name).filter(TaxUnit.id.in_(unit_ids)).all()
for unit in units:
unit_map[int(unit.id)] = {"code": unit.code, "name": unit.name}
result: Dict[int, Dict[str, Any]] = {}
for row in rows:
unit_info = unit_map.get(int(row.tax_unit_id)) if row.tax_unit_id else None
result[int(row.id)] = {
"tax_code": row.tax_code,
"tax_unit_id": int(row.tax_unit_id) if row.tax_unit_id else None,
"tax_unit_code": unit_info["code"] if unit_info else None,
"tax_unit_name": unit_info["name"] if unit_info else None,
"product_main_unit": row.main_unit,
}
return result
def refresh_invoice_line_tax_snapshots(db: Session, document: Document) -> int:
"""
به‌روزرسانی snapshot مالیاتی خطوط فاکتور از اطلاعات فعلی کالا.
فاکتور هنگام ثبت/ویرایش، وضعیت مالیاتی کالا را در extra_info هر ردیف ذخیره می‌کند.
اگر بعداً کد مالیاتی کالا تکمیل شود، بدون ویرایش مجدد فاکتور snapshot قدیمی
(بدون tax_code) باقی می‌ماند و ارسال به مودیان با خطا متوقف می‌شود.
"""
lines = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == document.id)
.all()
)
if not lines:
return 0
product_ids = [int(line.product_id) for line in lines if line.product_id]
tax_map = _build_product_tax_snapshot_map(db, int(document.business_id), product_ids)
updated = 0
for line in lines:
if not line.product_id:
continue
tax_meta = tax_map.get(int(line.product_id))
if not tax_meta:
continue
snapshot = {k: v for k, v in tax_meta.items() if v is not None}
if not snapshot:
continue
snapshot["captured_at"] = datetime.utcnow().isoformat()
info = dict(line.extra_info or {})
info["tax_snapshot"] = snapshot
line.extra_info = info
flag_modified(line, "extra_info")
updated += 1
if updated:
db.flush()
return updated
def _iter_product_movements(
db: Session,
business_id: int,
product_ids: List[int],
warehouse_ids: Optional[List[int]],
up_to_date: date,
exclude_document_id: Optional[int] = None,
):
"""
بازگرداندن حرکات موجودی (ورودی/خروجی) از اسناد قطعی تا تاریخ مشخص برای مجموعه کالا/انبار.
خروجی به ترتیب زمان/شناسه سند مرتب می‌شود.
منبع اصلی: جدول invoice_item_lines (اقلام فاکتور). برای اسناد قدیمی، در صورت نبود اقلام فاکتور،
از DocumentLine.product_id استفاده می‌شود تا از شمارش دوبل برای اسناد فاکتور جلوگیری شود.
"""
if not product_ids:
return []
# فقط کالاهای با کنترل موجودی را لحاظ کن
tracked_ids: List[int] = [
int(pid)
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == business_id,
Product.id.in_(list({int(pid) for pid in product_ids})),
).all()
if bool(tracked)
]
if not tracked_ids:
return []
movements: List[Dict[str, Any]] = []
# 1) حرکات از invoice_item_lines (معماری فعلی)
iil_q = (
db.query(InvoiceItemLine, Document)
.join(Document, Document.id == InvoiceItemLine.document_id)
.filter(
and_(
Document.business_id == business_id,
Document.is_proforma == False, # noqa: E712
Document.document_date <= up_to_date,
InvoiceItemLine.product_id.in_(tracked_ids),
)
)
)
if exclude_document_id is not None:
iil_q = iil_q.filter(Document.id != int(exclude_document_id))
iil_rows = iil_q.order_by(
Document.document_date.asc(),
Document.registered_at.asc(),
Document.id.asc(),
InvoiceItemLine.id.asc(),
).all()
for line, doc in iil_rows:
info = line.extra_info or {}
try:
posted = info.get("inventory_posted")
if posted is False:
continue
except Exception:
pass
if info.get("inventory_tracked") is False:
continue
movement = info.get("movement") or None
wh_id_raw = info.get("warehouse_id")
wh_id: Optional[int]
try:
wh_id = int(wh_id_raw) if wh_id_raw is not None else None
except Exception:
wh_id = None
if movement is None:
inv_move, _ = _movement_from_type(doc.document_type)
movement = inv_move
if warehouse_ids:
if wh_id is None:
continue
if int(wh_id) not in warehouse_ids:
continue
if movement not in ("in", "out"):
continue
qty = Decimal(str(line.quantity or 0))
if qty <= 0:
continue
cost_price = None
if info.get("cogs_amount") is not None and qty > 0 and movement == "out":
try:
cost_price = Decimal(str(info.get("cogs_amount"))) / qty
except Exception:
cost_price = None
if cost_price is None and info.get("cost_price") is not None:
cost_price = Decimal(str(info.get("cost_price")))
if cost_price is None and info.get("unit_price") is not None:
cost_price = Decimal(str(info.get("unit_price")))
movements.append(
{
"document_id": doc.id,
"document_date": doc.document_date,
"registered_at": doc.registered_at,
"product_id": int(line.product_id),
"warehouse_id": wh_id,
"movement": movement,
"quantity": qty,
"cost_price": cost_price,
"invoice_item_line_id": int(line.id),
}
)
# 2) legacy: DocumentLine با product_id (اسناد غیرفاکتور یا دادهٔ قدیمی)
q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter(
and_(
Document.business_id == business_id,
Document.is_proforma == False, # noqa: E712
Document.document_date <= up_to_date,
DocumentLine.product_id.in_(tracked_ids),
)
)
if exclude_document_id is not None:
q = q.filter(Document.id != int(exclude_document_id))
rows = q.order_by(
Document.document_date.asc(),
Document.registered_at.asc(),
Document.id.asc(),
DocumentLine.id.asc(),
).all()
for line, doc in rows:
doc_type = str(doc.document_type or "")
if doc_type in _INVOICE_DOC_TYPES_WITH_ITEM_LINES:
continue
info = line.extra_info or {}
try:
posted = info.get("inventory_posted")
if posted is False:
continue
except Exception:
pass
movement = (info.get("movement") or None)
wh_id_raw = info.get("warehouse_id")
wh_id: Optional[int]
try:
wh_id = int(wh_id_raw) if wh_id_raw is not None else None
except Exception:
wh_id = None
if movement is None:
inv_move, _ = _movement_from_type(doc.document_type)
movement = inv_move
if warehouse_ids:
if wh_id is None:
continue
if int(wh_id) not in warehouse_ids:
continue
if movement not in ("in", "out"):
continue
qty = Decimal(str(line.quantity or 0))
if qty <= 0:
continue
cost_price = None
if info.get("cogs_amount") is not None and qty > 0 and movement == "out":
try:
cost_price = Decimal(str(info.get("cogs_amount"))) / qty
except Exception:
cost_price = None
if cost_price is None and info.get("cost_price") is not None:
cost_price = Decimal(str(info.get("cost_price")))
if cost_price is None and info.get("unit_price") is not None:
cost_price = Decimal(str(info.get("unit_price")))
movements.append(
{
"document_id": doc.id,
"document_date": doc.document_date,
"registered_at": doc.registered_at,
"product_id": int(line.product_id),
"warehouse_id": wh_id,
"movement": movement,
"quantity": qty,
"cost_price": cost_price,
"invoice_item_line_id": 0,
}
)
movements.sort(key=_movement_sort_key)
return movements
def _compute_available_stock(
db: Session,
business_id: int,
product_id: int,
warehouse_id: Optional[int],
up_to_date: date,
exclude_document_id: Optional[int] = None,
*,
exclude_invoice_source_document_id: Optional[int] = None,
exclude_warehouse_document_ids: Optional[List[int]] = None,
) -> Decimal:
movements = _iter_product_movements(
db,
business_id,
[product_id],
[int(warehouse_id)] if warehouse_id is not None else None,
up_to_date,
exclude_document_id,
)
bal = Decimal(0)
for mv in movements:
if warehouse_id is not None and mv.get("warehouse_id") is not None and int(mv["warehouse_id"]) != int(warehouse_id):
continue
if mv["movement"] == "in":
bal += mv["quantity"]
elif mv["movement"] == "out":
bal -= mv["quantity"]
# اضافه کردن حرکات از حواله‌های انبار (WarehouseDocumentLine)
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
wh_movements_query = db.query(WarehouseDocumentLine).join(
WarehouseDocument,
WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id
).filter(
and_(
WarehouseDocument.business_id == business_id,
WarehouseDocument.status == "posted",
WarehouseDocument.document_date <= up_to_date,
WarehouseDocumentLine.product_id == product_id,
)
)
if warehouse_id is not None:
wh_movements_query = wh_movements_query.filter(
WarehouseDocumentLine.warehouse_id == warehouse_id
)
# حواله‌های ناشی از فاکتور قبلاً در invoice_item_lines (بخش _iter_product_movements) لحاظ شده‌اند؛
# شمردن دوبارهٔ خطوط این حواله‌ها موجودی را دو برابر نشان می‌دهد.
_wh_src_doc = aliased(Document)
wh_movements_query = wh_movements_query.outerjoin(
_wh_src_doc,
and_(
WarehouseDocument.source_document_id == _wh_src_doc.id,
_wh_src_doc.business_id == business_id,
),
).filter(
~or_(
func.lower(func.coalesce(WarehouseDocument.source_type, "")) == "invoice",
and_(
WarehouseDocument.source_document_id.isnot(None),
_wh_src_doc.id.isnot(None),
_wh_src_doc.document_type.in_(SUPPORTED_INVOICE_TYPES),
),
)
)
if exclude_warehouse_document_ids:
_xwh = sorted({int(x) for x in exclude_warehouse_document_ids if x is not None})
if _xwh:
wh_movements_query = wh_movements_query.filter(~WarehouseDocument.id.in_(_xwh))
# هنگام پست مجدد حوالهٔ همان فاکتور، حرکات تمام حواله‌های قطعی با source_document_id = همان سند
# از محاسبه حذف می‌شود (حواله لغو/دستی معمولاً به شناسه حواله دیگر اشاره می‌کند، نه فاکتور).
if exclude_invoice_source_document_id is not None:
inv_doc_id = int(exclude_invoice_source_document_id)
if inv_doc_id:
wh_movements_query = wh_movements_query.filter(
or_(
WarehouseDocument.source_document_id.is_(None),
WarehouseDocument.source_document_id != inv_doc_id,
)
)
wh_movements = wh_movements_query.with_entities(WarehouseDocumentLine, WarehouseDocument).all()
for wh_mv, wh_doc in wh_movements:
# حوالهٔ معکوس لغو: موجودی با cancelled شدن اصل اصلاح شده؛ شمارش مجدد دوبرابر می‌کند.
# (شامل معکوس فاکتور و معکوس دستی/کالای هزینه-درآمد)
if _warehouse_doc_is_cancel_reversal_of_cancelled(db, business_id, wh_doc):
continue
if wh_mv.movement == "in":
bal += Decimal(str(wh_mv.quantity))
elif wh_mv.movement == "out":
bal -= Decimal(str(wh_mv.quantity))
return bal
def get_financial_stock_bulk(
db: Session,
business_id: int,
product_ids: List[int],
as_of_date: Optional[date] = None,
warehouse_id: Optional[int] = None,
) -> Dict[int, Decimal]:
"""
محاسبه موجودی مالی برای لیستی از کالاها.
بر اساس حرکات موجودی از اقلام فاکتور (invoice_item_lines) و در نبود آن از خطوط سند (DocumentLine).
بازگشت: Dict[product_id, quantity]
"""
if not product_ids:
return {}
if as_of_date is None:
as_of_date = datetime.now().date()
# دریافت حرکات از اسناد مالی
movements = _iter_product_movements(
db,
business_id,
product_ids,
[warehouse_id] if warehouse_id is not None else None,
as_of_date,
exclude_document_id=None,
)
# محاسبه موجودی
stock_dict: Dict[int, Decimal] = {}
for mv in movements:
pid = int(mv["product_id"])
qty = Decimal(str(mv["quantity"] or 0))
if qty <= 0:
continue
# اگر انبار مشخص شده، فقط حرکات همان انبار را لحاظ کن
if warehouse_id is not None and mv.get("warehouse_id") is not None:
if int(mv["warehouse_id"]) != int(warehouse_id):
continue
if pid not in stock_dict:
stock_dict[pid] = Decimal(0)
if mv["movement"] == "in":
stock_dict[pid] += qty
elif mv["movement"] == "out":
stock_dict[pid] -= qty
# برای کالاهایی که حرکتی نداشتند، مقدار 0 برگردان
for pid in product_ids:
if pid not in stock_dict:
stock_dict[pid] = Decimal(0)
return stock_dict
def _ensure_stock_sufficient(
db: Session,
business_id: int,
document_date: date,
outgoing_lines: List[Dict[str, Any]],
exclude_document_id: Optional[int] = None,
*,
exclude_invoice_source_document_id: Optional[int] = None,
exclude_warehouse_document_ids: Optional[List[int]] = None,
) -> None:
# تجمیع نیاز خروجی به تفکیک کالا/انبار
required: Dict[Tuple[int, Optional[int]], Decimal] = {}
for ln in outgoing_lines:
pid = int(ln.get("product_id"))
info = ln.get("extra_info") or {}
wh_id = info.get("warehouse_id")
qty = Decimal(str(ln.get("quantity", 0) or 0))
key = (pid, int(wh_id) if wh_id is not None else None)
required[key] = required.get(key, Decimal(0)) + qty
from adapters.db.models.warehouse import Warehouse
pids = list({k[0] for k in required})
wh_ids_unique = sorted({int(k[1]) for k in required if k[1] is not None})
product_labels: Dict[int, Tuple[str, str]] = {}
if pids:
for row in (
db.query(Product.id, Product.name, Product.code)
.filter(Product.business_id == business_id, Product.id.in_(pids))
.all()
):
product_labels[int(row.id)] = (str(row.name or ""), str(row.code or ""))
warehouse_labels: Dict[int, str] = {}
if wh_ids_unique:
for row in (
db.query(Warehouse.id, Warehouse.name)
.filter(Warehouse.business_id == business_id, Warehouse.id.in_(wh_ids_unique))
.all()
):
warehouse_labels[int(row.id)] = str(row.name or "")
# بررسی موجودی
for (pid, wh_id), req in required.items():
avail = _compute_available_stock(
db,
business_id,
pid,
wh_id,
document_date,
exclude_document_id,
exclude_invoice_source_document_id=exclude_invoice_source_document_id,
exclude_warehouse_document_ids=exclude_warehouse_document_ids,
)
if avail < req:
pname, pcode = product_labels.get(pid, ("", ""))
if not pname:
pname = "کالا"
p_disp = f"«{pname}»" + (f" (کد {pcode})" if pcode else f" (شناسه {pid})")
if wh_id is not None:
wn = warehouse_labels.get(int(wh_id), "")
wh_disp = f"«{wn}»" if wn else f"انبار (شناسه {wh_id})"
else:
wh_disp = "—"
raise ApiError(
"INSUFFICIENT_STOCK",
f"موجودی کافی برای کالا {p_disp} در انبار {wh_disp} موجود نیست. "
f"موجودی قابل استفاده: {_format_quantity_for_user_message(avail)}، "
f"موردنیاز: {_format_quantity_for_user_message(req)}",
http_status=409,
details={"product_id": pid, "warehouse_id": wh_id},
)
def _validate_outgoing_stock_before_invoice_commit(
db: Session,
business_id: int,
document: Document,
invoice_type: str,
lines_input: List[Dict[str, Any]],
data: Dict[str, Any],
) -> None:
"""
اگر فاکتور با post_inventory و auto_post_warehouse ذخیره شود، قبل از commit همان کنترل کسری
که در post_warehouse_document انجام می‌شود را اجرا می‌کند تا فاکتور بدون حواله قطعی ثبت نشود.
"""
if document.is_proforma:
return
if not _is_inventory_posting_enabled(data):
return
if not bool((document.extra_info or {}).get("auto_post_warehouse", False)):
return
from app.services.warehouse_service import invoice_lines_have_trackable_inventory_products
if invoice_type == INVOICE_PRODUCTION:
lines_candidates = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "out"]
elif invoice_type in {
INVOICE_SALES,
INVOICE_PURCHASE_RETURN,
INVOICE_WASTE,
INVOICE_DIRECT_CONSUMPTION,
}:
lines_candidates = list(lines_input)
else:
return
if not lines_candidates:
return
if not invoice_lines_have_trackable_inventory_products(db, business_id, lines_candidates):
return
inv_wh = (document.extra_info or {}).get("warehouse_id")
try:
inv_wh_int = int(inv_wh) if inv_wh is not None else None
except (TypeError, ValueError):
inv_wh_int = None
outgoing_lines: List[Dict[str, Any]] = []
for ln in lines_candidates:
pid = ln.get("product_id")
try:
qty = Decimal(str(ln.get("quantity", 0) or 0))
except Exception:
qty = Decimal(0)
if not pid or qty <= 0:
continue
product = db.query(Product).filter(
and_(Product.id == int(pid), Product.business_id == business_id)
).first()
if not product or not getattr(product, "track_inventory", False):
continue
extra = dict(ln.get("extra_info") or {})
wh_raw = extra.get("warehouse_id")
if wh_raw is None:
wh_raw = inv_wh_int
if wh_raw is None:
raise ApiError(
"WAREHOUSE_REQUIRED",
"برای اقلام با کنترل موجودی، انبار (در خط یا سربرگ فاکتور) الزامی است",
http_status=400,
)
try:
wh_id = int(wh_raw)
except (TypeError, ValueError):
raise ApiError("WAREHOUSE_REQUIRED", "شناسه انبار نامعتبر است", http_status=400)
outgoing_lines.append(
{
"product_id": int(pid),
"quantity": float(qty),
"extra_info": {
"warehouse_id": wh_id,
"movement": "out",
"inventory_tracked": True,
},
}
)
if not outgoing_lines:
return
biz = db.query(Business).filter(Business.id == int(business_id)).first()
allow_bulk = bool(getattr(biz, "allow_negative_inventory_for_bulk", False)) if biz else False
allow_unique = bool(getattr(biz, "allow_negative_inventory_for_unique", False)) if biz else False
transfer_strict = bool(getattr(biz, "warehouse_transfer_require_positive_stock", True)) if biz else True
lines_to_check = filter_outgoing_lines_for_stock_enforcement(
db,
int(business_id),
outgoing_lines,
allow_negative_for_bulk=allow_bulk,
allow_negative_for_unique=allow_unique,
warehouse_doc_type="issue",
transfer_require_positive_stock=transfer_strict,
)
if not lines_to_check:
return
_ensure_stock_sufficient(
db,
business_id,
document.document_date,
lines_to_check,
exclude_document_id=None,
exclude_invoice_source_document_id=None,
exclude_warehouse_document_ids=None,
)
def _product_inventory_mode_is_unique(inventory_mode: Optional[str]) -> bool:
return (inventory_mode or "bulk").strip().lower() == "unique"
def filter_outgoing_lines_for_stock_enforcement(
db: Session,
business_id: int,
outgoing_lines: List[Dict[str, Any]],
*,
allow_negative_for_bulk: bool,
allow_negative_for_unique: bool,
warehouse_doc_type: Optional[str],
transfer_require_positive_stock: bool,
) -> List[Dict[str, Any]]:
"""
خطوطی که باید با _ensure_stock_sufficient کنترل شوند.
اگر transfer_require_positive_stock و نوع سند transfer باشد، همه خطوط برمی‌گردد (کنترل کامل).
در غیر این صورت، خطوط کالاهایی که سیاست کسب‌وکار اجازه منفی می‌دهد از لیست حذف می‌شوند.
"""
if not outgoing_lines:
return []
doc_type = (warehouse_doc_type or "").strip().lower()
if doc_type == "transfer" and transfer_require_positive_stock:
return list(outgoing_lines)
product_ids: List[int] = []
for ln in outgoing_lines:
pid = ln.get("product_id")
if pid is not None:
try:
product_ids.append(int(pid))
except (TypeError, ValueError):
continue
if not product_ids:
return list(outgoing_lines)
rows = (
db.query(Product.id, Product.inventory_mode)
.filter(Product.business_id == int(business_id), Product.id.in_(list(set(product_ids))))
.all()
)
mode_map: Dict[int, str] = {int(r.id): (r.inventory_mode or "bulk") for r in rows}
filtered: List[Dict[str, Any]] = []
for ln in outgoing_lines:
pid_raw = ln.get("product_id")
if pid_raw is None:
filtered.append(ln)
continue
try:
pid = int(pid_raw)
except (TypeError, ValueError):
filtered.append(ln)
continue
mode = mode_map.get(pid, "bulk")
if _product_inventory_mode_is_unique(mode):
if allow_negative_for_unique:
continue
else:
if allow_negative_for_bulk:
continue
filtered.append(ln)
return filtered
def _calculate_fifo_cogs_for_outgoing(
db: Session,
business_id: int,
document_date: date,
outgoing_lines: List[Dict[str, Any]],
exclude_document_id: Optional[int] = None,
) -> List[Decimal]:
"""
محاسبه COGS بر اساس FIFO برای لیست خطوط خروجی؛ خروجی به همان ترتیب ورودی است.
هر خط باید شامل product_id, quantity و extra_info.warehouse_id باشد.
"""
# گردآوری حرکات تاریخی همه کالاهای موردنیاز
product_ids = list({int(ln.get("product_id")) for ln in outgoing_lines})
movements = _iter_product_movements(db, business_id, product_ids, None, document_date, exclude_document_id)
movements = sorted(movements, key=_movement_sort_key)
# ساخت لایه‌های FIFO به تفکیک کالا/انبار
from collections import defaultdict, deque
layers: Dict[Tuple[int, Optional[int]], deque] = defaultdict(deque)
for mv in movements:
key = (int(mv["product_id"]), int(mv["warehouse_id"]) if mv.get("warehouse_id") is not None else None)
if mv["movement"] == "in":
cost_price = mv.get("cost_price") or Decimal(0)
layers[key].append({"qty": Decimal(mv["quantity"]), "cost": Decimal(cost_price)})
elif mv["movement"] == "out":
remain = Decimal(mv["quantity"])
while remain > 0 and layers[key]:
top = layers[key][0]
take = min(remain, top["qty"])
top["qty"] -= take
remain -= take
if top["qty"] <= 0:
layers[key].popleft()
# اگر خروجی تاریخی بیشتر از ورودی‌هاست، لایه‌ها منفی نشوند (کسری قبلی)
if remain > 0:
# اجازه کسری تاریخی: لایه منفی نمی‌سازیم، هزینه صفر می‌ماند
pass
# محاسبه هزینه برای خطوط فعلی
results: List[Decimal] = []
for ln in outgoing_lines:
pid = int(ln.get("product_id"))
qty = Decimal(str(ln.get("quantity", 0) or 0))
info = ln.get("extra_info") or {}
wh_id = int(info.get("warehouse_id")) if info.get("warehouse_id") is not None else None
key = (pid, wh_id)
cost_total = Decimal(0)
remain = qty
temp_stack = []
# مصرف از لایه‌ها
while remain > 0 and layers[key]:
top = layers[key][0]
take = min(remain, top["qty"])
cost_total += take * Decimal(top["cost"] or 0)
top["qty"] -= take
remain -= take
temp_stack.append((take, top))
if top["qty"] <= 0:
layers[key].popleft()
if remain > 0:
# اگر لایه کافی نبود، باقی‌مانده را با آخرین هزینه یا صفر حساب کنیم
last_cost = Decimal(0)
if temp_stack:
last_cost = Decimal(temp_stack[-1][1]["cost"] or 0)
cost_total += remain * last_cost
results.append(cost_total)
return results
def _parse_iso_date(dt: str | datetime | date) -> date:
if isinstance(dt, date):
return dt
if isinstance(dt, datetime):
return dt.date()
dt_str = str(dt).strip()
try:
dt_str_clean = dt_str.replace('Z', '+00:00')
parsed = datetime.fromisoformat(dt_str_clean)
return parsed.date()
except Exception:
pass
try:
if len(dt_str) == 10 and dt_str.count('-') == 2:
return datetime.strptime(dt_str, '%Y-%m-%d').date()
except Exception:
pass
try:
if len(dt_str) == 10 and dt_str.count('/') == 2:
parts = dt_str.split('/')
if len(parts) == 3:
year, month, day = parts
try:
year_int = int(year)
month_int = int(month)
day_int = int(day)
if year_int > 1500:
jalali_date = jdatetime.date(year_int, month_int, day_int)
gregorian_date = jalali_date.togregorian()
return gregorian_date
else:
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except (ValueError, jdatetime.JalaliDateError):
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except Exception:
pass
raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400)
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True,
)
).first()
if not fiscal_year:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fiscal_year
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 with code {account_code} not found", http_status=500)
return account
def _get_person_control_account(db: Session, invoice_type: str | None = None) -> Account:
# انتخاب حساب طرف‌شخص بر اساس نوع فاکتور
# فروش/برگشت از فروش → دریافتنی ها 10401
# خرید/برگشت از خرید → پرداختنی ها 20201 (پیش‌فرض)
try:
inv_type = (invoice_type or "").strip()
if inv_type in {INVOICE_SALES, INVOICE_SALES_RETURN}:
return _get_fixed_account_by_code(db, "10401")
# سایر موارد (شامل خرید/برگشت از خرید)
return _get_fixed_account_by_code(db, "20201")
except Exception:
# fallback امن
return _get_fixed_account_by_code(db, "20201")
def _build_doc_code(prefix_base: str) -> str:
today = datetime.now().date()
prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}"
return prefix
def _extract_totals_from_lines(lines: List[Dict[str, Any]]) -> Dict[str, Decimal]:
gross = Decimal(0)
discount = Decimal(0)
tax = Decimal(0)
net = Decimal(0)
for line in lines:
info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
unit_price = Decimal(str(info.get("unit_price", 0) or 0))
line_discount = Decimal(str(info.get("line_discount", 0) or 0))
tax_amount = Decimal(str(info.get("tax_amount", 0) or 0))
line_total = info.get("line_total")
if line_total is None:
line_total = (qty * unit_price) - line_discount + tax_amount
else:
line_total = Decimal(str(line_total))
gross += (qty * unit_price)
discount += line_discount
tax += tax_amount
net += line_total
return {
"gross": gross,
"discount": discount,
"tax": tax,
"net": net,
}
def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
total = Decimal(0)
for line in lines:
info = line.get("extra_info") or {}
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
if info.get("inventory_posted") is False:
continue
qty = Decimal(str(line.get("quantity", 0) or 0))
if info.get("cogs_amount") is not None:
total += Decimal(str(info.get("cogs_amount")))
continue
cost_price = info.get("cost_price")
if cost_price is not None:
total += (qty * Decimal(str(cost_price)))
continue
# fallback: use unit_price as cost if nothing provided
unit_price = info.get("unit_price")
if unit_price is not None:
total += (qty * Decimal(str(unit_price)))
return total
def _extract_cogs_total_for_invoice(lines: List[Dict[str, Any]]) -> Decimal:
"""
محاسبه COGS برای فاکتورها (بدون بررسی inventory_posted).
برای مصرف مستقیم، ضایعات و تولید استفاده می‌شود.
"""
total = Decimal(0)
for line in lines:
info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty <= 0:
continue
# اولویت: cogs_amount > cost_price > unit_price
if info.get("cogs_amount") is not None:
total += Decimal(str(info.get("cogs_amount")))
continue
cost_price = info.get("cost_price")
if cost_price is not None:
total += (qty * Decimal(str(cost_price)))
continue
# fallback: use unit_price as cost if nothing provided
unit_price = info.get("unit_price")
if unit_price is not None:
total += (qty * Decimal(str(unit_price)))
return total
def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]:
# امکان override از extra_info.account_codes
overrides = ((data.get("extra_info") or {}).get("account_codes") or {})
invoice_type = str(data.get("invoice_type", "")).strip()
def code(name: str, default_code: str) -> str:
return str(overrides.get(name) or default_code)
grni_default_code = "10107"
business_id_raw = data.get("business_id")
if invoice_type in (INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN) and business_id_raw is not None:
from app.services.purchase_accounting_service import (
PURCHASE_ACCOUNTING_DIRECT,
grni_account_code_for_mode,
resolve_purchase_accounting_mode,
)
purchase_mode = resolve_purchase_accounting_mode(
db,
int(business_id_raw),
extra_info=data.get("extra_info"),
)
if purchase_mode != PURCHASE_ACCOUNTING_DIRECT:
grni_default_code = grni_account_code_for_mode(purchase_mode)
return {
# درآمد و برگشت فروش مطابق چارت سید:
"revenue": _get_fixed_account_by_code(db, code("revenue", "50001")),
"sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")),
# تخفیفات فروش و خرید (به‌صورت مجزا)
"sales_discount": _get_fixed_account_by_code(db, code("sales_discount", "50003")),
"purchase_discount": _get_fixed_account_by_code(db, code("purchase_discount", "40003")),
# موجودی، GRNI و ساخته‌شده (در نبود حساب مجزا)
"inventory": _get_fixed_account_by_code(db, code("inventory", "10102")),
"inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")),
"grni": _get_fixed_account_by_code(db, code("grni", grni_default_code)),
# بهای تمام شده و VAT ها مطابق سید
"cogs": _get_fixed_account_by_code(db, code("cogs", "40001")),
"vat_out": _get_fixed_account_by_code(db, code("vat_out", "20101")),
"vat_in": _get_fixed_account_by_code(db, code("vat_in", "10104")),
# مصرف مستقیم و ضایعات
"direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "70406")),
"wip": _get_fixed_account_by_code(db, code("wip", "10106")),
"waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "70407")),
# هزینه عملیات/سربار تولید (برای انتقال به WIP در فاکتور تولید)
"production_overhead": _get_fixed_account_by_code(db, code("production_overhead", "70408")),
# حساب‌های فروش اقساطی
"unearned_installment_profit": _get_fixed_account_by_code(db, code("unearned_installment_profit", "10405")),
"installment_profit": _get_fixed_account_by_code(db, code("installment_profit", "60205")),
# طرف‌شخص بر اساس نوع فاکتور
"person": _get_person_control_account(db, invoice_type),
}
def _calculate_seller_commission(
db: Session,
invoice_type: str,
header_extra: Dict[str, Any],
totals: Dict[str, Any],
) -> Tuple[int | None, Decimal]:
"""محاسبه پورسانت فروشنده/بازاریاب بر اساس تنظیمات شخص یا override در فاکتور.
Returns: (seller_id, commission_amount)
"""
try:
ei = header_extra or {}
seller_id_raw = ei.get("seller_id")
seller_id: int | None = int(seller_id_raw) if seller_id_raw is not None else None
except Exception:
seller_id = None
if not seller_id:
return (None, Decimal(0))
# مبنای محاسبه
gross = Decimal(str((totals or {}).get("gross", 0)))
discount = Decimal(str((totals or {}).get("discount", 0)))
net = gross - discount
# اگر در فاکتور override شده باشد، همان اعمال شود
commission_cfg = ei.get("commission") if isinstance(ei.get("commission"), dict) else None
if commission_cfg:
value = Decimal(str(commission_cfg.get("value", 0))) if commission_cfg.get("value") is not None else Decimal(0)
ctype = (commission_cfg.get("type") or "").strip().lower()
if value <= 0:
return (seller_id, Decimal(0))
if ctype == "percentage":
amount = (net * value) / Decimal(100)
return (seller_id, amount)
if ctype == "amount":
return (seller_id, value)
return (seller_id, Decimal(0))
# در غیر اینصورت، از تنظیمات شخص استفاده می‌کنیم
person = db.query(Person).filter(Person.id == seller_id).first()
if not person:
return (seller_id, Decimal(0))
# اگر شخص اجازه‌ی ثبت پورسانت در سند فاکتور را نداده است، صفر برگردان
try:
if not bool(getattr(person, "commission_post_in_invoice_document", False)):
return (seller_id, Decimal(0))
except Exception:
pass
exclude_discounts = bool(getattr(person, "commission_exclude_discounts", False))
base_amount = gross if exclude_discounts else net
amount = Decimal(0)
if invoice_type == INVOICE_SALES:
percent = getattr(person, "commission_sale_percent", None)
fixed = getattr(person, "commission_sales_amount", None)
elif invoice_type == INVOICE_SALES_RETURN:
percent = getattr(person, "commission_sales_return_percent", None)
fixed = getattr(person, "commission_sales_return_amount", None)
else:
percent = None
fixed = None
if percent is not None:
try:
p = Decimal(str(percent))
if p > 0:
amount = (base_amount * p) / Decimal(100)
except Exception:
pass
elif fixed is not None:
try:
f = Decimal(str(fixed))
if f > 0:
amount = f
except Exception:
pass
return (seller_id, amount)
def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]:
"""شخص فاکتور از extra_info یا ریشهٔ payload (قرارداد ابزار AI)."""
try:
ei = data.get("extra_info") if isinstance(data.get("extra_info"), dict) else {}
for candidate in (
(ei or {}).get("person_id"),
data.get("person_id"),
data.get("customer_id"),
data.get("supplier_id"),
(ei or {}).get("customer_id"),
):
if candidate is None or candidate == "":
continue
parsed = int(candidate)
if parsed > 0:
return parsed
return None
except Exception:
return None
def _resolve_and_validate_person_id(db: Session, business_id: int, person_id: Optional[int]) -> Optional[int]:
"""
اگر person_id مقدار داشته باشد، وجود شخص در جدول persons و تعلق به همان business_id را بررسی می‌کند.
در صورت نامعتبر بودن (حذف شده یا متعلق به کسب‌وکار دیگر) ApiError با کد 400 پرتاب می‌شود
تا از خطای ForeignKeyViolation در document_lines جلوگیری شود.
"""
if person_id is None or person_id <= 0:
return None
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
raise ApiError(
"PERSON_NOT_FOUND_OR_WRONG_BUSINESS",
"شخص انتخاب‌شده وجود ندارد یا به این کسب‌وکار تعلق ندارد؛ امکان ذخیره فاکتور با این شخص وجود ندارد.",
http_status=400,
)
return int(person_id)
def _normalize_document_extra_info_for_storage(extra_info: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""نرمال‌سازی extra_info قبل از ذخیره در دیتابیس تا فیلدهای عددی (مثل person_id) به صورت int ذخیره شوند."""
if not extra_info or not isinstance(extra_info, dict):
return extra_info
from copy import deepcopy
out = deepcopy(extra_info)
for key in ("person_id", "seller_id"):
val = out.get(key)
if val is not None:
try:
out[key] = int(val)
except (TypeError, ValueError):
pass
dd_raw = out.get("due_date")
if dd_raw is not None and str(dd_raw).strip():
try:
dd_s = str(dd_raw).replace("Z", "+00:00")
if "T" in dd_s:
dd_s = dd_s.split("T")[0]
out["due_date"] = _parse_iso_date(dd_s).isoformat()
except Exception:
out.pop("due_date", None)
links = out.get("links")
if isinstance(links, dict):
links = dict(links)
for key in ("warehouse_document_ids", "receipt_payment_document_ids"):
arr = links.get(key)
if isinstance(arr, list):
try:
links[key] = [int(x) for x in arr if x is not None]
except (TypeError, ValueError):
pass
out["links"] = links
return out
def _movement_from_type(invoice_type: str) -> Tuple[Optional[str], Optional[str]]:
# Returns (movement_for_goods, reverse_movement) hints. Not strictly used for accounting.
if invoice_type == INVOICE_SALES:
return ("out", None)
if invoice_type == INVOICE_SALES_RETURN:
return ("in", None)
if invoice_type == INVOICE_PURCHASE:
return ("in", None)
if invoice_type == INVOICE_PURCHASE_RETURN:
return ("out", None)
if invoice_type in (INVOICE_DIRECT_CONSUMPTION, INVOICE_WASTE):
return ("out", None)
if invoice_type == INVOICE_PRODUCTION:
# production has both out (materials) and in (finished)
return (None, None)
return (None, None)
def _resolve_invoice_type_from_payload(
data: Dict[str, Any],
*,
fallback: Optional[str] = None,
) -> str:
"""نوع فاکتور را از payload (invoice_type) یا مقدار فعلی سند برمی‌گرداند."""
requested = str(data.get("invoice_type") or "").strip()
if requested in SUPPORTED_INVOICE_TYPES:
return requested
fb = str(fallback or "").strip()
if fb in SUPPORTED_INVOICE_TYPES:
return fb
return requested or fb
def _stamp_movement_on_invoice_lines(invoice_type: str, lines_input: List[Dict[str, Any]]) -> None:
"""movement ردیف‌ها را با نوع فاکتور هم‌راستا می‌کند (به‌جز تولید که in/out صریح دارد)."""
if invoice_type == INVOICE_PRODUCTION:
return
default_move, _ = _movement_from_type(invoice_type)
if default_move not in ("in", "out"):
return
for ln in lines_input:
info = dict(ln.get("extra_info") or {})
info["movement"] = default_move
ln["extra_info"] = info
def _extra_info_fields_incompatible_with_invoice_type(invoice_type: str) -> frozenset:
incompatible: set[str] = set()
if invoice_type not in (INVOICE_SALES, INVOICE_SALES_RETURN):
incompatible.update({"installment_plan", "seller_id", "commission"})
if invoice_type not in (INVOICE_SALES, INVOICE_PURCHASE):
incompatible.update({"invoice_adjustments", "global_discount"})
if invoice_type not in (INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN):
incompatible.add("purchase_accounting_mode")
if invoice_type != INVOICE_SALES:
incompatible.add("customer_club")
return frozenset(incompatible)
def _sanitize_extra_info_for_invoice_type_change(
extra: Dict[str, Any],
new_invoice_type: str,
old_invoice_type: str,
) -> Dict[str, Any]:
if new_invoice_type == old_invoice_type:
return extra
ex = dict(extra)
for key in _extra_info_fields_incompatible_with_invoice_type(new_invoice_type):
ex.pop(key, None)
return ex
def _warehouse_document_is_invoice_sourced(db: Session, business_id: int, wh_doc: Any) -> bool:
from adapters.db.models.warehouse_document import WarehouseDocument as _WhDoc
if not isinstance(wh_doc, _WhDoc):
return False
if str(getattr(wh_doc, "source_type", None) or "").strip().lower() == "invoice":
return True
src_doc_id = getattr(wh_doc, "source_document_id", None)
if src_doc_id is None:
return False
row = (
db.query(Document.document_type)
.filter(Document.id == int(src_doc_id), Document.business_id == int(business_id))
.first()
)
if row is None:
return False
return str(row[0] or "") in SUPPORTED_INVOICE_TYPES
def _warehouse_doc_cancels_invoice_sourced_wh(db: Session, business_id: int, wh_doc: Any) -> bool:
from adapters.db.models.warehouse_document import WarehouseDocument
ex = getattr(wh_doc, "extra_info", None) or {}
if not isinstance(ex, dict):
return False
cancel_id = ex.get("cancels_warehouse_document_id")
if cancel_id is None:
return False
try:
cid = int(cancel_id)
except (TypeError, ValueError):
return False
src = (
db.query(WarehouseDocument)
.filter(WarehouseDocument.id == cid, WarehouseDocument.business_id == int(business_id))
.first()
)
if not src:
return False
return _warehouse_document_is_invoice_sourced(db, business_id, src)
def _warehouse_doc_is_cancel_reversal_of_cancelled(db: Session, business_id: int, wh_doc: Any) -> bool:
"""حوالهٔ معکوسِ لغو وقتی اصل cancelled است؛ موجودی با حذف اصل اصلاح شده و شمارش معکوس دوبرابر می‌کند."""
from adapters.db.models.warehouse_document import WarehouseDocument
ex = getattr(wh_doc, "extra_info", None) or {}
if not isinstance(ex, dict):
return False
if ex.get("audit_only_reversal") or ex.get("stock_already_corrected_by_cancel"):
return True
cancel_id = ex.get("cancels_warehouse_document_id")
if cancel_id is None:
return False
try:
cid = int(cancel_id)
except (TypeError, ValueError):
return False
src = (
db.query(WarehouseDocument)
.filter(WarehouseDocument.id == cid, WarehouseDocument.business_id == int(business_id))
.first()
)
if not src:
return False
return (src.status or "").strip().lower() == "cancelled"
_INVENTORY_LEDGER_REFRESH_TYPES = frozenset({INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN})
def _validate_production_invoice_lines(
db: Session,
business_id: int,
lines_input: List[Dict[str, Any]],
header_extra: Dict[str, Any],
) -> None:
"""اعتبارسنجی ردیف‌ها و فرمول تولید برای فاکتور تولید."""
has_out = False
has_in = False
for i, line in enumerate(lines_input, start=1):
extra_info = line.get("extra_info") or {}
movement = extra_info.get("movement")
if movement is None or (movement != "in" and movement != "out"):
raise ApiError(
"INVALID_PRODUCTION_LINE",
f"ردیف {i} باید movement مشخص داشته باشد ('in' یا 'out'). برای فاکتور تولید، باید از فرمول تولید استفاده کنید.",
http_status=400,
)
if movement == "out":
has_out = True
elif movement == "in":
has_in = True
if not has_out:
raise ApiError(
"INVALID_PRODUCTION_INVOICE",
"فاکتور تولید باید حداقل یک ردیف با movement: 'out' داشته باشد (مواد اولیه). برای فاکتور تولید، باید از فرمول تولید استفاده کنید.",
http_status=400,
)
if not has_in:
raise ApiError(
"INVALID_PRODUCTION_INVOICE",
"فاکتور تولید باید حداقل یک ردیف با movement: 'in' داشته باشد (محصول نهایی). برای فاکتور تولید، باید از فرمول تولید استفاده کنید.",
http_status=400,
)
bom_ids = header_extra.get("bom_ids")
if not bom_ids or not isinstance(bom_ids, list) or len(bom_ids) == 0:
raise ApiError(
"BOM_REQUIRED",
"برای فاکتور تولید، باید حداقل یک فرمول تولید را منفجر کنید. فاکتور تولید بدون فرمول تولید قابل ثبت نیست.",
http_status=400,
)
output_product_ids_in_invoice: set[int] = set()
for line in lines_input:
line_extra = line.get("extra_info") or {}
if line_extra.get("movement") == "in":
product_id = line.get("product_id")
if product_id:
output_product_ids_in_invoice.add(int(product_id))
for bom_id in bom_ids:
try:
bom_id_int = int(bom_id)
except (ValueError, TypeError):
continue
bom = db.get(ProductBOM, bom_id_int)
if not bom or bom.business_id != business_id:
continue
bom_outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom_id_int).all()
if not bom_outputs:
logger.warning(
"فرمول تولید %s (کالا: %s) هیچ خروجی تعریف نشده است",
bom_id_int,
bom.product_id,
)
continue
bom_product_in_outputs = any(
output.output_product_id == bom.product_id for output in bom_outputs
)
if not bom_product_in_outputs:
logger.warning(
"کالای فرمول تولید %s (product_id: %s) در خروجی‌های فرمول تعریف نشده است.",
bom_id_int,
bom.product_id,
)
missing_outputs: List[str] = []
for output in bom_outputs:
if output.output_product_id not in output_product_ids_in_invoice:
output_product = db.get(Product, output.output_product_id)
product_name = output_product.name if output_product else f"کالا #{output.output_product_id}"
missing_outputs.append(product_name)
if missing_outputs:
missing_names = "، ".join(missing_outputs)
raise ApiError(
"MISSING_BOM_OUTPUTS",
f"خروجی‌های فرمول تولید '{bom.name}' (نسخه: {bom.version}) که در فاکتور وجود ندارند: {missing_names}. "
f"لطفاً همه خروجی‌های فرمول تولید را در فاکتور شامل کنید.",
http_status=400,
)
def _build_invoice_payment_account_line(p: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""ساخت یک خط حساب برای پرداخت فاکتور؛ مبالغ غیرمثبت نادیده گرفته می‌شوند."""
amount = Decimal(str(p.get("amount", 0) or 0))
if amount <= 0:
return None
transaction_type_value = p.get("transaction_type") or p.get("type")
account_line: Dict[str, Any] = {
"transaction_type": transaction_type_value,
"amount": float(amount),
"description": p.get("description"),
"transaction_date": p.get("transaction_date"),
"commission": p.get("commission"),
}
for key in (
"bank_id",
"bank_name",
"cash_register_id",
"cash_register_name",
"petty_cash_id",
"petty_cash_name",
"check_id",
"check_number",
"person_id",
"account_id",
):
if p.get(key) is not None:
account_line[key] = p.get(key)
return account_line
def _validate_invoice_payment_item_currency(
db: Session,
invoice_currency_id: int,
payment_item: Dict[str, Any],
invoice_type: str,
*,
business_id: Optional[int] = None,
invoice: Optional[Document] = None,
allow_cross_currency: bool = False,
) -> Optional[Dict[str, Any]]:
"""اعتبارسنجی ارز حساب پرداخت.
اگر allow_cross_currency و MC: طرح تسویه بین‌ارزی برمی‌گرداند؛ وگرنه None.
"""
from app.services.cross_currency_settlement_service import (
get_payment_item_account_currency_id,
resolve_cross_currency_payment_plan,
)
ttype = (payment_item.get("transaction_type") or payment_item.get("type") or "").strip().lower()
if ttype not in ("bank", "cash_register", "petty_cash", "check"):
return None
pay_cur = get_payment_item_account_currency_id(db, payment_item)
# مسیر بین‌ارزی
if (
allow_cross_currency
and business_id is not None
and invoice is not None
and pay_cur is not None
and int(pay_cur) != int(invoice_currency_id)
):
plan = resolve_cross_currency_payment_plan(
db,
business_id=int(business_id),
invoice=invoice,
payment_item=payment_item,
)
# چک نوع چک همچنان اعمال شود
if ttype == "check":
ref_id = payment_item.get("check_id")
if ref_id:
chk = db.query(Check).filter(Check.id == int(ref_id)).first()
if chk:
is_receipt_invoice = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
expected_check_type = CheckType.RECEIVED if is_receipt_invoice else CheckType.TRANSFERRED
if chk.type != expected_check_type:
check_type_name = "دریافتی" if chk.type == CheckType.RECEIVED else "پرداختی"
expected_type_name = "دریافتی" if expected_check_type == CheckType.RECEIVED else "پرداختی"
invoice_type_name = "فروش/برگشت از فروش" if is_receipt_invoice else "خرید/برگشت از خرید"
raise ApiError(
"CHECK_TYPE_MISMATCH_WITH_INVOICE",
f"نوع چک با نوع فاکتور هم‌خوانی ندارد. چک {check_type_name} نمی‌تواند در فاکتور {invoice_type_name} استفاده شود. باید چک {expected_type_name} استفاده شود.",
http_status=400,
)
return plan
if ttype == "bank":
ref_id = payment_item.get("bank_id")
if ref_id:
acct = db.query(BankAccount).filter(BankAccount.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
if business_id is not None and int(acct.business_id) != int(business_id):
raise ApiError(
"PAYMENT_ACCOUNT_BUSINESS_MISMATCH",
"Bank account does not belong to this business",
http_status=400,
)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError(
"PAYMENT_CURRENCY_MISMATCH",
"Currency of bank account does not match invoice currency",
http_status=400,
)
elif ttype == "cash_register":
ref_id = payment_item.get("cash_register_id")
if ref_id:
acct = db.query(CashRegister).filter(CashRegister.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Cash register not found", http_status=404)
if business_id is not None and int(acct.business_id) != int(business_id):
raise ApiError(
"PAYMENT_ACCOUNT_BUSINESS_MISMATCH",
"Cash register does not belong to this business",
http_status=400,
)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError(
"PAYMENT_CURRENCY_MISMATCH",
"Currency of cash register does not match invoice currency",
http_status=400,
)
elif ttype == "petty_cash":
ref_id = payment_item.get("petty_cash_id")
if ref_id:
acct = db.query(PettyCash).filter(PettyCash.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Petty cash not found", http_status=404)
if business_id is not None and int(acct.business_id) != int(business_id):
raise ApiError(
"PAYMENT_ACCOUNT_BUSINESS_MISMATCH",
"Petty cash does not belong to this business",
http_status=400,
)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError(
"PAYMENT_CURRENCY_MISMATCH",
"Currency of petty cash does not match invoice currency",
http_status=400,
)
elif ttype == "check":
ref_id = payment_item.get("check_id")
if not ref_id:
return None
chk = db.query(Check).filter(Check.id == int(ref_id)).first()
if not chk:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Check not found", http_status=404)
if business_id is not None and int(chk.business_id) != int(business_id):
raise ApiError(
"PAYMENT_ACCOUNT_BUSINESS_MISMATCH",
"Check does not belong to this business",
http_status=400,
)
if int(chk.currency_id) != invoice_currency_id:
raise ApiError(
"PAYMENT_CURRENCY_MISMATCH",
"Currency of check does not match invoice currency",
http_status=400,
)
is_receipt_invoice = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
expected_check_type = CheckType.RECEIVED if is_receipt_invoice else CheckType.TRANSFERRED
if chk.type != expected_check_type:
check_type_name = "دریافتی" if chk.type == CheckType.RECEIVED else "پرداختی"
expected_type_name = "دریافتی" if expected_check_type == CheckType.RECEIVED else "پرداختی"
invoice_type_name = "فروش/برگشت از فروش" if is_receipt_invoice else "خرید/برگشت از خرید"
raise ApiError(
"CHECK_TYPE_MISMATCH_WITH_INVOICE",
f"نوع چک با نوع فاکتور هم‌خوانی ندارد. چک {check_type_name} نمی‌تواند در فاکتور {invoice_type_name} استفاده شود. باید چک {expected_type_name} استفاده شود.",
http_status=400,
)
return None
def _create_receipt_payment_documents_for_invoice_payments(
db: Session,
*,
business_id: int,
user_id: int,
document: Document,
person_id: int,
payments: List[Dict[str, Any]],
invoice_type: str,
) -> List[int]:
"""ایجاد یک سند دریافت/پرداخت جداگانه برای هر آیتم پرداخت فاکتور."""
from app.services.receipt_payment_service import (
create_receipt_payment,
resolve_receipt_document_date_from_account_lines,
)
from app.services.cross_currency_settlement_service import (
FX_GAIN_ACCOUNT_CODE,
FX_LOSS_ACCOUNT_CODE,
build_fx_settlement_extra,
)
from app.services.fx_rate_provider_service import business_is_multi_currency
payment_docs: List[int] = []
invoice_currency_id = int(document.currency_id)
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
person_is_receivable = invoice_type in {INVOICE_SALES, INVOICE_SALES_RETURN}
allow_x = business_is_multi_currency(db, int(business_id))
for p in payments:
amount = Decimal(str(p.get("amount", 0) or 0))
if amount <= 0:
continue
xplan = _validate_invoice_payment_item_currency(
db,
invoice_currency_id,
p,
invoice_type,
business_id=business_id,
invoice=document,
allow_cross_currency=allow_x,
)
account_line = _build_invoice_payment_account_line(p)
if not account_line:
continue
rp_document_date = resolve_receipt_document_date_from_account_lines(
document.document_date,
[account_line],
)
if xplan is None:
# مسیر هم‌ارز (بدون تغییر رفتار)
rp_data = {
"document_type": "receipt" if is_receipt else "payment",
"document_date": rp_document_date.isoformat(),
"currency_id": document.currency_id,
"description": f"تسویه مرتبط با فاکتور {document.code}",
"person_lines": [{
"person_id": person_id,
"amount": float(amount),
"description": f"طرف حساب فاکتور {document.code}",
}],
"account_lines": [account_line],
"extra_info": {
"source": "invoice",
"invoice_id": document.id,
"person_is_receivable": person_is_receivable,
},
}
else:
# مسیر بین‌ارزی (E2): سند همیشه به ارز پایه؛ مانده بومی حساب با account_currency_amount
pay_cur = int(xplan["payment_currency_id"])
base_id = int(xplan["base_currency_id"])
doc_cur = int(xplan.get("document_currency_id") or base_id)
fx_settlement = build_fx_settlement_extra(xplan)
fx_diff = Decimal(str(xplan["fx_diff"]))
person_amount = float(xplan["ar_base"])
cash_base = float(xplan["cash_base"])
payment_native = float(xplan["payment_amount"])
extra_account_lines: List[Dict[str, Any]] = []
if abs(fx_diff) > 0:
if is_receipt:
if fx_diff > 0:
extra_account_lines.append({
"account_code": FX_LOSS_ACCOUNT_CODE,
"amount": float(fx_diff),
"description": "زیان تسعیر ارز تسویه فاکتور",
"side": "debit",
})
elif fx_diff < 0:
extra_account_lines.append({
"account_code": FX_GAIN_ACCOUNT_CODE,
"amount": float(-fx_diff),
"description": "سود تسعیر ارز تسویه فاکتور",
"side": "credit",
})
else:
if fx_diff > 0:
extra_account_lines.append({
"account_code": FX_GAIN_ACCOUNT_CODE,
"amount": float(fx_diff),
"description": "سود تسعیر ارز تسویه فاکتور",
"side": "credit",
})
elif fx_diff < 0:
extra_account_lines.append({
"account_code": FX_LOSS_ACCOUNT_CODE,
"amount": float(-fx_diff),
"description": "زیان تسعیر ارز تسویه فاکتور",
"side": "debit",
})
# مبلغ خط حساب = ارزش پایه؛ بومی در account_currency_amount
account_line = {
**account_line,
"amount": cash_base,
"account_currency_amount": payment_native,
"account_currency_id": pay_cur,
}
person_line_extra = {
"fx_settlement": fx_settlement,
"side": "person",
"person_id": person_id,
"invoice_id": document.id,
"invoice_code": document.code,
"link_to_invoice": True,
}
rp_data = {
"document_type": "receipt" if is_receipt else "payment",
"document_date": rp_document_date.isoformat(),
"currency_id": doc_cur,
"description": f"تسویه بین‌ارزی فاکتور {document.code}",
"person_lines": [{
"person_id": person_id,
"amount": float(person_amount),
"description": f"تسویه ارزی فاکتور {document.code}",
"extra_info": person_line_extra,
}],
"account_lines": [account_line],
"fx_adjustment_lines": extra_account_lines,
"extra_info": {
"source": "invoice",
"invoice_id": document.id,
"person_is_receivable": person_is_receivable,
"fx_settlement": fx_settlement,
"cross_currency": True,
},
}
rp_doc = create_receipt_payment(
db=db,
business_id=business_id,
user_id=user_id,
data=rp_data,
commit=False,
)
if isinstance(rp_doc, dict) and rp_doc.get("id"):
payment_docs.append(int(rp_doc["id"]))
else:
raise ApiError(
"RECEIPT_PAYMENT_CREATE_FAILED",
"ایجاد سند دریافت/پرداخت مرتبط با فاکتور ناموفق بود.",
http_status=500,
)
return payment_docs
def _sum_receipt_payment_amounts_for_invoice(
db: Session,
business_id: int,
invoice_id: int,
receipt_payment_ids: List[int],
) -> Decimal:
"""جمع مبالغ تسویه‌شدهٔ فاکتور از اسناد دریافت/پرداخت پیوندی."""
total = Decimal(0)
for rp_id in receipt_payment_ids:
rp_doc = (
db.query(Document)
.filter(Document.id == int(rp_id), Document.business_id == int(business_id))
.first()
)
if not rp_doc:
continue
via_person = _sum_receipt_doc_payments_for_invoice(rp_doc, int(invoice_id), via_person_line=True)
if via_person > 0:
total += via_person
else:
total += _sum_receipt_doc_payments_for_invoice(rp_doc, int(invoice_id), via_person_line=False)
return total
def _previous_sales_ar_effect_for_update(
db: Session,
business_id: int,
document: Document,
*,
old_document_type: str,
was_proforma: bool,
linked_receipt_payment_ids: List[int],
) -> Decimal:
"""
اثر قبلی فاکتور فروش قطعی روی ماندهٔ دریافتنی شخص (برای جلوگیری از شمارش دوبل در ویرایش).
"""
if was_proforma or old_document_type != INVOICE_SALES:
return Decimal(0)
from app.services.invoice_adjustments_service import total_with_tax_from_totals_dict
old_totals = dict((document.extra_info or {}).get("totals") or {})
try:
old_total_with_tax = total_with_tax_from_totals_dict(old_totals)
except Exception:
old_total_with_tax = Decimal(0)
old_paid = _sum_receipt_payment_amounts_for_invoice(
db,
int(business_id),
int(document.id),
linked_receipt_payment_ids,
)
effect = old_total_with_tax - old_paid
return effect if effect > 0 else Decimal(0)
def _validate_and_apply_sales_credit_checks(
db: Session,
business_id: int,
fiscal_year: Optional[FiscalYear],
person_id: Optional[int],
totals: Dict[str, Any],
header_extra: Dict[str, Any],
data: Dict[str, Any],
*,
previous_ar_effect: Decimal = Decimal(0),
) -> Dict[str, Any]:
"""
کنترل سقف اعتبار و مسدودی اقساط معوق برای فاکتور فروش.
previous_ar_effect: اثر AR قبلی همین سند در ویرایش (برای کسر از ماندهٔ فعلی).
"""
if not person_id:
return header_extra
from adapters.db.models.person import Person as _PersonModel
from adapters.db.models.business import Business as _BusinessModel
from app.services.invoice_adjustments_service import total_with_tax_from_totals_dict
from app.services.person_service import calculate_person_balance
person_obj = db.query(_PersonModel).filter(_PersonModel.id == int(person_id)).first()
business_obj = db.query(_BusinessModel).filter(_BusinessModel.id == int(business_id)).first()
check_enabled = None
credit_limit_val = None
if person_obj:
check_enabled = getattr(person_obj, "credit_check_enabled", None)
credit_limit_val = getattr(person_obj, "credit_limit", None)
if check_enabled is None and business_obj:
check_enabled = bool(getattr(business_obj, "check_credit_enabled_by_default", False))
if credit_limit_val is None and business_obj:
credit_limit_val = getattr(business_obj, "default_credit_limit", None)
if not check_enabled or credit_limit_val is None:
return header_extra
bal, _status = calculate_person_balance(
db, int(person_id), fiscal_year_id=fiscal_year.id if fiscal_year else None
)
current_debt = Decimal(0)
try:
if bal is not None:
bdec = Decimal(str(bal))
current_debt = (-bdec) if bdec < 0 else Decimal(0)
except Exception:
current_debt = Decimal(0)
total_with_tax = total_with_tax_from_totals_dict(totals)
planned_paid = Decimal(0)
try:
for p in data.get("payments") or []:
amt = Decimal(str(p.get("amount", 0) or 0))
if amt > 0:
planned_paid += amt
except Exception:
planned_paid = Decimal(0)
invoice_effect = total_with_tax - planned_paid
if invoice_effect < 0:
invoice_effect = Decimal(0)
adjusted_current = current_debt - previous_ar_effect
if adjusted_current < 0:
adjusted_current = Decimal(0)
new_debt = adjusted_current + invoice_effect
limit_dec = Decimal(str(credit_limit_val))
ignore_flag = bool((data.get("extra_info") or {}).get("ignore_credit_check", False))
ex_out = dict(header_extra)
if new_debt > limit_dec:
if not ignore_flag:
raise ApiError(
"CREDIT_LIMIT_EXCEEDED",
f"اعتبار مشتری کافی نیست. مانده فعلی: {float(adjusted_current):.2f}، "
f"اثر فاکتور: {float(invoice_effect):.2f}، سقف: {float(limit_dec):.2f}",
http_status=400,
)
warns = list(ex_out.get("warnings") or [])
warns.append({
"code": "CREDIT_LIMIT_EXCEEDED",
"message": "اعتبار مشتری از سقف عبور کرده است اما نادیده گرفته شد",
"current_debt": float(adjusted_current),
"invoice_effect": float(invoice_effect),
"new_debt": float(new_debt),
"limit": float(limit_dec),
})
ex_out["warnings"] = warns
credit_cfg = get_business_credit_settings(db, business_id)
auto_block_days_raw = credit_cfg.get("auto_block_after_days")
auto_block_days = int(auto_block_days_raw) if auto_block_days_raw is not None else None
if auto_block_days and auto_block_days > 0:
inst_data = search_installments(
db=db,
business_id=business_id,
query={"person_id": int(person_id), "status": "overdue"},
disable_pagination=True,
)
max_overdue = 0
for it in inst_data.get("items", []):
od = int(it.get("overdue_days") or 0)
if od > max_overdue:
max_overdue = od
if max_overdue > auto_block_days:
if not ignore_flag:
raise ApiError(
"CREDIT_AUTO_BLOCKED",
f"به دلیل اقساط معوق بیش از {auto_block_days} روز، ثبت فاکتور جدید مجاز نیست.",
http_status=400,
)
warns = list(ex_out.get("warnings") or [])
warns.append({
"code": "CREDIT_AUTO_BLOCKED_OVERRIDDEN",
"message": "به دلیل اقساط معوق، حساب به صورت خودکار باید مسدود می‌شد اما نادیده گرفته شد",
"auto_block_after_days": auto_block_days,
"max_overdue_days": max_overdue,
})
ex_out["warnings"] = warns
return ex_out
def _clear_invoice_linked_receipt_payments(
db: Session,
business_id: int,
invoice_id: int,
document: Document,
) -> None:
"""حذف اسناد دریافت/پرداخت پیوندی و پاک‌سازی لینک‌ها."""
from app.services.receipt_payment_service import delete_receipt_payment
extra_info = document.extra_info or {}
linked_ids = _get_receipt_payment_ids_linked_to_invoice(
db, int(business_id), int(invoice_id), extra_info=extra_info
)
for rp_id in linked_ids:
delete_receipt_payment(db, int(rp_id), commit=False)
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links") or {})
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
def _refresh_inventory_chain_ledgers_if_needed(
db: Session,
business_id: int,
fiscal_year_id: Optional[int],
inv_type: str,
old_document_type: str,
product_ids: List[int],
*,
is_proforma: bool,
) -> None:
"""هم‌رسانی FIFO/COGS پس از تغییرات روی فاکتورهای خرید یا خروج از آن‌ها."""
if is_proforma:
return
if inv_type not in _INVENTORY_LEDGER_REFRESH_TYPES and old_document_type not in _INVENTORY_LEDGER_REFRESH_TYPES:
return
ids = sorted({int(x) for x in product_ids if x is not None})
if not ids:
return
from app.services.invoice_profit_ledger_service import refresh_sales_ledgers_after_inventory_invoice_change
refresh_sales_ledgers_after_inventory_invoice_change(
db,
int(business_id),
ids,
fiscal_year_id=fiscal_year_id,
)
def _compute_installment_plan(
total_with_tax: Decimal,
header_extra: Dict[str, Any],
document_date: date,
) -> Tuple[Optional[Dict[str, Any]], Decimal]:
"""
ساخت طرح اقساط ساده (MVP) بر اساس ورودی فاکتور.
ورودی مورد انتظار در extra_info.installment_plan:
{
"down_payment": number,
"num_installments": int,
"first_due_date": "YYYY-MM-DD",
"period_days": int, // اختیاری؛ پیش‌فرض 30
"period": "monthly" | "days", // اختیاری؛ اگر monthly باشد 30 روزه در نظر می‌گیرد
"interest_total": number, // اختیاری؛ اگر نبود از interest_rate محاسبه می‌شود
"interest_rate": number, // درصد کل دوره (نه سالانه) - اختیاری
"method": "flat" // اختیاری
}
خروجی: (plan_dict | None, total_interest)
"""
try:
plan_input = (header_extra or {}).get("installment_plan")
if not isinstance(plan_input, dict):
return (None, Decimal(0))
# اگر برنامه دستی ارسال شده باشد، همان را مبنا قرار بده
provided_schedule = plan_input.get("schedule")
if isinstance(provided_schedule, list) and len(provided_schedule) > 0:
# نرمال‌سازی مقادیر و محاسبه جمع‌ها
schedule: List[Dict[str, Any]] = []
total_interest = Decimal(0)
principal_total = Decimal(0)
for idx, it in enumerate(provided_schedule):
try:
due = _parse_iso_date(it.get("due_date") or document_date.isoformat())
except Exception:
due = document_date
principal = Decimal(str(it.get("principal", 0) or 0))
interest = Decimal(str(it.get("interest", 0) or 0))
total = Decimal(str(it.get("total", 0) or (principal + interest)))
paid_dec = Decimal(str(it.get("paid_amount", 0) or 0))
if paid_dec - total > Decimal("0.05"):
raise ApiError(
"INVALID_INSTALLMENT_PLAN",
"مبلغ قسط نمی‌تواند کمتر از مبلغ پرداخت‌شده ثبت‌شده برای آن قسط باشد",
http_status=422,
)
principal_total += principal
total_interest += interest
schedule.append({
"seq": int(it.get("seq") or (idx + 1)),
"due_date": due.isoformat(),
"principal": float(principal),
"interest": float(interest),
"total": float(total),
"status": it.get("status") or "pending",
"paid_amount": float(paid_dec),
})
down_payment = Decimal(str(plan_input.get("down_payment", 0) or 0))
# ولیدیشن پایه: جمع اصل اقساط + پیش‌پرداخت با مبلغ فاکتور (با مالیات) هم‌خوان باشد
tolerance = Decimal("1")
target = (total_with_tax - down_payment).max(Decimal(0))
if (principal_total - target).copy_abs() > tolerance:
raise ApiError(
"INVALID_INSTALLMENT_PLAN",
f"installment principal total ({float(principal_total)}) does not match invoice amount ({float(target)})",
http_status=422,
)
plan_dict: Dict[str, Any] = {
"method": plan_input.get("method") or "flat",
"down_payment": float(down_payment),
"num_installments": len(schedule),
"first_due_date": schedule[0]["due_date"],
"period_days": int(plan_input.get("period_days") or 30),
"principal_total": float(principal_total),
"interest_total": float(total_interest),
"schedule": schedule,
}
return (plan_dict, total_interest)
num_installments = int(plan_input.get("num_installments") or 0)
if num_installments <= 0:
return (None, Decimal(0))
down_payment = Decimal(str(plan_input.get("down_payment", 0) or 0))
if down_payment < 0:
down_payment = Decimal(0)
principal_total = total_with_tax - down_payment
if principal_total < 0:
principal_total = Decimal(0)
# محاسبه سود کل
total_interest = plan_input.get("interest_total")
if total_interest is not None:
total_interest = Decimal(str(total_interest))
if total_interest < 0:
total_interest = Decimal(0)
else:
rate = Decimal(str(plan_input.get("interest_rate", 0) or 0))
total_interest = (principal_total * rate) / Decimal(100) if rate > 0 else Decimal(0)
# زمان‌بندی
fd_raw = plan_input.get("first_due_date") or document_date.isoformat()
first_due_date = _parse_iso_date(fd_raw)
period_days = plan_input.get("period_days")
if period_days is None:
period = str(plan_input.get("period", "monthly")).strip().lower()
period_days = 30 if period == "monthly" else 30
try:
period_days = int(period_days)
if period_days <= 0:
period_days = 30
except Exception:
period_days = 30
# اقلام برنامه
per_principal = (principal_total / num_installments) if num_installments > 0 else Decimal(0)
per_interest = (total_interest / num_installments) if num_installments > 0 else Decimal(0)
schedule: List[Dict[str, Any]] = []
for i in range(num_installments):
due = first_due_date + timedelta(days=period_days * i)
item = {
"seq": i + 1,
"due_date": due.isoformat(),
"principal": float(per_principal),
"interest": float(per_interest),
"total": float(per_principal + per_interest),
"status": "pending",
"paid_amount": 0.0,
}
schedule.append(item)
plan_dict: Dict[str, Any] = {
"method": plan_input.get("method") or "flat",
"down_payment": float(down_payment),
"num_installments": num_installments,
"first_due_date": first_due_date.isoformat(),
"period_days": period_days,
"principal_total": float(principal_total),
"interest_total": float(total_interest),
"schedule": schedule,
}
return (plan_dict, total_interest)
except ApiError:
raise
except Exception:
# در صورت خطای غیرمنتظره، طرح نادیده گرفته می‌شود تا فاکتور قابل ثبت باشد
return (None, Decimal(0))
def _build_invoice_code(db: Session, business_id: int, invoice_type: str) -> str:
# INV-YYYYMMDD-NNNN (type agnostic); can be extended per-type later
prefix = _build_doc_code("INV")
last_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%"),
)
).order_by(Document.code.desc()).first()
if last_doc:
try:
last_num = int(last_doc.code.split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
return f"{prefix}-{next_num:04d}"
def _validate_selected_instances(
db: Session,
business_id: int,
product_id: int,
selected_instance_ids: List[Any],
quantity: int,
invoice_type: str,
) -> None:
"""
اعتبارسنجی selected_instance_ids برای کالاهای یونیک در فاکتور.
این تابع فقط برای فاکتورهای فروش و برگشت از خرید معنا دارد (چون حواله خارج دارند).
"""
from adapters.db.models.product_instance import ProductInstance
# فقط برای فاکتورهای فروش و برگشت از خرید
if invoice_type not in (INVOICE_SALES, INVOICE_PURCHASE_RETURN):
return
if not selected_instance_ids or not isinstance(selected_instance_ids, list):
return
# بررسی محصول
product = db.query(Product).filter(
and_(Product.id == product_id, Product.business_id == business_id)
).first()
if not product:
raise ApiError("PRODUCT_NOT_FOUND", f"Product {product_id} not found", http_status=404)
# بررسی اینکه کالا یونیک است
if product.inventory_mode != "unique":
raise ApiError(
"NOT_UNIQUE_PRODUCT",
f"کالای {product.name} در حالت یونیک نیست. انتخاب instance فقط برای کالاهای یونیک امکان‌پذیر است.",
http_status=400
)
# بررسی تعداد - باید دقیقاً برابر quantity باشد
instance_count = len(selected_instance_ids)
if instance_count != quantity:
raise ApiError(
"INSTANCE_COUNT_MISMATCH",
f"تعداد instance های انتخاب شده ({instance_count}) باید دقیقاً برابر با تعداد کالا ({quantity}) باشد",
http_status=400
)
# بررسی تکرار در لیست
unique_ids = set()
for inst_id in selected_instance_ids:
try:
inst_id_int = int(inst_id)
if inst_id_int in unique_ids:
raise ApiError(
"DUPLICATE_INSTANCE",
f"instance با ID {inst_id_int} به صورت تکراری انتخاب شده است",
http_status=400
)
unique_ids.add(inst_id_int)
except (ValueError, TypeError):
raise ApiError(
"INVALID_INSTANCE_ID",
f"شناسه instance معتبر نیست: {inst_id}",
http_status=400
)
# بررسی دسترس بودن همه instance ها
for inst_id_int in unique_ids:
instance = db.query(ProductInstance).filter(
and_(
ProductInstance.id == inst_id_int,
ProductInstance.business_id == business_id,
ProductInstance.product_id == product_id,
ProductInstance.status == "available",
)
).first()
if not instance:
raise ApiError(
"INSTANCE_NOT_AVAILABLE",
f"کالای یونیک با ID {inst_id_int} یافت نشد یا در دسترس نیست (status != available)",
http_status=404
)
# ==================== توابع محاسبه سود فاکتور ====================
def _empty_profit_response() -> Dict[str, Any]:
"""پاسخ خالی برای سود"""
return {
"gross_profit": 0.0,
"net_profit": 0.0,
"gross_profit_percent": 0.0,
"net_profit_percent": 0.0,
"total_profit": 0.0,
"total_profit_percent": 0.0,
"total_overhead": 0.0,
"total_cost": 0.0,
"total_sales": 0.0,
"line_profits": []
}
def _calculate_average_purchase_cost(
db: Session,
business_id: int,
product_id: int,
as_of_date: date
) -> Decimal:
"""
محاسبه میانگین قیمت خرید محصول از تاریخچه
"""
# دریافت تمام ردیف‌های خرید از جدول اقلام فاکتور تا تاریخ مشخص
purchase_lines = (
db.query(InvoiceItemLine, Document)
.join(Document, Document.id == InvoiceItemLine.document_id)
.filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PURCHASE,
Document.document_date <= as_of_date,
Document.is_proforma == False, # noqa: E712
InvoiceItemLine.product_id == product_id
)
)
.all()
)
total_cost = Decimal(0)
total_qty = Decimal(0)
for line, _doc in purchase_lines:
qty = Decimal(str(line.quantity or 0))
if qty <= 0:
continue
extra_info = line.extra_info or {}
cost_per_unit = Decimal(0)
if extra_info.get("cost_price") is not None:
cost_per_unit = Decimal(str(extra_info.get("cost_price")))
elif extra_info.get("unit_price") is not None:
cost_per_unit = Decimal(str(extra_info.get("unit_price")))
total_cost += qty * cost_per_unit
total_qty += qty
if total_qty > 0:
return total_cost / total_qty
return Decimal(0)
def _iter_profit_movements_from_invoice_lines(
db: Session,
business_id: int,
product_id: int,
as_of_date: date,
warehouse_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
حرکات کالا برای محاسبه هزینه سود (FIFO/LIFO) از روی اقلام فاکتور.
این مسیر مستقل از جدول DocumentLine است و با معماری جدید invoice_item_lines سازگار است.
"""
rows = (
db.query(InvoiceItemLine, Document)
.join(Document, Document.id == InvoiceItemLine.document_id)
.filter(
and_(
Document.business_id == int(business_id),
Document.is_proforma == False, # noqa: E712
Document.document_date <= as_of_date,
InvoiceItemLine.product_id == int(product_id),
)
)
.order_by(
Document.document_date.asc(),
Document.registered_at.asc(),
Document.id.asc(),
InvoiceItemLine.id.asc(),
)
.all()
)
movements: List[Dict[str, Any]] = []
for line, doc in rows:
info = line.extra_info or {}
if info.get("inventory_tracked") is False:
continue
movement = (info.get("movement") or None)
if movement is None:
movement, _ = _movement_from_type(doc.document_type)
if movement not in ("in", "out"):
continue
qty = Decimal(str(line.quantity or 0))
if qty <= 0:
continue
wh_id_raw = info.get("warehouse_id")
wh_id: Optional[int]
try:
wh_id = int(wh_id_raw) if wh_id_raw is not None else None
except Exception:
wh_id = None
if warehouse_id is not None:
if wh_id is None or int(wh_id) != int(warehouse_id):
continue
cost_price = None
if info.get("cogs_amount") is not None and qty > 0 and movement == "out":
try:
cost_price = Decimal(str(info.get("cogs_amount"))) / qty
except Exception:
cost_price = None
if cost_price is None and info.get("cost_price") is not None:
cost_price = Decimal(str(info.get("cost_price")))
if cost_price is None and info.get("unit_price") is not None:
cost_price = Decimal(str(info.get("unit_price")))
movements.append(
{
"document_id": int(doc.id),
"document_date": doc.document_date,
"registered_at": doc.registered_at,
"document_type": str(doc.document_type or ""),
"product_id": int(product_id),
"warehouse_id": wh_id,
"movement": movement,
"quantity": qty,
"cost_price": cost_price,
"invoice_item_line_id": int(line.id),
}
)
return movements
def _find_profit_movement_index_for_line(
movements: List[Dict[str, Any]], invoice_item_line_id: int
) -> Optional[int]:
for idx, mv in enumerate(movements):
if int(mv.get("invoice_item_line_id") or 0) == int(invoice_item_line_id):
return idx
return None
def _historical_sale_line_unit_cost(
db: Session,
business_id: int,
product_id: int,
warehouse_id_wh: Optional[int],
*,
normalized_basis: str,
fifo_sm: str,
invoice_item_line_id: int,
sale_document_date: date,
) -> Decimal:
"""
بهای تمام‌شدهٔ واحدی که برای همان ردیف خروجی فاکتور فروش، در تاریخ فروش تشخیص داده شده (COGS تاریخی).
"""
ln = db.query(InvoiceItemLine).filter(InvoiceItemLine.id == int(invoice_item_line_id)).first()
product = db.query(Product).filter(Product.id == int(product_id)).first()
if not ln or not product:
return Decimal(0)
nb = _normalize_invoice_profit_basis(normalized_basis)
extra_info = ln.extra_info or {}
qty = Decimal(str(ln.quantity or 0))
if nb == "purchase_price":
base_cost = product.base_purchase_price or 0
if base_cost == 0 and extra_info.get("cost_price") is not None:
base_cost = extra_info.get("cost_price")
return Decimal(str(base_cost))
if nb == "cost_price":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
return Decimal(str(product.base_purchase_price or 0))
if nb == "actual_cost":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
if extra_info.get("cogs_amount") is not None and qty > 0:
return Decimal(str(extra_info.get("cogs_amount"))) / qty
return Decimal(str(product.base_purchase_price or 0))
if nb == "average_cost":
return _calculate_average_purchase_cost(db, business_id, product.id, sale_document_date)
if nb == "fifo":
return _calculate_fifo_cost(
db,
business_id,
product.id,
qty,
sale_document_date,
warehouse_id_wh,
document_id=getattr(ln, "document_id", None),
invoice_item_line_id=int(ln.id),
fifo_shortage_mode=fifo_sm,
)
if nb == "fifo_jbfn":
return _calculate_fifo_jbfn_cost(
db,
business_id,
product.id,
qty,
sale_document_date,
warehouse_id_wh,
document_id=getattr(ln, "document_id", None),
invoice_item_line_id=int(ln.id),
fifo_shortage_mode=fifo_sm,
)
if nb == "lifo":
return _calculate_lifo_cost(
db,
business_id,
product.id,
qty,
sale_document_date,
warehouse_id_wh,
document_id=getattr(ln, "document_id", None),
invoice_item_line_id=int(ln.id),
fifo_shortage_mode=fifo_sm,
)
if nb == "moving_weighted_average":
return _calculate_moving_weighted_average_cost(
db,
business_id,
product.id,
qty,
sale_document_date,
warehouse_id_wh,
document_id=getattr(ln, "document_id", None),
invoice_item_line_id=int(ln.id),
fifo_shortage_mode=fifo_sm,
)
if nb == "weighted_average":
return _calculate_weighted_average_cost(db, business_id, product.id, sale_document_date)
if nb == "standard_cost":
if extra_info.get("standard_cost") is not None:
return Decimal(str(extra_info.get("standard_cost")))
return Decimal(str(product.base_purchase_price or 0))
return Decimal(str(product.base_purchase_price or 0))
def _movement_warehouse_matches(a: Any, b: Any) -> bool:
try:
ai = int(a) if a is not None else None
except Exception:
ai = None
try:
bi = int(b) if b is not None else None
except Exception:
bi = None
return ai == bi
def _subtract_return_qty_from_sale_out_avail(
movements: List[Dict[str, Any]],
avail: Dict[int, Decimal],
*,
qty_to_return_from_stock: Decimal,
start_backward_from_idx: Optional[int],
) -> None:
"""کاهش سهم قابل قبول ماندهٔ خروج‌های فروش به‌محض برگشت وارده پیش از بازگشت فعلی (فقط موجودی مجازی، نه سود)."""
need = Decimal(str(qty_to_return_from_stock or 0))
if need <= 0:
return
j = int(start_backward_from_idx) - 1
while need > 0 and j >= 0:
mv = movements[j]
dt = mv.get("document_type") or ""
if mv.get("movement") != "out" or dt != INVOICE_SALES:
j -= 1
continue
rem = Decimal(str(avail.get(j, Decimal(0)) or 0))
if rem <= 0:
j -= 1
continue
take = min(need, rem)
avail[j] = rem - take
need -= take
if avail[j] <= 0:
j -= 1
def _restoration_unit_cost_sales_return_match_prior_sales(
db: Session,
business_id: int,
product_id: int,
warehouse_wh: Optional[int],
*,
normalized_basis: str,
fifo_sm: str,
restoration_qty: Decimal,
movements: List[Dict[str, Any]],
return_mv_idx: int,
return_document_as_of_date: date,
prior_adjust_end_idx_exclusive: Optional[int],
) -> Decimal:
"""
واحد بازگشت جریمهٔ COGS برای برگشت از فروش: تخصیص LIFO نسبت به مقادیر باقی‌ماندهٔ
از خروجی‌های فروش قبلی قبل از تاریخ/ردیف برگشت (با بازپخش برگشت‌های پیشین روی موجودی مجازی)،
سپس میانگین وزنی تاریخی بهای هر «خروجی فروش» که از همان سناریوی COGS زمانِ فروش بازسازی می‌شود.
در شکافی که خروجی فروش کافی نباشد: میانگین خرید تا تاریخ بازگشت.
"""
qty_need = Decimal(str(restoration_qty or 0))
if qty_need <= 0:
return Decimal(0)
avail: Dict[int, Decimal] = {}
for j, mv in enumerate(movements):
dt = mv.get("document_type") or ""
if mv.get("movement") != "out" or dt != INVOICE_SALES:
continue
mq = Decimal(str(mv.get("quantity") or 0))
if mq > 0:
avail[int(j)] = mq
if prior_adjust_end_idx_exclusive is None:
prior_adjust_end_idx_exclusive = int(return_mv_idx)
# بازپخش بازگشت‌های فروشِ پیش از این خط با همان کالا/انبار؛ روی سهم ماندهٔ خروج‌های فروش تأثیر می‌گذارد
for k in range(0, prior_adjust_end_idx_exclusive):
k_mv = movements[k]
k_dt = k_mv.get("document_type") or ""
if (
int(k_mv.get("product_id") or 0) != int(product_id)
or not _movement_warehouse_matches(k_mv.get("warehouse_id"), warehouse_wh)
):
continue
if k_mv.get("movement") != "in" or k_dt != INVOICE_SALES_RETURN:
continue
qk = Decimal(str(k_mv.get("quantity") or 0))
if qk > 0:
_subtract_return_qty_from_sale_out_avail(movements, avail, qty_to_return_from_stock=qk, start_backward_from_idx=k)
sum_cost = Decimal(0)
qty_left = qty_need
j = int(return_mv_idx) - 1
while qty_left > 0 and j >= 0:
mv = movements[j]
dt = mv.get("document_type") or ""
if mv.get("movement") != "out" or dt != INVOICE_SALES:
j -= 1
continue
rem_sale = Decimal(str(avail.get(int(j), Decimal(0)) or 0))
if rem_sale <= 0:
j -= 1
continue
take = min(qty_left, rem_sale)
line_id_sale = int(mv["invoice_item_line_id"])
sale_date = mv["document_date"]
mw = mv.get("warehouse_id")
wh_line: Optional[int] = None
try:
wh_line = int(mw) if mw is not None else None
except Exception:
wh_line = None
u = _historical_sale_line_unit_cost(
db,
business_id,
product_id,
wh_line,
normalized_basis=normalized_basis,
fifo_sm=fifo_sm,
invoice_item_line_id=line_id_sale,
sale_document_date=sale_date,
)
sum_cost += take * u
qty_left -= take
j -= 1
if qty_left > 0:
avg_fallback = _calculate_average_purchase_cost(db, business_id, product_id, return_document_as_of_date)
sum_cost += qty_left * avg_fallback
qty_left = Decimal(0)
return sum_cost / qty_need if qty_need > 0 else Decimal(0)
def _calculate_fifo_cost(
db: Session,
business_id: int,
product_id: int,
quantity: Decimal,
as_of_date: date,
warehouse_id: Optional[int] = None,
*,
document_id: Optional[int] = None,
invoice_item_line_id: Optional[int] = None,
fifo_shortage_mode: str = "perpetual_mixed",
) -> Decimal:
"""
بهای تمام‌شده میانگین واحد با FIFO دائمی برای همان ردیف فاکتور (بلافاصله پیش از خروج این ردیف).
ترتیب حرکات: تاریخ سند، سپس registered_at، سپس شناسه سند، سپس خط فاکتور.
بدون شناسه ردیف، از میانگین خریدهای تاریخی تا تاریخ سند استفاده می‌شود.
"""
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
movements = _iter_profit_movements_from_invoice_lines(
db=db,
business_id=business_id,
product_id=product_id,
as_of_date=as_of_date,
warehouse_id=warehouse_id,
)
qty = Decimal(str(quantity or 0))
if document_id is not None and invoice_item_line_id is not None:
avg_for_short: Optional[Decimal] = None
if sm == "average_purchase_on_shortage":
avg_for_short = _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
unit = _unit_cost_at_target_outbound_line(
movements,
qty,
int(document_id),
int(invoice_item_line_id),
reverse=False,
fifo_shortage_mode=sm,
average_unit_for_shortage=avg_for_short,
)
if unit is not None:
return unit
# ردیف در زنجیره انبار نیست یا شناسه ناقص است → تحلیلی امن
return _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
def _jbfn_resolve_shortage_tail(
need: Decimal,
*,
fifo_shortage_mode: str,
average_unit_for_shortage: Optional[Decimal],
last_cost_used: Decimal,
used_any_layer_or_borrow: bool,
) -> Decimal:
"""هزینهٔ باقی‌مانده در کسِ جی‌بی‌اف‌ان پس از پایان لایه‌ها و ورودهای آیندهٔ قابل‌دسترس."""
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
need = Decimal(str(need or 0))
if need <= 0:
return Decimal(0)
use_avg = sm == "average_purchase_on_shortage" and average_unit_for_shortage is not None
if use_avg:
av = Decimal(str(average_unit_for_shortage))
return need * av
if used_any_layer_or_borrow:
return need * Decimal(str(last_cost_used))
# همان منطق _consume_cost_layers برای «بدون لایه»: باقی‌مانده صفر می‌ماند
return Decimal(0)
def _unit_cost_fifo_jbfn_at_target_outbound_line(
movements: List[Dict[str, Any]],
quantity: Decimal,
document_id: int,
invoice_item_line_id: int,
*,
fifo_shortage_mode: str = "perpetual_mixed",
average_unit_for_shortage: Optional[Decimal] = None,
) -> Optional[Decimal]:
"""
FIFO با تأمین کسری از ورودهای زمان‌بلاتر تا تاریخ سند (JBFN):
هر خروج ابتدا از لایه‌های انباشتهٔ گذشته، سپس از ورودهای بعدی به‌ترتیب زمان
(با پیش‌تخصیص به همان اندازه) مصرف می‌کند؛ اگر هنوز کسری مانَد، مانند کمبود FIFO
عمل می‌شود.
پیش‌تخصیص روی هر ردیف ورود تضمین می‌کند چند خروج پشت سر هم بتوانند از یک خرید
یا چند خریدِ بعدتر سهم ببرند؛ هنگام رسید به آن ورود، فقط ماندهٔ فیزیکی به لایه اضافه می‌شود.
"""
qty_param = Decimal(str(quantity or 0))
if qty_param <= 0:
return Decimal(0)
sorted_movements = sorted(movements, key=_movement_sort_key)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
avg_for_short: Optional[Decimal] = None
if sm == "average_purchase_on_shortage" and average_unit_for_shortage is not None:
avg_for_short = Decimal(str(average_unit_for_shortage))
layers: deque = deque()
prealloc: Dict[int, Decimal] = defaultdict(lambda: Decimal(0))
for idx, mv in enumerate(sorted_movements):
mv_type = mv.get("movement")
mq = Decimal(str(mv.get("quantity") or 0))
if mq <= 0:
continue
if mv_type == "in":
cp = Decimal(str(mv.get("cost_price") or 0))
rem_in = mq - prealloc[idx]
if rem_in > 0:
layers.append({"qty": rem_in, "cost": cp})
continue
if mv_type != "out":
continue
need = mq
total_cost = Decimal(0)
last_c = Decimal(0)
used_any = False
while need > 0 and layers:
top = layers[0]
lq = Decimal(str(top.get("qty") or 0))
lc = Decimal(str(top.get("cost") or 0))
if lq <= 0:
layers.popleft()
continue
take = min(need, lq)
total_cost += take * lc
last_c = lc
used_any = True
top["qty"] = lq - take
need -= take
if top["qty"] <= 0:
layers.popleft()
j = idx + 1
while need > 0 and j < len(sorted_movements):
fut = sorted_movements[j]
if fut.get("movement") == "in":
fut_qty = Decimal(str(fut.get("quantity") or 0))
avail = fut_qty - prealloc[j]
if avail > 0:
take = min(need, avail)
c = Decimal(str(fut.get("cost_price") or 0))
total_cost += take * c
last_c = c
used_any = True
prealloc[j] += take
need -= take
j += 1
if need > 0:
total_cost += _jbfn_resolve_shortage_tail(
need,
fifo_shortage_mode=sm,
average_unit_for_shortage=avg_for_short,
last_cost_used=last_c,
used_any_layer_or_borrow=used_any,
)
if int(mv["document_id"]) == int(document_id) and int(mv.get("invoice_item_line_id") or 0) == int(
invoice_item_line_id
):
return total_cost / mq if mq > 0 else Decimal(0)
return None
def _calculate_fifo_jbfn_cost(
db: Session,
business_id: int,
product_id: int,
quantity: Decimal,
as_of_date: date,
warehouse_id: Optional[int] = None,
*,
document_id: Optional[int] = None,
invoice_item_line_id: Optional[int] = None,
fifo_shortage_mode: str = "perpetual_mixed",
) -> Decimal:
"""
مانند FIFO دائمی، با این تفاوت که برای کسری، ورودهای ثبت‌شده بعد از همان تاریخ/زنجیره
(تا تاریخ سند) به ترتیب زمان مانند پیش‌خور به لایه اضافه می‌شوند، سپس در نهایت
مانده با همان fifo_shortage_mode جبران می‌گردد.
"""
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
movements = _iter_profit_movements_from_invoice_lines(
db=db,
business_id=business_id,
product_id=product_id,
as_of_date=as_of_date,
warehouse_id=warehouse_id,
)
qty = Decimal(str(quantity or 0))
if document_id is not None and invoice_item_line_id is not None:
avg_for_short: Optional[Decimal] = None
if sm == "average_purchase_on_shortage":
avg_for_short = _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
unit = _unit_cost_fifo_jbfn_at_target_outbound_line(
movements,
qty,
int(document_id),
int(invoice_item_line_id),
fifo_shortage_mode=sm,
average_unit_for_shortage=avg_for_short,
)
if unit is not None:
return unit
return _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
def _calculate_lifo_cost(
db: Session,
business_id: int,
product_id: int,
quantity: Decimal,
as_of_date: date,
warehouse_id: Optional[int] = None,
*,
document_id: Optional[int] = None,
invoice_item_line_id: Optional[int] = None,
fifo_shortage_mode: str = "perpetual_mixed",
) -> Decimal:
"""
بهای تمام‌شده میانگین واحد با LIFO دائمی برای همان ردیف فاکتور.
بدون شناسه ردیف، از میانگین خریدهای تاریخی تا تاریخ سند استفاده می‌شود.
"""
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
movements = _iter_profit_movements_from_invoice_lines(
db=db,
business_id=business_id,
product_id=product_id,
as_of_date=as_of_date,
warehouse_id=warehouse_id,
)
qty = Decimal(str(quantity or 0))
if document_id is not None and invoice_item_line_id is not None:
avg_for_short: Optional[Decimal] = None
if sm == "average_purchase_on_shortage":
avg_for_short = _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
unit = _unit_cost_at_target_outbound_line(
movements,
qty,
int(document_id),
int(invoice_item_line_id),
reverse=True,
fifo_shortage_mode=sm,
average_unit_for_shortage=avg_for_short,
)
if unit is not None:
return unit
return _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
def _calculate_moving_weighted_average_cost(
db: Session,
business_id: int,
product_id: int,
quantity: Decimal,
as_of_date: date,
warehouse_id: Optional[int] = None,
*,
document_id: Optional[int] = None,
invoice_item_line_id: Optional[int] = None,
fifo_shortage_mode: str = "perpetual_mixed",
) -> Decimal:
"""
بهای تمام‌شده میانگین موزون متحرک (دائمی) برای همان ردیف فاکتور، بلافاصله پیش از آن خروج.
بدون شناسه ردیف، از میانگین خریدهای تاریخی تا تاریخ سند استفاده می‌شود.
"""
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
movements = _iter_profit_movements_from_invoice_lines(
db=db,
business_id=business_id,
product_id=product_id,
as_of_date=as_of_date,
warehouse_id=warehouse_id,
)
qty = Decimal(str(quantity or 0))
if document_id is not None and invoice_item_line_id is not None:
avg_for_short: Optional[Decimal] = None
if sm == "average_purchase_on_shortage":
avg_for_short = _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
unit = _unit_wma_cost_at_target_outbound_line(
movements,
qty,
int(document_id),
int(invoice_item_line_id),
fifo_shortage_mode=sm,
average_unit_for_shortage=avg_for_short,
)
if unit is not None:
return unit
return _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
def _calculate_weighted_average_cost(
db: Session,
business_id: int,
product_id: int,
as_of_date: date
) -> Decimal:
"""
محاسبه میانگین وزنی قیمت خرید
(همان _calculate_average_purchase_cost)
"""
return _calculate_average_purchase_cost(db, business_id, product_id, as_of_date)
def _get_cost_per_unit_by_basis(
db: Session,
business_id: int,
product: Product,
line: Any,
calculation_basis: str,
document_date: date,
warehouse_id: Optional[int] = None,
*,
fifo_shortage_mode: str = "perpetual_mixed",
) -> Decimal:
"""
محاسبه هزینه هر واحد بر اساس مبنای انتخاب شده
"""
extra_info = line.extra_info or {}
normalized_basis = _normalize_invoice_profit_basis(calculation_basis)
sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
if normalized_basis == "purchase_price":
return Decimal(str(product.base_purchase_price or 0))
elif normalized_basis == "cost_price":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
return Decimal(str(product.base_purchase_price or 0))
elif normalized_basis == "actual_cost":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
if extra_info.get("cogs_amount") is not None and line.quantity > 0:
return Decimal(str(extra_info.get("cogs_amount"))) / Decimal(str(line.quantity))
return Decimal(str(product.base_purchase_price or 0))
elif normalized_basis == "average_cost":
return _calculate_average_purchase_cost(db, business_id, product.id, document_date)
elif normalized_basis == "fifo":
_doc_id = getattr(line, "document_id", None)
_line_id = getattr(line, "id", None)
return _calculate_fifo_cost(
db,
business_id,
product.id,
Decimal(str(line.quantity)),
document_date,
warehouse_id,
document_id=int(_doc_id) if _doc_id is not None else None,
invoice_item_line_id=int(_line_id) if _line_id is not None else None,
fifo_shortage_mode=sm,
)
elif normalized_basis == "fifo_jbfn":
_doc_id = getattr(line, "document_id", None)
_line_id = getattr(line, "id", None)
return _calculate_fifo_jbfn_cost(
db,
business_id,
product.id,
Decimal(str(line.quantity)),
document_date,
warehouse_id,
document_id=int(_doc_id) if _doc_id is not None else None,
invoice_item_line_id=int(_line_id) if _line_id is not None else None,
fifo_shortage_mode=sm,
)
elif normalized_basis == "lifo":
_doc_id = getattr(line, "document_id", None)
_line_id = getattr(line, "id", None)
return _calculate_lifo_cost(
db,
business_id,
product.id,
Decimal(str(line.quantity)),
document_date,
warehouse_id,
document_id=int(_doc_id) if _doc_id is not None else None,
invoice_item_line_id=int(_line_id) if _line_id is not None else None,
fifo_shortage_mode=sm,
)
elif normalized_basis == "moving_weighted_average":
_doc_id = getattr(line, "document_id", None)
_line_id = getattr(line, "id", None)
return _calculate_moving_weighted_average_cost(
db,
business_id,
product.id,
Decimal(str(line.quantity)),
document_date,
warehouse_id,
document_id=int(_doc_id) if _doc_id is not None else None,
invoice_item_line_id=int(_line_id) if _line_id is not None else None,
fifo_shortage_mode=sm,
)
elif normalized_basis == "weighted_average":
return _calculate_weighted_average_cost(db, business_id, product.id, document_date)
elif normalized_basis == "standard_cost":
if extra_info.get("standard_cost") is not None:
return Decimal(str(extra_info.get("standard_cost")))
return Decimal(str(product.base_purchase_price or 0))
else:
return Decimal(str(product.base_purchase_price or 0))
def _calculate_overhead_cost(
db: Session,
business_id: int,
document_id: int,
total_cost: Decimal,
overhead_type: str,
overhead_percent: Optional[Decimal] = None
) -> Decimal:
"""
محاسبه هزینه‌های سربار
"""
if overhead_type == "none":
return Decimal(0)
elif overhead_type in ("custom_percent", "all_overhead"):
if overhead_percent is None or overhead_percent <= 0:
return Decimal(0)
return total_cost * (overhead_percent / Decimal(100))
elif overhead_type == "production_overhead":
document = db.query(Document).filter(Document.id == document_id).first()
if document and document.document_type == "invoice_production":
extra_info = document.extra_info or {}
operations_total = Decimal(str(extra_info.get("production_operations_total", 0) or 0))
return operations_total
return Decimal(0)
return Decimal(0)
def _invoice_profit_line_sale_cost_per_unit(
db: Session,
business_id: int,
product: Product,
item_line: Any,
qty: Decimal,
normalized_basis: str,
document_date: date,
warehouse_id: Any,
document_id_hdr: int,
fifo_sm: str,
) -> Decimal:
"""بهای هر واحد در ردیف فاکتور فروش یا خدمت (مسیر عمومی)، بدون منطق خاص بازگشت کالای ورودی."""
extra_info = item_line.extra_info or {}
if normalized_basis == "purchase_price":
base_cost = product.base_purchase_price or 0
if base_cost == 0 and extra_info.get("cost_price") is not None:
base_cost = extra_info.get("cost_price")
return Decimal(str(base_cost))
if normalized_basis == "cost_price":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
return Decimal(str(product.base_purchase_price or 0))
if normalized_basis == "actual_cost":
if extra_info.get("cost_price") is not None:
return Decimal(str(extra_info.get("cost_price")))
if extra_info.get("cogs_amount") is not None and qty > 0:
return Decimal(str(extra_info.get("cogs_amount"))) / qty
return Decimal(str(product.base_purchase_price or 0))
if normalized_basis == "average_cost":
return _calculate_average_purchase_cost(db, business_id, product.id, document_date)
if normalized_basis == "fifo":
return _calculate_fifo_cost(
db,
business_id,
product.id,
qty,
document_date,
warehouse_id,
document_id=document_id_hdr,
invoice_item_line_id=int(item_line.id),
fifo_shortage_mode=fifo_sm,
)
if normalized_basis == "fifo_jbfn":
return _calculate_fifo_jbfn_cost(
db,
business_id,
product.id,
qty,
document_date,
warehouse_id,
document_id=document_id_hdr,
invoice_item_line_id=int(item_line.id),
fifo_shortage_mode=fifo_sm,
)
if normalized_basis == "lifo":
return _calculate_lifo_cost(
db,
business_id,
product.id,
qty,
document_date,
warehouse_id,
document_id=document_id_hdr,
invoice_item_line_id=int(item_line.id),
fifo_shortage_mode=fifo_sm,
)
if normalized_basis == "moving_weighted_average":
return _calculate_moving_weighted_average_cost(
db,
business_id,
product.id,
qty,
document_date,
warehouse_id,
document_id=document_id_hdr,
invoice_item_line_id=int(item_line.id),
fifo_shortage_mode=fifo_sm,
)
if normalized_basis == "weighted_average":
return _calculate_weighted_average_cost(db, business_id, product.id, document_date)
if normalized_basis == "standard_cost":
if extra_info.get("standard_cost") is not None:
return Decimal(str(extra_info.get("standard_cost")))
return Decimal(str(product.base_purchase_price or 0))
return Decimal(str(product.base_purchase_price or 0))
def _invoice_line_net_before_global_discount(item_line: Any) -> Decimal:
extra_info = item_line.extra_info or {}
qty = Decimal(str(item_line.quantity or 0))
unit_price = Decimal(str(extra_info.get("unit_price", 0) or 0))
line_discount = Decimal(str(extra_info.get("line_discount", 0) or 0))
return qty * unit_price - line_discount
def _document_global_discount_amount_for_profit(document: Document, item_lines: List[Any]) -> Decimal:
"""مبلغ تخفیف کلی؛ اولویت با global_discount.amount، وگرنه totals.discount − جمع تخفیف ردیفی."""
extra = document.extra_info or {}
gd = extra.get("global_discount")
if isinstance(gd, dict) and gd.get("amount") is not None:
try:
v = Decimal(str(gd.get("amount")))
if v > 0:
return v
except Exception:
pass
totals = dict(extra.get("totals") or {})
try:
tot_disc = Decimal(str(totals.get("discount", 0) or 0))
except Exception:
tot_disc = Decimal(0)
sum_line_disc = Decimal(0)
for ln in item_lines:
try:
sum_line_disc += Decimal(str((ln.extra_info or {}).get("line_discount", 0) or 0))
except Exception:
pass
cand = tot_disc - sum_line_disc
return cand if cand > 0 else Decimal(0)
def _document_adjustments_net_for_profit(document: Document) -> Decimal:
extras = document.extra_info or {}
rows = extras.get("invoice_adjustments")
if isinstance(rows, list):
total = Decimal(0)
for row in rows:
if not isinstance(row, dict) or row.get("exclude_from_profit"):
continue
try:
amount = Decimal(str(row.get("amount", 0) or 0))
except Exception:
continue
if amount <= 0:
continue
kind = str(row.get("kind") or "addition").strip().lower()
total += amount if kind == "addition" else -amount
return total
totals = dict(extras.get("totals") or {})
try:
return Decimal(str(totals.get("adjustments_net", 0) or 0))
except Exception:
return Decimal(0)
def _allocate_global_discount_to_lines(
item_lines: List[Any],
global_discount_amount: Decimal,
*,
is_sales_return: bool,
) -> Dict[int, Decimal]:
"""شناسهٔ خط فاکتور → سهم تخفیف کلی از آن خط (متناسب با زیرجمع قبل از تخفیف کلی)."""
out: Dict[int, Decimal] = {}
if global_discount_amount <= 0 or not item_lines:
return out
nets = [_invoice_line_net_before_global_discount(ln) for ln in item_lines]
if is_sales_return:
weights = [max(Decimal(0), abs(n)) for n in nets]
else:
weights = [max(Decimal(0), n) for n in nets]
wsum = sum(weights)
n_lines = len(item_lines)
def _emit_equal() -> Dict[int, Decimal]:
if n_lines <= 0:
return {}
per_mid = (global_discount_amount / Decimal(n_lines)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
acc = Decimal(0)
eqmap: Dict[int, Decimal] = {}
for i, ln in enumerate(item_lines):
lid = int(ln.id)
if i == n_lines - 1:
eqmap[lid] = global_discount_amount - acc
else:
eqmap[lid] = per_mid
acc += per_mid
return eqmap
if wsum <= 0:
return _emit_equal()
allocated = Decimal(0)
for i, ln in enumerate(item_lines):
lid = int(ln.id)
if i == n_lines - 1:
out[lid] = global_discount_amount - allocated
else:
share = (global_discount_amount * (weights[i] / wsum)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
out[lid] = share
allocated += share
return out
def _calculate_invoice_profit(
db: Session,
business_id: int,
document_id: int,
calculation_method: str = "automatic",
calculation_basis: str = "purchase_price",
include_overhead: bool = False,
overhead_type: str = "none",
overhead_percent: Optional[Decimal] = None,
calculation_type: str = "gross",
*,
fifo_shortage_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""
محاسبه سود فاکتور با پشتیبانی از روش‌های مختلف و هزینه‌های سربار
"""
normalized_method = _normalize_invoice_profit_method(calculation_method)
normalized_basis = _normalize_invoice_profit_basis(calculation_basis)
normalized_type = _normalize_invoice_profit_type(calculation_type)
normalized_overhead_type = _normalize_invoice_profit_overhead_type(overhead_type)
fifo_sm = _normalize_invoice_profit_fifo_shortage_mode(fifo_shortage_mode)
# اگر محاسبه سود غیرفعال است
if normalized_method == "disabled":
return _empty_profit_response()
# دریافت فاکتور
document = db.query(Document).filter(Document.id == document_id).first()
if not document or not document.document_type.startswith("invoice"):
return _empty_profit_response()
# فقط برای فاکتورهای فروش و تولید محاسبه می‌شود
if document.document_type not in ["invoice_sales", "invoice_sales_return", "invoice_production"]:
return _empty_profit_response()
total_gross_profit = Decimal(0)
total_net_profit = Decimal(0)
total_sales = Decimal(0)
total_cost = Decimal(0)
line_profits = []
adjustments_net_total = Decimal(0)
total_overhead = Decimal(0)
# برای فاکتور تولید
if document.document_type == "invoice_production":
lines = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document_id).all()
out_lines = [ln for ln in lines if (ln.extra_info or {}).get("movement") == "out"]
in_lines = [ln for ln in lines if (ln.extra_info or {}).get("movement") == "in"]
total_materials_cost = Decimal(0)
for line in out_lines:
if not line.product_id:
continue
product = db.query(Product).filter(Product.id == line.product_id).first()
if not product:
continue
qty = Decimal(str(line.quantity or 0))
line_extra = line.extra_info or {}
line_warehouse_id = line_extra.get("warehouse_id")
cost_per_unit = _get_cost_per_unit_by_basis(
db, business_id, product, line, normalized_basis,
document.document_date, line_warehouse_id,
fifo_shortage_mode=fifo_sm,
)
total_materials_cost += qty * cost_per_unit
extra_info = document.extra_info or {}
operations_total = Decimal(str(extra_info.get("production_operations_total", 0) or 0))
total_production_cost = total_materials_cost + operations_total
out_specs: List[Dict[str, Any]] = []
for line in in_lines:
qty = Decimal(str(line.quantity or 0))
line_extra = line.extra_info or {}
unit_price = Decimal(str(line_extra.get("unit_price", 0) or 0))
product = None
if line.product_id:
product = db.query(Product).filter(Product.id == line.product_id).first()
if unit_price <= 0 and product is not None:
unit_price = Decimal(str(product.base_sales_price or 0))
sales_amt = qty * unit_price
pname = (product.name if product else None) or (line.description or "").strip() or "—"
pcode = product.code if product else None
pid_i = int(product.id) if product else (int(line.product_id) if line.product_id else None)
out_specs.append({
"line": line,
"qty": qty,
"unit_price": unit_price,
"sales_amount": sales_amt,
"product_code": pcode,
"product_name": pname,
"product_id": pid_i,
})
weight_sum = sum(s["sales_amount"] for s in out_specs)
n_out = len(out_specs)
for spec in out_specs:
line = spec["line"]
qty = spec["qty"]
unit_price = spec["unit_price"]
sales_amount = spec["sales_amount"]
if weight_sum > 0:
line_cost = total_production_cost * (sales_amount / weight_sum)
elif n_out > 0:
line_cost = total_production_cost / Decimal(n_out)
else:
line_cost = Decimal(0)
line_gross_profit = sales_amount - line_cost
line_gross_profit_percent = (
(line_gross_profit / sales_amount * Decimal(100)) if sales_amount > 0 else Decimal(0)
)
total_gross_profit += line_gross_profit
total_sales += sales_amount
line_profits.append({
"line_id": line.id,
"product_id": spec["product_id"],
"product_code": spec["product_code"],
"product_name": spec["product_name"],
"quantity": float(qty),
"unit_price": float(unit_price),
"cost_per_unit": float(line_cost / qty) if qty > 0 else 0,
"sales_amount": float(sales_amount),
"total_cost": float(line_cost),
"gross_profit": float(line_gross_profit),
"gross_profit_percent": float(line_gross_profit_percent),
"net_profit": float(line_gross_profit),
"net_profit_percent": float(line_gross_profit_percent),
"overhead": 0.0,
})
if include_overhead and normalized_overhead_type != "production_overhead":
total_overhead = _calculate_overhead_cost(
db, business_id, document.id, total_production_cost,
normalized_overhead_type, overhead_percent
)
total_net_profit = total_gross_profit - total_overhead
# برای فاکتورهای فروش
elif document.document_type in ["invoice_sales", "invoice_sales_return"]:
is_sales_return = document.document_type == INVOICE_SALES_RETURN
item_lines = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == document_id)
.order_by(InvoiceItemLine.id.asc())
.all()
)
mv_cache: Dict[str, List[Dict[str, Any]]] = {}
def _mv_list_pid_wh(pid_v: int, wh_key: Optional[int]) -> List[Dict[str, Any]]:
cache_k = f"{int(pid_v)}:{'' if wh_key is None else int(wh_key)}"
if cache_k not in mv_cache:
mv_cache[cache_k] = _iter_profit_movements_from_invoice_lines(
db,
business_id,
int(pid_v),
document.document_date,
warehouse_id=wh_key,
)
return mv_cache[cache_k]
gd_amt = _document_global_discount_amount_for_profit(document, item_lines)
gd_alloc = _allocate_global_discount_to_lines(item_lines, gd_amt, is_sales_return=is_sales_return)
adjustments_net_total = _document_adjustments_net_for_profit(document)
profit_rows: List[Dict[str, Any]] = []
for item_line in item_lines:
extra_info = item_line.extra_info or {}
qty = Decimal(str(item_line.quantity or 0))
unit_price = Decimal(str(extra_info.get("unit_price", 0) or 0))
line_discount = Decimal(str(extra_info.get("line_discount", 0) or 0))
warehouse_id = extra_info.get("warehouse_id")
inv_tracked_ok = extra_info.get("inventory_tracked", True)
try:
wh_iter = int(warehouse_id) if warehouse_id is not None else None
except Exception:
wh_iter = None
raw_net_sales = (qty * unit_price) - line_discount
alloc_g = gd_alloc.get(int(item_line.id), Decimal(0))
raw_after_global = raw_net_sales - alloc_g
if is_sales_return:
if raw_after_global > 0:
sales_amount = -raw_after_global
else:
sales_amount = raw_after_global
else:
sales_amount = raw_after_global
product = None
if item_line.product_id:
product = db.query(Product).filter(Product.id == item_line.product_id).first()
mv_hint, _inv_pair = _movement_from_type(document.document_type)
ex_mv = extra_info.get("movement")
ex_mv_norm: Optional[str] = None
if isinstance(ex_mv, str):
el = ex_mv.strip().lower()
if el in ("in", "out"):
ex_mv_norm = el
effective_move = ex_mv_norm if ex_mv_norm in ("in", "out") else mv_hint
cost_per_unit = Decimal(0)
total_line_cost = Decimal(0)
line_gross_profit = Decimal(0)
if product:
inbound_return_goods = (
bool(is_sales_return) and (effective_move == "in") and inv_tracked_ok is not False
)
if inbound_return_goods:
cp_raw = extra_info.get("cost_price")
if cp_raw is not None:
cost_per_unit = Decimal(str(cp_raw))
else:
mvl = _mv_list_pid_wh(product.id, wh_iter)
idx_ln = _find_profit_movement_index_for_line(mvl, int(item_line.id))
if idx_ln is not None:
cost_per_unit = _restoration_unit_cost_sales_return_match_prior_sales(
db,
business_id,
int(product.id),
wh_iter,
normalized_basis=normalized_basis,
fifo_sm=fifo_sm,
restoration_qty=qty,
movements=mvl,
return_mv_idx=int(idx_ln),
return_document_as_of_date=document.document_date,
prior_adjust_end_idx_exclusive=int(idx_ln),
)
else:
cost_per_unit = _calculate_average_purchase_cost(
db, business_id, product.id, document.document_date
)
total_line_cost = qty * cost_per_unit
line_gross_profit = sales_amount + total_line_cost
else:
cost_per_unit = _invoice_profit_line_sale_cost_per_unit(
db,
business_id,
product,
item_line,
qty,
normalized_basis,
document.document_date,
warehouse_id,
int(document.id),
fifo_sm,
)
total_line_cost = qty * cost_per_unit
line_gross_profit = sales_amount - total_line_cost
else:
line_gross_profit = sales_amount
denom_row = (
abs(sales_amount)
if sales_amount != Decimal(0)
else (abs(total_line_cost) if total_line_cost != Decimal(0) else Decimal(0))
)
line_gross_profit_percent = (
(line_gross_profit / denom_row * Decimal(100)) if denom_row != Decimal(0) else Decimal(0)
)
pname = (product.name if product else None) or (item_line.description or "").strip() or "—"
pcode = product.code if product else None
pid_disp = int(product.id) if product else (int(item_line.product_id) if item_line.product_id else None)
profit_rows.append({
"line_id": item_line.id,
"product_id": pid_disp,
"product_code": pcode,
"product_name": pname,
"quantity": float(qty),
"unit_price": float(unit_price),
"cost_per_unit": float(cost_per_unit),
"sales_amount": sales_amount,
"total_line_cost": total_line_cost,
"line_gross_profit": line_gross_profit,
"line_gross_profit_percent": line_gross_profit_percent,
"denom_row": denom_row,
})
total_gross_from_lines = sum(pr["line_gross_profit"] for pr in profit_rows)
total_gross_profit = total_gross_from_lines + adjustments_net_total
total_sales = sum(pr["sales_amount"] for pr in profit_rows)
total_cost = sum(pr["total_line_cost"] for pr in profit_rows)
total_overhead = Decimal(0)
if include_overhead:
total_overhead = _calculate_overhead_cost(
db, business_id, document_id, total_cost,
normalized_overhead_type, overhead_percent
)
n_rows = len(profit_rows)
for pr in profit_rows:
tlc = pr["total_line_cost"]
if include_overhead and total_overhead > 0:
if total_cost > 0:
line_overhead = total_overhead * (tlc / total_cost)
elif n_rows > 0:
line_overhead = total_overhead / Decimal(n_rows)
else:
line_overhead = Decimal(0)
else:
line_overhead = Decimal(0)
lg = pr["line_gross_profit"]
line_net_profit = lg - line_overhead
denom_row = pr["denom_row"]
line_net_profit_percent = (
(line_net_profit / denom_row * Decimal(100)) if denom_row != Decimal(0) else Decimal(0)
)
line_profits.append({
"line_id": pr["line_id"],
"product_id": pr["product_id"],
"product_code": pr["product_code"],
"product_name": pr["product_name"],
"quantity": pr["quantity"],
"unit_price": pr["unit_price"],
"cost_per_unit": pr["cost_per_unit"],
"sales_amount": float(pr["sales_amount"]),
"total_cost": float(tlc),
"gross_profit": float(lg),
"net_profit": float(line_net_profit),
"gross_profit_percent": float(pr["line_gross_profit_percent"]),
"net_profit_percent": float(line_net_profit_percent),
"overhead": float(line_overhead),
})
total_net_profit = total_gross_profit - total_overhead
if document.document_type == INVOICE_PRODUCTION:
total_cost = sum(
Decimal(str(lp.get("total_cost", 0) or 0)) for lp in line_profits
)
# محاسبه درصد سود
revenue_basis = total_sales + adjustments_net_total
denom_total = abs(revenue_basis) if revenue_basis != Decimal(0) else Decimal(0)
gross_profit_percent = (
(total_gross_profit / denom_total * Decimal(100)) if denom_total != Decimal(0) else Decimal(0)
)
net_profit_percent = (
(total_net_profit / denom_total * Decimal(100)) if denom_total != Decimal(0) else Decimal(0)
)
# ساخت response
result = {
"total_overhead": float(total_overhead),
"total_cost": float(total_cost),
"total_sales": float(total_sales),
"line_profits": line_profits,
}
if normalized_type in ["gross", "both"]:
result["gross_profit"] = float(total_gross_profit)
result["gross_profit_percent"] = float(gross_profit_percent)
if normalized_type in ["net", "both"]:
result["net_profit"] = float(total_net_profit)
result["net_profit_percent"] = float(net_profit_percent)
# برای سازگاری با کد قدیم
if normalized_type == "gross":
result["total_profit"] = result["gross_profit"]
result["total_profit_percent"] = result["gross_profit_percent"]
elif normalized_type == "net":
result["total_profit"] = result["net_profit"]
result["total_profit_percent"] = result["net_profit_percent"]
return result
def create_invoice(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
user_can_select_fx_rate: bool = False,
user_can_change_invoice_unit_price: bool = True,
*,
commit: bool = True,
skip_post_commit_hooks: bool = False,
) -> Dict[str, Any]:
logger.info("=== شروع ایجاد فاکتور ===")
invoice_type = str(data.get("invoice_type", "")).strip()
if invoice_type not in SUPPORTED_INVOICE_TYPES:
raise ApiError("INVALID_INVOICE_TYPE", "Unsupported invoice_type", http_status=400)
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
fiscal_year = _get_current_fiscal_year(db, business_id)
_apply_business_defaults_invoice_warehouse(db, business_id, data)
lines_input: List[Dict[str, Any]] = list(data.get("lines") or [])
if not isinstance(lines_input, list) or len(lines_input) == 0:
raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400)
# اعتبارسنجی ویژه برای فاکتور تولید
if invoice_type == INVOICE_PRODUCTION:
header_extra_check = data.get("extra_info") or {}
_validate_production_invoice_lines(db, business_id, lines_input, header_extra_check)
_stamp_movement_on_invoice_lines(invoice_type, lines_input)
# Basic person requirement for AR/AP invoices
person_id = _person_id_from_header(data)
if invoice_type in {INVOICE_SALES, INVOICE_SALES_RETURN, INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN} and not person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required for this invoice type", http_status=400)
# جمع‌ها: تخفیف کلی (در صورت وجود) سپس totals از extra_info یا خطوط
raw_extra = data.get("extra_info")
header_extra: Dict[str, Any] = dict(raw_extra) if isinstance(raw_extra, dict) else {}
if person_id and header_extra.get("person_id") is None:
header_extra["person_id"] = int(person_id)
dd_top = data.get("due_date")
if dd_top is not None and str(dd_top).strip():
try:
ds = str(dd_top).replace("Z", "+00:00")
if "T" in ds:
ds = ds.split("T")[0]
header_extra["due_date"] = _parse_iso_date(ds).isoformat()
except Exception:
pass
data["extra_info"] = header_extra
try:
from app.services.invoice_global_discount_service import apply_global_discount_to_invoice_payload
apply_global_discount_to_invoice_payload(
db, business_id, invoice_type, lines_input, data
)
except ApiError:
raise
except Exception:
logger.exception("invoice_global_discount_failed")
raise ApiError(
"GLOBAL_DISCOUNT_ERROR",
"خطا در محاسبه تخفیف کلی فاکتور",
http_status=400,
)
header_extra = dict(data.get("extra_info") or {})
totals = (header_extra.get("totals") or {}) if isinstance(header_extra, dict) else {}
totals_missing = not all(k in totals for k in ("gross", "discount", "tax", "net"))
if totals_missing:
totals = _extract_totals_from_lines(lines_input)
header_extra["totals"] = totals
data["extra_info"] = header_extra
try:
from app.services.customer_club_service import maybe_apply_loyalty_redemption_to_invoice_payload
maybe_apply_loyalty_redemption_to_invoice_payload(
db,
business_id,
invoice_type,
bool(data.get("is_proforma", False)),
int(person_id) if person_id else None,
totals,
header_extra,
data,
user_id,
)
except ApiError:
raise
try:
from app.services.invoice_adjustments_service import merge_invoice_adjustments_into_header_extra
merge_invoice_adjustments_into_header_extra(db, business_id, invoice_type, header_extra)
data["extra_info"] = header_extra
except ApiError:
raise
header_extra = dict(data.get("extra_info") or {})
totals = dict(header_extra.get("totals") or {})
# Inventory posting is decoupled; no stock validation here
post_inventory: bool = _is_inventory_posting_enabled(data)
movement_hint, _ = _movement_from_type(invoice_type)
# --- اعتبارسنجی اعتبار مشتری (قبل از ایجاد سند) ---
is_proforma_req = bool(data.get("is_proforma", False))
if not is_proforma_req and invoice_type == INVOICE_SALES and person_id:
header_extra = _validate_and_apply_sales_credit_checks(
db,
business_id,
fiscal_year,
int(person_id),
totals,
header_extra,
data,
previous_ar_effect=Decimal(0),
)
data["extra_info"] = header_extra
# Resolve inventory tracking per product and annotate lines
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
track_map: Dict[int, bool] = {}
if all_product_ids:
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == business_id,
Product.id.in_(all_product_ids),
).all():
track_map[int(pid)] = bool(tracked)
product_tax_map = _build_product_tax_snapshot_map(db, business_id, all_product_ids)
for ln in lines_input:
pid = ln.get("product_id")
if not pid:
continue
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
tax_meta = product_tax_map.get(int(pid))
if tax_meta:
snapshot = {k: v for k, v in tax_meta.items() if v is not None}
snapshot["captured_at"] = datetime.utcnow().isoformat()
info["tax_snapshot"] = snapshot
ln["extra_info"] = info
_apply_invoice_missing_line_warehouse_policy(
db,
business_id,
lines_input,
header_extra,
is_proforma_req,
post_inventory,
)
# انبار از فاکتور جدا شده است؛ انتخاب انبار در فاکتور اجباری نیست
# Costing method (only for tracked products)
costing_method = _get_costing_method(data)
# COGS دفتر کل در invoice_cogs_gl_service هم‌رسانی می‌شود (پس از ثبت فاکتور یا پست حواله).
gross = Decimal(str(totals["gross"]))
discount = Decimal(str(totals["discount"]))
net = gross - discount
tax = Decimal(str(totals["tax"]))
from app.services.invoice_adjustments_service import total_with_tax_from_totals_dict
total_with_tax = total_with_tax_from_totals_dict(totals)
ensure_document_policy_allows_creation(
db,
business_id,
document_type=invoice_type,
document_date=document_date,
amount=abs(total_with_tax),
)
# Enrich extra_info (نرمال‌سازی person_id و سایر فیلدهای عددی برای ذخیره یکسان در دیتابیس)
new_extra_info = _normalize_document_extra_info_for_storage(dict(header_extra)) or {}
new_extra_info["totals"] = {
"gross": float(Decimal(str(totals["gross"]))),
"discount": float(Decimal(str(totals["discount"]))),
"tax": float(Decimal(str(totals["tax"]))),
"net": float(Decimal(str(totals["net"]))),
"adjustments_net": float(Decimal(str(totals.get("adjustments_net", 0)))),
"adjustments_tax": float(Decimal(str(totals.get("adjustments_tax", 0)))),
}
from app.services.invoice_fx_revaluation import apply_fx_revaluation_to_invoice_extra
business_for_fx = db.get(Business, business_id)
if business_for_fx is not None:
try:
new_extra_info = apply_fx_revaluation_to_invoice_extra(
db,
business_for_fx,
document_currency_id=int(currency_id),
document_date=document_date,
extra_info=new_extra_info,
data=data,
user_can_select_fx_rate=user_can_select_fx_rate,
registered_at_utc=datetime.now(timezone.utc),
)
except ApiError:
raise
except Exception:
logger.exception("invoice_fx_revaluation apply failed (create)")
raise ApiError("FX_REVALUATION_ERROR", "خطا در محاسبه تسعیر ارز", http_status=400)
new_extra_info = _normalize_document_extra_info_for_storage(new_extra_info) or new_extra_info
if invoice_type in (INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN):
from app.services.purchase_accounting_service import stamp_purchase_accounting_mode_on_extra_info
new_extra_info = stamp_purchase_accounting_mode_on_extra_info(db, business_id, new_extra_info)
# Create document با کنترل رقابت در شماره‌گذاری (خودکار) یا ثبت شماره دستی
from app.services.document_numbering_service import generate_document_code
document: Optional[Document] = None
max_code_attempts = 5
# دریافت project_id (اختیاری)
project_id = data.get("project_id")
if project_id:
# اعتبارسنجی پروژه
from adapters.db.models.project import Project
project = db.query(Project).filter(
and_(Project.id == project_id, Project.business_id == business_id, Project.is_active == True)
).first()
if not project:
raise ApiError("PROJECT_NOT_FOUND", "پروژه یافت نشد یا غیرفعال است", http_status=404)
requested_code = str(data.get("code") or "").strip()
code_reservation_id = str(data.get("code_reservation_id") or "").strip() or None
if requested_code:
if not re.match(r"^[A-Za-z0-9_-]+$", requested_code):
raise ApiError(
"INVALID_DOCUMENT_CODE",
"شماره فاکتور فقط می‌تواند شامل حروف انگلیسی، اعداد، خط تیره و زیرخط باشد",
http_status=400,
)
candidate_manual = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=requested_code,
document_type=invoice_type,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=utc_now_aware(),
is_proforma=bool(data.get("is_proforma", False)),
description=data.get("description"),
extra_info=new_extra_info,
project_id=project_id,
)
try:
with db.begin_nested():
db.add(candidate_manual)
db.flush()
except IntegrityError as exc:
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
raise ApiError(
"DUPLICATE_DOCUMENT_CODE",
"این شماره فاکتور برای این کسب‌وکار قبلاً ثبت شده است",
http_status=400,
)
raise
document = candidate_manual
if not document and code_reservation_id:
from app.services.document_code_reservation_service import (
mark_reservation_used,
validate_reservation_for_invoice_create,
)
reserved_document = validate_reservation_for_invoice_create(
db,
business_id=business_id,
user_id=user_id,
reservation_id=code_reservation_id,
invoice_type=invoice_type,
document_date=document_date,
)
candidate_reserved = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=reserved_document.code,
document_type=invoice_type,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=utc_now_aware(),
is_proforma=bool(data.get("is_proforma", False)),
description=data.get("description"),
extra_info=new_extra_info,
project_id=project_id,
)
try:
with db.begin_nested():
db.add(candidate_reserved)
db.flush()
mark_reservation_used(db, reserved_document)
except IntegrityError as exc:
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
raise ApiError(
"DUPLICATE_DOCUMENT_CODE",
"شماره رزروشده دیگر در دسترس نیست. لطفاً شماره جدید دریافت کنید.",
http_status=409,
)
raise
document = candidate_reserved
if not document:
for attempt in range(max_code_attempts):
doc_code = generate_document_code(db, business_id, invoice_type, document_date)
candidate = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=invoice_type,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=utc_now_aware(),
is_proforma=bool(data.get("is_proforma", False)),
description=data.get("description"),
extra_info=new_extra_info,
project_id=project_id,
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
except IntegrityError as exc:
# اگر به دلیل تکرار کد شکست خورد، دوباره تلاش کن
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
continue
raise
else:
document = candidate
break
if not document:
raise ApiError(
"DOCUMENT_CODE_RACE",
"تولید شماره سند پس از چند تلاش ناموفق بود. لطفاً دوباره تلاش کنید.",
http_status=409,
)
from app.services.invoice_line_unit_price_guard import validate_invoice_lines_unit_price_policy
validate_invoice_lines_unit_price_policy(
db,
business_id,
invoice_type,
int(currency_id),
lines_input,
user_can_change_invoice_unit_price,
)
# ذخیره اقلام فاکتور در جدول مجزا (invoice_item_lines)
for line in lines_input:
product_id = line.get("product_id")
qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = dict(line.get("extra_info") or {})
extra_info.pop("inventory_posted", None)
# اعتبارسنجی selected_instance_ids برای کالاهای یونیک
selected_instance_ids = extra_info.get("selected_instance_ids")
if selected_instance_ids:
_validate_selected_instances(
db, business_id, int(product_id), selected_instance_ids, int(qty), invoice_type
)
_validate_invoice_line_custom_attributes(db, business_id, line)
db.add(InvoiceItemLine(
document_id=document.id,
product_id=int(product_id),
quantity=qty,
description=line.get("description"),
extra_info=extra_info,
))
# Accounting lines for finalized invoices (COGS/Inventory در invoice_cogs_gl_service ثبت می‌شود)
if not document.is_proforma:
from app.services.invoice_adjustments_service import add_adjustment_document_lines
accounts = _resolve_accounts_for_invoice(
db,
{**data, "invoice_type": invoice_type, "business_id": business_id, "extra_info": document.extra_info},
)
_adj_extra = dict(document.extra_info or {})
_purchase_mode = (_adj_extra.get("purchase_accounting_mode") or "").strip()
# COGS/Inventory در invoice_cogs_gl_service ثبت می‌شود
# Sales
if invoice_type == INVOICE_SALES:
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_with_tax,
credit=Decimal(0),
description=data.get("description"),
extra_info={"side": "person", "person_id": person_id},
))
# فروش قبل از تخفیف
if gross > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["revenue"].id,
debit=Decimal(0),
credit=gross,
description="فروش کالا (قبل از تخفیف)",
))
# تخفیفات فروش به‌صورت مستقل
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_discount"].id,
debit=discount,
credit=Decimal(0),
description="تخفیفات فروش",
))
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_out"].id,
debit=Decimal(0),
credit=tax,
description="مالیات بر ارزش افزوده خروجی",
))
add_adjustment_document_lines(
db,
business_id=business_id,
document_id=int(document.id),
invoice_type=invoice_type,
accounts=accounts,
header_extra=_adj_extra,
)
# --- فروش اقساطی (ثبت سود تحقق‌نیافته و افزایش AR) ---
plan_dict, total_interest = _compute_installment_plan(total_with_tax, header_extra, document_date)
if plan_dict:
# در همه حالات طرح روی سند ذخیره می‌شود
extra = document.extra_info or {}
extra["installment_plan"] = plan_dict
document.extra_info = _normalize_document_extra_info_for_storage(extra)
# اگر سود اقساط مثبت باشد، ثبت‌های حسابداری سود اعمال می‌شود
if total_interest > 0:
# افزایش بدهکار دریافتنی به میزان سود کل اقساط
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_interest,
credit=Decimal(0),
description="سود کل اقساط افزوده به دریافتنی",
extra_info={"installment": True, "side": "person", "person_id": person_id},
))
# بستانکار سود تحقق‌نیافته
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["unearned_installment_profit"].id,
debit=Decimal(0),
credit=total_interest,
description="سود تحقق‌نیافته فروش اقساطی",
extra_info={"installment": True},
))
# Sales Return
elif invoice_type == INVOICE_SALES_RETURN:
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=Decimal(0),
credit=total_with_tax,
description=data.get("description"),
extra_info={"side": "person", "person_id": person_id},
))
# برگشت از فروش قبل از تخفیف
if gross > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_return"].id,
debit=gross,
credit=Decimal(0),
description="برگشت از فروش (قبل از تخفیف)",
))
# برگشت تخفیفات فروش
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_discount"].id,
debit=Decimal(0),
credit=discount,
description="برگشت تخفیفات فروش",
))
if tax > 0:
# تعدیل VAT خروجی فاکتور فروش
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_out"].id,
debit=tax,
credit=Decimal(0),
description="تعدیل VAT برگشت از فروش",
))
# ورود موجودی/تعدیل COGS در پست حواله انجام می‌شود
# Purchase
elif invoice_type == INVOICE_PURCHASE:
from app.services.purchase_accounting_service import add_purchase_invoice_debit_lines
add_purchase_invoice_debit_lines(
db,
document_id=int(document.id),
mode=_purchase_mode,
accounts=accounts,
gross=gross,
)
# تخفیفات خرید به صورت جداگانه
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["purchase_discount"].id,
debit=Decimal(0),
credit=discount,
description="تخفیفات خرید",
))
# VAT ورودی
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_in"].id,
debit=tax,
credit=Decimal(0),
description="مالیات بر ارزش افزوده ورودی",
))
# طرف‌شخص (حساب‌های پرداختنی) به مبلغ خالص با مالیات
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=Decimal(0),
credit=total_with_tax,
description=data.get("description"),
extra_info={"side": "person", "person_id": person_id},
))
add_adjustment_document_lines(
db,
business_id=business_id,
document_id=int(document.id),
invoice_type=invoice_type,
accounts=accounts,
header_extra=_adj_extra,
)
# Purchase Return
elif invoice_type == INVOICE_PURCHASE_RETURN:
from app.services.purchase_accounting_service import add_purchase_return_credit_lines
add_purchase_return_credit_lines(
db,
document_id=int(document.id),
mode=_purchase_mode,
accounts=accounts,
gross=gross,
)
# برگشت تخفیفات خرید
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["purchase_discount"].id,
debit=discount,
credit=Decimal(0),
description="برگشت تخفیفات خرید",
))
# تعدیل VAT ورودی
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_in"].id,
debit=Decimal(0),
credit=tax,
description="تعدیل VAT ورودی برگشت خرید",
))
# طرف‌شخص (کاهش بدهی)
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_with_tax,
credit=Decimal(0),
description=data.get("description"),
extra_info={"side": "person", "person_id": person_id},
))
# Direct consumption / Waste / Production
elif invoice_type in (INVOICE_DIRECT_CONSUMPTION, INVOICE_WASTE, INVOICE_PRODUCTION):
# برای این انواع، ثبت‌های موجودی و بهای تمام‌شده در فاکتور انجام می‌شود
# (نه فقط در پست حواله)
if invoice_type == INVOICE_DIRECT_CONSUMPTION:
# محاسبه COGS برای مصرف مستقیم
total_cogs = _extract_cogs_total_for_invoice(lines_input)
if total_cogs > 0:
# بدهکار: هزینه مصرف مستقیم
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["direct_consumption"].id,
debit=total_cogs,
credit=Decimal(0),
description="هزینه مصرف مستقیم کالا",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_cogs,
description="خروج کالا از موجودی (مصرف مستقیم)",
))
elif invoice_type == INVOICE_WASTE:
# محاسبه COGS برای ضایعات
total_cogs = _extract_cogs_total_for_invoice(lines_input)
if total_cogs > 0:
# بدهکار: هزینه ضایعات
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["waste_expense"].id,
debit=total_cogs,
credit=Decimal(0),
description="هزینه کسری و ضایعات کالا",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_cogs,
description="خروج کالا از موجودی (ضایعات)",
))
elif invoice_type == INVOICE_PRODUCTION:
# جداسازی خطوط ورودی و خروجی
out_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "out"]
in_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "in"]
# محاسبه COGS برای مواد اولیه (خروج)
total_materials_cost = _extract_cogs_total_for_invoice(out_lines)
# هزینه عملیات/سربار تولید (در صورت ارسال از UI)
try:
operations_total = Decimal(str((header_extra or {}).get("production_operations_total", 0) or 0))
except Exception:
operations_total = Decimal(0)
# محاسبه مجموع هزینه تمام‌شده (مواد اولیه + هزینه عملیات)
total_production_cost = total_materials_cost + operations_total
# محاسبه مجموع تعداد محصولات نهایی برای توزیع هزینه
total_output_quantity = Decimal(0)
for line in in_lines:
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0:
total_output_quantity += qty
# محاسبه هزینه محصول نهایی (ورود)
# ابتدا بررسی می‌کنیم که آیا cost_price دستی ارسال شده است یا نه
total_finished_cost = Decimal(0)
total_manual_cost = Decimal(0) # مجموع هزینه‌های دستی
remaining_quantity = Decimal(0) # مجموع تعداد محصولاتی که cost_price دستی ندارند
# مرحله 1: محاسبه هزینه‌های دستی (cost_price از extra_info)
for line in in_lines:
extra_info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty <= 0:
continue
if extra_info.get("cost_price") is not None:
# استفاده از cost_price دستی
cost_line = qty * Decimal(str(extra_info.get("cost_price")))
total_manual_cost += cost_line
total_finished_cost += cost_line
else:
# این خط نیاز به محاسبه خودکار دارد
remaining_quantity += qty
# مرحله 2: محاسبه خودکار برای خطوطی که cost_price ندارند
if remaining_quantity > 0:
remaining_cost = total_production_cost - total_manual_cost
if remaining_cost < 0:
remaining_cost = Decimal(0)
cost_per_unit = remaining_cost / remaining_quantity if remaining_quantity > 0 else Decimal(0)
for line in in_lines:
extra_info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0 and extra_info.get("cost_price") is None:
cost_line = qty * cost_per_unit
total_finished_cost += cost_line
# مرحله 3: اعتبارسنجی توازن WIP
# اگر همه خطوط cost_price دستی دارند، باید بررسی کنیم که توازن برقرار است
if remaining_quantity == 0 and total_output_quantity > 0:
# همه خطوط cost_price دستی دارند
total_wip_debit = total_materials_cost + operations_total
total_wip_credit = total_finished_cost
balance_diff = abs(total_wip_debit - total_wip_credit)
tolerance = Decimal("0.01")
if balance_diff > tolerance:
# توازن برقرار نیست، باید cost_price را اصلاح کنیم
# محاسبه خودکار بر اساس کل هزینه و تعداد کل
cost_per_unit = total_production_cost / total_output_quantity if total_output_quantity > 0 else Decimal(0)
total_finished_cost = Decimal(0)
for line in in_lines:
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0:
total_finished_cost += qty * cost_per_unit
# اعتبارسنجی نهایی توازن WIP
total_wip_debit = total_materials_cost + operations_total
total_wip_credit = total_finished_cost
balance_diff = abs(total_wip_debit - total_wip_credit)
tolerance = Decimal("0.01")
if balance_diff > tolerance:
# اگر هنوز توازن برقرار نیست، خطا می‌دهیم
raise ApiError(
"PRODUCTION_COST_MISMATCH",
f"عدم توازن در حساب WIP. بدهکار: {total_wip_debit:,.0f}, بستانکار: {total_wip_credit:,.0f}, اختلاف: {balance_diff:,.0f}",
http_status=400
)
# ثبت حسابداری برای مواد اولیه (خروج)
if total_materials_cost > 0:
# بدهکار: WIP
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=total_materials_cost,
credit=Decimal(0),
description="انتقال مواد اولیه به WIP",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_materials_cost,
description="خروج مواد اولیه از موجودی",
))
# ثبت حسابداری هزینه عملیات/سربار تولید: بدهکار WIP / بستانکار حساب سربار
if operations_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=operations_total,
credit=Decimal(0),
description="هزینه عملیات تولید (انتقال به WIP)",
extra_info={"source": "production_operations"},
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["production_overhead"].id,
debit=Decimal(0),
credit=operations_total,
description="هزینه عملیات/سربار تولید",
extra_info={"source": "production_operations"},
))
# ثبت حسابداری برای محصول نهایی (ورود)
if total_finished_cost > 0:
# بدهکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=total_finished_cost,
credit=Decimal(0),
description="ورود محصول نهایی به موجودی",
))
# بستانکار: WIP
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=Decimal(0),
credit=total_finished_cost,
description="انتقال محصول نهایی از WIP",
))
# --- پورسانت فروشنده/بازاریاب (تکمیلی پس از ثبت خطوط انواع فاکتور) ---
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
if seller_id and commission_amount > 0:
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if invoice_type == INVOICE_SALES:
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
# همگام‌سازی قیمت پایه کالا (ارز پیش‌فرض کسب‌وکار) از فاکتور قطعی
# Session با autoflush=False است؛ بدون flush، کوئری invoice_item_lines خالی می‌ماند.
if not document.is_proforma:
db.flush()
from adapters.db.models.business import Business as _BizForPriceSync
_biz_ps = db.query(_BizForPriceSync).filter(_BizForPriceSync.id == business_id).first()
if _biz_ps:
from app.services.invoice_product_price_sync_service import apply_invoice_product_price_sync
apply_invoice_product_price_sync(db, _biz_ps, document, invoice_type)
if not document.is_proforma:
try:
from app.services.customer_club_service import commit_loyalty_redemption_for_sales_invoice
commit_loyalty_redemption_for_sales_invoice(db, document, user_id)
except ApiError:
raise
# برچسب‌های فاکتور (همان تراکنش)
if data.get("tag_ids") is not None:
from app.services.invoice_tag_service import replace_document_invoice_tags
replace_document_invoice_tags(db, business_id, int(document.id), data.get("tag_ids"))
_validate_outgoing_stock_before_invoice_commit(
db, business_id, document, invoice_type, lines_input, data
)
# یک تراکنش واحد تا پایان: فاکتور + تراکنش دریافت/پرداخت (فقط غیرپیش‌فاکتور) + حواله + ثبت‌های وابسته
db.flush()
db.refresh(document)
# Optional: create receipt/payment document(s) — فقط برای فاکتور قطعی
payment_docs: List[int] = []
payments = data.get("payments") or []
# بررسی تنظیمات auto_create_payment_document برای فروش سریع
extra_info_all = data.get("extra_info") or {}
auto_create_payment_doc = extra_info_all.get("auto_create_payment_document", True)
is_quick_sale = extra_info_all.get("quick_sale", False)
if not document.is_proforma and payments and isinstance(payments, list):
if person_id:
if is_quick_sale and not auto_create_payment_doc:
logger.info(
"Skipping auto-create payment document for quick sale invoice %s "
"(auto_create_payment_document is False)",
document.id,
)
else:
payment_docs = _create_receipt_payment_documents_for_invoice_payments(
db,
business_id=business_id,
user_id=user_id,
document=document,
person_id=int(person_id),
payments=payments,
invoice_type=invoice_type,
)
if payment_docs:
logger.info(f"Linking payment_docs to invoice {document.id}: {payment_docs}")
db.add(document)
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
db.flush()
elif payments and isinstance(payments, list) and payments:
logger.debug(f"No payment_docs for invoice {document.id}; payments payload present: {payments}")
# ایجاد حواله انبار در صورت نیاز (همان منطق در _create_warehouse_documents_for_invoice)
try:
created_wh_ids = _create_warehouse_documents_for_invoice(
db, business_id, document, user_id, lines_input, invoice_type
)
if created_wh_ids:
db.flush()
except ApiError:
raise
except Exception as ex:
logger.exception(f"Failed to create warehouse document for invoice {document.id}: {ex}")
raise ApiError(
"WAREHOUSE_SYNC_FAILED",
"همگام‌سازی حواله انبار با فاکتور ناموفق بود.",
http_status=409,
) from ex
# شناسایی سود قطعی دفتر وقتی مبنا «فاکتور» است (حواله جداگانه مسیر خود را دارد)
if not document.is_proforma:
from app.services.invoice_profit_ledger_service import on_sales_invoice_document_finalized
on_sales_invoice_document_finalized(db, int(document.id))
try:
from app.services.customer_club_service import sync_customer_club_for_invoice
sync_customer_club_for_invoice(db, int(document.id))
except Exception as club_ex:
logger.warning(
"customer_club sync on create failed doc_id=%s err=%s",
document.id,
club_ex,
exc_info=True,
)
db.flush()
# پس از خرید/برگشت از خرید: هم‌رسانی ledger فاکتورهای فروش/تولید همان کالاها (FIFO به‌روز)
if not document.is_proforma:
_refresh_inventory_chain_ledgers_if_needed(
db,
business_id,
int(document.fiscal_year_id) if document.fiscal_year_id else None,
invoice_type,
invoice_type,
_distinct_product_ids_for_invoice(db, int(document.id)),
is_proforma=False,
)
db.flush()
# P2.5: قفل مبالغ پایه روی خطوط پس از تکمیل همه ثبت‌های حسابداری فاکتور
try:
from app.services.document_line_fx_service import stamp_document_lines_fx_base
stamp_document_lines_fx_base(
db,
document,
business=db.get(Business, int(business_id)),
only_missing=False,
allow_infer=True,
)
db.flush()
except Exception:
logger.exception("stamp_document_lines_fx_base failed (create) doc_id=%s", getattr(document, "id", None))
raise ApiError("FX_BASE_STAMP_ERROR", "خطا در ثبت مبالغ پایه خطوط سند", http_status=400)
if commit:
db.commit()
db.refresh(document)
else:
db.flush()
if skip_post_commit_hooks or not commit:
return success_response(
message="Invoice created",
data={"id": document.id, "code": document.code, "document_type": document.document_type},
)
# فراخوانی workflow triggers برای فاکتور ایجاد شده
try:
from app.services.workflow.workflow_trigger_service import (
build_invoice_trigger_enrichment,
trigger_invoice_created,
)
_inv_extra = build_invoice_trigger_enrichment(db, document)
trigger_invoice_created(
db=db,
business_id=business_id,
invoice_id=document.id,
invoice_type=invoice_type,
total_amount=float(total_with_tax),
user_id=user_id,
extra_fields=_inv_extra,
)
except Exception as e:
# عدم موفقیت در trigger نباید مانع بازگشت فاکتور شود
logger.warning(f"Failed to trigger workflows for invoice {document.id}: {e}")
result = invoice_document_to_dict(db, document)
# Invalidate cache بعد از ایجاد موفق فاکتور
invalidate_invoices_cache(
business_id=business_id,
fiscal_year_id=document.fiscal_year_id,
invoice_id=document.id,
document_type=document.document_type,
project_id=document.project_id
)
# همچنین اسناد عمومی را هم invalidate کن (چون فاکتورها از Document ارث‌بری دارند)
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id,
document_type=document.document_type
)
if payment_docs:
for _pid in payment_docs:
_rp = db.query(Document).filter(Document.id == int(_pid)).first()
if _rp:
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=_rp.fiscal_year_id,
document_id=_rp.id,
document_type=_rp.document_type,
)
# اگر expense/income باشد، cache آن را هم invalidate کن
if document.document_type in ['expense', 'income']:
from app.services.expense_income_service import invalidate_expense_income_cache
invalidate_expense_income_cache(
business_id=business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id
)
return result
def update_invoice(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any],
user_can_select_fx_rate: bool = False,
user_can_change_invoice_unit_price: bool = True,
) -> Dict[str, Any]:
document = db.query(Document).filter(Document.id == document_id).first()
if not document or document.document_type not in SUPPORTED_INVOICE_TYPES:
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
_pre_links_wh = (document.extra_info or {}).get("links") or {}
_old_warehouse_document_ids = list(_pre_links_wh.get("warehouse_document_ids") or [])
_old_is_proforma = bool(document.is_proforma)
_old_receipt_payment_ids = list(_pre_links_wh.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or [])
_old_expense_income_ids = list(_pre_links_wh.get(INVOICE_LINK_EXPENSE_INCOME_IDS) or [])
# Only editable in current fiscal year
fiscal_year = (
db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
)
try:
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد", http_status=409)
except ApiError:
raise
except Exception:
pass
_old_document_type = str(document.document_type or "")
inv_type = _resolve_invoice_type_from_payload(data, fallback=_old_document_type)
if inv_type not in SUPPORTED_INVOICE_TYPES:
raise ApiError("INVALID_INVOICE_TYPE", "Unsupported invoice_type", http_status=400)
if inv_type != _old_document_type:
from app.services.tax_reference_service import validate_invoice_type_change_for_tax
validate_invoice_type_change_for_tax(document, _old_document_type, inv_type)
document.document_type = inv_type
data["invoice_type"] = inv_type
# Update header
document_date = _parse_iso_date(data.get("document_date", document.document_date))
currency_id = data.get("currency_id", document.currency_id)
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
document.document_date = document_date
document.currency_id = int(currency_id)
# به‌روزرسانی وضعیت پیش‌فاکتور
if "is_proforma" in data:
_new_is_proforma = bool(data.get("is_proforma", False))
if _new_is_proforma != _old_is_proforma:
from app.services.tax_reference_service import validate_invoice_proforma_change_for_tax
validate_invoice_proforma_change_for_tax(
document,
old_is_proforma=_old_is_proforma,
new_is_proforma=_new_is_proforma,
)
document.is_proforma = _new_is_proforma
# به‌روزرسانی پروژه
if "project_id" in data:
project_id = data.get("project_id")
if project_id:
from adapters.db.models.project import Project
project = db.query(Project).filter(
and_(Project.id == project_id, Project.business_id == document.business_id, Project.is_active == True)
).first()
if not project:
raise ApiError("PROJECT_NOT_FOUND", "پروژه یافت نشد یا غیرفعال است", http_status=404)
document.project_id = project_id
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
# merge extra_info: ابتدا old_extra را کپی کن، سپس new_extra را merge کن
old_extra = dict(document.extra_info) if document.extra_info else {}
new_extra = dict(data.get("extra_info") or {})
dd_top = data.get("due_date")
if dd_top is not None and str(dd_top).strip():
try:
ds = str(dd_top).replace("Z", "+00:00")
if "T" in ds:
ds = ds.split("T")[0]
new_extra["due_date"] = _parse_iso_date(ds).isoformat()
except Exception:
pass
# merge کردن: new_extra فیلدهای old_extra را override می‌کند؛ نرمال‌سازی برای ذخیره یکسان
merged_extra = _normalize_document_extra_info_for_storage({**old_extra, **new_extra})
if inv_type != _old_document_type:
merged_extra = _sanitize_extra_info_for_invoice_type_change(
merged_extra, inv_type, _old_document_type
)
document.extra_info = merged_extra
# پیش‌فاکتور: با برگشت از قطعی، اسناد دریافت/پرداخت و هزینه/درآمد پیوندی حذف می‌شوند؛ لینک‌ها از extra حذف می‌گردد
if not _old_is_proforma and document.is_proforma:
_delete_invoice_linked_side_documents(
db,
receipt_payment_ids=_old_receipt_payment_ids,
expense_income_ids=_old_expense_income_ids,
)
if document.is_proforma:
_strip_invoice_side_document_links_from_document(document)
if isinstance(data.get("description"), str) or data.get("description") is None:
if data.get("description") is not None:
document.description = data.get("description")
if "code" in data:
new_code = str(data.get("code") or "").strip()
if not new_code:
raise ApiError("CODE_REQUIRED", "شماره فاکتور الزامی است", http_status=400)
if not re.match(r"^[A-Za-z0-9_-]+$", new_code):
raise ApiError(
"INVALID_DOCUMENT_CODE",
"شماره فاکتور فقط می‌تواند شامل حروف انگلیسی، اعداد، خط تیره و زیرخط باشد",
http_status=400,
)
if new_code != (document.code or ""):
dup = (
db.query(Document)
.filter(
Document.business_id == document.business_id,
Document.code == new_code,
Document.id != document.id,
)
.first()
)
if dup:
raise ApiError(
"DUPLICATE_DOCUMENT_CODE",
"این شماره فاکتور برای این کسب‌وکار قبلاً ثبت شده است",
http_status=400,
)
document.code = new_code
# آزادسازی instance های قبلی این فاکتور (برای کالاهای یونیک)
# قبل از حذف سطرها، instance های sold شده توسط این فاکتور را به حالت available برمی‌گردانیم
from adapters.db.models.product_instance import ProductInstance
try:
old_invoice_lines = db.query(InvoiceItemLine).filter(
InvoiceItemLine.document_id == document.id
).all()
for old_line in old_invoice_lines:
if old_line.extra_info:
selected_ids = old_line.extra_info.get("selected_instance_ids")
if selected_ids and isinstance(selected_ids, list):
# آزاد کردن instance هایی که به این فاکتور اختصاص داده شده بودند
db.query(ProductInstance).filter(
and_(
ProductInstance.id.in_([int(x) for x in selected_ids]),
ProductInstance.business_id == document.business_id,
ProductInstance.current_invoice_id == document.id,
)
).update(
{
"status": "available",
"current_invoice_id": None,
},
synchronize_session=False
)
db.flush()
except Exception as e:
# اگر خطایی رخ داد، فقط log می‌کنیم و ادامه می‌دهیم
logger.warning(f"Failed to release instances for invoice {document.id}: {e}")
_old_invoice_product_ids = _distinct_product_ids_for_invoice(db, int(document.id))
_previous_sales_ar_effect = _previous_sales_ar_effect_for_update(
db,
int(document.business_id),
document,
old_document_type=_old_document_type,
was_proforma=_old_is_proforma,
linked_receipt_payment_ids=_old_receipt_payment_ids,
)
# Recreate lines: حذف سطرهای حسابداری و اقلام فاکتور و بازایجاد
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).delete(synchronize_session=False)
lines_input: List[Dict[str, Any]] = list(data.get("lines") or [])
if not lines_input:
raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400)
if inv_type == INVOICE_PRODUCTION:
prod_extra = dict(data.get("extra_info") or document.extra_info or {})
_validate_production_invoice_lines(
db, int(document.business_id), lines_input, prod_extra
)
_stamp_movement_on_invoice_lines(inv_type, lines_input)
# Inventory decoupled from invoices
movement_hint, _ = _movement_from_type(inv_type)
# Resolve and annotate inventory tracking for all lines
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
track_map: Dict[int, bool] = {}
if all_product_ids:
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == document.business_id,
Product.id.in_(all_product_ids),
).all():
track_map[int(pid)] = bool(tracked)
product_tax_map = _build_product_tax_snapshot_map(db, document.business_id, all_product_ids)
for ln in lines_input:
pid = ln.get("product_id")
if not pid:
continue
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
tax_meta = product_tax_map.get(int(pid))
if tax_meta:
snapshot = {k: v for k, v in tax_meta.items() if v is not None}
snapshot["captured_at"] = datetime.utcnow().isoformat()
info["tax_snapshot"] = snapshot
ln["extra_info"] = info
_doc_ex_work = dict(document.extra_info or {})
_apply_invoice_missing_line_warehouse_policy(
db,
int(document.business_id),
lines_input,
_doc_ex_work,
bool(document.is_proforma),
_is_inventory_posting_enabled({"extra_info": _doc_ex_work}),
)
document.extra_info = _normalize_document_extra_info_for_storage(_doc_ex_work)
flag_modified(document, "extra_info")
if isinstance(data.get("extra_info"), dict) and _int_or_none(_doc_ex_work.get("warehouse_id")) is not None:
de = dict(data["extra_info"])
if _int_or_none(de.get("warehouse_id")) is None:
de["warehouse_id"] = _doc_ex_work.get("warehouse_id")
data["extra_info"] = de
# انتخاب انبار در مرحله فاکتور الزامی نیست
try:
from app.services.invoice_global_discount_service import apply_global_discount_to_invoice_payload
apply_global_discount_to_invoice_payload(
db, int(document.business_id), inv_type, lines_input, data
)
except ApiError:
raise
except Exception:
logger.exception("invoice_global_discount_failed_update")
raise ApiError(
"GLOBAL_DISCOUNT_ERROR",
"خطا در محاسبه تخفیف کلی فاکتور",
http_status=400,
)
from app.services.invoice_line_unit_price_guard import validate_invoice_lines_unit_price_policy
validate_invoice_lines_unit_price_policy(
db,
int(document.business_id),
inv_type,
int(currency_id),
lines_input,
user_can_change_invoice_unit_price,
)
if not document.is_proforma and inv_type in {
INVOICE_SALES,
INVOICE_SALES_RETURN,
INVOICE_PURCHASE,
INVOICE_PURCHASE_RETURN,
}:
person_id_required = _person_id_from_header(
{"extra_info": document.extra_info or {}, **(data or {})}
)
if not person_id_required:
raise ApiError(
"PERSON_REQUIRED",
"person_id is required for this invoice type",
http_status=400,
)
header_for_costing = data if data else {"extra_info": document.extra_info}
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
for line in lines_input:
product_id = line.get("product_id")
qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = dict(line.get("extra_info") or {})
# اعتبارسنجی selected_instance_ids برای کالاهای یونیک
selected_instance_ids = extra_info.get("selected_instance_ids")
if selected_instance_ids:
_validate_selected_instances(
db, document.business_id, int(product_id), selected_instance_ids, int(qty), inv_type
)
_validate_invoice_line_custom_attributes(db, document.business_id, line)
db.add(InvoiceItemLine(
document_id=document.id,
product_id=int(product_id),
quantity=qty,
description=line.get("description"),
extra_info=extra_info,
))
# Accounting lines if finalized
if not document.is_proforma:
header_extra = dict(data.get("extra_info") or document.extra_info or {})
if inv_type in (INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN):
from app.services.purchase_accounting_service import stamp_purchase_accounting_mode_on_extra_info
header_extra = stamp_purchase_accounting_mode_on_extra_info(
db, int(document.business_id), header_extra
)
header_for_accounts: Dict[str, Any] = {
"invoice_type": inv_type,
"business_id": int(document.business_id),
"extra_info": header_extra,
**(data or {}),
}
accounts = _resolve_accounts_for_invoice(db, header_for_accounts)
totals = dict(header_extra.get("totals") or {})
if not totals:
totals = _extract_totals_from_lines(lines_input)
header_extra["totals"] = totals
if data.get("loyalty_redemption_points") is None:
cc_prev = dict(header_extra.get("customer_club") or {})
cc_prev.pop("redeem_points_requested", None)
cc_prev.pop("redeem_discount_amount", None)
if cc_prev:
header_extra["customer_club"] = cc_prev
else:
header_extra.pop("customer_club", None)
try:
from app.services.customer_club_service import maybe_apply_loyalty_redemption_to_invoice_payload
pid_for_club = _person_id_from_header({"extra_info": header_extra})
maybe_apply_loyalty_redemption_to_invoice_payload(
db,
int(document.business_id),
inv_type,
bool(document.is_proforma),
int(pid_for_club) if pid_for_club else None,
totals,
header_extra,
data,
user_id,
)
except ApiError:
raise
from app.services.invoice_adjustments_service import (
merge_invoice_adjustments_into_header_extra,
add_adjustment_document_lines,
total_with_tax_from_totals_dict,
)
merge_invoice_adjustments_into_header_extra(db, int(document.business_id), inv_type, header_extra)
totals = dict(header_extra.get("totals") or {})
_adj_extra_hdr = dict(header_extra)
ex_merge = dict(document.extra_info or {})
if isinstance(header_extra, dict):
if header_extra.get("global_discount"):
ex_merge["global_discount"] = header_extra["global_discount"]
else:
ex_merge.pop("global_discount", None)
ex_merge["totals"] = totals
if "invoice_adjustments" in header_extra:
ex_merge["invoice_adjustments"] = header_extra["invoice_adjustments"]
else:
ex_merge.pop("invoice_adjustments", None)
if header_extra.get("purchase_accounting_mode"):
ex_merge["purchase_accounting_mode"] = header_extra["purchase_accounting_mode"]
if "customer_club" in header_extra:
ex_merge["customer_club"] = header_extra["customer_club"]
elif "customer_club" in ex_merge and data.get("loyalty_redemption_points") is None:
cc_m = dict(ex_merge.get("customer_club") or {})
cc_m.pop("redeem_points_requested", None)
cc_m.pop("redeem_discount_amount", None)
if cc_m:
ex_merge["customer_club"] = cc_m
else:
ex_merge.pop("customer_club", None)
document.extra_info = _normalize_document_extra_info_for_storage(ex_merge)
flag_modified(document, "extra_info")
gross = Decimal(str(totals.get("gross", 0)))
discount = Decimal(str(totals.get("discount", 0)))
net = gross - discount
tax = Decimal(str(totals.get("tax", 0)))
total_with_tax = total_with_tax_from_totals_dict(totals)
person_id = _person_id_from_header({"extra_info": header_extra})
person_id = _resolve_and_validate_person_id(db, document.business_id, person_id)
if inv_type == INVOICE_SALES and person_id:
header_extra = _validate_and_apply_sales_credit_checks(
db,
int(document.business_id),
fiscal_year,
int(person_id),
totals,
header_extra,
data,
previous_ar_effect=_previous_sales_ar_effect,
)
if header_extra.get("warnings"):
ex_merge_warn = dict(document.extra_info or {})
ex_merge_warn["warnings"] = header_extra["warnings"]
document.extra_info = _normalize_document_extra_info_for_storage(ex_merge_warn)
flag_modified(document, "extra_info")
# inventory/COGS handled in warehouse posting
if inv_type == INVOICE_SALES:
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_with_tax,
credit=Decimal(0),
description=document.description,
))
# فروش قبل از تخفیف
if gross > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["revenue"].id,
debit=Decimal(0),
credit=gross,
description="فروش کالا (قبل از تخفیف)",
))
# تخفیفات فروش
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_discount"].id,
debit=discount,
credit=Decimal(0),
description="تخفیفات فروش",
))
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
add_adjustment_document_lines(
db,
business_id=int(document.business_id),
document_id=int(document.id),
invoice_type=inv_type,
accounts=accounts,
header_extra=_adj_extra_hdr,
)
# COGS/Inventory by warehouse posting
# فروش اقساطی (ثبت سود تحقق‌نیافته و افزایش AR)
plan_dict, total_interest = _compute_installment_plan(total_with_tax, header_extra, document.document_date)
if plan_dict:
# merge extra_info to include plan (preserve links)
ex_old = document.extra_info or {}
ex_new = dict(ex_old)
ex_new["installment_plan"] = plan_dict
document.extra_info = _normalize_document_extra_info_for_storage(ex_new)
if total_interest > 0:
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_interest,
credit=Decimal(0),
description="سود کل اقساط افزوده به دریافتنی",
extra_info={"installment": True, "side": "person", "person_id": person_id},
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["unearned_installment_profit"].id,
debit=Decimal(0),
credit=total_interest,
description="سود تحقق‌نیافته فروش اقساطی",
extra_info={"installment": True},
))
elif inv_type == INVOICE_SALES_RETURN:
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=Decimal(0),
credit=total_with_tax,
description=document.description,
))
# برگشت از فروش قبل از تخفیف
if gross > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_return"].id,
debit=gross,
credit=Decimal(0),
description="برگشت از فروش (قبل از تخفیف)",
))
# برگشت تخفیفات فروش
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_discount"].id,
debit=Decimal(0),
credit=discount,
description="برگشت تخفیفات فروش",
))
if tax > 0:
# تعدیل VAT خروجی فاکتور فروش
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT برگشت از فروش"))
# Inventory/COGS handled in warehouse posting
elif inv_type == INVOICE_PURCHASE:
from app.services.purchase_accounting_service import add_purchase_invoice_debit_lines
_purchase_mode_upd = (header_extra.get("purchase_accounting_mode") or "").strip()
add_purchase_invoice_debit_lines(
db,
document_id=int(document.id),
mode=_purchase_mode_upd,
accounts=accounts,
gross=gross,
)
# تخفیفات خرید
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["purchase_discount"].id,
debit=Decimal(0),
credit=discount,
description="تخفیفات خرید",
))
# VAT ورودی
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_in"].id,
debit=tax,
credit=Decimal(0),
description="مالیات ورودی",
))
# طرف‌شخص
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=Decimal(0),
credit=total_with_tax,
description=document.description,
))
add_adjustment_document_lines(
db,
business_id=int(document.business_id),
document_id=int(document.id),
invoice_type=inv_type,
accounts=accounts,
header_extra=_adj_extra_hdr,
)
elif inv_type == INVOICE_PURCHASE_RETURN:
from app.services.purchase_accounting_service import add_purchase_return_credit_lines
_purchase_mode_upd = (header_extra.get("purchase_accounting_mode") or "").strip()
add_purchase_return_credit_lines(
db,
document_id=int(document.id),
mode=_purchase_mode_upd,
accounts=accounts,
gross=gross,
)
# برگشت تخفیفات خرید
if discount > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["purchase_discount"].id,
debit=discount,
credit=Decimal(0),
description="برگشت تخفیفات خرید",
))
# تعدیل VAT ورودی
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_in"].id,
debit=Decimal(0),
credit=tax,
description="تعدیل VAT ورودی",
))
# طرف‌شخص (کاهش بدهی)
if person_id:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["person"].id,
person_id=person_id,
debit=total_with_tax,
credit=Decimal(0),
description=document.description,
))
elif inv_type == INVOICE_DIRECT_CONSUMPTION:
# محاسبه COGS برای مصرف مستقیم
total_cogs = _extract_cogs_total_for_invoice(lines_input)
if total_cogs > 0:
# بدهکار: هزینه مصرف مستقیم
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["direct_consumption"].id,
debit=total_cogs,
credit=Decimal(0),
description="هزینه مصرف مستقیم کالا",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_cogs,
description="خروج کالا از موجودی (مصرف مستقیم)",
))
elif inv_type == INVOICE_WASTE:
# محاسبه COGS برای ضایعات
total_cogs = _extract_cogs_total_for_invoice(lines_input)
if total_cogs > 0:
# بدهکار: هزینه ضایعات
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["waste_expense"].id,
debit=total_cogs,
credit=Decimal(0),
description="هزینه کسری و ضایعات کالا",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_cogs,
description="خروج کالا از موجودی (ضایعات)",
))
elif inv_type == INVOICE_PRODUCTION:
# جداسازی خطوط ورودی و خروجی
out_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "out"]
in_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "in"]
# محاسبه COGS برای مواد اولیه (خروج)
total_materials_cost = _extract_cogs_total_for_invoice(out_lines)
# هزینه عملیات/سربار تولید (در صورت ارسال از UI)
operations_total_raw = (header_extra or {}).get("production_operations_total", 0)
try:
operations_total = Decimal(str(operations_total_raw or 0))
except Exception:
operations_total = Decimal(0)
# محاسبه مجموع هزینه تمام‌شده (مواد اولیه + هزینه عملیات)
total_production_cost = total_materials_cost + operations_total
# محاسبه مجموع تعداد محصولات نهایی برای توزیع هزینه
total_output_quantity = Decimal(0)
for line in in_lines:
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0:
total_output_quantity += qty
# محاسبه هزینه محصول نهایی (ورود)
# ابتدا بررسی می‌کنیم که آیا cost_price دستی ارسال شده است یا نه
total_finished_cost = Decimal(0)
total_manual_cost = Decimal(0) # مجموع هزینه‌های دستی
remaining_quantity = Decimal(0) # مجموع تعداد محصولاتی که cost_price دستی ندارند
# مرحله 1: محاسبه هزینه‌های دستی (cost_price از extra_info)
for line in in_lines:
extra_info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty <= 0:
continue
if extra_info.get("cost_price") is not None:
# استفاده از cost_price دستی
cost_line = qty * Decimal(str(extra_info.get("cost_price")))
total_manual_cost += cost_line
total_finished_cost += cost_line
else:
# این خط نیاز به محاسبه خودکار دارد
remaining_quantity += qty
# مرحله 2: محاسبه خودکار برای خطوطی که cost_price ندارند
if remaining_quantity > 0:
remaining_cost = total_production_cost - total_manual_cost
if remaining_cost < 0:
remaining_cost = Decimal(0)
cost_per_unit = remaining_cost / remaining_quantity if remaining_quantity > 0 else Decimal(0)
for line in in_lines:
extra_info = line.get("extra_info") or {}
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0 and extra_info.get("cost_price") is None:
cost_line = qty * cost_per_unit
total_finished_cost += cost_line
# مرحله 3: اعتبارسنجی توازن WIP
# اگر همه خطوط cost_price دستی دارند، باید بررسی کنیم که توازن برقرار است
if remaining_quantity == 0 and total_output_quantity > 0:
# همه خطوط cost_price دستی دارند
total_wip_debit = total_materials_cost + operations_total
total_wip_credit = total_finished_cost
balance_diff = abs(total_wip_debit - total_wip_credit)
tolerance = Decimal("0.01")
if balance_diff > tolerance:
# توازن برقرار نیست، باید cost_price را اصلاح کنیم
# محاسبه خودکار بر اساس کل هزینه و تعداد کل
cost_per_unit = total_production_cost / total_output_quantity if total_output_quantity > 0 else Decimal(0)
total_finished_cost = Decimal(0)
for line in in_lines:
qty = Decimal(str(line.get("quantity", 0) or 0))
if qty > 0:
total_finished_cost += qty * cost_per_unit
# اعتبارسنجی نهایی توازن WIP
total_wip_debit = total_materials_cost + operations_total
total_wip_credit = total_finished_cost
balance_diff = abs(total_wip_debit - total_wip_credit)
tolerance = Decimal("0.01")
if balance_diff > tolerance:
# اگر هنوز توازن برقرار نیست، خطا می‌دهیم
raise ApiError(
"PRODUCTION_COST_MISMATCH",
f"عدم توازن در حساب WIP. بدهکار: {total_wip_debit:,.0f}, بستانکار: {total_wip_credit:,.0f}, اختلاف: {balance_diff:,.0f}",
http_status=400
)
# ثبت حسابداری برای مواد اولیه (خروج)
if total_materials_cost > 0:
# بدهکار: WIP
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=total_materials_cost,
credit=Decimal(0),
description="انتقال مواد اولیه به WIP",
))
# بستانکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=total_materials_cost,
description="خروج مواد اولیه از موجودی",
))
# ثبت حسابداری هزینه عملیات/سربار تولید: بدهکار WIP / بستانکار حساب سربار
if operations_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=operations_total,
credit=Decimal(0),
description="هزینه عملیات تولید (انتقال به WIP)",
extra_info={"source": "production_operations"},
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["production_overhead"].id,
debit=Decimal(0),
credit=operations_total,
description="هزینه عملیات/سربار تولید",
extra_info={"source": "production_operations"},
))
# ثبت حسابداری برای محصول نهایی (ورود)
if total_finished_cost > 0:
# بدهکار: موجودی کالا
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=total_finished_cost,
credit=Decimal(0),
description="ورود محصول نهایی به موجودی",
))
# بستانکار: WIP
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=Decimal(0),
credit=total_finished_cost,
description="انتقال محصول نهایی از WIP",
))
# --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) ---
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, inv_type, header_extra, totals)
if seller_id and commission_amount > 0:
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if inv_type == INVOICE_SALES:
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
# پردازش تراکنش‌های پرداخت (مشابه create_invoice)
payment_docs: List[int] = []
_invoice_type_changed = inv_type != _old_document_type
payments_provided = "payments" in data and isinstance(data.get("payments"), list)
payments = list(data["payments"]) if payments_provided else []
# تغییر نوع فاکتور بدون ارسال payments: اسناد دریافت/پرداخت قبلی با نوع قدیم نامعتبرند
if _invoice_type_changed and not document.is_proforma and not payments_provided:
_clear_invoice_linked_receipt_payments(
db,
int(document.business_id),
int(document.id),
document,
)
if payments_provided and not document.is_proforma:
from app.services.receipt_payment_service import delete_receipt_payment
# person_id از extra_info مرج‌شده روی خود سند (نه فقط payload خام) تا با API ناقص هم‌خوان باشد
header_extra_pm = document.extra_info or {}
person_id_pm = _person_id_from_header({"extra_info": header_extra_pm})
old_receipt_payment_ids = _get_receipt_payment_ids_linked_to_invoice(
db,
document.business_id,
int(document.id),
extra_info=header_extra_pm,
)
has_positive_payment = any(
Decimal(str(p.get("amount", 0) or 0)) > 0 for p in payments
)
if payments and has_positive_payment:
person_id_pm = _resolve_and_validate_person_id(db, document.business_id, person_id_pm)
if not person_id_pm:
raise ApiError(
"PERSON_REQUIRED",
"برای ثبت تراکنش‌های پرداخت، انتخاب طرف حساب الزامی است.",
http_status=400,
)
# هرگاه payments در payload باشد، اسناد دریافت/پرداخت قبلی حذف می‌شوند؛ خطای حذف باید به کاربر برگردد
for old_rp_id in old_receipt_payment_ids:
delete_receipt_payment(db, int(old_rp_id), commit=False)
if not payments:
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
else:
payment_docs = _create_receipt_payment_documents_for_invoice_payments(
db,
business_id=int(document.business_id),
user_id=user_id,
document=document,
person_id=int(person_id_pm),
payments=payments,
invoice_type=inv_type,
)
if payment_docs:
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = payment_docs
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
else:
extra = dict(document.extra_info) if document.extra_info else {}
links = dict(extra.get("links", {}))
links.pop(INVOICE_LINK_RECEIPT_PAYMENT_IDS, None)
extra["links"] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(document, "extra_info")
try:
_remove_old_invoice_warehouse_documents(
db,
int(document.business_id),
int(document.id),
_old_warehouse_document_ids,
user_id,
)
extra_wh = dict(document.extra_info) if document.extra_info else {}
links_wh = dict(extra_wh.get("links") or {})
links_wh.pop("warehouse_document_ids", None)
extra_wh["links"] = links_wh
document.extra_info = _normalize_document_extra_info_for_storage(extra_wh)
flag_modified(document, "extra_info")
db.flush()
if not document.is_proforma:
_create_warehouse_documents_for_invoice(
db,
int(document.business_id),
document,
user_id,
lines_input,
inv_type,
stock_exclude_warehouse_document_ids=_old_warehouse_document_ids,
)
except ApiError:
raise
except Exception as ex:
logger.exception("invoice update warehouse sync failed for document %s: %s", document.id, ex)
raise ApiError(
"WAREHOUSE_SYNC_FAILED",
"همگام‌سازی حواله انبار با فاکتور ناموفق بود. در صورت حواله قطعی، وضعیت انبار یا وابستگی‌ها را بررسی کنید.",
http_status=409,
)
if not document.is_proforma:
db.flush()
from adapters.db.models.business import Business as _BizForPriceSyncU
_biz_ps_u = db.query(_BizForPriceSyncU).filter(_BizForPriceSyncU.id == document.business_id).first()
if _biz_ps_u:
from app.services.invoice_product_price_sync_service import apply_invoice_product_price_sync
apply_invoice_product_price_sync(db, _biz_ps_u, document, document.document_type)
if not document.is_proforma:
try:
from app.services.invoice_profit_ledger_service import on_sales_invoice_document_finalized
on_sales_invoice_document_finalized(db, int(document.id))
except Exception as ledger_ex:
logger.warning(
"invoice profit ledger on update failed doc_id=%s err=%s",
document.id,
ledger_ex,
exc_info=True,
)
if not document.is_proforma:
try:
_ledger_product_ids = sorted({
int(x) for x in (_old_invoice_product_ids + _distinct_product_ids_for_invoice(db, int(document.id)))
if x is not None
})
_refresh_inventory_chain_ledgers_if_needed(
db,
int(document.business_id),
int(document.fiscal_year_id) if document.fiscal_year_id else None,
inv_type,
_old_document_type,
_ledger_product_ids,
is_proforma=False,
)
except Exception as inv_chain_u_ex:
logger.warning(
"inventory-chain ledger refresh on update failed doc_id=%s err=%s",
document.id,
inv_chain_u_ex,
exc_info=True,
)
if not document.is_proforma:
try:
from app.services.customer_club_service import commit_loyalty_redemption_for_sales_invoice
commit_loyalty_redemption_for_sales_invoice(db, document, user_id)
except ApiError:
raise
if not document.is_proforma:
try:
from app.services.customer_club_service import sync_customer_club_for_invoice
sync_customer_club_for_invoice(db, int(document.id))
except Exception as club_ex:
logger.warning(
"customer_club sync on update failed doc_id=%s err=%s",
document.id,
club_ex,
exc_info=True,
)
from app.services.invoice_fx_revaluation import apply_fx_revaluation_to_invoice_extra
biz_fx = db.get(Business, int(document.business_id))
if biz_fx is not None:
try:
ex_fx = apply_fx_revaluation_to_invoice_extra(
db,
biz_fx,
document_currency_id=int(document.currency_id),
document_date=document.document_date,
extra_info=dict(document.extra_info or {}),
data=data,
user_can_select_fx_rate=user_can_select_fx_rate,
registered_at_utc=datetime.now(timezone.utc),
)
except ApiError:
raise
except Exception:
logger.exception("invoice_fx_revaluation apply failed (update)")
raise ApiError("FX_REVALUATION_ERROR", "خطا در محاسبه تسعیر ارز", http_status=400)
document.extra_info = _normalize_document_extra_info_for_storage(ex_fx) or ex_fx
flag_modified(document, "extra_info")
if "tag_ids" in data:
from app.services.invoice_tag_service import replace_document_invoice_tags
replace_document_invoice_tags(
db, int(document.business_id), int(document.id), data.get("tag_ids")
)
_validate_outgoing_stock_before_invoice_commit(
db, int(document.business_id), document, inv_type, lines_input, data
)
# P2.5: قفل مبالغ پایه روی خطوط پس از تکمیل به‌روزرسانی فاکتور
try:
from app.services.document_line_fx_service import stamp_document_lines_fx_base
stamp_document_lines_fx_base(
db,
document,
business=biz_fx if "biz_fx" in locals() else None,
only_missing=False,
allow_infer=True,
)
except Exception:
logger.exception("stamp_document_lines_fx_base failed (update) doc_id=%s", getattr(document, "id", None))
raise ApiError("FX_BASE_STAMP_ERROR", "خطا در ثبت مبالغ پایه خطوط سند", http_status=400)
db.commit()
db.refresh(document)
result = invoice_document_to_dict(db, document)
# Invalidate cache بعد از به‌روزرسانی موفق فاکتور
invalidate_invoices_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
invoice_id=document.id,
document_type=document.document_type,
project_id=document.project_id
)
if _old_document_type != document.document_type:
invalidate_invoices_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
invoice_id=document.id,
document_type=_old_document_type,
project_id=document.project_id,
)
# همچنین اسناد عمومی را هم invalidate کن (چون فاکتورها از Document ارث‌بری دارند)
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
document_type=document.document_type
)
if _old_document_type != document.document_type:
invalidate_documents_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
document_type=_old_document_type,
)
# اگر expense/income باشد، cache آن را هم invalidate کن
if document.document_type in ['expense', 'income']:
from app.services.expense_income_service import invalidate_expense_income_cache
invalidate_expense_income_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id
)
try:
from app.services.workflow.workflow_trigger_service import trigger_invoice_updated
trigger_invoice_updated(
db=db,
business_id=int(document.business_id),
document=document,
user_id=user_id,
)
except Exception as wfe:
logger.warning("invoice.updated workflow trigger failed: %s", wfe, exc_info=True)
return result
def delete_invoice(db: Session, document_id: int, *, commit: bool = True) -> bool:
"""
حذف یک فاکتور
Args:
db: جلسه دیتابیس
document_id: شناسه سند فاکتور
Returns:
True در صورت موفقیت، False در غیر این صورت
Raises:
ApiError: در صورت عدم وجود سند، عدم امکان حذف، یا خطاهای دیگر
"""
logger.info(f"[DELETE_INVOICE] ===== Starting delete process for invoice {document_id} =====")
try:
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
logger.error(f"[DELETE_INVOICE] Invoice {document_id} not found")
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found document - code={document.code}, type={document.document_type}, business_id={document.business_id}")
# بررسی نوع سند
if document.document_type not in SUPPORTED_INVOICE_TYPES:
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Invalid document type {document.document_type}")
raise ApiError("INVALID_DOCUMENT_TYPE", "Document is not an invoice", http_status=400)
# 1) جلوگیری از حذف در سال مالی غیر جاری
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 2) جلوگیری از حذف در صورت قفل بودن سند
try:
locked_flags = []
if isinstance(document.extra_info, dict):
locked_flags.append(bool(document.extra_info.get("locked")))
locked_flags.append(bool(document.extra_info.get("is_locked")))
if isinstance(document.developer_settings, dict):
locked_flags.append(bool(document.developer_settings.get("locked")))
locked_flags.append(bool(document.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
"این سند قفل است و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 3) بررسی کارپوشه مودیان
try:
extra_info = document.extra_info or {}
tax_workspace = bool(extra_info.get("tax_workspace"))
tax_status = extra_info.get("tax_status", "")
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Tax workspace check - in_workspace={tax_workspace}, status={tax_status}")
if tax_workspace:
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Cannot delete - invoice is in tax workspace")
raise ApiError(
"TAX_WORKSPACE_INVOICE",
"این فاکتور در کارپوشه سامانه مودیان قرار دارد و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception as ex:
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: Error checking tax workspace: {ex}")
pass
# 3.5) جلوگیری از حذف اگر سند به تراکنش‌های کیف پول مرتبط باشد
try:
from app.services.wallet_service import check_document_has_wallet_transactions
wallet_check = check_document_has_wallet_transactions(db, document_id)
if wallet_check["has_wallet_transactions"] and wallet_check.get("has_protected_transactions", False):
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Cannot delete - has wallet transactions")
raise ApiError(
"DOCUMENT_HAS_WALLET_TRANSACTIONS",
wallet_check["message"],
http_status=409,
)
except ApiError:
raise
except Exception as ex:
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: Error checking wallet transactions: {ex}")
pass
# 4) بررسی و حذف حواله‌های انبار مرتبط و اسناد دریافت/پرداخت
# همه عملیات در یک transaction انجام می‌شوند و در صورت خطا rollback می‌شود
extra_info = document.extra_info or {}
links = extra_info.get("links") or {}
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Extra info links: {links}")
# بررسی و حذف حواله‌های انبار (همه انواع: draft و finalized)
warehouse_document_ids = links.get("warehouse_document_ids") or []
if warehouse_document_ids:
try:
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
warehouse_docs = db.query(WarehouseDocument).filter(
WarehouseDocument.id.in_(warehouse_document_ids)
).all()
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found {len(warehouse_docs)} warehouse documents")
# حذف همه حواله‌های انبار (draft و finalized) - بدون commit
for wd in warehouse_docs:
status = getattr(wd, "status", None)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Warehouse document {wd.id} (code: {getattr(wd, 'code', 'N/A')}, status: {status})")
# حذف خطوط حواله
lines_deleted = db.query(WarehouseDocumentLine).filter(
WarehouseDocumentLine.warehouse_document_id == wd.id
).delete(synchronize_session=False)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Deleted {lines_deleted} lines from warehouse document {wd.id}")
# حذف حواله
db.delete(wd)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Marked warehouse document {wd.id} for deletion")
except ImportError:
# اگر مدل WarehouseDocument وجود نداشت، از بررسی صرف‌نظر می‌کنیم
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: WarehouseDocument model not available")
except Exception as ex:
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Error processing warehouse documents: {ex}", exc_info=True)
raise # خطا را propagate کن تا rollback شود
# 5) حذف خودکار همه اسناد دریافت/پرداخت مرتبط (بدون توجه به مبلغ) - بدون commit
receipt_payment_document_ids = links.get("receipt_payment_document_ids") or []
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found {len(receipt_payment_document_ids)} receipt/payment document IDs in links: {receipt_payment_document_ids}")
if receipt_payment_document_ids:
# ابتدا همه اسناد را بخوان
related_docs = db.query(Document).filter(
Document.id.in_(receipt_payment_document_ids)
).all()
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Found {len(related_docs)} receipt/payment documents in database")
# حذف همه اسناد دریافت/پرداخت مرتبط (بدون توجه به مبلغ) - بدون commit
deleted_count = 0
for rp_doc in related_docs:
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Checking receipt/payment document {rp_doc.id} (code: {rp_doc.code}, type: {rp_doc.document_type})")
# بررسی اینکه آیا این سند واقعاً برای این فاکتور است
rp_extra_info = rp_doc.extra_info or {}
rp_invoice_id = rp_extra_info.get("invoice_id")
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Receipt/payment {rp_doc.id} extra_info.invoice_id={rp_invoice_id}, expected invoice_id={document_id}")
# اگر invoice_id در extra_info وجود دارد و با document_id مطابقت ندارد، این سند برای فاکتور دیگری است
if rp_invoice_id is not None and int(rp_invoice_id) != int(document_id):
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: Receipt/payment {rp_doc.id} belongs to different invoice {rp_invoice_id}, skipping deletion")
continue
# بررسی نوع سند (باید receipt یا payment باشد)
if rp_doc.document_type not in ("receipt", "payment"):
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: Receipt/payment {rp_doc.id} has invalid type {rp_doc.document_type}, skipping")
continue
# بررسی محدودیت‌های حذف (سال مالی، قفل بودن، چک)
# این بررسی‌ها را انجام می‌دهیم اما commit نمی‌کنیم
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == rp_doc.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
f"سند دریافت/پرداخت {rp_doc.code} متعلق به سال مالی غیر جاری است",
http_status=409,
)
# بررسی قفل بودن
locked_flags = []
if isinstance(rp_doc.extra_info, dict):
locked_flags.append(bool(rp_doc.extra_info.get("locked")))
locked_flags.append(bool(rp_doc.extra_info.get("is_locked")))
if isinstance(rp_doc.developer_settings, dict):
locked_flags.append(bool(rp_doc.developer_settings.get("locked")))
locked_flags.append(bool(rp_doc.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
f"سند دریافت/پرداخت {rp_doc.code} قفل است",
http_status=409,
)
# بررسی چک
has_related_checks = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == rp_doc.id,
DocumentLine.check_id.isnot(None),
)
).first() is not None
if has_related_checks:
raise ApiError(
"DOCUMENT_REFERENCED",
f"سند دریافت/پرداخت {rp_doc.code} دارای اقلام مرتبط با چک است",
http_status=409,
)
except ApiError:
raise # خطاهای ApiError را propagate کن تا rollback شود
except Exception as ex:
logger.warning(f"[DELETE_INVOICE] Invoice {document_id}: Error checking receipt/payment {rp_doc.id} constraints: {ex}")
raise # هر خطای دیگری را هم propagate کن
# حذف خطوط سند دریافت/پرداخت
rp_lines_deleted = db.query(DocumentLine).filter(
DocumentLine.document_id == rp_doc.id
).delete(synchronize_session=False)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Deleted {rp_lines_deleted} lines from receipt/payment document {rp_doc.id}")
# حذف سند دریافت/پرداخت (بدون commit)
db.delete(rp_doc)
deleted_count += 1
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Marked receipt/payment document {rp_doc.id} for deletion")
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Marked {deleted_count} receipt/payment documents for deletion")
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: All checks passed, proceeding with final deletion")
# حذف خطوط سند حسابداری فاکتور
lines_deleted = db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete(synchronize_session=False)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Deleted {lines_deleted} document lines")
# حذف اقلام فاکتور
items_deleted = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document_id).delete(synchronize_session=False)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Deleted {items_deleted} invoice item lines")
# اقساط در extra_info.installment_plan ذخیره می‌شوند و با حذف سند خودشان حذف می‌شوند
# نیازی به حذف جداگانه نیست
# دریافت اطلاعات قبل از حذف برای invalidation
business_id = document.business_id
fiscal_year_id = document.fiscal_year_id
document_type = document.document_type
project_id = document.project_id
# برگشت امتیاز باشگاه مشتریان (قبل از حذف سند)
try:
from app.services.customer_club_service import reverse_customer_club_on_invoice_delete
reverse_customer_club_on_invoice_delete(db, int(document_id), int(business_id))
except Exception as club_ex:
logger.warning(
"[DELETE_INVOICE] customer_club reversal failed for %s: %s",
document_id,
club_ex,
exc_info=True,
)
# حذف سند فاکتور
db.delete(document)
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: Marked invoice document for deletion")
# commit یا flush (برای حذف گروهی / برگشت سال مالی بدون commit میانی)
if commit:
try:
db.commit()
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: ===== Successfully committed all deletions =====")
except Exception as commit_ex:
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Error committing transaction: {commit_ex}", exc_info=True)
db.rollback()
raise ApiError("DELETE_FAILED", f"Failed to commit invoice deletion: {str(commit_ex)}", http_status=500)
else:
db.flush()
logger.info(f"[DELETE_INVOICE] Invoice {document_id}: flushed (no commit)")
if commit:
# Invalidate cache بعد از حذف موفق فاکتور
invalidate_invoices_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
invoice_id=document_id,
document_type=document_type,
project_id=project_id
)
# همچنین اسناد عمومی را هم invalidate کن
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
document_id=document_id,
document_type=document_type
)
# اگر expense/income باشد
if document_type in ['expense', 'income']:
from app.services.expense_income_service import invalidate_expense_income_cache
invalidate_expense_income_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
document_id=document_id
)
return True
except ApiError as api_err:
# ApiError code و message در detail ذخیره می‌شوند
error_detail = api_err.detail if isinstance(api_err.detail, dict) else {}
error_info = error_detail.get("error", {}) if isinstance(error_detail, dict) else {}
error_code = error_info.get("code", "UNKNOWN") if isinstance(error_info, dict) else "UNKNOWN"
error_message = error_info.get("message", str(api_err.detail)) if isinstance(error_info, dict) else str(api_err.detail)
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: ApiError raised - code={error_code}, message={error_message}, status={api_err.status_code}")
db.rollback()
raise
except Exception as e:
logger.error(f"[DELETE_INVOICE] Invoice {document_id}: Unexpected error deleting invoice: {e}", exc_info=True)
db.rollback()
raise ApiError("DELETE_FAILED", f"Failed to delete invoice: {str(e)}", http_status=500)
BULK_DELETE_INVOICES_MAX = 100
def bulk_delete_invoices(
db: Session,
business_id: int,
invoice_ids: List[int],
) -> Dict[str, Any]:
"""
حذف گروهی فاکتورها. برای هر فاکتور delete_invoice فراخوانی می‌شود.
مواردی که به هر دلیل حذف نشوند در skipped با دلیل برگردانده می‌شوند.
Returns:
{"deleted": [id, ...], "skipped": [{"id": int, "code": str, "reason": str}, ...]}
"""
invoice_ids = list(dict.fromkeys(invoice_ids)) # unique, preserve order
if len(invoice_ids) > BULK_DELETE_INVOICES_MAX:
raise ApiError(
"TOO_MANY_ITEMS",
f"حداکثر {BULK_DELETE_INVOICES_MAX} فاکتور در هر درخواست قابل حذف است",
http_status=400,
)
deleted: List[int] = []
skipped: List[Dict[str, Any]] = []
for invoice_id in invoice_ids:
doc = db.query(Document).filter(Document.id == invoice_id).first()
code = doc.code if doc else ""
if not doc or doc.business_id != business_id:
skipped.append({"id": invoice_id, "code": code, "reason": "سند یافت نشد یا متعلق به این کسب‌وکار نیست"})
continue
if doc.document_type not in SUPPORTED_INVOICE_TYPES:
skipped.append({"id": invoice_id, "code": code, "reason": "نوع سند فاکتور نیست"})
continue
try:
delete_invoice(db, invoice_id)
deleted.append(invoice_id)
except ApiError as api_err:
detail = api_err.detail
if isinstance(detail, dict) and "error" in detail and isinstance(detail["error"], dict):
reason = detail["error"].get("message", str(detail))
else:
reason = str(detail) if detail else api_err.detail
skipped.append({"id": invoice_id, "code": code, "reason": reason})
except Exception as e:
skipped.append({"id": invoice_id, "code": code, "reason": str(e)})
return {"deleted": deleted, "skipped": skipped}
def _cleanup_dead_receipt_payment_links(db: Session, document: Document) -> bool:
"""
پاک‌سازی لینک‌های مرده receipt_payment_document_ids از extra_info فاکتور.
این تابع لینک‌هایی که به اسناد حذف شده اشاره می‌کنند را پیدا و حذف می‌کند.
Returns:
bool: True اگر تغییری اعمال شد، False در غیر این صورت
"""
try:
extra_info = document.extra_info or {}
links = extra_info.get('links', {})
receipt_payment_ids = links.get('receipt_payment_document_ids', [])
if not receipt_payment_ids:
return False
# بررسی وجود واقعی هر سند
valid_ids = []
for doc_id in receipt_payment_ids:
try:
doc_id_int = int(doc_id)
# بررسی وجود سند در دیتابیس
doc = db.query(Document).filter(
Document.id == doc_id_int,
Document.document_type.in_(['receipt', 'payment']),
).first()
if doc:
valid_ids.append(doc_id_int)
except (ValueError, TypeError):
# شناسه نامعتبر، رد می‌شود
continue
# اگر همه لینک‌ها معتبر هستند، نیازی به به‌روزرسانی نیست
if len(valid_ids) == len(receipt_payment_ids):
return False
# به‌روزرسانی extra_info با لینک‌های معتبر
extra_info = dict(extra_info)
links = dict(links)
links['receipt_payment_document_ids'] = valid_ids
extra_info['links'] = links
document.extra_info = _normalize_document_extra_info_for_storage(extra_info)
flag_modified(document, "extra_info")
db.add(document)
logger.info(f"پاک‌سازی لینک‌های مرده برای فاکتور {document.id}: {len(receipt_payment_ids)} -> {len(valid_ids)}")
return True
except Exception as e:
logger.warning(f"خطا در پاک‌سازی لینک‌های مرده برای فاکتور {document.id}: {e}")
return False
def invoice_document_to_dict(
db: Session,
document: Document,
*,
persist_link_cleanup: bool = True,
include_tags: bool = True,
) -> Dict[str, Any]:
# اقلام فاکتور از جدول مجزا خوانده می‌شوند
item_rows = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).all()
product_lines: List[Dict[str, Any]] = []
for it in item_rows:
product = db.query(Product).filter(Product.id == it.product_id).first()
line_extra = it.extra_info or {}
row_dict: Dict[str, Any] = {
"id": it.id,
"product_id": it.product_id,
"product_code": getattr(product, "code", None) if product else None,
"product_name": getattr(product, "name", None),
"product_main_unit": getattr(product, "main_unit", None) if product else None,
"product_secondary_unit": getattr(product, "secondary_unit", None) if product else None,
"product_tax_code": getattr(product, "tax_code", None) if product else None,
"product_tax_unit_id": getattr(product, "tax_unit_id", None) if product else None,
"quantity": float(it.quantity) if it.quantity else None,
"description": it.description,
"extra_info": it.extra_info,
}
for _key in ("unit_price", "line_total", "line_discount", "tax_amount"):
if line_extra.get(_key) is not None:
try:
row_dict[_key] = float(line_extra[_key])
except (TypeError, ValueError):
pass
if getattr(it, "ledger_unit_cogs", None) is not None:
row_dict["ledger_unit_cogs"] = float(it.ledger_unit_cogs)
if getattr(it, "ledger_line_cogs", None) is not None:
row_dict["ledger_line_cogs"] = float(it.ledger_line_cogs)
if getattr(it, "ledger_line_gross_profit", None) is not None:
row_dict["ledger_line_gross_profit"] = float(it.ledger_line_gross_profit)
if getattr(it, "ledger_recognized_at", None) is not None:
row_dict["ledger_recognized_at"] = it.ledger_recognized_at.isoformat()
if getattr(it, "ledger_recognition_event", None):
row_dict["ledger_recognition_event"] = it.ledger_recognition_event
product_lines.append(row_dict)
# سطرهای حسابداری از document_lines خوانده می‌شوند
acc_rows = db.query(DocumentLine).filter(DocumentLine.document_id == document.id, DocumentLine.account_id != None).all() # noqa: E711
account_lines: List[Dict[str, Any]] = []
for line in acc_rows:
account = db.query(Account).filter(Account.id == line.account_id).first()
account_lines.append({
"id": line.id,
"account_id": line.account_id,
"account_name": getattr(account, "name", None),
"account_code": getattr(account, "code", None),
"debit": float(line.debit),
"credit": float(line.credit),
"person_id": line.person_id,
"description": line.description,
"extra_info": line.extra_info,
})
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
created_by_name = f"{getattr(created_by, 'first_name', '')} {getattr(created_by, 'last_name', '')}".strip() if created_by else None
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
# دریافت نام پروژه
project_name = None
if document.project_id:
from adapters.db.models.project import Project
project = db.query(Project).filter(Project.id == document.project_id).first()
if project:
project_name = project.name
# محاسبه سود فاکتور (در صورت فعال بودن)
profit_data = {}
business = db.query(Business).filter(Business.id == document.business_id).first()
if business and business.invoice_profit_calculation_method != "disabled":
try:
profit_data = _calculate_invoice_profit(
db,
document.business_id,
document.id,
business.invoice_profit_calculation_method or "automatic",
business.invoice_profit_calculation_basis or "purchase_price",
business.invoice_profit_include_overhead or False,
business.invoice_profit_overhead_type or "none",
Decimal(str(business.invoice_profit_overhead_percent or 0)) if business.invoice_profit_overhead_percent else None,
business.invoice_profit_calculation_type or "gross",
fifo_shortage_mode=getattr(business, "invoice_profit_fifo_shortage_mode", None),
)
except Exception as e:
logger.warning(f"Error calculating invoice profit for document {document.id}: {e}")
profit_data = {}
# پاک‌سازی لینک‌های مرده قبل از بازگرداندن نتیجه (در مسیرهای فقط‌خواندنی عمومی غیرفعال)
if persist_link_cleanup:
try:
if _cleanup_dead_receipt_payment_links(db, document):
# commit تغییرات
db.commit()
db.refresh(document)
except Exception as e:
logger.warning(f"خطا در پاک‌سازی لینک‌های مرده در invoice_document_to_dict: {e}")
try:
db.rollback()
except Exception:
pass
result = {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"document_type": document.document_type,
"document_date": document.document_date.isoformat(),
"registered_at": utc_naive_to_iso_z(document.registered_at),
"currency_id": document.currency_id,
"currency_code": getattr(currency, "code", None),
"currency_title": getattr(currency, "title", None) if currency else None,
"currency_name": getattr(currency, "name", None) if currency else None,
"created_by_user_id": document.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": document.is_proforma,
"description": document.description,
"project_id": document.project_id,
"project_name": project_name,
"extra_info": _normalize_document_extra_info_for_storage(document.extra_info),
"product_lines": product_lines,
"account_lines": account_lines,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}
# اضافه کردن اطلاعات سود به response
if profit_data:
if "gross_profit" in profit_data:
result["gross_profit"] = profit_data["gross_profit"]
result["gross_profit_percent"] = profit_data["gross_profit_percent"]
if "net_profit" in profit_data:
result["net_profit"] = profit_data["net_profit"]
result["net_profit_percent"] = profit_data["net_profit_percent"]
if "total_profit" in profit_data:
result["total_profit"] = profit_data["total_profit"]
result["total_profit_percent"] = profit_data["total_profit_percent"]
result["total_overhead"] = profit_data.get("total_overhead", 0.0)
result["total_cost"] = profit_data.get("total_cost", 0.0)
result["total_sales"] = profit_data.get("total_sales", 0.0)
result["line_profits"] = profit_data.get("line_profits", [])
# پس‌زمینه: gross_profit / line_profits = محاسبه تحلیلی زنده (تنظیمات جاری)
result["profit_calculation_context"] = "analytical_live"
# سود و بهای تمام‌شده شناسایی‌شده در دفتر (پس از ذخیره روی خطوط)
if business and business.invoice_profit_calculation_method != "disabled":
try:
from app.services.invoice_profit_ledger_service import build_recognized_profit_summary
rp = build_recognized_profit_summary(db, document_id=int(document.id))
if rp:
result["recognized_profit_ledger"] = rp
except Exception as e:
logger.warning(f"recognized profit summary failed for document {document.id}: {e}")
if include_tags:
from app.services.invoice_tag_service import tags_for_single_document
_tlist, _tdisp = tags_for_single_document(db, int(document.business_id), int(document.id))
result["tags"] = _tlist
result["tags_display"] = _tdisp
else:
result["tags"] = []
result["tags_display"] = ""
try:
from app.services.tax_reference_service import build_tax_edit_constraints_for_api
from app.services.tax_submission_service import build_tax_status_fields_for_api
_tax_extra = dict(document.extra_info or {})
result.update(build_tax_status_fields_for_api(_tax_extra))
result.update(build_tax_edit_constraints_for_api(document))
except Exception as tax_meta_ex:
logger.warning(
"tax edit constraints for invoice %s failed: %s",
document.id,
tax_meta_ex,
exc_info=True,
)
# P2: جمع دوگانه (ارزی + پایه) برای UI چندارزی؛ تک‌ارزی show_dual=false
try:
from app.services.document_line_fx_service import build_invoice_dual_totals
dual = build_invoice_dual_totals(db, document, business=business)
result["fx_totals"] = dual
# ارز پایه برای نمایش برچسب در کلاینت
if business and getattr(business, "default_currency", None):
bc = business.default_currency
result["base_currency"] = {
"id": bc.id,
"code": getattr(bc, "code", None),
"title": getattr(bc, "title", None),
"symbol": getattr(bc, "symbol", None),
"decimal_places": getattr(bc, "decimal_places", 0),
}
elif business and business.default_currency_id:
result["base_currency"] = {"id": int(business.default_currency_id)}
except Exception as fx_tot_ex:
logger.warning(
"fx dual totals for invoice %s failed: %s",
document.id,
fx_tot_ex,
exc_info=True,
)
return result
def _parse_installment_status_in(raw: Any) -> set[str] | None:
if raw is None:
return None
parts: list[str] = []
if isinstance(raw, str):
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
elif isinstance(raw, (list, tuple)):
for p in raw:
s = str(p).strip().lower()
if s:
parts.append(s)
else:
return None
allowed = {"pending", "partial", "paid", "overdue"}
out = {p for p in parts if p in allowed}
return out or None
def _installment_row_passes_filters(
st: str,
due: date,
today: date,
overdue_days: int,
*,
status_filter: str,
status_in: set[str] | None,
bucket: str | None,
min_overdue_days: int | None,
) -> bool:
b = (bucket or "").strip().lower() if bucket else None
if b == "unpaid":
if st not in ("pending", "partial", "overdue"):
return False
elif b == "upcoming":
if due < today or st == "paid":
return False
elif b == "overdue_only":
if st != "overdue":
return False
if min_overdue_days is not None and overdue_days < int(min_overdue_days):
return False
if status_in:
return st in status_in
if status_filter:
return st == status_filter
return True
def _worst_installment_status(statuses: set[str]) -> str:
for s in ("overdue", "partial", "pending", "paid"):
if s in statuses:
return s
return "paid"
def _build_grouped_installment_invoices(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
by_inv: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in items:
by_inv[int(row["invoice_id"])].append(row)
grouped: List[Dict[str, Any]] = []
for inv_id, rows in by_inv.items():
rows = sorted(rows, key=lambda r: (r["due_date"], r["seq"]))
first = rows[0]
statuses = {str(r.get("status")) for r in rows if r.get("status")}
next_due = min(r["due_date"] for r in rows)
max_od = max(int(r.get("overdue_days") or 0) for r in rows)
sum_rem = sum(float(r.get("remaining") or 0) for r in rows)
sum_paid = sum(float(r.get("paid_amount") or 0) for r in rows)
sum_total = sum(float(r.get("total") or 0) for r in rows)
od_count = sum(1 for r in rows if r.get("status") == "overdue")
paid_count = sum(1 for r in rows if r.get("status") == "paid")
grouped.append({
"invoice_id": inv_id,
"invoice_code": first.get("invoice_code"),
"person_id": first.get("person_id"),
"person_name": first.get("person_name"),
"person_mobile": first.get("person_mobile"),
"document_date": first.get("document_date"),
"currency_id": first.get("currency_id"),
"currency_code": first.get("currency_code"),
"amounts_in_base": first.get("amounts_in_base", False),
"installment_count": len(rows),
"paid_installment_count": paid_count,
"overdue_installment_count": od_count,
"next_due_date": next_due,
"remaining_sum": sum_rem,
"paid_sum": sum_paid,
"total_sum": sum_total,
"worst_status": _worst_installment_status(statuses),
"max_overdue_days": max_od,
})
grouped.sort(key=lambda g: (g["next_due_date"], g["invoice_id"]))
return grouped
def _backfill_installment_payments_from_receipts(
db: Session,
business_id: int,
invoice_id: int,
invoice_extra: Dict[str, Any],
) -> Dict[int, List[Dict[str, Any]]]:
"""
اگر در JSON طرح اقساط، paid_amount پر باشد ولی آرایه payments خالی مانده باشد،
از extra_info.settlements اسناد دریافت مرتبط، ردیف‌های payments را بازسازی می‌کند.
"""
out: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
doc_ids: set[int] = set()
links = (invoice_extra or {}).get("links") or {}
for raw in links.get("receipt_payment_document_ids") or []:
try:
doc_ids.add(int(raw))
except (TypeError, ValueError):
continue
try:
ex_col = cast(DocumentLine.extra_info, JSONB)
rows = (
db.query(DocumentLine.document_id)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
Document.business_id == int(business_id),
Document.document_type == "receipt",
DocumentLine.person_id.isnot(None),
DocumentLine.extra_info.isnot(None),
ex_col["invoice_id"].astext == str(int(invoice_id)),
)
.distinct()
.all()
)
for (did,) in rows:
if did is not None:
doc_ids.add(int(did))
except Exception:
logger.exception(
"backfill_installment_payments: query receipt lines failed invoice_id=%s business_id=%s",
invoice_id,
business_id,
)
if not doc_ids:
return out
inv_id = int(invoice_id)
docs = (
db.query(Document)
.filter(
Document.id.in_(list(doc_ids)),
Document.business_id == int(business_id),
Document.document_type == "receipt",
)
.all()
)
for doc in docs:
ex = doc.extra_info or {}
settlements = ex.get("settlements") or []
if not isinstance(settlements, list):
continue
for st in settlements:
if not isinstance(st, dict):
continue
try:
st_inv = int(st.get("invoice_id") or 0)
except (TypeError, ValueError):
continue
if st_inv != inv_id:
continue
allocs = st.get("allocations") or []
if not isinstance(allocs, list):
continue
for al in allocs:
if not isinstance(al, dict):
continue
try:
seq = int(al.get("seq") or 0)
amt = float(al.get("amount") or 0)
except (TypeError, ValueError):
continue
if seq <= 0 or amt <= 0:
continue
out[seq].append(
{
"document_id": int(doc.id),
"document_code": getattr(doc, "code", None),
"document_date": doc.document_date.isoformat()
if getattr(doc, "document_date", None)
else None,
"seq": seq,
"amount": amt,
}
)
for lst in out.values():
lst.sort(key=lambda x: ((x.get("document_date") or ""), int(x.get("document_id") or 0)))
return out
def get_invoice_installment_plan(
db: Session,
business_id: int,
invoice_id: int,
) -> Dict[str, Any]:
"""
بازگرداندن طرح اقساط ذخیره شده برای فاکتور فروش به‌همراه محاسبات مانده هر قسط.
"""
document = db.query(Document).filter(
and_(
Document.id == int(invoice_id),
Document.business_id == int(business_id),
)
).first()
if not document:
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
if document.document_type not in {INVOICE_SALES, INVOICE_SALES_RETURN, INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN}:
# فقط برای فاکتورهای طرف شخص معنی‌دار است؛ ولی اگر طرح موجود باشد، برمی‌گردانیم
pass
extra = document.extra_info or {}
plan = extra.get("installment_plan")
if not isinstance(plan, dict):
raise ApiError("INSTALLMENT_PLAN_NOT_FOUND", "Installment plan not found on document", http_status=404)
schedule = plan.get("schedule") or []
backfill_by_seq = _backfill_installment_payments_from_receipts(
db, int(business_id), int(invoice_id), extra
)
# محاسبات مانده هر قسط
enriched_schedule: List[Dict[str, Any]] = []
total_principal = Decimal(str(plan.get("principal_total", 0) or 0))
total_interest = Decimal(str(plan.get("interest_total", 0) or 0))
sum_remaining = Decimal(0)
for item in schedule:
try:
total = Decimal(str(item.get("total", 0) or 0))
paid = Decimal(str(item.get("paid_amount", 0) or 0))
except Exception:
total, paid = Decimal(0), Decimal(0)
remaining = max(total - paid, Decimal(0))
sum_remaining += remaining
new_item = dict(item)
new_item["remaining"] = float(remaining)
pays = new_item.get("payments")
new_item["payments"] = list(pays) if isinstance(pays, list) else []
if not new_item["payments"] and paid > Decimal("0.009"):
try:
seq_i = int(new_item.get("seq") or 0)
except (TypeError, ValueError):
seq_i = 0
bf = backfill_by_seq.get(seq_i) if seq_i > 0 else None
if bf:
new_item["payments"] = list(bf)
# اطمینان از وجود status - اگر موجود نباشد، محاسبه کن
if "status" not in new_item or not new_item.get("status"):
if total > 0 and paid >= total:
new_item["status"] = "paid"
elif paid > 0:
new_item["status"] = "partial"
else:
new_item["status"] = "pending"
enriched_schedule.append(new_item)
return {
"invoice_id": int(document.id),
"invoice_code": document.code,
"document_date": document.document_date.isoformat(),
"currency_id": int(document.currency_id),
"currency_code": getattr(document.currency, "code", None),
"person_id": (extra or {}).get("person_id"),
"plan": {
**plan,
"schedule": enriched_schedule,
"principal_total": float(total_principal),
"interest_total": float(total_interest),
"remaining_total": float(sum_remaining),
},
}
def search_installments(
db: Session,
business_id: int,
query: Dict[str, Any],
*,
disable_pagination: bool = False,
) -> Dict[str, Any]:
"""
جستجوی اقساط به تفکیک ردیف‌های برنامه اقساط در فاکتورهای فروش.
فیلترها:
- fiscal_year_id (اختیاری): بازه سررسید در سال مالی انتخابی
- due_from, due_to (اختیاری): بازه تاریخ سررسید
- status: pending|partial|paid|overdue (تک‌انتخاب، سازگار با قبل)
- status_in: آرایه یا رشتهٔ comma-separated از همان وضعیت‌ها (چندانتخاب)
- bucket: unpaid | upcoming | overdue_only (فیلتر از پیش‌تعریف‌شده)
- min_overdue_days: حداقل روز تأخیر (همراه bucket=overdue_only)
- group_by: invoice برای صفحه‌بندی و خروجی خلاصه به ازای هر فاکتور
- person_id: فیلتر بر اساس شخص
- invoice_id: فاکتور خاص
- currency_id: ارز سند؛ در نبود آن همهٔ مبالغ معادل ارز پایه هستند
- take/skip: صفحه‌بندی روی ردیف اقساط یا روی فاکتورها وقتی group_by=invoice
"""
# تاریخ امروز برای تشخیص overdue
today = datetime.utcnow().date()
# ورودی‌ها
fiscal_year_id = query.get("fiscal_year_id")
due_from = query.get("due_from")
due_to = query.get("due_to")
status_filter = (query.get("status") or "").strip().lower()
status_in = _parse_installment_status_in(query.get("status_in"))
bucket_raw = query.get("bucket")
bucket = str(bucket_raw).strip().lower() if bucket_raw else None
if bucket not in ("unpaid", "upcoming", "overdue_only"):
bucket = None
try:
_mod = query.get("min_overdue_days")
min_overdue_days_val: int | None = int(_mod) if _mod is not None and str(_mod).strip() != "" else None
except Exception:
min_overdue_days_val = None
group_by_invoice = (query.get("group_by") or "").strip().lower() == "invoice"
person_id_filter = query.get("person_id")
invoice_id_filter = query.get("invoice_id")
currency_id_filter: int | None = None
try:
raw_currency_id = query.get("currency_id")
if raw_currency_id is not None and str(raw_currency_id).strip() != "":
currency_id_filter = int(raw_currency_id)
except (TypeError, ValueError):
currency_id_filter = None
try:
take = int(query.get("take", 200))
except Exception:
take = 200
try:
skip = int(query.get("skip", 0))
except Exception:
skip = 0
take = max(1, min(take, 1000))
skip = max(0, skip)
fy_start: date | None = None
fy_end: date | None = None
if fiscal_year_id:
fy = db.query(FiscalYear).filter(
and_(
FiscalYear.id == int(fiscal_year_id),
FiscalYear.business_id == int(business_id),
)
).first()
if fy:
fy_start = getattr(fy, "start_date", None)
fy_end = getattr(fy, "end_date", None)
def _parse_date(v: Any) -> date | None:
if not v:
return None
try:
return _parse_iso_date(v)
except Exception:
return None
due_from_dt = _parse_date(due_from)
due_to_dt = _parse_date(due_to)
# تنظیمات اعتبار برای محاسبه جریمه دیرکرد
late_fee_rate_dec: Decimal | None = None
grace_days_val: int | None = None
try:
credit_cfg = get_business_credit_settings(db, business_id)
if credit_cfg.get("late_fee_rate") is not None:
late_fee_rate_dec = Decimal(str(credit_cfg.get("late_fee_rate")))
gd = credit_cfg.get("grace_days")
if gd is not None:
try:
grace_days_val = int(gd)
except Exception:
grace_days_val = None
except Exception:
late_fee_rate_dec = None
grace_days_val = None
# اسناد فروش دارای طرح اقساط
docs_q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
Document.is_proforma == False, # noqa: E712
)
)
if invoice_id_filter:
try:
docs_q = docs_q.filter(Document.id == int(invoice_id_filter))
except Exception:
pass
if currency_id_filter is not None:
docs_q = docs_q.filter(Document.currency_id == currency_id_filter)
docs = docs_q.order_by(Document.id.desc()).all()
business = db.get(Business, int(business_id))
base_currency_id = (
int(business.default_currency_id)
if business and business.default_currency_id is not None
else None
)
base_currency = db.get(Currency, base_currency_id) if base_currency_id is not None else None
amounts_in_base = currency_id_filter is None
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {
int(business_id): base_currency_id,
}
if amounts_in_base:
from app.services.person_service import amount_in_document_currency_to_base
items: List[Dict[str, Any]] = []
sum_principal = Decimal(0)
sum_interest = Decimal(0)
sum_total = Decimal(0)
sum_paid = Decimal(0)
sum_remaining = Decimal(0)
sum_late_fee = Decimal(0)
status_counts: Dict[str, int] = {"pending": 0, "partial": 0, "paid": 0, "overdue": 0}
for doc in docs:
document_currency_id = int(doc.currency_id)
document_currency_code = getattr(doc.currency, "code", None)
extra = doc.extra_info or {}
plan = extra.get("installment_plan") if isinstance(extra, dict) else None
if not isinstance(plan, dict):
continue
if person_id_filter is not None:
try:
if int(extra.get("person_id")) != int(person_id_filter):
continue
except Exception:
continue
# تلاش برای استخراج نام و موبایل شخص برای نمایش در گزارش
person_name = (extra.get("person_name") or extra.get("person_title")) if isinstance(extra, dict) else None
person_mobile: str | None = None
try:
if extra.get("person_id") is not None:
pid = int(extra.get("person_id"))
person = db.query(Person).filter(
and_(Person.id == pid, Person.business_id == int(business_id))
).first()
if person is not None:
person_name = getattr(person, "name", None) or getattr(person, "alias_name", None) or person_name
person_mobile = getattr(person, "mobile", None)
except Exception:
person_mobile = None
schedule = plan.get("schedule") or []
for it in schedule:
# استخراج تاریخ سررسید
try:
due = _parse_iso_date(it.get("due_date"))
except Exception:
continue
# فیلتر سال مالی (بر اساس تاریخ سررسید)
if fy_start and due < fy_start:
continue
if fy_end and due > fy_end:
continue
# فیلترهای تاریخ
if due_from_dt and due < due_from_dt:
continue
if due_to_dt and due > due_to_dt:
continue
# محاسبات
principal = Decimal(str(it.get("principal", 0) or 0))
interest = Decimal(str(it.get("interest", 0) or 0))
total = Decimal(str(it.get("total", 0) or 0))
paid = Decimal(str(it.get("paid_amount", 0) or 0))
remaining = max(total - paid, Decimal(0))
# وضعیت
if remaining <= Decimal("0.01"):
st = "paid"
elif paid > 0:
st = "partial"
else:
st = "pending"
if due < today and st != "paid":
st = "overdue"
overdue_days = 0
if st == "overdue":
try:
overdue_days = max((today - due).days, 0)
except Exception:
overdue_days = 0
if not _installment_row_passes_filters(
st,
due,
today,
overdue_days,
status_filter=status_filter,
status_in=status_in,
bucket=bucket,
min_overdue_days=min_overdue_days_val,
):
continue
# جریمه دیرکرد ساده بر اساس تنظیمات کسب‌وکار (بدون اعشار)
late_fee_amount = Decimal(0)
if (
st == "overdue"
and late_fee_rate_dec is not None
and late_fee_rate_dec > Decimal(0)
and remaining > Decimal(0)
):
apply_fee = True
if grace_days_val is not None and grace_days_val > 0:
if overdue_days <= grace_days_val:
apply_fee = False
if apply_fee:
try:
late_fee_amount = (remaining * late_fee_rate_dec / Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
except Exception:
late_fee_amount = Decimal(0)
if amounts_in_base:
principal = amount_in_document_currency_to_base(
db, doc, principal, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
interest = amount_in_document_currency_to_base(
db, doc, interest, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
total = amount_in_document_currency_to_base(
db, doc, total, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
paid = amount_in_document_currency_to_base(
db, doc, paid, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
remaining = amount_in_document_currency_to_base(
db, doc, remaining, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
late_fee_amount = amount_in_document_currency_to_base(
db, doc, late_fee_amount, rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
sum_principal += principal
sum_interest += interest
sum_total += total
sum_paid += paid
sum_remaining += remaining
sum_late_fee += late_fee_amount
status_counts[st] = status_counts.get(st, 0) + 1
items.append({
"invoice_id": int(doc.id),
"invoice_code": doc.code,
"person_id": extra.get("person_id"),
"person_name": person_name,
"person_mobile": person_mobile,
"document_date": doc.document_date,
"currency_id": document_currency_id,
"currency_code": document_currency_code,
"amounts_in_base": amounts_in_base,
"seq": int(it.get("seq") or 0),
"due_date": due,
"principal": float(principal),
"interest": float(interest),
"total": float(total),
"paid_amount": float(paid),
"remaining": float(remaining),
"status": st,
"overdue_days": overdue_days,
"late_fee_amount": float(late_fee_amount),
})
items.sort(key=lambda row: (row["due_date"], row["invoice_id"], row["seq"]))
total_flat = len(items)
grouped_full = _build_grouped_installment_invoices(items) if group_by_invoice else []
total_groups = len(grouped_full)
if disable_pagination:
page_items = items
grouped_page = grouped_full if group_by_invoice else []
pagination_total = total_groups if group_by_invoice else total_flat
effective_take = pagination_total or take
effective_skip = 0
has_next = False
elif group_by_invoice:
page_items = []
grouped_page = grouped_full[skip: skip + take]
pagination_total = total_groups
effective_take = take
effective_skip = skip
has_next = skip + take < total_groups
else:
page_items = items[skip: skip + take]
grouped_page = []
pagination_total = total_flat
effective_take = take
effective_skip = skip
has_next = skip + take < total_flat
stats = {
"total_count": total_flat,
"principal_total": float(sum_principal),
"interest_total": float(sum_interest),
"amount_total": float(sum_total),
"paid_total": float(sum_paid),
"remaining_total": float(sum_remaining),
"late_fee_total": float(sum_late_fee),
"status_breakdown": {k: int(v) for k, v in status_counts.items()},
}
return {
"items": page_items,
"grouped_items": grouped_page,
"pagination": {
"total": pagination_total,
"take": effective_take,
"skip": effective_skip,
"page": (effective_skip // effective_take) + 1 if effective_take else 1,
"has_next": has_next,
},
"stats": stats,
"meta": {
"currency_id": currency_id_filter,
"amounts_in_base": amounts_in_base,
"base_currency_id": base_currency_id,
"base_currency_code": getattr(base_currency, "code", None),
},
"filters": {
"fiscal_year_id": fiscal_year_id,
"due_from": due_from,
"due_to": due_to,
"status": status_filter,
"status_in": sorted(status_in) if status_in else None,
"bucket": bucket,
"min_overdue_days": min_overdue_days_val,
"group_by": "invoice" if group_by_invoice else None,
"person_id": person_id_filter,
"invoice_id": invoice_id_filter,
"currency_id": currency_id_filter,
},
}
def _format_date_for_calendar(value: Any, calendar_type: CalendarType) -> str | None:
if value is None:
return None
dt_value: datetime | None = None
if isinstance(value, datetime):
dt_value = value
elif isinstance(value, date):
dt_value = datetime.combine(value, datetime.min.time())
elif isinstance(value, str):
return value
if dt_value is None:
return str(value)
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
return formatted.get("date_only") or formatted.get("formatted")
def _installments_query_for_flat_export(query: Dict[str, Any]) -> Dict[str, Any]:
q = dict(query or {})
q.pop("group_by", None)
return q
def export_installments_csv(
db: Session,
business_id: int,
query: Dict[str, Any],
calendar_type: CalendarType = "gregorian",
) -> bytes:
"""
خروجی CSV اقساط بر اساس همان فیلترهای search_installments.
"""
data = search_installments(db, business_id, _installments_query_for_flat_export(query), disable_pagination=True)
items = data.get("items") or []
output = io.StringIO()
writer = csv.writer(output)
# header
writer.writerow([
"invoice_id",
"invoice_code",
"person_id",
"person_name",
"person_mobile",
"document_date",
"seq",
"due_date",
"status",
"principal",
"interest",
"total",
"paid_amount",
"remaining",
"overdue_days",
"late_fee_amount",
])
for it in items:
writer.writerow([
it.get("invoice_id"),
it.get("invoice_code"),
it.get("person_id"),
it.get("person_name"),
it.get("person_mobile"),
_format_date_for_calendar(it.get("document_date"), calendar_type),
it.get("seq"),
_format_date_for_calendar(it.get("due_date"), calendar_type),
it.get("status"),
it.get("principal"),
it.get("interest"),
it.get("total"),
it.get("paid_amount"),
it.get("remaining"),
it.get("overdue_days"),
it.get("late_fee_amount"),
])
return output.getvalue().encode("utf-8-sig")
def export_installments_xlsx(
db: Session,
business_id: int,
query: Dict[str, Any],
calendar_type: CalendarType = "gregorian",
) -> tuple[bytes, str, str]:
"""
تلاش برای ساخت فایل XLSX؛ اگر کتابخانه موجود نبود، به CSV برمی‌گردیم.
Returns: (content_bytes, mime_type, file_ext)
"""
try:
from openpyxl import Workbook # type: ignore
wb = Workbook()
ws = wb.active
ws.title = "Installments"
headers = [
"invoice_id",
"invoice_code",
"person_id",
"person_name",
"person_mobile",
"document_date",
"seq",
"due_date",
"status",
"principal",
"interest",
"total",
"paid_amount",
"remaining",
"overdue_days",
"late_fee_amount",
]
ws.append(headers)
data = search_installments(db, business_id, _installments_query_for_flat_export(query), disable_pagination=True)
for it in data.get("items", []):
ws.append([
it.get("invoice_id"),
it.get("invoice_code"),
it.get("person_id"),
it.get("person_name"),
it.get("person_mobile"),
_format_date_for_calendar(it.get("document_date"), calendar_type),
it.get("seq"),
_format_date_for_calendar(it.get("due_date"), calendar_type),
it.get("status"),
it.get("principal"),
it.get("interest"),
it.get("total"),
it.get("paid_amount"),
it.get("remaining"),
it.get("overdue_days"),
it.get("late_fee_amount"),
])
bio = io.BytesIO()
wb.save(bio)
return bio.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"
except Exception:
# Fallback: CSV
content = export_installments_csv(db, business_id, query, calendar_type=calendar_type)
return content, "text/csv; charset=utf-8", "csv"
def _invoice_amount_for_aggregate(
db: Session,
document: Document,
amount: Any,
*,
currency_id: Optional[int],
rate_cache: Dict[int, Decimal],
base_currency_by_business: Dict[int, Optional[int]],
) -> Decimal:
"""برای تجمیع گزارش‌ها: بدون فیلتر ارز → معادل پایه؛ با فیلتر → مبلغ بومی."""
native = Decimal(str(amount or 0))
if currency_id is not None:
return native
from app.services.person_service import amount_in_document_currency_to_base
return amount_in_document_currency_to_base(
db,
document,
native,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
def get_daily_sales_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش فروش روزانه
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری؛ خالی = جمع معادل ارز پایه)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست روزها با آمار فروش,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from collections import defaultdict
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
date_from_obj = date.today()
# Query فاکتورهای فروش (کل Document برای تبدیل FX)
sales_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
sales_query = sales_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
sales_query = sales_query.filter(Document.fiscal_year_id == fiscal_year_id)
sales_documents = sales_query.order_by(Document.document_date.asc()).all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
amounts_in_base = currency_id is None
# گروه‌بندی بر اساس روز
daily_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
'date': None,
'invoice_count': 0,
'total_gross': Decimal(0),
'total_discount': Decimal(0),
'total_tax': Decimal(0),
'total_net': Decimal(0),
})
for doc in sales_documents:
doc_date = doc.document_date
if not doc_date:
continue
date_key = doc_date.isoformat()
extra_info = doc.extra_info or {}
totals = extra_info.get('totals') or {}
daily_stats[date_key]['date'] = doc_date.isoformat()
daily_stats[date_key]['invoice_count'] += 1
daily_stats[date_key]['total_gross'] += _invoice_amount_for_aggregate(
db, doc, totals.get('gross', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_discount'] += _invoice_amount_for_aggregate(
db, doc, totals.get('discount', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_tax'] += _invoice_amount_for_aggregate(
db, doc, totals.get('tax', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_net'] += _invoice_amount_for_aggregate(
db, doc, totals.get('net', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
# تبدیل به لیست و مرتب‌سازی (ترتیب نزولی)
items = []
for date_key in sorted(daily_stats.keys(), reverse=True):
stats = daily_stats[date_key]
items.append({
'date': stats['date'],
'invoice_count': stats['invoice_count'],
'total_gross': float(stats['total_gross']),
'total_discount': float(stats['total_discount']),
'total_tax': float(stats['total_tax']),
'total_net': float(stats['total_net']),
})
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
total_invoice_count = sum(item['invoice_count'] for item in items)
total_gross_sum = sum(item['total_gross'] for item in items)
total_discount_sum = sum(item['total_discount'] for item in items)
total_tax_sum = sum(item['total_tax'] for item in items)
total_net_sum = sum(item['total_net'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_invoice_count': total_invoice_count,
'total_gross': float(total_gross_sum),
'total_discount': float(total_discount_sum),
'total_tax': float(total_tax_sum),
'total_net': float(total_net_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
def get_daily_purchases_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش خرید روزانه
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست روزها با آمار خرید,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from collections import defaultdict
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
date_from_obj = date.today()
# Query فاکتورهای خرید (کل Document برای تبدیل FX)
purchases_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PURCHASE,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
purchases_query = purchases_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
purchases_query = purchases_query.filter(Document.fiscal_year_id == fiscal_year_id)
purchases_documents = purchases_query.order_by(Document.document_date.asc()).all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
amounts_in_base = currency_id is None
# گروه‌بندی بر اساس روز
daily_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
'date': None,
'invoice_count': 0,
'total_gross': Decimal(0),
'total_discount': Decimal(0),
'total_tax': Decimal(0),
'total_net': Decimal(0),
})
for doc in purchases_documents:
doc_date = doc.document_date
if not doc_date:
continue
date_key = doc_date.isoformat()
extra_info = doc.extra_info or {}
totals = extra_info.get('totals') or {}
daily_stats[date_key]['date'] = doc_date.isoformat()
daily_stats[date_key]['invoice_count'] += 1
daily_stats[date_key]['total_gross'] += _invoice_amount_for_aggregate(
db, doc, totals.get('gross', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_discount'] += _invoice_amount_for_aggregate(
db, doc, totals.get('discount', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_tax'] += _invoice_amount_for_aggregate(
db, doc, totals.get('tax', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
daily_stats[date_key]['total_net'] += _invoice_amount_for_aggregate(
db, doc, totals.get('net', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
# تبدیل به لیست و مرتب‌سازی (ترتیب نزولی)
items = []
for date_key in sorted(daily_stats.keys(), reverse=True):
stats = daily_stats[date_key]
items.append({
'date': stats['date'],
'invoice_count': stats['invoice_count'],
'total_gross': float(stats['total_gross']),
'total_discount': float(stats['total_discount']),
'total_tax': float(stats['total_tax']),
'total_net': float(stats['total_net']),
})
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
total_invoice_count = sum(item['invoice_count'] for item in items)
total_gross_sum = sum(item['total_gross'] for item in items)
total_discount_sum = sum(item['total_discount'] for item in items)
total_tax_sum = sum(item['total_tax'] for item in items)
total_net_sum = sum(item['total_net'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_invoice_count': total_invoice_count,
'total_gross': float(total_gross_sum),
'total_discount': float(total_discount_sum),
'total_tax': float(total_tax_sum),
'total_net': float(total_net_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
def get_monthly_sales_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش فروش ماهانه
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست ماه‌ها با آمار فروش,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from collections import defaultdict
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
date_from_obj = date.today()
# Query فاکتورهای فروش (کل Document برای تبدیل FX)
sales_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
sales_query = sales_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
sales_query = sales_query.filter(Document.fiscal_year_id == fiscal_year_id)
sales_documents = sales_query.order_by(Document.document_date.asc()).all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
amounts_in_base = currency_id is None
# گروه‌بندی بر اساس ماه
monthly_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
'year': None,
'month': None,
'month_key': None,
'invoice_count': 0,
'total_gross': Decimal(0),
'total_discount': Decimal(0),
'total_tax': Decimal(0),
'total_net': Decimal(0),
})
for doc in sales_documents:
doc_date = doc.document_date
if not doc_date:
continue
# کلید ماه: YYYY-MM
month_key = f"{doc_date.year:04d}-{doc_date.month:02d}"
extra_info = doc.extra_info or {}
totals = extra_info.get('totals') or {}
monthly_stats[month_key]['year'] = doc_date.year
monthly_stats[month_key]['month'] = doc_date.month
monthly_stats[month_key]['month_key'] = month_key
monthly_stats[month_key]['invoice_count'] += 1
monthly_stats[month_key]['total_gross'] += _invoice_amount_for_aggregate(
db, doc, totals.get('gross', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
monthly_stats[month_key]['total_discount'] += _invoice_amount_for_aggregate(
db, doc, totals.get('discount', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
monthly_stats[month_key]['total_tax'] += _invoice_amount_for_aggregate(
db, doc, totals.get('tax', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
monthly_stats[month_key]['total_net'] += _invoice_amount_for_aggregate(
db, doc, totals.get('net', 0), currency_id=currency_id,
rate_cache=rate_cache, base_currency_by_business=base_currency_by_business,
)
# تبدیل به لیست و مرتب‌سازی (ترتیب نزولی)
items = []
for month_key in sorted(monthly_stats.keys(), reverse=True):
stats = monthly_stats[month_key]
# ساخت تاریخ اول ماه برای نمایش
try:
from datetime import date as date_class
first_day_of_month = date_class(stats['year'], stats['month'], 1)
except Exception:
first_day_of_month = None
items.append({
'year': stats['year'],
'month': stats['month'],
'month_key': stats['month_key'],
'date': first_day_of_month.isoformat() if first_day_of_month else None,
'invoice_count': stats['invoice_count'],
'total_gross': float(stats['total_gross']),
'total_discount': float(stats['total_discount']),
'total_tax': float(stats['total_tax']),
'total_net': float(stats['total_net']),
})
# Pagination
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
total_invoice_count = sum(item['invoice_count'] for item in items)
total_gross_sum = sum(item['total_gross'] for item in items)
total_discount_sum = sum(item['total_discount'] for item in items)
total_tax_sum = sum(item['total_tax'] for item in items)
total_net_sum = sum(item['total_net'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_invoice_count': total_invoice_count,
'total_gross': float(total_gross_sum),
'total_discount': float(total_discount_sum),
'total_tax': float(total_tax_sum),
'total_net': float(total_net_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
def get_top_customers_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش برترین مشتریان بر اساس مبلغ فروش
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
limit: تعداد مشتریان برتر (اختیاری، برای pagination)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست مشتریان برتر,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from collections import defaultdict
from app.services.person_service import amount_in_document_currency_to_base
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده، از یک بازه زمانی معقول استفاده کن
# مثلاً از ابتدای سال جاری تا امروز
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
# اگر سال مالی پیدا نشد، از ابتدای سال جاری استفاده کن
try:
current_year = date.today().year
date_from_obj = date(current_year, 1, 1)
except Exception:
date_from_obj = date.today()
# Query فاکتورهای فروش
sales_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
sales_query = sales_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
sales_query = sales_query.filter(Document.fiscal_year_id == fiscal_year_id)
sales_documents = sales_query.order_by(Document.document_date.asc()).all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
# Debug: لاگ تعداد فاکتورهای پیدا شده و بازه زمانی
logger.debug(f"Top customers report: Found {len(sales_documents)} sales invoices for business {business_id}, date_range: {date_from_obj} to {date_to_obj}")
# استخراج person_id از DocumentLine ها به صورت batch برای بهبود کارایی
doc_ids = [doc.id for doc in sales_documents]
person_id_map = {}
if doc_ids:
# دریافت person_id از DocumentLine برای تمام فاکتورها در یک query
doc_lines_with_person = db.query(
DocumentLine.document_id,
DocumentLine.person_id
).filter(
and_(
DocumentLine.document_id.in_(doc_ids),
DocumentLine.person_id.isnot(None)
)
).all()
# ساخت map از document_id به person_id (اولین person_id پیدا شده)
for line in doc_lines_with_person:
if line.document_id not in person_id_map:
person_id_map[line.document_id] = line.person_id
# گروه‌بندی بر اساس person_id
customer_stats: Dict[int, Dict[str, Any]] = defaultdict(lambda: {
'person_id': None,
'invoice_count': 0,
'total_sales': Decimal(0),
'last_sale_date': None,
})
# دریافت اطلاعات شخص‌ها از پایگاه داده
person_ids_set = set()
invoices_without_person = 0
for doc in sales_documents:
extra_info = doc.extra_info or {}
# استخراج person_id از extra_info
person_id = extra_info.get('person_id')
# اگر person_id در extra_info نبود، از person_id_map استفاده کن (از DocumentLine)
if person_id is None:
person_id = person_id_map.get(doc.id)
if person_id is None:
invoices_without_person += 1
continue
try:
person_id_int = int(person_id)
except (ValueError, TypeError):
invoices_without_person += 1
continue
person_ids_set.add(person_id_int)
# محاسبه مبلغ فروش از extra_info.totals.net (به ارز پایه برای مقایسهٔ بین ارزها)
totals = extra_info.get('totals') or {}
net_amount = Decimal(str(totals.get('net', 0) or 0))
net_base = amount_in_document_currency_to_base(
db,
doc,
net_amount,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
customer_stats[person_id_int]['person_id'] = person_id_int
customer_stats[person_id_int]['invoice_count'] += 1
customer_stats[person_id_int]['total_sales'] += net_base
# به‌روزرسانی آخرین تاریخ فروش
doc_date = doc.document_date
if doc_date:
current_last_date = customer_stats[person_id_int]['last_sale_date']
if current_last_date is None or doc_date > current_last_date:
customer_stats[person_id_int]['last_sale_date'] = doc_date
# Debug: لاگ تعداد فاکتورهای بدون person_id
if invoices_without_person > 0:
logger.debug(f"Top customers report: {invoices_without_person} invoices without person_id")
# دریافت اطلاعات شخص‌ها از پایگاه داده
persons_map = {}
if person_ids_set:
persons = db.query(Person).filter(
and_(
Person.business_id == business_id,
Person.id.in_(list(person_ids_set))
)
).all()
for person in persons:
# ساخت نام نمایشی
display_name = person.alias_name or ''
if person.company_name:
display_name = person.company_name
elif person.first_name or person.last_name:
parts = []
if person.first_name:
parts.append(person.first_name)
if person.last_name:
parts.append(person.last_name)
display_name = ' '.join(parts) if parts else person.alias_name or ''
persons_map[person.id] = {
'id': person.id,
'code': person.code,
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'company_name': person.company_name,
'display_name': display_name,
}
# تبدیل به لیست و مرتب‌سازی بر اساس مبلغ فروش (ترتیب نزولی)
items = []
for person_id, stats in customer_stats.items():
person_info = persons_map.get(person_id, {})
items.append({
'person_id': person_id,
'person_code': person_info.get('code'),
'person_name': person_info.get('display_name', person_info.get('alias_name', '')),
'invoice_count': stats['invoice_count'],
'total_sales': float(stats['total_sales']),
'last_sale_date': stats['last_sale_date'].isoformat() if stats['last_sale_date'] else None,
})
# مرتب‌سازی بر اساس مبلغ فروش (ترتیب نزولی)
items.sort(key=lambda x: x['total_sales'], reverse=True)
# اعمال limit اگر مشخص شده باشد
if limit is not None and limit > 0:
items = items[:limit]
# Pagination
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
# محاسبه خلاصه
total_customers = len(customer_stats)
total_invoice_count = sum(item['invoice_count'] for item in items)
total_sales_sum = sum(item['total_sales'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_customers': total_customers,
'total_invoice_count': total_invoice_count,
'total_sales': float(total_sales_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
}
}
def get_top_suppliers_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش برترین تامین‌کنندگان بر اساس مبلغ خرید
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
limit: تعداد تامین‌کنندگان برتر (اختیاری، برای pagination)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست تامین‌کنندگان برتر,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from collections import defaultdict
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده، از یک بازه زمانی معقول استفاده کن
# مثلاً از ابتدای سال جاری تا امروز
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
# اگر سال مالی پیدا نشد، از ابتدای سال جاری استفاده کن
try:
current_year = date.today().year
date_from_obj = date(current_year, 1, 1)
except Exception:
date_from_obj = date.today()
# Query فاکتورهای خرید
purchases_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PURCHASE,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
purchases_query = purchases_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
purchases_query = purchases_query.filter(Document.fiscal_year_id == fiscal_year_id)
purchases_documents = purchases_query.order_by(Document.document_date.asc()).all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
# Debug: لاگ تعداد فاکتورهای پیدا شده
logger.debug(f"Top suppliers report: Found {len(purchases_documents)} purchase invoices for business {business_id}")
# استخراج person_id از DocumentLine ها به صورت batch برای بهبود کارایی
doc_ids = [doc.id for doc in purchases_documents]
person_id_map = {}
if doc_ids:
# دریافت person_id از DocumentLine برای تمام فاکتورها در یک query
doc_lines_with_person = db.query(
DocumentLine.document_id,
DocumentLine.person_id
).filter(
and_(
DocumentLine.document_id.in_(doc_ids),
DocumentLine.person_id.isnot(None)
)
).all()
# ساخت map از document_id به person_id (اولین person_id پیدا شده)
for line in doc_lines_with_person:
if line.document_id not in person_id_map:
person_id_map[line.document_id] = line.person_id
# گروه‌بندی بر اساس person_id
supplier_stats: Dict[int, Dict[str, Any]] = defaultdict(lambda: {
'person_id': None,
'invoice_count': 0,
'total_purchases': Decimal(0),
'last_purchase_date': None,
})
# دریافت اطلاعات شخص‌ها از پایگاه داده
person_ids_set = set()
invoices_without_person = 0
for doc in purchases_documents:
extra_info = doc.extra_info or {}
# استخراج person_id از extra_info
person_id = extra_info.get('person_id')
# اگر person_id در extra_info نبود، از person_id_map استفاده کن (از DocumentLine)
if person_id is None:
person_id = person_id_map.get(doc.id)
if person_id is None:
invoices_without_person += 1
continue
try:
person_id_int = int(person_id)
except (ValueError, TypeError):
invoices_without_person += 1
continue
person_ids_set.add(person_id_int)
# محاسبه مبلغ خرید از extra_info.totals.net (به ارز پایه)
totals = extra_info.get('totals') or {}
net_amount = Decimal(str(totals.get('net', 0) or 0))
net_base = amount_in_document_currency_to_base(
db,
doc,
net_amount,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
supplier_stats[person_id_int]['person_id'] = person_id_int
supplier_stats[person_id_int]['invoice_count'] += 1
supplier_stats[person_id_int]['total_purchases'] += net_base
# به‌روزرسانی آخرین تاریخ خرید
doc_date = doc.document_date
if doc_date:
current_last_date = supplier_stats[person_id_int]['last_purchase_date']
if current_last_date is None or doc_date > current_last_date:
supplier_stats[person_id_int]['last_purchase_date'] = doc_date
# Debug: لاگ تعداد فاکتورهای بدون person_id و تعداد تامین‌کنندگان پیدا شده
if invoices_without_person > 0:
logger.debug(f"Top suppliers report: {invoices_without_person} invoices without person_id out of {len(purchases_documents)} total")
logger.debug(f"Top suppliers report: Found {len(person_ids_set)} unique suppliers")
# دریافت اطلاعات شخص‌ها از پایگاه داده
persons_map = {}
if person_ids_set:
persons = db.query(Person).filter(
and_(
Person.business_id == business_id,
Person.id.in_(list(person_ids_set))
)
).all()
for person in persons:
# ساخت نام نمایشی
display_name = person.alias_name or ''
if person.company_name:
display_name = person.company_name
elif person.first_name or person.last_name:
parts = []
if person.first_name:
parts.append(person.first_name)
if person.last_name:
parts.append(person.last_name)
display_name = ' '.join(parts) if parts else person.alias_name or ''
persons_map[person.id] = {
'id': person.id,
'code': person.code,
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'company_name': person.company_name,
'display_name': display_name,
}
# تبدیل به لیست و مرتب‌سازی بر اساس مبلغ خرید (ترتیب نزولی)
items = []
for person_id, stats in supplier_stats.items():
person_info = persons_map.get(person_id, {})
items.append({
'person_id': person_id,
'person_code': person_info.get('code'),
'person_name': person_info.get('display_name', person_info.get('alias_name', '')),
'invoice_count': stats['invoice_count'],
'total_purchases': float(stats['total_purchases']),
'last_purchase_date': stats['last_purchase_date'].isoformat() if stats['last_purchase_date'] else None,
})
# مرتب‌سازی بر اساس مبلغ خرید (ترتیب نزولی)
items.sort(key=lambda x: x['total_purchases'], reverse=True)
# اعمال limit اگر مشخص شده باشد
if limit is not None and limit > 0:
items = items[:limit]
# Pagination
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
# محاسبه خلاصه
total_suppliers = len(supplier_stats)
total_invoice_count = sum(item['invoice_count'] for item in items)
total_purchases_sum = sum(item['total_purchases'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_suppliers': total_suppliers,
'total_invoice_count': total_invoice_count,
'total_purchases': float(total_purchases_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
}
}
def get_materials_consumption_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
product_id: Optional[int] = None,
warehouse_id: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش مصرف مواد از فاکتورهای تولید
این گزارش خطوط فاکتورهای تولید (invoice_production) که movement: "out" دارند را نمایش می‌دهد.
این خطوط نشان‌دهنده مواد اولیه مصرف شده در فرآیند تولید هستند.
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
product_id: شناسه محصول (اختیاری)
warehouse_id: شناسه انبار (اختیاری)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست خطوط مصرف مواد,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده، از یک بازه زمانی معقول استفاده کن
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
try:
current_year = date.today().year
date_from_obj = date(current_year, 1, 1)
except Exception:
date_from_obj = date.today()
# Query فاکتورهای تولید
production_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PRODUCTION,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
production_query = production_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
production_query = production_query.filter(Document.fiscal_year_id == fiscal_year_id)
production_documents = production_query.order_by(Document.document_date.asc()).all()
doc_ids = [doc.id for doc in production_documents]
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
amounts_in_base = currency_id is None
if not doc_ids:
return {
'items': [],
'summary': {
'total_count': 0,
'total_quantity': 0.0,
'total_amount': 0.0,
},
'pagination': {
'total': 0,
'page': 1,
'per_page': take,
'total_pages': 1,
'has_next': False,
'has_prev': False,
}
}
# Query خطوط فاکتورها که movement: "out" دارند (مواد مصرف شده)
lines_query = db.query(
InvoiceItemLine,
Document,
).join(
Document, Document.id == InvoiceItemLine.document_id
).filter(
and_(
InvoiceItemLine.document_id.in_(doc_ids),
InvoiceItemLine.product_id.isnot(None),
)
)
if product_id:
lines_query = lines_query.filter(InvoiceItemLine.product_id == product_id)
lines_with_docs = lines_query.all()
# فیلتر کردن خطوطی که movement: "out" دارند
items = []
product_ids_set = set()
warehouse_ids_set = set()
for line, doc in lines_with_docs:
line_info = line.extra_info or {}
movement = line_info.get('movement')
# فقط خطوطی که movement: "out" دارند (مواد مصرف شده)
if movement != 'out':
continue
# فیلتر انبار
line_warehouse_id = line_info.get('warehouse_id')
if warehouse_id and line_warehouse_id != warehouse_id:
continue
product_ids_set.add(line.product_id)
if line_warehouse_id:
warehouse_ids_set.add(line_warehouse_id)
# محاسبه مبلغ (بدون فیلتر ارز → معادل پایه)
unit_price = Decimal(str(line_info.get('unit_price', 0) or 0))
quantity = Decimal(str(line.quantity or 0))
amount = unit_price * quantity
amount = _invoice_amount_for_aggregate(
db,
doc,
amount,
currency_id=currency_id,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
unit_price_out = float(amount / quantity) if quantity != 0 else float(unit_price)
items.append({
'document_id': doc.id,
'document_code': doc.code,
'document_date': doc.document_date.isoformat() if doc.document_date else None,
'product_id': line.product_id,
'warehouse_id': line_warehouse_id,
'quantity': float(quantity),
'unit_price': unit_price_out,
'amount': float(amount),
'description': line.description,
'document_currency_id': doc.currency_id,
'amounts_in_base': amounts_in_base,
})
# دریافت اطلاعات محصولات
products_map = {}
if product_ids_set:
products = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.id.in_(list(product_ids_set))
)
).all()
for product in products:
products_map[product.id] = {
'id': product.id,
'code': product.code,
'name': product.name,
'unit': product.main_unit or "",
}
# دریافت اطلاعات انبارها
warehouses_map = {}
if warehouse_ids_set:
from adapters.db.models.warehouse import Warehouse
warehouses = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_(list(warehouse_ids_set))
)
).all()
for warehouse in warehouses:
warehouses_map[warehouse.id] = {
'id': warehouse.id,
'code': warehouse.code,
'name': warehouse.name,
}
# اضافه کردن اطلاعات محصول و انبار به آیتم‌ها
for item in items:
product_info = products_map.get(item['product_id'], {})
item['product_code'] = product_info.get('code')
item['product_name'] = product_info.get('name')
item['product_unit'] = product_info.get('unit')
if item.get('warehouse_id'):
warehouse_info = warehouses_map.get(item['warehouse_id'], {})
item['warehouse_code'] = warehouse_info.get('code')
item['warehouse_name'] = warehouse_info.get('name')
else:
item['warehouse_code'] = None
item['warehouse_name'] = None
# مرتب‌سازی بر اساس تاریخ سند
items.sort(key=lambda x: (x['document_date'] or '', x['document_code'] or ''))
# Pagination
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
# محاسبه خلاصه
total_quantity_sum = sum(item['quantity'] for item in items)
total_amount_sum = sum(item['amount'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_quantity': float(total_quantity_sum),
'total_amount': float(total_amount_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
def get_production_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
product_id: Optional[int] = None,
warehouse_id: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش تولید (کالاهای ساخته شده) از فاکتورهای تولید
این گزارش خطوط فاکتورهای تولید (invoice_production) که movement: "in" دارند را نمایش می‌دهد.
این خطوط نشان‌دهنده کالاهای ساخته شده در فرآیند تولید هستند.
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
product_id: شناسه محصول (اختیاری)
warehouse_id: شناسه انبار (اختیاری)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست خطوط تولید,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
# تبدیل تاریخ‌ها
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = _parse_iso_date(date_from)
except Exception:
pass
if date_to:
try:
date_to_obj = _parse_iso_date(date_to)
except Exception:
pass
# اگر تاریخ‌ها مشخص نشده‌اند، از سال مالی استفاده کن
if date_from_obj is None or date_to_obj is None:
try:
if fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == fiscal_year_id).first()
else:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
if date_from_obj is None:
date_from_obj = fiscal_year.start_date
if date_to_obj is None:
date_to_obj = fiscal_year.end_date if fiscal_year.end_date else date.today()
except Exception:
pass
# اگر هنوز تاریخ مشخص نشده، از یک بازه زمانی معقول استفاده کن
if date_to_obj is None:
date_to_obj = date.today()
if date_from_obj is None:
try:
current_year = date.today().year
date_from_obj = date(current_year, 1, 1)
except Exception:
date_from_obj = date.today()
# Query فاکتورهای تولید
production_query = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_PRODUCTION,
Document.is_proforma == False,
Document.document_date >= date_from_obj,
Document.document_date <= date_to_obj,
)
)
if currency_id:
production_query = production_query.filter(Document.currency_id == currency_id)
if fiscal_year_id:
production_query = production_query.filter(Document.fiscal_year_id == fiscal_year_id)
production_documents = production_query.order_by(Document.document_date.asc()).all()
doc_ids = [doc.id for doc in production_documents]
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
amounts_in_base = currency_id is None
if not doc_ids:
return {
'items': [],
'summary': {
'total_count': 0,
'total_quantity': 0.0,
'total_amount': 0.0,
},
'pagination': {
'total': 0,
'page': 1,
'per_page': take,
'total_pages': 1,
'has_next': False,
'has_prev': False,
}
}
# Query خطوط فاکتورها که movement: "in" دارند (کالاهای تولید شده)
lines_query = db.query(
InvoiceItemLine,
Document,
).join(
Document, Document.id == InvoiceItemLine.document_id
).filter(
and_(
InvoiceItemLine.document_id.in_(doc_ids),
InvoiceItemLine.product_id.isnot(None),
)
)
if product_id:
lines_query = lines_query.filter(InvoiceItemLine.product_id == product_id)
lines_with_docs = lines_query.all()
# فیلتر کردن خطوطی که movement: "in" دارند
items = []
product_ids_set = set()
warehouse_ids_set = set()
for line, doc in lines_with_docs:
line_info = line.extra_info or {}
movement = line_info.get('movement')
# فقط خطوطی که movement: "in" دارند (کالاهای تولید شده)
if movement != 'in':
continue
# فیلتر انبار
line_warehouse_id = line_info.get('warehouse_id')
if warehouse_id and line_warehouse_id != warehouse_id:
continue
product_ids_set.add(line.product_id)
if line_warehouse_id:
warehouse_ids_set.add(line_warehouse_id)
# محاسبه مبلغ (بدون فیلتر ارز → معادل پایه)
unit_price = Decimal(str(line_info.get('unit_price', 0) or 0))
quantity = Decimal(str(line.quantity or 0))
amount = unit_price * quantity
amount = _invoice_amount_for_aggregate(
db,
doc,
amount,
currency_id=currency_id,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
unit_price_out = float(amount / quantity) if quantity != 0 else float(unit_price)
items.append({
'document_id': doc.id,
'document_code': doc.code,
'document_date': doc.document_date.isoformat() if doc.document_date else None,
'product_id': line.product_id,
'warehouse_id': line_warehouse_id,
'quantity': float(quantity),
'unit_price': unit_price_out,
'amount': float(amount),
'description': line.description,
'document_currency_id': doc.currency_id,
'amounts_in_base': amounts_in_base,
})
# دریافت اطلاعات محصولات
products_map = {}
if product_ids_set:
products = db.query(Product).filter(
and_(
Product.business_id == business_id,
Product.id.in_(list(product_ids_set))
)
).all()
for product in products:
products_map[product.id] = {
'id': product.id,
'code': product.code,
'name': product.name,
'unit': product.main_unit or "",
}
# دریافت اطلاعات انبارها
warehouses_map = {}
if warehouse_ids_set:
from adapters.db.models.warehouse import Warehouse
warehouses = db.query(Warehouse).filter(
and_(
Warehouse.business_id == business_id,
Warehouse.id.in_(list(warehouse_ids_set))
)
).all()
for warehouse in warehouses:
warehouses_map[warehouse.id] = {
'id': warehouse.id,
'code': warehouse.code,
'name': warehouse.name,
}
# اضافه کردن اطلاعات محصول و انبار به آیتم‌ها
for item in items:
product_info = products_map.get(item['product_id'], {})
item['product_code'] = product_info.get('code')
item['product_name'] = product_info.get('name')
item['product_unit'] = product_info.get('unit')
if item.get('warehouse_id'):
warehouse_info = warehouses_map.get(item['warehouse_id'], {})
item['warehouse_code'] = warehouse_info.get('code')
item['warehouse_name'] = warehouse_info.get('name')
else:
item['warehouse_code'] = None
item['warehouse_name'] = None
# مرتب‌سازی بر اساس تاریخ سند
items.sort(key=lambda x: (x['document_date'] or '', x['document_code'] or ''))
# Pagination
total = len(items)
current_page = (skip // take) + 1
total_pages = (total + take - 1) // take if take > 0 else 1
paginated_items = items[skip:skip + take]
# محاسبه خلاصه
total_quantity_sum = sum(item['quantity'] for item in items)
total_amount_sum = sum(item['amount'] for item in items)
return {
'items': paginated_items,
'summary': {
'total_count': total,
'total_quantity': float(total_quantity_sum),
'total_amount': float(total_amount_sum),
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
def _is_account_payment_line(line: DocumentLine) -> bool:
line_extra = line.extra_info or {}
if line_extra.get("is_commission_line"):
return False
if line_extra.get("is_check_target_account") or line_extra.get("is_check_source_account"):
return False
tt = str(line_extra.get("transaction_type") or "").strip().lower()
is_wallet_line = line.person_id is None and tt == "wallet"
if line.person_id is None and (
line.bank_account_id is not None
or line.cash_register_id is not None
or line.petty_cash_id is not None
or line.check_id is not None
or is_wallet_line
):
return True
return False
def _sum_receipt_doc_account_payments(doc: Document) -> Decimal:
total = Decimal(0)
for line in doc.lines:
if _is_account_payment_line(line):
total += Decimal(str(line.debit)) + Decimal(str(line.credit))
return total
def _sum_receipt_doc_payments_for_invoice(doc: Document, invoice_id: int, *, via_person_line: bool) -> Decimal:
"""مبلغ پرداخت‌شدهٔ سند دریافت/پرداخت نسبت به یک فاکتور (به ارز فاکتور)."""
inv_id = int(invoice_id)
doc_extra = doc.extra_info if isinstance(doc.extra_info, dict) else {}
doc_inv = doc_extra.get("invoice_id")
try:
doc_matches = doc_inv is not None and int(doc_inv) == inv_id
except (TypeError, ValueError):
doc_matches = False
if via_person_line:
total = Decimal(0)
for line in doc.lines:
if line.person_id is None:
continue
extra = line.extra_info or {}
raw_inv = extra.get("invoice_id")
line_matches = False
if raw_inv is not None:
try:
line_matches = int(raw_inv) == inv_id
except (TypeError, ValueError):
line_matches = False
if not line_matches and not doc_matches:
continue
# پرداخت بین‌ارزی: مبلغ تسویه به ارز فاکتور
fx_set = extra.get("fx_settlement") if isinstance(extra, dict) else None
if isinstance(fx_set, dict) and fx_set.get("settles_amount") is not None:
try:
total += Decimal(str(fx_set["settles_amount"]))
continue
except Exception:
pass
if isinstance(doc_extra.get("fx_settlement"), dict) and doc_extra["fx_settlement"].get("settles_amount") is not None:
try:
total += Decimal(str(doc_extra["fx_settlement"]["settles_amount"]))
continue
except Exception:
pass
total += Decimal(str(line.debit)) + Decimal(str(line.credit))
return total
if doc_matches and isinstance(doc_extra.get("fx_settlement"), dict):
try:
return Decimal(str(doc_extra["fx_settlement"].get("settles_amount") or 0))
except Exception:
pass
return _sum_receipt_doc_account_payments(doc)
def _get_receipt_payment_ids_linked_to_invoice(
db: Session,
business_id: int,
invoice_id: int,
*,
extra_info: Optional[Dict[str, Any]] = None,
) -> List[int]:
"""شناسهٔ اسناد دریافت/پرداخت مرتبط با فاکتور (لینک مستقیم + person_line.invoice_id)."""
ids: set[int] = set()
if isinstance(extra_info, dict):
links = extra_info.get("links") or {}
for raw in links.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or []:
try:
ids.add(int(raw))
except (TypeError, ValueError):
continue
line_invoice_id_expr = cast(
cast(DocumentLine.extra_info, JSONB)["invoice_id"].astext,
Integer,
)
rows = (
db.query(DocumentLine.document_id)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
Document.business_id == business_id,
Document.document_type.in_(["receipt", "payment"]),
DocumentLine.person_id.isnot(None),
line_invoice_id_expr == int(invoice_id),
)
.distinct()
.all()
)
for row in rows:
ids.add(int(row[0]))
return list(ids)
def _invoice_total_amount_for_document(
document: Document,
item_lines: Optional[List[InvoiceItemLine]] = None,
) -> Decimal:
total_amount = Decimal(0)
extra_info = document.extra_info or {}
totals = extra_info.get("totals", {})
if isinstance(totals, dict):
if "net" in totals or "gross" in totals:
try:
from app.services.invoice_adjustments_service import payable_total_from_totals_dict
return payable_total_from_totals_dict(totals)
except (ValueError, TypeError):
pass
if "net" in totals:
try:
total_amount = Decimal(str(totals["net"]))
except (ValueError, TypeError):
pass
if total_amount != 0:
return total_amount
if item_lines is None:
return total_amount
for item_line in item_lines:
item_extra = item_line.extra_info or {}
line_total = item_extra.get("line_total")
if line_total is not None:
total_amount += Decimal(str(line_total))
else:
qty = Decimal(str(item_line.quantity or 0))
unit_price = Decimal(str(item_extra.get("unit_price", 0)))
line_discount = Decimal(str(item_extra.get("line_discount", 0)))
tax_amount = Decimal(str(item_extra.get("tax_amount", 0)))
total_amount += (qty * unit_price) - line_discount + tax_amount
return total_amount
def invoice_documents_to_list_dicts(
db: Session,
documents: List[Document],
) -> List[Dict[str, Any]]:
"""سریال‌سازی سبک فاکتورها برای لیست (بدون بارگذاری خطوط و محاسبه سود)."""
if not documents:
return []
user_ids = {int(d.created_by_user_id) for d in documents if d.created_by_user_id}
currency_ids = {int(d.currency_id) for d in documents if d.currency_id}
project_ids = {int(d.project_id) for d in documents if d.project_id}
users_by_id: Dict[int, User] = {}
if user_ids:
for u in db.query(User).filter(User.id.in_(list(user_ids))).all():
users_by_id[int(u.id)] = u
currencies_by_id: Dict[int, Currency] = {}
if currency_ids:
for c in db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all():
currencies_by_id[int(c.id)] = c
projects_by_id: Dict[int, Any] = {}
if project_ids:
from adapters.db.models.project import Project
for p in db.query(Project).filter(Project.id.in_(list(project_ids))).all():
projects_by_id[int(p.id)] = p
results: List[Dict[str, Any]] = []
for doc in documents:
created_by = users_by_id.get(int(doc.created_by_user_id)) if doc.created_by_user_id else None
created_by_name = (
f"{getattr(created_by, 'first_name', '')} {getattr(created_by, 'last_name', '')}".strip()
if created_by
else None
)
currency = currencies_by_id.get(int(doc.currency_id)) if doc.currency_id else None
project_name = None
if doc.project_id and int(doc.project_id) in projects_by_id:
project_name = projects_by_id[int(doc.project_id)].name
results.append(
{
"id": doc.id,
"code": doc.code,
"business_id": doc.business_id,
"document_type": doc.document_type,
"document_date": doc.document_date.isoformat(),
"registered_at": utc_naive_to_iso_z(doc.registered_at),
"currency_id": doc.currency_id,
"currency_code": getattr(currency, "code", None),
"created_by_user_id": doc.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": doc.is_proforma,
"description": doc.description,
"project_id": doc.project_id,
"project_name": project_name,
"extra_info": _normalize_document_extra_info_for_storage(doc.extra_info),
"product_lines": [],
"account_lines": [],
"tags": [],
"tags_display": "",
"created_at": doc.created_at.isoformat(),
"updated_at": doc.updated_at.isoformat(),
}
)
return results
def batch_add_counterparty_to_invoice_items(
db: Session,
items: List[Dict[str, Any]],
) -> None:
"""افزودن counterparty به چند آیتم فاکتور با یک query."""
person_ids: set[int] = set()
for item in items:
extra = item.get("extra_info") or {}
pid = extra.get("person_id")
if pid is not None:
try:
person_ids.add(int(pid))
except (ValueError, TypeError):
pass
if not person_ids:
for item in items:
item["counterparty"] = ""
return
persons_by_id: Dict[int, Person] = {}
for p in db.query(Person).filter(Person.id.in_(list(person_ids))).all():
persons_by_id[int(p.id)] = p
for item in items:
try:
extra = item.get("extra_info") or {}
person_id = extra.get("person_id")
person_name = None
if person_id is not None:
p = persons_by_id.get(int(person_id))
if p is not None:
person_name = getattr(p, "display_name", None) or getattr(p, "name", None)
if not person_name:
person_name = getattr(p, "alias_name", None)
if not person_name and (
getattr(p, "first_name", None) or getattr(p, "last_name", None)
):
name_parts = []
if getattr(p, "first_name", None):
name_parts.append(p.first_name)
if getattr(p, "last_name", None):
name_parts.append(p.last_name)
person_name = " ".join(name_parts) if name_parts else None
if not person_name and getattr(p, "company_name", None):
person_name = p.company_name
item["counterparty"] = person_name or ""
except Exception:
item["counterparty"] = ""
def batch_calculate_invoices_remaining(
db: Session,
business_id: int,
documents: List[Document],
) -> Dict[int, Dict[str, Any]]:
"""محاسبه batch مانده فاکتورها برای لیست."""
if not documents:
return {}
invoice_ids = [int(d.id) for d in documents]
need_item_lines: List[int] = []
link_doc_ids: set[int] = set()
for doc in documents:
extra = doc.extra_info or {}
totals = extra.get("totals", {})
has_net = isinstance(totals, dict) and "net" in totals
if not has_net:
need_item_lines.append(int(doc.id))
links = extra.get("links", {}) or {}
for doc_id in links.get("receipt_payment_document_ids", []) or []:
try:
link_doc_ids.add(int(doc_id))
except (ValueError, TypeError):
pass
item_lines_by_doc: Dict[int, List[InvoiceItemLine]] = defaultdict(list)
if need_item_lines:
rows = db.query(InvoiceItemLine).filter(
InvoiceItemLine.document_id.in_(need_item_lines)
).all()
for row in rows:
item_lines_by_doc[int(row.document_id)].append(row)
totals_by_id = {
int(doc.id): _invoice_total_amount_for_document(
doc, item_lines_by_doc.get(int(doc.id))
)
for doc in documents
}
invoice_id_set = set(invoice_ids)
line_invoice_id_expr = cast(
cast(DocumentLine.extra_info, JSONB)["invoice_id"].astext,
Integer,
)
person_linked_doc_ids: set[int] = set()
invoice_to_person_rp: Dict[int, set[int]] = defaultdict(set)
if invoice_id_set:
plines = (
db.query(DocumentLine.document_id, line_invoice_id_expr)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
Document.business_id == business_id,
Document.document_type.in_(["receipt", "payment"]),
DocumentLine.person_id.isnot(None),
line_invoice_id_expr.in_(list(invoice_id_set)),
)
.all()
)
for doc_id, inv_id in plines:
if inv_id is None:
continue
doc_id_int = int(doc_id)
inv_id_int = int(inv_id)
person_linked_doc_ids.add(doc_id_int)
invoice_to_person_rp[inv_id_int].add(doc_id_int)
all_rp_doc_ids = link_doc_ids | person_linked_doc_ids
rp_docs_by_id: Dict[int, Document] = {}
if all_rp_doc_ids:
rp_docs = (
db.query(Document)
.options(selectinload(Document.lines))
.filter(
Document.id.in_(list(all_rp_doc_ids)),
Document.business_id == business_id,
Document.document_type.in_(["receipt", "payment"]),
)
.all()
)
rp_docs_by_id = {int(d.id): d for d in rp_docs}
results: Dict[int, Dict[str, Any]] = {}
for doc in documents:
inv_id = int(doc.id)
total_amount = totals_by_id.get(inv_id, Decimal(0))
total_paid = Decimal(0)
processed_doc_ids: set[int] = set()
extra = doc.extra_info or {}
links = extra.get("links", {}) or {}
for doc_id in links.get("receipt_payment_document_ids", []) or []:
try:
doc_id_int = int(doc_id)
except (ValueError, TypeError):
continue
rp_doc = rp_docs_by_id.get(doc_id_int)
if not rp_doc:
continue
processed_doc_ids.add(doc_id_int)
total_paid += _sum_receipt_doc_payments_for_invoice(rp_doc, inv_id, via_person_line=False)
for doc_id_int in invoice_to_person_rp.get(inv_id, set()):
if doc_id_int in processed_doc_ids:
continue
rp_doc = rp_docs_by_id.get(doc_id_int)
if not rp_doc:
continue
processed_doc_ids.add(doc_id_int)
total_paid += _sum_receipt_doc_payments_for_invoice(rp_doc, inv_id, via_person_line=True)
remaining = total_amount - total_paid
results[inv_id] = {
"invoice_id": inv_id,
"total_amount": float(total_amount),
"paid_amount": float(total_paid),
"remaining": float(remaining),
"is_settled": float(remaining) <= 0.01,
}
return results
def calculate_invoice_remaining(
db: Session,
business_id: int,
invoice_id: int,
) -> Dict[str, Any]:
"""
محاسبه مانده فاکتور بر اساس تراکنش‌های مرتبط
Args:
db: Session دیتابیس
business_id: شناسه کسب‌وکار
invoice_id: شناسه فاکتور
Returns:
{
'invoice_id': int,
'total_amount': float,
'paid_amount': float,
'remaining': float,
'is_settled': bool
}
"""
try:
logger.debug("calculate_invoice_remaining invoice_id=%s business_id=%s", invoice_id, business_id)
# دریافت فاکتور
invoice = db.query(Document).filter(
Document.id == invoice_id,
Document.business_id == business_id,
).first()
if not invoice:
logger.warning("Invoice not found invoice_id=%s business_id=%s", invoice_id, business_id)
raise ApiError("INVOICE_NOT_FOUND", "فاکتور یافت نشد", http_status=404)
# پاک‌سازی لینک‌های مرده قبل از محاسبه مانده
try:
if _cleanup_dead_receipt_payment_links(db, invoice):
# commit تغییرات
db.commit()
db.refresh(invoice)
except Exception as e:
logger.warning(f"خطا در پاک‌سازی لینک‌های مرده در calculate_invoice_remaining: {e}")
db.rollback()
# محاسبه مبلغ کل فاکتور
extra_info = invoice.extra_info or {}
item_lines = None
totals = extra_info.get('totals', {})
if not (isinstance(totals, dict) and 'net' in totals):
item_lines = db.query(InvoiceItemLine).filter(
InvoiceItemLine.document_id == invoice_id
).all()
total_amount = _invoice_total_amount_for_document(invoice, item_lines)
# محاسبه مبلغ پرداخت شده
total_paid = Decimal(0)
processed_doc_ids = set()
# 1. بررسی از طریق links.receipt_payment_document_ids
links = extra_info.get('links', {})
receipt_payment_ids = links.get('receipt_payment_document_ids', [])
for doc_id in receipt_payment_ids:
try:
doc_id_int = int(doc_id)
doc = db.query(Document).options(
selectinload(Document.lines)
).filter(
Document.id == doc_id_int,
Document.business_id == business_id,
Document.document_type.in_(['receipt', 'payment']),
).first()
if not doc:
continue
processed_doc_ids.add(doc_id_int)
total_paid += _sum_receipt_doc_payments_for_invoice(doc, invoice_id, via_person_line=False)
except (ValueError, TypeError) as e:
logger.warning("receipt_payment_id parse error %s: %s", doc_id, e)
continue
# 2. receipts/payments لینک‌شده از person_lines
line_invoice_id_expr = cast(
cast(DocumentLine.extra_info, JSONB)['invoice_id'].astext,
Integer,
)
person_linked_rows = (
db.query(DocumentLine.document_id)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
Document.business_id == business_id,
Document.document_type.in_(['receipt', 'payment']),
DocumentLine.person_id.isnot(None),
line_invoice_id_expr == int(invoice_id),
)
.distinct()
.all()
)
person_doc_ids = [int(r[0]) for r in person_linked_rows]
if person_doc_ids:
receipt_payment_docs = (
db.query(Document)
.options(selectinload(Document.lines))
.filter(
Document.id.in_(person_doc_ids),
Document.business_id == business_id,
)
.all()
)
for doc in receipt_payment_docs:
if doc.id in processed_doc_ids:
continue
processed_doc_ids.add(doc.id)
total_paid += _sum_receipt_doc_payments_for_invoice(doc, invoice_id, via_person_line=True)
remaining = total_amount - total_paid
logger.debug(
"calculate_invoice_remaining done invoice_id=%s total=%s paid=%s remaining=%s",
invoice_id, total_amount, total_paid, remaining,
)
return {
'invoice_id': invoice_id,
'total_amount': float(total_amount),
'paid_amount': float(total_paid),
'remaining': float(remaining),
'is_settled': float(remaining) <= 0.01, # tolerance برای خطای ممیز شناور
}
except ApiError:
raise
except Exception as e:
logger.exception(f"خطا در محاسبه مانده فاکتور {invoice_id}: {e}")
raise