hesabixArc/hesabixAPI/app/services/invoice_service.py

1325 lines
57 KiB
Python
Raw Normal View History

2025-10-31 21:32:23 +03:30
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, date
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
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
2025-11-09 08:46:37 +03:30
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
2025-10-31 21:32:23 +03:30
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
2025-11-09 08:46:37 +03:30
from adapters.db.models.invoice_item_line import InvoiceItemLine
2025-10-31 21:32:23 +03:30
from app.core.responses import ApiError
import jdatetime
logger = logging.getLogger(__name__)
# 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,
}
# --- 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"
2025-11-04 05:21:23 +03:30
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
2025-10-31 21:32:23 +03:30
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,
):
"""
بازگرداندن حرکات موجودی (ورودی/خروجی) از اسناد قطعی تا تاریخ مشخص برای مجموعه کالا/انبار.
خروجی به ترتیب زمان/شناسه سند مرتب میشود.
"""
if not product_ids:
return []
2025-11-02 13:11:38 +03:30
# فقط کالاهای با کنترل موجودی را لحاظ کن
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 []
2025-10-31 21:32:23 +03:30
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,
2025-11-02 13:11:38 +03:30
DocumentLine.product_id.in_(tracked_ids),
2025-10-31 21:32:23 +03:30
)
)
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.id.asc(), DocumentLine.id.asc()).all()
movements = []
for line, doc in rows:
info = line.extra_info or {}
2025-11-04 05:21:23 +03:30
# اگر خط صراحتاً به عنوان عدم ثبت انبار علامت‌گذاری شده، از حرکت صرف‌نظر کن
try:
posted = info.get("inventory_posted")
if posted is False:
continue
except Exception:
pass
2025-10-31 21:32:23 +03:30
movement = (info.get("movement") or None)
wh_id = info.get("warehouse_id")
if movement is None:
# fallback از نوع سند اگر صراحتاً مشخص نشده باشد
inv_move, _ = _movement_from_type(doc.document_type)
movement = inv_move
if warehouse_ids and wh_id is not None and 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,
"product_id": line.product_id,
"warehouse_id": wh_id,
"movement": movement,
"quantity": qty,
"cost_price": cost_price,
})
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,
) -> 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"]
return bal
def _ensure_stock_sufficient(
db: Session,
business_id: int,
document_date: date,
outgoing_lines: List[Dict[str, Any]],
exclude_document_id: Optional[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
# بررسی موجودی
for (pid, wh_id), req in required.items():
avail = _compute_available_stock(db, business_id, pid, wh_id, document_date, exclude_document_id)
if avail < req:
raise ApiError(
"INSUFFICIENT_STOCK",
f"موجودی کافی برای کالا {pid} در انبار {wh_id or '-'} موجود نیست. موجودی: {float(avail)}, موردنیاز: {float(req)}",
http_status=409,
)
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)
# ساخت لایه‌های 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
2025-11-04 05:21:23 +03:30
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")
2025-10-31 21:32:23 +03:30
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 {}
2025-11-04 05:21:23 +03:30
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
if info.get("inventory_posted") is False:
continue
2025-10-31 21:32:23 +03:30
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 _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 {})
2025-11-04 05:21:23 +03:30
invoice_type = str(data.get("invoice_type", "")).strip()
2025-10-31 21:32:23 +03:30
def code(name: str, default_code: str) -> str:
return str(overrides.get(name) or default_code)
return {
2025-11-04 05:21:23 +03:30
# درآمد و برگشت فروش مطابق چارت سید:
"revenue": _get_fixed_account_by_code(db, code("revenue", "50001")),
"sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")),
# موجودی و ساخته‌شده (در نبود حساب مجزا) هر دو 10102
"inventory": _get_fixed_account_by_code(db, code("inventory", "10102")),
"inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")),
# بهای تمام شده و 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")),
# طرف‌شخص بر اساس نوع فاکتور
"person": _get_person_control_account(db, invoice_type),
2025-10-31 21:32:23 +03:30
}
2025-11-04 05:21:23 +03:30
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)
2025-10-31 21:32:23 +03:30
def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]:
try:
ei = data.get("extra_info") or {}
pid = ei.get("person_id")
return int(pid) if pid is not None else None
except Exception:
return None
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 _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 create_invoice(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> 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)
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)
# 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)
# Compute totals from lines if not provided
header_extra = 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)
2025-11-09 08:46:37 +03:30
# Inventory posting is decoupled; no stock validation here
2025-11-04 05:21:23 +03:30
post_inventory: bool = _is_inventory_posting_enabled(data)
2025-10-31 21:32:23 +03:30
movement_hint, _ = _movement_from_type(invoice_type)
2025-11-02 13:11:38 +03:30
# 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)
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))
ln["extra_info"] = info
2025-11-09 08:46:37 +03:30
# انبار از فاکتور جدا شده است؛ انتخاب انبار در فاکتور اجباری نیست
2025-11-02 13:11:38 +03:30
2025-11-09 08:46:37 +03:30
# بدون کنترل کسری در مرحله فاکتور؛ کنترل در پست حواله انجام می‌شود
2025-10-31 21:32:23 +03:30
2025-11-02 13:11:38 +03:30
# Costing method (only for tracked products)
2025-10-31 21:32:23 +03:30
costing_method = _get_costing_method(data)
2025-11-09 08:46:37 +03:30
# محاسبه COGS به پست حواله منتقل می‌شود
2025-10-31 21:32:23 +03:30
# Create document
doc_code = _build_invoice_code(db, business_id, invoice_type)
# Enrich extra_info
new_extra_info = dict(header_extra)
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"]))),
}
document = 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=datetime.utcnow(),
is_proforma=bool(data.get("is_proforma", False)),
description=data.get("description"),
extra_info=new_extra_info,
)
db.add(document)
db.flush()
2025-11-09 08:46:37 +03:30
# ذخیره اقلام فاکتور در جدول مجزا (invoice_item_lines)
2025-10-31 21:32:23 +03:30
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)
2025-11-04 05:21:23 +03:30
extra_info = dict(line.get("extra_info") or {})
2025-11-09 08:46:37 +03:30
extra_info.pop("inventory_posted", None)
db.add(InvoiceItemLine(
2025-10-31 21:32:23 +03:30
document_id=document.id,
product_id=int(product_id),
quantity=qty,
description=line.get("description"),
extra_info=extra_info,
))
2025-11-09 08:46:37 +03:30
# Accounting lines for finalized invoices (بدون خطوط COGS/Inventory؛ به حواله موکول شد)
2025-10-31 21:32:23 +03:30
if not document.is_proforma:
accounts = _resolve_accounts_for_invoice(db, data)
net = Decimal(str(totals["gross"])) - Decimal(str(totals["discount"]))
tax = Decimal(str(totals["tax"]))
total_with_tax = net + tax
2025-11-09 08:46:37 +03:30
# COGS به پست حواله منتقل شد
2025-10-31 21:32:23 +03:30
# Sales
if invoice_type == INVOICE_SALES:
# AR (person) Dr, Revenue Cr, VAT out Cr, COGS Dr, Inventory Cr (optional)
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},
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["revenue"].id,
debit=Decimal(0),
credit=net,
description="درآمد فروش",
))
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_out"].id,
debit=Decimal(0),
credit=tax,
description="مالیات بر ارزش افزوده خروجی",
))
2025-11-09 08:46:37 +03:30
# COGS/Inventory در پست حواله ثبت خواهد شد
2025-10-31 21:32:23 +03:30
2025-11-04 05:21:23 +03:30
2025-10-31 21:32:23 +03:30
# 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},
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["sales_return"].id,
debit=net,
credit=Decimal(0),
description="برگشت از فروش",
))
if tax > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["vat_in"].id,
debit=tax,
credit=Decimal(0),
description="تعدیل VAT برگشت از فروش",
))
2025-11-09 08:46:37 +03:30
# ورود موجودی/تعدیل COGS در پست حواله انجام می‌شود
2025-10-31 21:32:23 +03:30
# Purchase
elif invoice_type == INVOICE_PURCHASE:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=net,
credit=Decimal(0),
description="ورود به موجودی بابت خرید",
))
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},
))
# Purchase Return
elif invoice_type == INVOICE_PURCHASE_RETURN:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=net,
description="خروج از موجودی بابت برگشت خرید",
))
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
elif invoice_type == INVOICE_DIRECT_CONSUMPTION:
2025-11-09 08:46:37 +03:30
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
2025-10-31 21:32:23 +03:30
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["direct_consumption"].id,
debit=cogs_total,
credit=Decimal(0),
description="مصرف مستقیم",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=cogs_total,
description="خروج از موجودی بابت مصرف",
))
# Waste
elif invoice_type == INVOICE_WASTE:
2025-11-09 08:46:37 +03:30
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
2025-10-31 21:32:23 +03:30
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["waste_expense"].id,
debit=cogs_total,
credit=Decimal(0),
description="ضایعات",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=cogs_total,
description="خروج از موجودی بابت ضایعات",
))
# Production (WIP)
elif invoice_type == INVOICE_PRODUCTION:
# materials (out) → Debit WIP, Credit Inventory
materials_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "out"])
if materials_cost > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=materials_cost,
credit=Decimal(0),
description="انتقال مواد به کاردرجریان",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=materials_cost,
description="خروج مواد اولیه",
))
# finished goods (in) → Debit Finished Inventory, Credit WIP
finished_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "in"])
if finished_cost > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory_finished"].id,
debit=finished_cost,
credit=Decimal(0),
description="ورود کالای ساخته‌شده",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["wip"].id,
debit=Decimal(0),
credit=finished_cost,
description="انتقال از کاردرجریان",
))
2025-11-09 08:46:37 +03:30
# --- پورسانت فروشنده/بازاریاب (تکمیلی پس از ثبت خطوط انواع فاکتور) ---
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="تعدیل هزینه پورسانت",
))
2025-10-31 21:32:23 +03:30
# Persist invoice first
db.commit()
db.refresh(document)
# Optional: create receipt/payment document(s)
payment_docs: List[int] = []
payments = data.get("payments") or []
if payments and isinstance(payments, list):
try:
# Only when person is present
if person_id:
from app.services.receipt_payment_service import create_receipt_payment
# Aggregate amounts into one receipt/payment with multiple account_lines
account_lines: List[Dict[str, Any]] = []
total_amount = Decimal(0)
2025-11-09 08:46:37 +03:30
# Validate currency of payment accounts vs invoice currency
invoice_currency_id = int(currency_id)
2025-10-31 21:32:23 +03:30
for p in payments:
amount = Decimal(str(p.get("amount", 0) or 0))
if amount <= 0:
continue
total_amount += amount
2025-11-09 08:46:37 +03:30
ttype = str(p.get("transaction_type") or "").strip().lower()
# Currency match checks for money accounts
if ttype in ("bank", "cash_register", "petty_cash", "check"):
if ttype == "bank":
ref_id = p.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 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 = p.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 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 = p.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 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 = p.get("check_id")
if ref_id:
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 int(chk.currency_id) != invoice_currency_id:
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of check does not match invoice currency", http_status=400)
# Build account line entry including ids/names for linking
account_line: Dict[str, Any] = {
2025-10-31 21:32:23 +03:30
"transaction_type": p.get("transaction_type"),
"amount": float(amount),
"description": p.get("description"),
"transaction_date": p.get("transaction_date"),
"commission": p.get("commission"),
2025-11-09 08:46:37 +03:30
}
# pass through reference ids/names if provided
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)
account_lines.append(account_line)
2025-10-31 21:32:23 +03:30
if total_amount > 0 and account_lines:
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
rp_data = {
"document_type": "receipt" if is_receipt else "payment",
"document_date": document.document_date.isoformat(),
"currency_id": document.currency_id,
"description": f"تسویه مرتبط با فاکتور {document.code}",
"person_lines": [{
"person_id": person_id,
"amount": float(total_amount),
"description": f"طرف حساب فاکتور {document.code}",
}],
"account_lines": account_lines,
"extra_info": {"source": "invoice", "invoice_id": document.id},
}
rp_doc = create_receipt_payment(db=db, business_id=business_id, user_id=user_id, data=rp_data)
if isinstance(rp_doc, dict) and rp_doc.get("id"):
payment_docs.append(int(rp_doc["id"]))
except Exception as ex:
logger.exception("could not create receipt/payment for invoice: %s", ex)
# Save links back to invoice
if payment_docs:
extra = document.extra_info or {}
links = dict((extra.get("links") or {}))
links["receipt_payment_document_ids"] = payment_docs
extra["links"] = links
document.extra_info = extra
db.commit()
db.refresh(document)
2025-11-09 08:46:37 +03:30
# ایجاد حواله انبار draft در صورت نیاز و جدا از فاکتور
try:
if bool(data.get("extra_info", {}).get("post_inventory", True)):
from app.services.warehouse_service import create_from_invoice
created_wh_ids: List[int] = []
if 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"]
if 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 in_lines:
wh_receipt = create_from_invoice(db, business_id, document, in_lines, "receipt", user_id)
created_wh_ids.append(int(wh_receipt.id))
else:
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_input, wh_type, user_id)
created_wh_ids.append(int(wh.id))
if created_wh_ids:
# ذخیره لینک حواله‌ها در extra_info.links
extra = document.extra_info or {}
links = dict((extra.get("links") or {}))
links["warehouse_document_ids"] = created_wh_ids
extra["links"] = links
document.extra_info = extra
db.commit()
except Exception:
# عدم موفقیت در ساخت حواله نباید مانع بازگشت فاکتور شود
db.rollback()
2025-10-31 21:32:23 +03:30
return invoice_document_to_dict(db, document)
def update_invoice(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any],
) -> 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)
# Only editable in current fiscal year
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
# 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 isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
# preserve links if present
new_extra = data.get("extra_info") or {}
old_extra = document.extra_info or {}
links = old_extra.get("links")
if links and "links" not in new_extra:
new_extra["links"] = links
document.extra_info = new_extra
if isinstance(data.get("description"), str) or data.get("description") is None:
if data.get("description") is not None:
document.description = data.get("description")
2025-11-09 08:46:37 +03:30
# Recreate lines: حذف سطرهای حسابداری و اقلام فاکتور و بازایجاد
2025-10-31 21:32:23 +03:30
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
2025-11-09 08:46:37 +03:30
db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).delete(synchronize_session=False)
2025-10-31 21:32:23 +03:30
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)
2025-11-09 08:46:37 +03:30
# Inventory decoupled from invoices
2025-10-31 21:32:23 +03:30
inv_type = document.document_type
movement_hint, _ = _movement_from_type(inv_type)
2025-11-02 13:11:38 +03:30
# 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)
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))
ln["extra_info"] = info
2025-11-09 08:46:37 +03:30
# انتخاب انبار در مرحله فاکتور الزامی نیست
2025-11-02 13:11:38 +03:30
2025-11-04 05:21:23 +03:30
header_for_costing = data if data else {"extra_info": document.extra_info}
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
2025-10-31 21:32:23 +03:30
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)
2025-11-04 05:21:23 +03:30
extra_info = dict(line.get("extra_info") or {})
2025-11-09 08:46:37 +03:30
db.add(InvoiceItemLine(
2025-10-31 21:32:23 +03:30
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:
2025-11-04 05:21:23 +03:30
header_for_accounts: Dict[str, Any] = {"invoice_type": inv_type, **(data or {"extra_info": document.extra_info})}
accounts = _resolve_accounts_for_invoice(db, header_for_accounts)
2025-10-31 21:32:23 +03:30
header_extra = data.get("extra_info") or document.extra_info or {}
totals = (header_extra.get("totals") or {})
if not totals:
totals = _extract_totals_from_lines(lines_input)
net = Decimal(str(totals.get("gross", 0))) - Decimal(str(totals.get("discount", 0)))
tax = Decimal(str(totals.get("tax", 0)))
total_with_tax = net + tax
person_id = _person_id_from_header({"extra_info": header_extra})
2025-11-09 08:46:37 +03:30
# inventory/COGS handled in warehouse posting
2025-10-31 21:32:23 +03:30
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))
db.add(DocumentLine(document_id=document.id, account_id=accounts["revenue"].id, debit=Decimal(0), credit=net, description="درآمد فروش"))
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
2025-11-09 08:46:37 +03:30
# COGS/Inventory by warehouse posting
2025-10-31 21:32:23 +03:30
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))
db.add(DocumentLine(document_id=document.id, account_id=accounts["sales_return"].id, debit=net, credit=Decimal(0), description="برگشت از فروش"))
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT"))
2025-11-09 08:46:37 +03:30
# Inventory/COGS handled in warehouse posting
2025-10-31 21:32:23 +03:30
elif inv_type == INVOICE_PURCHASE:
2025-11-09 08:46:37 +03:30
# Inventory via warehouse posting; invoice handles VAT/AP only (or GRNI if فعال)
2025-10-31 21:32:23 +03:30
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))
elif inv_type == INVOICE_PURCHASE_RETURN:
2025-11-09 08:46:37 +03:30
# Inventory via warehouse posting
2025-10-31 21:32:23 +03:30
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:
2025-11-09 08:46:37 +03:30
# Expense/Inventory in warehouse posting
pass
2025-10-31 21:32:23 +03:30
elif inv_type == INVOICE_WASTE:
2025-11-09 08:46:37 +03:30
# Expense/Inventory in warehouse posting
pass
2025-10-31 21:32:23 +03:30
elif inv_type == INVOICE_PRODUCTION:
2025-11-09 08:46:37 +03:30
# WIP/Inventory in warehouse posting
pass
2025-10-31 21:32:23 +03:30
2025-11-04 05:21:23 +03:30
# --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) ---
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="تعدیل هزینه پورسانت",
))
2025-10-31 21:32:23 +03:30
db.commit()
db.refresh(document)
return invoice_document_to_dict(db, document)
def invoice_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
2025-11-09 08:46:37 +03:30
# اقلام فاکتور از جدول مجزا خوانده می‌شوند
item_rows = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).all()
2025-10-31 21:32:23 +03:30
product_lines: List[Dict[str, Any]] = []
2025-11-09 08:46:37 +03:30
for it in item_rows:
product = db.query(Product).filter(Product.id == it.product_id).first()
product_lines.append({
"id": it.id,
"product_id": it.product_id,
"product_name": getattr(product, "name", None),
"quantity": float(it.quantity) if it.quantity else None,
"description": it.description,
"extra_info": it.extra_info,
})
2025-10-31 21:32:23 +03:30
2025-11-09 08:46:37 +03:30
# سطرهای حسابداری از 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,
})
2025-10-31 21:32:23 +03:30
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()
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"document_type": document.document_type,
"document_date": document.document_date.isoformat(),
"registered_at": document.registered_at.isoformat(),
"currency_id": document.currency_id,
"currency_code": getattr(currency, "code", None),
"created_by_user_id": document.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": document.is_proforma,
"description": document.description,
"extra_info": document.extra_info,
"product_lines": product_lines,
"account_lines": account_lines,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}