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

1429 lines
43 KiB
Python
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.

"""
سرویس کالای هزینه‌شده / کالای درآمدشده
جریان:
- simple: ایجاد (+ تخصیص حساب) و در صورت تنظیمات/مجوز، قطعی‌سازی یک‌مرحله‌ای
- two_step: پیش‌نویس عملیاتی → ارسال به حسابداری → تخصیص حساب → قطعی
قطعی‌سازی اتمی: حواله انبار posted + سند دفتر کل متعادل
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from adapters.db.models.account import Account
from adapters.db.models.business import Business
from adapters.db.models.currency import Currency
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.goods_expense_income import (
GoodsExpenseIncomeDocument,
GoodsExpenseIncomeLine,
)
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.warehouse import Warehouse
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from app.core.responses import ApiError
from app.services.document_monetization_service import ensure_document_policy_allows_creation
from app.services.document_numbering_service import generate_document_code
logger = logging.getLogger(__name__)
DOC_KIND_EXPENSE = "goods_expense"
DOC_KIND_INCOME = "goods_income"
VALID_DOC_KINDS = frozenset({DOC_KIND_EXPENSE, DOC_KIND_INCOME})
STATUS_DRAFT_OPS = "draft_ops"
STATUS_PENDING_ACCOUNTING = "pending_accounting"
STATUS_DRAFT_ACCOUNTING = "draft_accounting"
STATUS_POSTED = "posted"
STATUS_CANCELLED = "cancelled"
EDITABLE_STATUSES = frozenset({STATUS_DRAFT_OPS, STATUS_PENDING_ACCOUNTING, STATUS_DRAFT_ACCOUNTING})
OPS_EDITABLE_STATUSES = frozenset({STATUS_DRAFT_OPS})
ACCOUNTING_EDITABLE_STATUSES = frozenset(
{STATUS_PENDING_ACCOUNTING, STATUS_DRAFT_ACCOUNTING, STATUS_DRAFT_OPS}
)
DEFAULT_EXPENSE_ACCOUNT_CODE = "70407"
DEFAULT_INCOME_ACCOUNT_CODE = "60103"
INVENTORY_ACCOUNT_CODE = "10102"
# نوع شماره‌گذاری سند عملیاتی (جدا از سند دفتر کل goods_expense/goods_income)
VOUCHER_NUMBERING_BY_KIND = {
DOC_KIND_EXPENSE: "goods_expense_voucher",
DOC_KIND_INCOME: "goods_income_voucher",
}
WORKFLOW_SIMPLE = "simple"
WORKFLOW_TWO_STEP = "two_step"
STOCK_COUNT_GOODS_DOCS = "goods_docs"
STOCK_COUNT_PHYSICAL = "physical_adjustment"
STOCK_COUNT_ASK = "ask"
def _money(value: Decimal | float | int | str | None) -> Decimal:
return Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _qty(value: Decimal | float | int | str | None) -> Decimal:
return Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
def _parse_iso_date(dt: str | datetime | date | None) -> date:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
if not dt:
return datetime.utcnow().date()
try:
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_business(db: Session, business_id: int) -> Business:
biz = db.query(Business).filter(Business.id == int(business_id)).first()
if not biz:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
return biz
def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = (
db.query(FiscalYear)
.filter(and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)) # noqa: E712
.order_by(FiscalYear.start_date.desc())
.first()
)
if not fy:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی فعال یافت نشد", http_status=404)
return fy
def get_workflow_settings(biz: Business) -> Dict[str, Any]:
mode = str(getattr(biz, "goods_expense_income_workflow_mode", None) or WORKFLOW_SIMPLE).strip().lower()
if mode not in (WORKFLOW_SIMPLE, WORKFLOW_TWO_STEP):
mode = WORKFLOW_SIMPLE
stock_mode = str(
getattr(biz, "goods_expense_income_stock_count_mode", None) or STOCK_COUNT_GOODS_DOCS
).strip().lower()
if stock_mode not in (STOCK_COUNT_GOODS_DOCS, STOCK_COUNT_PHYSICAL, STOCK_COUNT_ASK):
stock_mode = STOCK_COUNT_GOODS_DOCS
return {
"workflow_mode": mode,
"auto_post_in_simple_mode": bool(
getattr(biz, "goods_expense_income_auto_post_in_simple_mode", True)
),
"default_expense_account_code": str(
getattr(biz, "goods_expense_income_default_expense_account_code", None)
or DEFAULT_EXPENSE_ACCOUNT_CODE
),
"default_income_account_code": str(
getattr(biz, "goods_expense_income_default_income_account_code", None)
or DEFAULT_INCOME_ACCOUNT_CODE
),
"stock_count_mode": stock_mode,
"allow_manual_unit_cost": bool(
getattr(biz, "goods_expense_income_allow_manual_unit_cost", False)
),
"require_person": bool(getattr(biz, "goods_expense_income_require_person", False)),
}
def resolve_account_by_code(
db: Session,
business_id: int,
account_code: str,
) -> Account:
code = str(account_code or "").strip()
if not code:
raise ApiError("ACCOUNT_CODE_REQUIRED", "کد حساب الزامی است", http_status=400)
acc = (
db.query(Account)
.filter(and_(Account.code == code, Account.business_id == business_id))
.first()
)
if not acc:
acc = (
db.query(Account)
.filter(and_(Account.code == code, Account.business_id.is_(None)))
.first()
)
if not acc:
raise ApiError("ACCOUNT_NOT_FOUND", f"حساب با کد {code} یافت نشد", http_status=404)
return acc
def resolve_account_by_id(db: Session, business_id: int, account_id: int) -> Account:
acc = db.query(Account).filter(Account.id == int(account_id)).first()
if not acc:
raise ApiError("ACCOUNT_NOT_FOUND", "حساب یافت نشد", http_status=404)
if acc.business_id is not None and int(acc.business_id) != int(business_id):
raise ApiError("ACCOUNT_NOT_FOUND", "حساب متعلق به این کسب‌وکار نیست", http_status=404)
return acc
def _assert_leaf_account(db: Session, account: Account, business_id: Optional[int] = None) -> None:
"""حساب گروه (دارای فرزند) نباید مقصد ثبت باشد."""
q = db.query(Account.id).filter(Account.parent_id == account.id)
if business_id is not None:
# فرزندان قالب سراسری یا همین کسب‌وکار
q = q.filter(or_(Account.business_id.is_(None), Account.business_id == int(business_id)))
has_child = q.limit(1).first()
if has_child:
raise ApiError(
"ACCOUNT_NOT_LEAF",
f"حساب «{account.code} - {account.name}» گروه است و قابل ثبت نیست؛ یک حساب برگ انتخاب کنید",
http_status=400,
)
def _assert_account_kind_compatible(account: Account, doc_kind: str) -> None:
code = str(account.code or "")
if doc_kind == DOC_KIND_EXPENSE:
if not (code.startswith("7") or code.startswith("4")):
raise ApiError(
"ACCOUNT_KIND_MISMATCH",
"برای کالای هزینه‌شده باید حساب هزینه (شاخه ۷) انتخاب شود",
http_status=400,
)
elif doc_kind == DOC_KIND_INCOME:
if not code.startswith("6"):
raise ApiError(
"ACCOUNT_KIND_MISMATCH",
"برای کالای درآمدشده باید حساب درآمد (شاخه ۶) انتخاب شود",
http_status=400,
)
def default_effect_account(db: Session, business_id: int, biz: Business, doc_kind: str) -> Account:
settings = get_workflow_settings(biz)
code = (
settings["default_expense_account_code"]
if doc_kind == DOC_KIND_EXPENSE
else settings["default_income_account_code"]
)
acc = resolve_account_by_code(db, business_id, code)
_assert_leaf_account(db, acc, business_id)
_assert_account_kind_compatible(acc, doc_kind)
return acc
def resolve_product_unit_cost(product: Product) -> Decimal:
"""بهای واحد از قیمت خرید پایه کالا (مطابق توضیح کاربر)."""
raw = getattr(product, "base_purchase_price", None)
if raw is None:
return Decimal("0")
return _qty(raw)
def _load_document(
db: Session,
business_id: int,
document_id: int,
*,
with_lines: bool = True,
) -> GoodsExpenseIncomeDocument:
q = db.query(GoodsExpenseIncomeDocument).filter(
and_(
GoodsExpenseIncomeDocument.id == int(document_id),
GoodsExpenseIncomeDocument.business_id == int(business_id),
)
)
if with_lines:
q = q.options(
joinedload(GoodsExpenseIncomeDocument.lines),
joinedload(GoodsExpenseIncomeDocument.effect_account),
joinedload(GoodsExpenseIncomeDocument.person),
)
doc = q.first()
if not doc:
raise ApiError("NOT_FOUND", "سند کالای هزینه/درآمد یافت نشد", http_status=404)
return doc
def _document_to_dict(doc: GoodsExpenseIncomeDocument, db: Optional[Session] = None) -> Dict[str, Any]:
lines_out: List[Dict[str, Any]] = []
for ln in list(doc.lines or []):
product_name = None
product_code = None
warehouse_name = None
if db is not None:
p = db.query(Product).filter(Product.id == ln.product_id).first()
if p:
product_name = p.name
product_code = getattr(p, "code", None)
w = db.query(Warehouse).filter(Warehouse.id == ln.warehouse_id).first()
if w:
warehouse_name = w.name
lines_out.append(
{
"id": ln.id,
"line_no": ln.line_no,
"product_id": ln.product_id,
"product_name": product_name,
"product_code": product_code,
"warehouse_id": ln.warehouse_id,
"warehouse_name": warehouse_name,
"quantity": float(ln.quantity or 0),
"unit_cost": float(ln.unit_cost or 0),
"amount": float(ln.amount or 0),
"description": ln.description,
"extra_info": ln.extra_info,
}
)
effect_account = None
if doc.effect_account_id and doc.effect_account is not None:
effect_account = {
"id": doc.effect_account.id,
"code": doc.effect_account.code,
"name": doc.effect_account.name,
}
elif doc.effect_account_id and db is not None:
acc = db.query(Account).filter(Account.id == doc.effect_account_id).first()
if acc:
effect_account = {"id": acc.id, "code": acc.code, "name": acc.name}
person = None
if doc.person_id and doc.person is not None:
person = {"id": doc.person.id, "name": getattr(doc.person, "name", None)}
elif doc.person_id and db is not None:
p = db.query(Person).filter(Person.id == doc.person_id).first()
if p:
person = {"id": p.id, "name": getattr(p, "name", None)}
return {
"id": doc.id,
"business_id": doc.business_id,
"fiscal_year_id": doc.fiscal_year_id,
"code": doc.code,
"document_date": doc.document_date.isoformat() if doc.document_date else None,
"doc_kind": doc.doc_kind,
"status": doc.status,
"effect_account_id": doc.effect_account_id,
"effect_account": effect_account,
"person_id": doc.person_id,
"person": person,
"currency_id": doc.currency_id,
"description": doc.description,
"warehouse_document_id": doc.warehouse_document_id,
"accounting_document_id": doc.accounting_document_id,
"total_amount": float(doc.total_amount or 0),
"created_by_user_id": doc.created_by_user_id,
"submitted_by_user_id": doc.submitted_by_user_id,
"allocated_by_user_id": doc.allocated_by_user_id,
"posted_by_user_id": doc.posted_by_user_id,
"cancelled_by_user_id": doc.cancelled_by_user_id,
"submitted_at": doc.submitted_at.isoformat() if doc.submitted_at else None,
"allocated_at": doc.allocated_at.isoformat() if doc.allocated_at else None,
"posted_at": doc.posted_at.isoformat() if doc.posted_at else None,
"cancelled_at": doc.cancelled_at.isoformat() if doc.cancelled_at else None,
"extra_info": doc.extra_info,
"created_at": doc.created_at.isoformat() if doc.created_at else None,
"updated_at": doc.updated_at.isoformat() if doc.updated_at else None,
"lines": lines_out,
}
def _validate_and_build_lines(
db: Session,
business_id: int,
biz: Business,
lines_data: List[Dict[str, Any]],
*,
allow_manual_unit_cost: bool,
user_can_change_unit_cost: bool,
) -> Tuple[List[Dict[str, Any]], Decimal]:
if not lines_data:
raise ApiError("LINES_REQUIRED", "حداقل یک سطر کالا الزامی است", http_status=400)
built: List[Dict[str, Any]] = []
total = Decimal("0")
for i, raw in enumerate(lines_data, start=1):
product_id = raw.get("product_id")
warehouse_id = raw.get("warehouse_id")
if not product_id:
raise ApiError("PRODUCT_REQUIRED", f"سطر {i}: کالا الزامی است", http_status=400)
if not warehouse_id:
raise ApiError("WAREHOUSE_REQUIRED", f"سطر {i}: انبار الزامی است", http_status=400)
product = (
db.query(Product)
.filter(and_(Product.id == int(product_id), Product.business_id == business_id))
.first()
)
if not product:
raise ApiError("PRODUCT_NOT_FOUND", f"سطر {i}: کالا یافت نشد", http_status=404)
if not bool(getattr(product, "track_inventory", False)):
raise ApiError(
"PRODUCT_NOT_TRACKED",
f"سطر {i}: کالا «{product.name}» کنترل موجودی ندارد",
http_status=400,
)
wh = (
db.query(Warehouse)
.filter(and_(Warehouse.id == int(warehouse_id), Warehouse.business_id == business_id))
.first()
)
if not wh:
raise ApiError("WAREHOUSE_NOT_FOUND", f"سطر {i}: انبار یافت نشد", http_status=404)
qty = _qty(raw.get("quantity"))
if qty <= 0:
raise ApiError("INVALID_QUANTITY", f"سطر {i}: مقدار باید مثبت باشد", http_status=400)
system_cost = resolve_product_unit_cost(product)
unit_cost = system_cost
if raw.get("unit_cost") is not None:
requested = _qty(raw.get("unit_cost"))
if requested != system_cost:
if not allow_manual_unit_cost or not user_can_change_unit_cost:
raise ApiError(
"UNIT_COST_CHANGE_FORBIDDEN",
f"سطر {i}: تغییر دستی بهای واحد مجاز نیست",
http_status=403,
)
if requested < 0:
raise ApiError("INVALID_UNIT_COST", f"سطر {i}: بهای واحد نامعتبر است", http_status=400)
unit_cost = requested
amount = _money(qty * unit_cost)
if amount <= 0:
raise ApiError(
"ZERO_COST",
f"سطر {i}: بهای کالا صفر یا نامعتبر است؛ ابتدا قیمت خرید پایه کالا را تنظیم کنید",
http_status=400,
)
built.append(
{
"line_no": i,
"product_id": int(product_id),
"warehouse_id": int(warehouse_id),
"quantity": qty,
"unit_cost": unit_cost,
"amount": amount,
"description": (str(raw.get("description")).strip() if raw.get("description") else None),
"extra_info": raw.get("extra_info") if isinstance(raw.get("extra_info"), dict) else None,
}
)
total += amount
return built, _money(total)
def _apply_header_fields(
db: Session,
business_id: int,
biz: Business,
doc: GoodsExpenseIncomeDocument,
data: Dict[str, Any],
*,
allow_account_change: bool,
) -> None:
settings = get_workflow_settings(biz)
if "document_date" in data and data.get("document_date") is not None:
doc.document_date = _parse_iso_date(data.get("document_date"))
fy = _get_business_fiscal_year(db, business_id)
if doc.document_date < fy.start_date or (fy.end_date and doc.document_date > fy.end_date):
raise ApiError(
"DATE_OUT_OF_RANGE",
f"تاریخ باید در بازه سال مالی ({fy.start_date} تا {fy.end_date or 'نامحدود'}) باشد",
http_status=400,
)
if "description" in data:
doc.description = (str(data.get("description")).strip() if data.get("description") else None)
if "person_id" in data:
person_id = data.get("person_id")
if person_id:
person = (
db.query(Person)
.filter(and_(Person.id == int(person_id), Person.business_id == business_id))
.first()
)
if not person:
raise ApiError("PERSON_NOT_FOUND", "شخص یافت نشد", http_status=404)
doc.person_id = int(person_id)
else:
doc.person_id = None
if settings["require_person"] and not doc.person_id:
raise ApiError("PERSON_REQUIRED", "انتخاب شخص طبق تنظیمات کسب‌وکار الزامی است", http_status=400)
if allow_account_change and ("effect_account_id" in data or "effect_account_code" in data):
if data.get("effect_account_id"):
acc = resolve_account_by_id(db, business_id, int(data["effect_account_id"]))
elif data.get("effect_account_code"):
acc = resolve_account_by_code(db, business_id, str(data["effect_account_code"]))
else:
acc = None
if acc is not None:
_assert_leaf_account(db, acc, business_id)
_assert_account_kind_compatible(acc, doc.doc_kind)
doc.effect_account_id = acc.id
if "extra_info" in data and isinstance(data.get("extra_info"), dict):
base = dict(doc.extra_info or {})
base.update(data["extra_info"])
doc.extra_info = base
def create_document(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
*,
user_can_allocate: bool = False,
user_can_post: bool = False,
user_can_change_unit_cost: bool = False,
commit: bool = True,
) -> Dict[str, Any]:
biz = _get_business(db, business_id)
settings = get_workflow_settings(biz)
doc_kind = str(data.get("doc_kind") or "").strip().lower()
if doc_kind not in VALID_DOC_KINDS:
raise ApiError(
"INVALID_DOC_KIND",
"doc_kind باید goods_expense یا goods_income باشد",
http_status=400,
)
document_date = _parse_iso_date(data.get("document_date"))
fy = _get_business_fiscal_year(db, business_id)
if document_date < fy.start_date or (fy.end_date and document_date > fy.end_date):
raise ApiError("DATE_OUT_OF_RANGE", "تاریخ خارج از سال مالی است", http_status=400)
currency_id = data.get("currency_id") or getattr(biz, "default_currency_id", None)
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "ارز کسب‌وکار تنظیم نشده است", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "ارز یافت نشد", http_status=404)
built_lines, total_amount = _validate_and_build_lines(
db,
business_id,
biz,
list(data.get("lines") or []),
allow_manual_unit_cost=settings["allow_manual_unit_cost"],
user_can_change_unit_cost=user_can_change_unit_cost,
)
ensure_document_policy_allows_creation(
db,
business_id,
document_type=doc_kind,
document_date=document_date,
amount=total_amount,
)
# تعیین حساب اثر
effect_account: Optional[Account] = None
if data.get("effect_account_id"):
effect_account = resolve_account_by_id(db, business_id, int(data["effect_account_id"]))
elif data.get("effect_account_code"):
effect_account = resolve_account_by_code(db, business_id, str(data["effect_account_code"]))
elif settings["workflow_mode"] == WORKFLOW_SIMPLE or user_can_allocate:
effect_account = default_effect_account(db, business_id, biz, doc_kind)
if effect_account is not None:
_assert_leaf_account(db, effect_account, business_id)
_assert_account_kind_compatible(effect_account, doc_kind)
person_id = data.get("person_id")
if person_id:
person = (
db.query(Person)
.filter(and_(Person.id == int(person_id), Person.business_id == business_id))
.first()
)
if not person:
raise ApiError("PERSON_NOT_FOUND", "شخص یافت نشد", http_status=404)
elif settings["require_person"]:
raise ApiError("PERSON_REQUIRED", "انتخاب شخص طبق تنظیمات کسب‌وکار الزامی است", http_status=400)
# وضعیت اولیه
if settings["workflow_mode"] == WORKFLOW_SIMPLE:
initial_status = STATUS_DRAFT_ACCOUNTING if effect_account else STATUS_DRAFT_OPS
else:
initial_status = STATUS_DRAFT_OPS
# در حالت ساده بدون حساب، پیش‌فرض را اجباری کن تا سند ناقص ساخته نشود
if settings["workflow_mode"] == WORKFLOW_SIMPLE and effect_account is None:
effect_account = default_effect_account(db, business_id, biz, doc_kind)
initial_status = STATUS_DRAFT_ACCOUNTING
doc: Optional[GoodsExpenseIncomeDocument] = None
voucher_type = VOUCHER_NUMBERING_BY_KIND[doc_kind]
for attempt in range(8):
code = generate_document_code(db, business_id, voucher_type, document_date)
try:
with db.begin_nested():
doc = GoodsExpenseIncomeDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=document_date,
doc_kind=doc_kind,
status=initial_status,
effect_account_id=effect_account.id if effect_account else None,
person_id=int(person_id) if person_id else None,
currency_id=int(currency_id),
description=(str(data.get("description")).strip() if data.get("description") else None),
total_amount=total_amount,
created_by_user_id=user_id,
extra_info=data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(doc)
db.flush()
break
except IntegrityError:
if attempt >= 7:
raise
continue
if doc is None:
raise ApiError("CODE_CONFLICT", "تولید کد سند ناموفق بود", http_status=500)
for ln in built_lines:
db.add(
GoodsExpenseIncomeLine(
document_id=doc.id,
line_no=ln["line_no"],
product_id=ln["product_id"],
warehouse_id=ln["warehouse_id"],
quantity=ln["quantity"],
unit_cost=ln["unit_cost"],
amount=ln["amount"],
description=ln["description"],
extra_info=ln["extra_info"],
)
)
db.flush()
# simple + auto_post
should_auto_post = (
settings["workflow_mode"] == WORKFLOW_SIMPLE
and settings["auto_post_in_simple_mode"]
and user_can_post
and doc.effect_account_id is not None
and bool(data.get("auto_post", True))
)
if should_auto_post:
_post_document_internal(db, business_id, user_id, doc)
elif (
settings["workflow_mode"] == WORKFLOW_SIMPLE
and effect_account is not None
and user_can_allocate
and doc.status == STATUS_DRAFT_OPS
):
doc.status = STATUS_DRAFT_ACCOUNTING
doc.allocated_by_user_id = user_id
doc.allocated_at = datetime.utcnow()
doc.touch()
if commit:
db.commit()
db.refresh(doc)
else:
db.flush()
return _document_to_dict(doc, db)
def update_document(
db: Session,
business_id: int,
user_id: int,
document_id: int,
data: Dict[str, Any],
*,
user_can_allocate: bool = False,
user_can_change_unit_cost: bool = False,
is_ops_edit: bool = False,
commit: bool = True,
) -> Dict[str, Any]:
biz = _get_business(db, business_id)
settings = get_workflow_settings(biz)
doc = _load_document(db, business_id, document_id)
if doc.status not in EDITABLE_STATUSES:
raise ApiError("NOT_EDITABLE", "این سند قابل ویرایش نیست", http_status=400)
if is_ops_edit and doc.status not in OPS_EDITABLE_STATUSES and settings["workflow_mode"] == WORKFLOW_TWO_STEP:
raise ApiError(
"OPS_EDIT_FORBIDDEN",
"پس از ارسال به حسابداری، انباردار نمی‌تواند سند را ویرایش کند",
http_status=403,
)
allow_account = user_can_allocate or settings["workflow_mode"] == WORKFLOW_SIMPLE
_apply_header_fields(
db,
business_id,
biz,
doc,
data,
allow_account_change=allow_account,
)
if "lines" in data:
built_lines, total_amount = _validate_and_build_lines(
db,
business_id,
biz,
list(data.get("lines") or []),
allow_manual_unit_cost=settings["allow_manual_unit_cost"],
user_can_change_unit_cost=user_can_change_unit_cost,
)
for old in list(doc.lines):
db.delete(old)
db.flush()
for ln in built_lines:
db.add(
GoodsExpenseIncomeLine(
document_id=doc.id,
line_no=ln["line_no"],
product_id=ln["product_id"],
warehouse_id=ln["warehouse_id"],
quantity=ln["quantity"],
unit_cost=ln["unit_cost"],
amount=ln["amount"],
description=ln["description"],
extra_info=ln["extra_info"],
)
)
doc.total_amount = total_amount
doc.touch()
if commit:
db.commit()
doc = _load_document(db, business_id, document_id)
else:
db.flush()
return _document_to_dict(doc, db)
def submit_to_accounting(
db: Session,
business_id: int,
user_id: int,
document_id: int,
*,
commit: bool = True,
) -> Dict[str, Any]:
biz = _get_business(db, business_id)
settings = get_workflow_settings(biz)
doc = _load_document(db, business_id, document_id)
if settings["workflow_mode"] != WORKFLOW_TWO_STEP:
raise ApiError(
"WORKFLOW_NOT_TWO_STEP",
"ارسال به حسابداری فقط در حالت گردش‌کار دو مرحله‌ای فعال است",
http_status=400,
)
if doc.status != STATUS_DRAFT_OPS:
raise ApiError("INVALID_STATUS", "فقط پیش‌نویس عملیاتی قابل ارسال است", http_status=400)
if not doc.lines:
raise ApiError("LINES_REQUIRED", "سند بدون سطر قابل ارسال نیست", http_status=400)
# اگر حساب هنوز ست نشده، پیش‌فرض را بگذار تا حسابدار سریع‌تر تأیید کند
if not doc.effect_account_id:
acc = default_effect_account(db, business_id, biz, doc.doc_kind)
doc.effect_account_id = acc.id
doc.status = STATUS_PENDING_ACCOUNTING
doc.submitted_by_user_id = user_id
doc.submitted_at = datetime.utcnow()
doc.touch()
if commit:
db.commit()
doc = _load_document(db, business_id, document_id)
else:
db.flush()
return _document_to_dict(doc, db)
def allocate_account(
db: Session,
business_id: int,
user_id: int,
document_id: int,
data: Dict[str, Any],
*,
commit: bool = True,
) -> Dict[str, Any]:
biz = _get_business(db, business_id)
doc = _load_document(db, business_id, document_id)
if doc.status not in (STATUS_PENDING_ACCOUNTING, STATUS_DRAFT_ACCOUNTING, STATUS_DRAFT_OPS):
raise ApiError("INVALID_STATUS", "وضعیت سند برای تخصیص حساب مناسب نیست", http_status=400)
if data.get("effect_account_id"):
acc = resolve_account_by_id(db, business_id, int(data["effect_account_id"]))
elif data.get("effect_account_code"):
acc = resolve_account_by_code(db, business_id, str(data["effect_account_code"]))
else:
acc = default_effect_account(db, business_id, biz, doc.doc_kind)
_assert_leaf_account(db, acc, business_id)
_assert_account_kind_compatible(acc, doc.doc_kind)
doc.effect_account_id = acc.id
doc.status = STATUS_DRAFT_ACCOUNTING
doc.allocated_by_user_id = user_id
doc.allocated_at = datetime.utcnow()
doc.touch()
if "description" in data and data.get("description") is not None:
doc.description = str(data.get("description")).strip() or doc.description
if commit:
db.commit()
doc = _load_document(db, business_id, document_id)
else:
db.flush()
return _document_to_dict(doc, db)
def _create_warehouse_and_post(
db: Session,
business_id: int,
user_id: int,
doc: GoodsExpenseIncomeDocument,
) -> WarehouseDocument:
from app.services.warehouse_service import (
_generate_warehouse_document_code,
_is_duplicate_warehouse_document_code_error,
post_warehouse_document,
)
is_expense = doc.doc_kind == DOC_KIND_EXPENSE
wh_doc_type = "issue" if is_expense else "receipt"
movement = "out" if is_expense else "in"
# انبار غالب برای هدر
warehouse_ids = {int(ln.warehouse_id) for ln in doc.lines}
primary_wh = next(iter(warehouse_ids))
wh: Optional[WarehouseDocument] = None
for attempt in range(10):
code = _generate_warehouse_document_code(db, business_id, doc.document_date)
try:
with db.begin_nested():
wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=doc.fiscal_year_id,
code=code,
document_date=doc.document_date,
status="draft",
doc_type=wh_doc_type,
warehouse_id_from=primary_wh if is_expense else None,
warehouse_id_to=None if is_expense else primary_wh,
source_type="goods_expense_income",
source_document_id=doc.id,
created_by_user_id=user_id,
extra_info={
"goods_expense_income_id": doc.id,
"goods_expense_income_code": doc.code,
"doc_kind": doc.doc_kind,
"description": doc.description,
},
)
db.add(wh)
db.flush()
break
except IntegrityError as e:
if _is_duplicate_warehouse_document_code_error(e) and attempt < 9:
continue
raise
if wh is None:
raise ApiError("WAREHOUSE_CODE_CONFLICT", "تولید کد حواله ناموفق بود", http_status=500)
for ln in doc.lines:
db.add(
WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=ln.product_id,
warehouse_id=ln.warehouse_id,
movement=movement,
quantity=ln.quantity,
extra_info={
"unit_cost": float(ln.unit_cost or 0),
"amount": float(ln.amount or 0),
"goods_expense_income_line_id": ln.id,
"description": ln.description,
},
)
)
db.flush()
post_warehouse_document(db, wh.id)
return wh
def _create_accounting_document(
db: Session,
business_id: int,
user_id: int,
doc: GoodsExpenseIncomeDocument,
) -> Document:
if not doc.effect_account_id:
raise ApiError("ACCOUNT_REQUIRED", "حساب هزینه/درآمد برای قطعی‌سازی الزامی است", http_status=400)
inventory_acc = resolve_account_by_code(db, business_id, INVENTORY_ACCOUNT_CODE)
effect_acc = resolve_account_by_id(db, business_id, int(doc.effect_account_id))
_assert_leaf_account(db, effect_acc, business_id)
_assert_account_kind_compatible(effect_acc, doc.doc_kind)
total = _money(doc.total_amount)
if total <= 0:
raise ApiError("ZERO_AMOUNT", "مبلغ سند باید بزرگ‌تر از صفر باشد", http_status=400)
is_expense = doc.doc_kind == DOC_KIND_EXPENSE
gl_type = DOC_KIND_EXPENSE if is_expense else DOC_KIND_INCOME
desc = doc.description or (
f"کالای هزینه‌شده {doc.code}" if is_expense else f"کالای درآمدشده {doc.code}"
)
ensure_document_policy_allows_creation(
db,
business_id,
document_type=gl_type,
document_date=doc.document_date,
amount=total,
)
gl_doc: Optional[Document] = None
for attempt in range(8):
code = generate_document_code(db, business_id, gl_type, doc.document_date)
try:
with db.begin_nested():
gl_doc = Document(
code=code,
business_id=business_id,
fiscal_year_id=doc.fiscal_year_id,
currency_id=doc.currency_id,
created_by_user_id=user_id,
document_date=doc.document_date,
document_type=gl_type,
is_proforma=False,
description=desc,
extra_info={
"goods_expense_income_id": doc.id,
"goods_expense_income_code": doc.code,
"doc_kind": doc.doc_kind,
"person_id": doc.person_id,
"warehouse_document_id": doc.warehouse_document_id,
},
)
db.add(gl_doc)
db.flush()
break
except IntegrityError:
if attempt >= 7:
raise
continue
if gl_doc is None:
raise ApiError("CODE_CONFLICT", "تولید کد سند حسابداری ناموفق بود", http_status=500)
# آرتیکل اثر (هزینه یا درآمد)
# جزئیات کالا فقط در extra_info نگهداری می‌شود تا موجودی مالی از DocumentLine.product_id دوبار شمارش نشود.
product_snapshot = [
{
"line_no": ln.line_no,
"product_id": ln.product_id,
"warehouse_id": ln.warehouse_id,
"quantity": float(ln.quantity or 0),
"unit_cost": float(ln.unit_cost or 0),
"amount": float(ln.amount or 0),
"description": ln.description,
"goods_expense_income_line_id": ln.id,
}
for ln in doc.lines
]
if is_expense:
# Dr هزینه / Cr موجودی
db.add(
DocumentLine(
document_id=gl_doc.id,
account_id=effect_acc.id,
person_id=doc.person_id,
debit=total,
credit=Decimal("0"),
description=desc,
extra_info={
"side": "effect",
"goods_expense_income_id": doc.id,
"product_lines": product_snapshot,
},
)
)
db.add(
DocumentLine(
document_id=gl_doc.id,
account_id=inventory_acc.id,
debit=Decimal("0"),
credit=total,
description=f"خروج موجودی بابت {doc.code}",
extra_info={
"side": "inventory",
"goods_expense_income_id": doc.id,
"inventory_posted": False,
"product_lines": product_snapshot,
},
)
)
else:
# Dr موجودی / Cr درآمد
db.add(
DocumentLine(
document_id=gl_doc.id,
account_id=inventory_acc.id,
debit=total,
credit=Decimal("0"),
description=f"ورود موجودی بابت {doc.code}",
extra_info={
"side": "inventory",
"goods_expense_income_id": doc.id,
"inventory_posted": False,
"product_lines": product_snapshot,
},
)
)
db.add(
DocumentLine(
document_id=gl_doc.id,
account_id=effect_acc.id,
person_id=doc.person_id,
debit=Decimal("0"),
credit=total,
description=desc,
extra_info={
"side": "effect",
"goods_expense_income_id": doc.id,
"product_lines": product_snapshot,
},
)
)
db.flush()
return gl_doc
def _create_reversing_accounting_document(
db: Session,
business_id: int,
user_id: int,
doc: GoodsExpenseIncomeDocument,
original_gl: Document,
) -> Document:
lines = (
db.query(DocumentLine)
.filter(DocumentLine.document_id == original_gl.id)
.all()
)
money_lines = [ln for ln in lines if (ln.debit or 0) != 0 or (ln.credit or 0) != 0]
if not money_lines:
raise ApiError("NO_GL_LINES", "سند حسابداری اصلی خط ریالی ندارد", http_status=400)
gl_type = f"{doc.doc_kind}_reversal"
desc = f"ابطال {doc.code}"
total = _money(doc.total_amount)
ensure_document_policy_allows_creation(
db,
business_id,
document_type=doc.doc_kind,
document_date=doc.document_date,
amount=total,
)
rev: Optional[Document] = None
for attempt in range(8):
code = generate_document_code(db, business_id, doc.doc_kind, doc.document_date)
try:
with db.begin_nested():
rev = Document(
code=code,
business_id=business_id,
fiscal_year_id=doc.fiscal_year_id,
currency_id=doc.currency_id,
created_by_user_id=user_id,
document_date=doc.document_date,
document_type=doc.doc_kind,
is_proforma=False,
description=desc,
extra_info={
"goods_expense_income_id": doc.id,
"reverses_document_id": original_gl.id,
"is_reversal": True,
},
)
db.add(rev)
db.flush()
break
except IntegrityError:
if attempt >= 7:
raise
continue
if rev is None:
raise ApiError("CODE_CONFLICT", "تولید کد سند ابطال ناموفق بود", http_status=500)
for ln in money_lines:
db.add(
DocumentLine(
document_id=rev.id,
account_id=ln.account_id,
person_id=ln.person_id,
product_id=ln.product_id,
quantity=ln.quantity,
debit=_money(ln.credit),
credit=_money(ln.debit),
description=desc,
extra_info={
**(ln.extra_info or {}),
"reverses_line_id": ln.id,
"is_reversal": True,
},
)
)
db.flush()
_ = gl_type # reserved for future numbering split
return rev
def _post_document_internal(
db: Session,
business_id: int,
user_id: int,
doc: GoodsExpenseIncomeDocument,
) -> None:
if doc.status == STATUS_POSTED:
return
if doc.status == STATUS_CANCELLED:
raise ApiError("ALREADY_CANCELLED", "سند ابطال‌شده قابل قطعی‌سازی نیست", http_status=400)
if not doc.effect_account_id:
raise ApiError("ACCOUNT_REQUIRED", "ابتدا حساب هزینه/درآمد را تخصیص دهید", http_status=400)
if not doc.lines:
raise ApiError("LINES_REQUIRED", "سند بدون سطر قابل قطعی‌سازی نیست", http_status=400)
if _money(doc.total_amount) <= 0:
raise ApiError("ZERO_AMOUNT", "مبلغ سند نامعتبر است", http_status=400)
wh = _create_warehouse_and_post(db, business_id, user_id, doc)
doc.warehouse_document_id = wh.id
db.flush()
gl = _create_accounting_document(db, business_id, user_id, doc)
# لینک دوطرفه
extra = dict(gl.extra_info or {})
extra["warehouse_document_id"] = wh.id
gl.extra_info = extra
doc.accounting_document_id = gl.id
doc.status = STATUS_POSTED
doc.posted_by_user_id = user_id
doc.posted_at = datetime.utcnow()
doc.touch()
db.flush()
def post_document(
db: Session,
business_id: int,
user_id: int,
document_id: int,
*,
commit: bool = True,
) -> Dict[str, Any]:
doc = _load_document(db, business_id, document_id)
if doc.status not in (
STATUS_DRAFT_OPS,
STATUS_PENDING_ACCOUNTING,
STATUS_DRAFT_ACCOUNTING,
):
raise ApiError("INVALID_STATUS", "وضعیت سند برای قطعی‌سازی مناسب نیست", http_status=400)
_post_document_internal(db, business_id, user_id, doc)
if commit:
db.commit()
doc = _load_document(db, business_id, document_id)
else:
db.flush()
return _document_to_dict(doc, db)
def cancel_document(
db: Session,
business_id: int,
user_id: int,
document_id: int,
*,
commit: bool = True,
) -> Dict[str, Any]:
from app.services.warehouse_service import cancel_warehouse_document
doc = _load_document(db, business_id, document_id)
if doc.status != STATUS_POSTED:
raise ApiError("NOT_CANCELLABLE", "فقط اسناد قطعی قابل ابطال هستند", http_status=400)
if doc.warehouse_document_id:
# seal_reversal پیش‌فرض برای source_type=goods_expense_income فعال است
cancel_wh = cancel_warehouse_document(
db, business_id, int(doc.warehouse_document_id), user_id
)
extra = dict(doc.extra_info or {})
extra["cancelled_warehouse_document_id"] = doc.warehouse_document_id
extra["cancel_reversal_warehouse_document_id"] = cancel_wh.id
extra["cancel_reversal_sealed"] = bool(
(cancel_wh.extra_info or {}).get("audit_only_reversal")
)
doc.extra_info = extra
if doc.accounting_document_id:
original_gl = (
db.query(Document)
.filter(
and_(
Document.id == int(doc.accounting_document_id),
Document.business_id == business_id,
)
)
.first()
)
if original_gl:
rev = _create_reversing_accounting_document(db, business_id, user_id, doc, original_gl)
extra = dict(doc.extra_info or {})
extra["reversal_accounting_document_id"] = rev.id
if "cancelled_warehouse_document_id" not in extra:
extra["cancelled_warehouse_document_id"] = doc.warehouse_document_id
doc.extra_info = extra
doc.status = STATUS_CANCELLED
doc.cancelled_by_user_id = user_id
doc.cancelled_at = datetime.utcnow()
doc.touch()
if commit:
db.commit()
doc = _load_document(db, business_id, document_id)
else:
db.flush()
return _document_to_dict(doc, db)
def delete_document(
db: Session,
business_id: int,
document_id: int,
*,
commit: bool = True,
) -> Dict[str, Any]:
doc = _load_document(db, business_id, document_id)
if doc.status not in (STATUS_DRAFT_OPS, STATUS_PENDING_ACCOUNTING, STATUS_DRAFT_ACCOUNTING):
raise ApiError("NOT_DELETABLE", "فقط اسناد غیرقطعی قابل حذف هستند", http_status=400)
payload = _document_to_dict(doc, db)
db.delete(doc)
if commit:
db.commit()
else:
db.flush()
return payload
def get_document(db: Session, business_id: int, document_id: int) -> Dict[str, Any]:
doc = _load_document(db, business_id, document_id)
return _document_to_dict(doc, db)
def list_documents(
db: Session,
business_id: int,
query: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
query = query or {}
q = db.query(GoodsExpenseIncomeDocument).filter(
GoodsExpenseIncomeDocument.business_id == int(business_id)
)
fiscal_year_id = query.get("fiscal_year_id")
if fiscal_year_id:
q = q.filter(GoodsExpenseIncomeDocument.fiscal_year_id == int(fiscal_year_id))
doc_kind = query.get("doc_kind")
if doc_kind:
q = q.filter(GoodsExpenseIncomeDocument.doc_kind == str(doc_kind))
status = query.get("status")
if status:
q = q.filter(GoodsExpenseIncomeDocument.status == str(status))
person_id = query.get("person_id")
if person_id:
q = q.filter(GoodsExpenseIncomeDocument.person_id == int(person_id))
search = (query.get("search") or query.get("q") or "").strip()
if search:
like = f"%{search}%"
q = q.filter(
or_(
GoodsExpenseIncomeDocument.code.ilike(like),
GoodsExpenseIncomeDocument.description.ilike(like),
)
)
date_from = query.get("date_from") or query.get("from_date")
date_to = query.get("date_to") or query.get("to_date")
if date_from:
q = q.filter(GoodsExpenseIncomeDocument.document_date >= _parse_iso_date(date_from))
if date_to:
q = q.filter(GoodsExpenseIncomeDocument.document_date <= _parse_iso_date(date_to))
total = q.with_entities(func.count(GoodsExpenseIncomeDocument.id)).scalar() or 0
page = max(int(query.get("page") or 1), 1)
page_size = min(max(int(query.get("page_size") or query.get("take") or 20), 1), 200)
rows = (
q.order_by(
GoodsExpenseIncomeDocument.document_date.desc(),
GoodsExpenseIncomeDocument.id.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items = [_document_to_dict(r, db) for r in rows]
return {
"items": items,
"total": int(total),
"page": page,
"page_size": page_size,
}
def create_from_stock_count(
db: Session,
business_id: int,
user_id: int,
*,
stock_count_code: str,
stock_count_date: date,
items: List[Dict[str, Any]],
notes: Optional[str] = None,
user_can_allocate: bool = True,
user_can_post: bool = False,
commit: bool = True,
) -> Dict[str, Any]:
"""
از اختلافات انبارگردانی دو سند (کسری=هزینه، اضافه=درآمد) می‌سازد.
"""
biz = _get_business(db, business_id)
settings = get_workflow_settings(biz)
# فقط ردیف‌هایی که واقعاً اختلاف دارند
diff_items = [
item for item in items
if Decimal(str((item or {}).get("difference") or 0)) != 0
]
if not diff_items:
raise ApiError("NO_DIFFERENCES", "هیچ اختلافی برای ثبت وجود ندارد", http_status=400)
shortage_lines: List[Dict[str, Any]] = []
surplus_lines: List[Dict[str, Any]] = []
for item in diff_items:
diff = Decimal(str(item.get("difference") or 0))
if diff == 0:
continue
base = {
"product_id": item.get("product_id"),
"warehouse_id": item.get("warehouse_id"),
"quantity": abs(diff),
"description": item.get("description"),
"extra_info": {
"system_quantity": item.get("system_quantity"),
"physical_quantity": item.get("physical_quantity"),
"difference": float(diff),
"stock_count_code": stock_count_code,
},
}
if diff < 0:
shortage_lines.append(base)
else:
surplus_lines.append(base)
if not shortage_lines and not surplus_lines:
raise ApiError("NO_DIFFERENCES", "هیچ اختلافی برای ثبت وجود ندارد", http_status=400)
created: Dict[str, Any] = {"expense": None, "income": None, "settings": settings}
extra = {
"stock_count_code": stock_count_code,
"stock_count_date": stock_count_date.isoformat(),
"notes": notes,
"source": "stock_count",
}
if shortage_lines:
created["expense"] = create_document(
db,
business_id,
user_id,
{
"doc_kind": DOC_KIND_EXPENSE,
"document_date": stock_count_date.isoformat(),
"description": notes or f"کسری انبارگردانی {stock_count_code}",
"lines": shortage_lines,
"extra_info": extra,
"auto_post": False,
},
user_can_allocate=user_can_allocate,
user_can_post=False,
commit=False,
)
if surplus_lines:
created["income"] = create_document(
db,
business_id,
user_id,
{
"doc_kind": DOC_KIND_INCOME,
"document_date": stock_count_date.isoformat(),
"description": notes or f"اضافه انبارگردانی {stock_count_code}",
"lines": surplus_lines,
"extra_info": extra,
"auto_post": False,
},
user_can_allocate=user_can_allocate,
user_can_post=False,
commit=False,
)
if commit:
db.commit()
else:
db.flush()
return created