hesabixArc/hesabixAPI/app/services/invoice_service.py
2025-11-09 05:16:37 +00:00

1325 lines
57 KiB
Python
Raw 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
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
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
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 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"
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 _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 []
# فقط کالاهای با کنترل موجودی را لحاظ کن
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 []
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.id.asc(), DocumentLine.id.asc()).all()
movements = []
for line, doc in rows:
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 = 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
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 _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)
return {
# درآمد و برگشت فروش مطابق چارت سید:
"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),
}
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]:
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)
# Inventory posting is decoupled; no stock validation here
post_inventory: bool = _is_inventory_posting_enabled(data)
movement_hint, _ = _movement_from_type(invoice_type)
# 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
# انبار از فاکتور جدا شده است؛ انتخاب انبار در فاکتور اجباری نیست
# بدون کنترل کسری در مرحله فاکتور؛ کنترل در پست حواله انجام می‌شود
# Costing method (only for tracked products)
costing_method = _get_costing_method(data)
# محاسبه COGS به پست حواله منتقل می‌شود
# 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()
# ذخیره اقلام فاکتور در جدول مجزا (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)
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؛ به حواله موکول شد)
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
# COGS به پست حواله منتقل شد
# 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="مالیات بر ارزش افزوده خروجی",
))
# COGS/Inventory در پست حواله ثبت خواهد شد
# 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 برگشت از فروش",
))
# ورود موجودی/تعدیل COGS در پست حواله انجام می‌شود
# 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:
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)
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:
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)
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="انتقال از کاردرجریان",
))
# --- پورسانت فروشنده/بازاریاب (تکمیلی پس از ثبت خطوط انواع فاکتور) ---
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="تعدیل هزینه پورسانت",
))
# 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)
# Validate currency of payment accounts vs invoice currency
invoice_currency_id = int(currency_id)
for p in payments:
amount = Decimal(str(p.get("amount", 0) or 0))
if amount <= 0:
continue
total_amount += amount
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] = {
"transaction_type": p.get("transaction_type"),
"amount": float(amount),
"description": p.get("description"),
"transaction_date": p.get("transaction_date"),
"commission": p.get("commission"),
}
# 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)
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)
# ایجاد حواله انبار 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()
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")
# 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)
# Inventory decoupled from invoices
inv_type = document.document_type
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)
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
# انتخاب انبار در مرحله فاکتور الزامی نیست
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 {})
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_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)
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})
# 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))
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="مالیات خروجی"))
# COGS/Inventory by warehouse posting
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"))
# Inventory/COGS handled in warehouse posting
elif inv_type == INVOICE_PURCHASE:
# Inventory via warehouse posting; invoice handles VAT/AP only (or GRNI if فعال)
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:
# Inventory via warehouse posting
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:
# Expense/Inventory in warehouse posting
pass
elif inv_type == INVOICE_WASTE:
# Expense/Inventory in warehouse posting
pass
elif inv_type == INVOICE_PRODUCTION:
# WIP/Inventory in warehouse posting
pass
# --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) ---
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="تعدیل هزینه پورسانت",
))
db.commit()
db.refresh(document)
return invoice_document_to_dict(db, document)
def invoice_document_to_dict(db: Session, document: Document) -> 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()
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,
})
# سطرهای حسابداری از 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()
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(),
}